diff --git a/lib/src/commonMain/kotlin/xyz/mcxross/kaptos/exception/ParsingError.kt b/lib/src/commonMain/kotlin/xyz/mcxross/kaptos/exception/ParsingError.kt index e69de29b..aaad8a38 100644 --- a/lib/src/commonMain/kotlin/xyz/mcxross/kaptos/exception/ParsingError.kt +++ b/lib/src/commonMain/kotlin/xyz/mcxross/kaptos/exception/ParsingError.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2024 McXross + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package xyz.mcxross.kaptos.exception + +/** This error is used to explain why parsing failed. */ +open class ParsingError(message: String) : Exception(message) diff --git a/lib/src/commonMain/kotlin/xyz/mcxross/kaptos/model/AccountAddress.kt b/lib/src/commonMain/kotlin/xyz/mcxross/kaptos/model/AccountAddress.kt index a321717b..7433a53f 100644 --- a/lib/src/commonMain/kotlin/xyz/mcxross/kaptos/model/AccountAddress.kt +++ b/lib/src/commonMain/kotlin/xyz/mcxross/kaptos/model/AccountAddress.kt @@ -17,30 +17,93 @@ package xyz.mcxross.kaptos.model import kotlinx.serialization.Serializable +import xyz.mcxross.kaptos.exception.ParsingError +/** + * This enum is used to explain why an address was invalid. + * + * @property reason The reason why the address was invalid. + */ +enum class AddressInvalidReason(val reason: String) { + INCORRECT_NUMBER_OF_BYTES("Incorrect number of bytes"), + INVALID_NUM_OF_HEX_CHARS("Invalid num of hex chars"), + INVALID_HEX_CHARS("Invalid hex chars"), + TOO_SHORT("Too short"), + TOO_LONG("Too long"), + LEADING_ZERO_X_REQUIRED("Leading zero x required"), + LONG_FORM_REQUIRED_UNLESS_SPECIAL("Long form required unless special"), + INVALID_PADDING_ZEROES("INVALID PADDING ZEROES"), +} + +/** + * This interface is used to define the input for an account address. + * + * @property value The value of the account address. + */ interface AccountAddressInput { val value: String } +/** + * This class is used to represent an account address. + * + * It is used for working with account addresses. Account addresses, when represented as a string, + * generally look like these examples: + * - 0x1 + * - 0xaa86fe99004361f747f91342ca13c426ca0cccb0c1217677180c9493bad6ef0c + * + * @constructor Creates an account address from a hex string. + * @property data The data of the account address. + */ @Serializable data class AccountAddress(val data: ByteArray) : AccountAddressInput { - constructor(hex: String) : this(hex.removePrefix("0x").chunked(2) { 0.toByte() }.toByteArray()) + /** + * This constructor is used to create an account address from a hex string. + * + * It is made to be as sensitive as possible to invalid hex characters. There are instances where + * a character less is considered valid, but we are not allowing that here. It must fail at both + * the char level and the byte level. + * + * @param hex The hex string to create the account address from. + */ + constructor( + hex: String + ) : this( + hex.removePrefix("0x").let { + // We need this to be as sensitive as possible to invalid hex characters + if (it.length % 2 != 0) + throw ParsingError(AddressInvalidReason.INVALID_NUM_OF_HEX_CHARS.reason) + it.chunked(2).map { pair -> pair.toInt(16).toByte() }.toByteArray() + } + ) init { - if (data.size != LENGTH) { - throw TODO("AccountAddress data should be exactly 32 bytes long") + if (data.size < LENGTH) { + throw ParsingError( + AddressInvalidReason.INCORRECT_NUMBER_OF_BYTES.reason + + " Expected $LENGTH bytes, got ${data.size}." + ) } } fun isSpecial(): Boolean { - return data.sliceArray(0 until data.size - 1).all { it == 0.toByte() } && data.last() < 16 + return this.data.sliceArray(0 until this.data.size - 1).all { byte -> byte.toInt() == 0 } && + this.data[this.data.size - 1].toInt() < 0b10000 } override fun toString(): String = "0x${toStringWithoutPrefix()}" fun toStringWithoutPrefix(): String { - val hex = data.joinToString("") { byte -> "%02x${byte}" } + val hex = + data.joinToString("") { + val str = it.toInt().and(0xff).toString(16) + if (str.length == 1) { + "0$str" + } else { + str + } + } return if (isSpecial()) { hex.takeLast(1) } else { @@ -51,7 +114,7 @@ data class AccountAddress(val data: ByteArray) : AccountAddressInput { fun toStringLong(): String = "0x${toStringLongWithoutPrefix()}" fun toStringLongWithoutPrefix(): String { - return data.joinToString("") { byte -> "%02x$byte" } + return data.joinToString("") { byte -> "${byte}${byte}" } } override val value: String @@ -80,19 +143,133 @@ data class AccountAddress(val data: ByteArray) : AccountAddressInput { val THREE: AccountAddress = AccountAddress(ByteArray(LENGTH) { if (it == LENGTH - 1) 3 else 0 }) val FOUR: AccountAddress = AccountAddress(ByteArray(LENGTH) { if (it == LENGTH - 1) 4 else 0 }) - fun fromHexString(input: String): AccountAddress { + /** + * NOTE: This function has strict parsing behavior. For relaxed behavior, please use the + * `[fromString]` function. + * + * Creates an instance of AccountAddress from a hex string. + * + * This function allows only the strictest formats defined by AIP-40. In short this means only + * the following formats are accepted: + * - LONG + * - SHORT for special addresses + * + * Where: + * - LONG is defined as 0x + 64 hex characters. + * - SHORT for special addresses is 0x0 to 0xf inclusive without padding zeroes. + * + * This means the following are not accepted: + * - SHORT for non-special addresses. + * - Any address without a leading 0x. + * + * Learn more about the different address formats by reading AIP-40: + * https://github.com/aptos-foundation/AIPs/blob/main/aips/aip-40.md. + * + * @param input A hex string representing an account address. + * @returns An instance of [AccountAddress]. + */ + fun fromStringStrict(input: String): AccountAddress { if (!input.startsWith("0x")) { - throw IllegalArgumentException("Hex string must start with a leading 0x.") + throw ParsingError(AddressInvalidReason.LEADING_ZERO_X_REQUIRED.reason) } - val hex = input.drop(2) - val byteArray = - ByteArray(LENGTH) { idx -> - val hexIndex = hex.length - 2 * (LENGTH - idx) - if (hexIndex < 0) 0 else hex.substring(hexIndex, hexIndex + 2).toInt(16).toByte() + + val address = fromString(input) + + // Check if the address is in LONG form. If it is not, this is only allowed for + // special addresses, in which case we check it is in proper SHORT form. + if (input.length != LONG_STRING_LENGTH + 2) { + if (!address.isSpecial()) { + throw ParsingError(AddressInvalidReason.LONG_FORM_REQUIRED_UNLESS_SPECIAL.reason) + } else if (input.length != 3) { + throw ParsingError(AddressInvalidReason.INVALID_PADDING_ZEROES.reason) } - return AccountAddress(byteArray) + } + + return address + } + + /** + * NOTE: This function has relaxed parsing behavior. For strict behavior, please use the + * `[fromStringStrict]` function. Where possible use `fromStringStrict` rather than this + * function, `[fromString]` is only provided for backwards compatibility. + * + * Creates an instance of [AccountAddress] from a hex string. + * + * This function allows all formats defined by AIP-40. In short this means the following formats + * are accepted: + * - LONG, with or without leading 0x + * - SHORT, with or without leading 0x + * + * Where: + * - LONG is 64 hex characters. + * - SHORT is 1 to 63 hex characters inclusive. + * - Padding zeroes are allowed, e.g. 0x0123 is valid. + * + * Learn more about the different address formats by reading AIP-40: + * https://github.com/aptos-foundation/AIPs/blob/main/aips/aip-40.md. + * + * @param input A hex string representing an account address. + * @returns An instance of [AccountAddress]. + */ + fun fromString(input: String): AccountAddress { + val parsedInput = input.removePrefix("0x") + + if (parsedInput.isEmpty()) { + throw ParsingError(AddressInvalidReason.TOO_SHORT.reason) + } + + if (parsedInput.length > 64) { + throw ParsingError(AddressInvalidReason.TOO_LONG.reason) + } + + val paddedInput = parsedInput.padStart(64, '0') + return try { + AccountAddress(paddedInput.chunked(2).map { it.toInt(16).and(0xff).toByte() }.toByteArray()) + } catch (e: NumberFormatException) { + throw ParsingError(AddressInvalidReason.INVALID_HEX_CHARS.reason) + } + } + + /** + * Convenience method for creating an AccountAddress from all known inputs. + * + * This handles, HexInput, and AccountAddress itself + * + * @param input + */ + fun from(input: AccountAddressInput): AccountAddress { + if (input is AccountAddress) { + return input + } + return fromString(input.value) + } + + /** + * Check if the string is a valid [AccountAddress]. + * + * @param input A hex string representing an account address. + * @param strict If true, use strict parsing behavior. If false, use relaxed parsing behavior. + * Default is false. + * @returns valid = true if the string is valid, valid = false if not. If the string is not + * valid, invalidReason will be set explaining why it is invalid. + */ + fun isValid(input: String, strict: Boolean = false): Boolean { + return try { + if (strict) { + fromStringStrict(input) + } else { + fromString(input) + } + true + } catch (e: ParsingError) { + false + } } } } -data class HexInput(override val value: String) : AccountAddressInput +data class HexInput(override val value: String) : AccountAddressInput { + override fun toString(): String { + return value + } +} diff --git a/lib/src/commonTest/kotlin/xyz/mcxross/kaptos/unit/AccountAddressTest.kt b/lib/src/commonTest/kotlin/xyz/mcxross/kaptos/unit/AccountAddressTest.kt new file mode 100644 index 00000000..f6beba9a --- /dev/null +++ b/lib/src/commonTest/kotlin/xyz/mcxross/kaptos/unit/AccountAddressTest.kt @@ -0,0 +1,372 @@ +/* + * Copyright 2024 McXross + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package xyz.mcxross.kaptos.unit + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import xyz.mcxross.kaptos.exception.ParsingError +import xyz.mcxross.kaptos.model.AccountAddress + +class AccountAddressTest { + + private val ADDRESS_ZERO: Addresses = + mapOf( + "shortWith0x" to "0x0", + "shortWithout0x" to "0", + "longWith0x" to "0x0000000000000000000000000000000000000000000000000000000000000000", + "longWithout0x" to "0000000000000000000000000000000000000000000000000000000000000000", + "bytes" to ByteArray(32) { 0 }, + ) + + private val ADDRESS_ONE: Addresses = + mapOf( + "shortWith0x" to "0x1", + "shortWithout0x" to "1", + "longWith0x" to "0x0000000000000000000000000000000000000000000000000000000000000001", + "longWithout0x" to "0000000000000000000000000000000000000000000000000000000000000001", + "bytes" to ByteArray(32) { if (it == 31) 1 else 0 }, + ) + + private val ADDRESS_TWO: Addresses = + mapOf( + "shortWith0x" to "0x2", + "shortWithout0x" to "2", + "longWith0x" to "0x0000000000000000000000000000000000000000000000000000000000000002", + "longWithout0x" to "0000000000000000000000000000000000000000000000000000000000000002", + "bytes" to ByteArray(32) { if (it == 31) 2 else 0 }, + ) + + private val ADDRESS_THREE: Addresses = + mapOf( + "shortWith0x" to "0x3", + "shortWithout0x" to "3", + "longWith0x" to "0x0000000000000000000000000000000000000000000000000000000000000003", + "longWithout0x" to "0000000000000000000000000000000000000000000000000000000000000003", + "bytes" to ByteArray(32) { if (it == 31) 3 else 0 }, + ) + + private val ADDRESS_FOUR: Addresses = + mapOf( + "shortWith0x" to "0x4", + "shortWithout0x" to "4", + "longWith0x" to "0x0000000000000000000000000000000000000000000000000000000000000004", + "longWithout0x" to "0000000000000000000000000000000000000000000000000000000000000004", + "bytes" to ByteArray(32) { if (it == 31) 4 else 0 }, + ) + + private val ADDRESS_F: Addresses = + mapOf( + "shortWith0x" to "0xf", + "shortWithout0x" to "f", + "longWith0x" to "0x000000000000000000000000000000000000000000000000000000000000000f", + "longWithout0x" to "000000000000000000000000000000000000000000000000000000000000000f", + "bytes" to ByteArray(32) { if (it == 31) 15 else 0 }, + ) + + val ADDRESS_F_PADDED_SHORT_FORM: Addresses = + mapOf( + "shortWith0x" to "0x0f", + "shortWithout0x" to "0f", + "longWith0x" to "0x000000000000000000000000000000000000000000000000000000000000000f", + "longWithout0x" to "000000000000000000000000000000000000000000000000000000000000000f", + "bytes" to ByteArray(32) { if (it == 31) 15 else 0 }, + ) + + val ADDRESS_TEN: Addresses = + mapOf( + "shortWith0x" to "0x10", + "shortWithout0x" to "10", + "longWith0x" to "0x0000000000000000000000000000000000000000000000000000000000000010", + "longWithout0x" to "0000000000000000000000000000000000000000000000000000000000000010", + "bytes" to ByteArray(32) { if (it == 31) 16 else 0 }, + ) + + val ADDRESS_OTHER: Addresses = + mapOf( + "shortWith0x" to "0xca843279e3427144cead5e4d5999a3d0ca843279e3427144cead5e4d5999a3d0", + "shortWithout0x" to "ca843279e3427144cead5e4d5999a3d0ca843279e3427144cead5e4d5999a3d0", + "longWith0x" to "0xca843279e3427144cead5e4d5999a3d0ca843279e3427144cead5e4d5999a3d0", + "longWithout0x" to "ca843279e3427144cead5e4d5999a3d0ca843279e3427144cead5e4d5999a3d0", + "bytes" to + byteArrayOf( + 202.toByte(), + 132.toByte(), + 50.toByte(), + 121.toByte(), + 227.toByte(), + 66.toByte(), + 113.toByte(), + 68.toByte(), + 206.toByte(), + 173.toByte(), + 94.toByte(), + 77.toByte(), + 89.toByte(), + 153.toByte(), + 163.toByte(), + 208.toByte(), + 202.toByte(), + 132.toByte(), + 50.toByte(), + 121.toByte(), + 227.toByte(), + 66.toByte(), + 113.toByte(), + 68.toByte(), + 206.toByte(), + 173.toByte(), + 94.toByte(), + 77.toByte(), + 89.toByte(), + 153.toByte(), + 163.toByte(), + 208.toByte(), + ), + ) + + // These tests show that fromStringRelaxed works fine, parses all formats. + @Test + fun testFromStringRelaxed() { + + assertEquals( + ADDRESS_ZERO["shortWith0x"], + AccountAddress.fromString(ADDRESS_ZERO["longWith0x"].toString()).toString(), + ) + assertEquals( + ADDRESS_ZERO["shortWith0x"], + AccountAddress.fromString(ADDRESS_ZERO["longWithout0x"].toString()).toString(), + ) + assertEquals( + ADDRESS_ZERO["shortWith0x"], + AccountAddress.fromString(ADDRESS_ZERO["shortWith0x"].toString()).toString(), + ) + assertEquals( + ADDRESS_ZERO["shortWith0x"], + AccountAddress.fromString(ADDRESS_ZERO["shortWithout0x"].toString()).toString(), + ) + + assertEquals( + ADDRESS_ONE["shortWith0x"], + AccountAddress.fromString(ADDRESS_ONE["longWith0x"].toString()).toString(), + ) + assertEquals( + ADDRESS_ONE["shortWith0x"], + AccountAddress.fromString(ADDRESS_ONE["longWithout0x"].toString()).toString(), + ) + assertEquals( + ADDRESS_ONE["shortWith0x"], + AccountAddress.fromString(ADDRESS_ONE["shortWith0x"].toString()).toString(), + ) + assertEquals( + ADDRESS_ONE["shortWith0x"], + AccountAddress.fromString(ADDRESS_ONE["shortWithout0x"].toString()).toString(), + ) + + assertEquals( + ADDRESS_TWO["shortWith0x"], + AccountAddress.fromString(ADDRESS_TWO["longWith0x"].toString()).toString(), + ) + assertEquals( + ADDRESS_TWO["shortWith0x"], + AccountAddress.fromString(ADDRESS_TWO["longWithout0x"].toString()).toString(), + ) + assertEquals( + ADDRESS_TWO["shortWith0x"], + AccountAddress.fromString(ADDRESS_TWO["shortWith0x"].toString()).toString(), + ) + assertEquals( + ADDRESS_TWO["shortWith0x"], + AccountAddress.fromString(ADDRESS_TWO["shortWithout0x"].toString()).toString(), + ) + + assertEquals( + ADDRESS_THREE["shortWith0x"], + AccountAddress.fromString(ADDRESS_THREE["longWith0x"].toString()).toString(), + ) + assertEquals( + ADDRESS_THREE["shortWith0x"], + AccountAddress.fromString(ADDRESS_THREE["longWithout0x"].toString()).toString(), + ) + assertEquals( + ADDRESS_THREE["shortWith0x"], + AccountAddress.fromString(ADDRESS_THREE["shortWith0x"].toString()).toString(), + ) + assertEquals( + ADDRESS_THREE["shortWith0x"], + AccountAddress.fromString(ADDRESS_THREE["shortWithout0x"].toString()).toString(), + ) + + assertEquals( + ADDRESS_FOUR["shortWith0x"], + AccountAddress.fromString(ADDRESS_FOUR["longWith0x"].toString()).toString(), + ) + assertEquals( + ADDRESS_FOUR["shortWith0x"], + AccountAddress.fromString(ADDRESS_FOUR["longWithout0x"].toString()).toString(), + ) + assertEquals( + ADDRESS_FOUR["shortWith0x"], + AccountAddress.fromString(ADDRESS_FOUR["shortWith0x"].toString()).toString(), + ) + assertEquals( + ADDRESS_FOUR["shortWith0x"], + AccountAddress.fromString(ADDRESS_FOUR["shortWithout0x"].toString()).toString(), + ) + + assertEquals( + ADDRESS_F["shortWith0x"], + AccountAddress.fromString(ADDRESS_F["longWith0x"].toString()).toString(), + ) + assertEquals( + ADDRESS_F["shortWith0x"], + AccountAddress.fromString(ADDRESS_F["longWithout0x"].toString()).toString(), + ) + assertEquals( + ADDRESS_F["shortWith0x"], + AccountAddress.fromString(ADDRESS_F["shortWith0x"].toString()).toString(), + ) + assertEquals( + ADDRESS_F["shortWith0x"], + AccountAddress.fromString(ADDRESS_F["shortWithout0x"].toString()).toString(), + ) + + assertEquals( + ADDRESS_F["shortWith0x"].toString(), + AccountAddress.fromString(ADDRESS_F_PADDED_SHORT_FORM["shortWith0x"].toString()).toString(), + ) + assertEquals( + ADDRESS_F["shortWith0x"].toString(), + AccountAddress.fromString(ADDRESS_F_PADDED_SHORT_FORM["shortWithout0x"].toString()).toString(), + ) + + assertEquals( + ADDRESS_TEN["longWith0x"], + AccountAddress.fromString(ADDRESS_TEN["longWith0x"].toString()).toString(), + ) + assertEquals( + ADDRESS_TEN["longWith0x"], + AccountAddress.fromString(ADDRESS_TEN["longWithout0x"].toString()).toString(), + ) + assertEquals( + ADDRESS_TEN["longWith0x"], + AccountAddress.fromString(ADDRESS_TEN["shortWith0x"].toString()).toString(), + ) + assertEquals( + ADDRESS_TEN["longWith0x"], + AccountAddress.fromString(ADDRESS_TEN["shortWithout0x"].toString()).toString(), + ) + + assertEquals( + ADDRESS_OTHER["longWith0x"], + AccountAddress.fromString(ADDRESS_OTHER["longWith0x"].toString()).toString(), + ) + assertEquals( + ADDRESS_OTHER["longWith0x"], + AccountAddress.fromString(ADDRESS_OTHER["longWithout0x"].toString()).toString(), + ) + } + + // Tests ensure that the constant special addresses in the static AccountAddress class are + // correct. + @Test + fun testStaticSpecialAddresses() { + assertEquals(ADDRESS_ZERO["shortWith0x"], AccountAddress.ZERO.toString()) + assertEquals(ADDRESS_ONE["shortWith0x"], AccountAddress.ONE.toString()) + assertEquals(ADDRESS_TWO["shortWith0x"], AccountAddress.TWO.toString()) + assertEquals(ADDRESS_THREE["shortWith0x"], AccountAddress.THREE.toString()) + assertEquals(ADDRESS_FOUR["shortWith0x"], AccountAddress.FOUR.toString()) + } + + @Test + fun testFromString() { + assertEquals( + ADDRESS_ZERO["shortWith0x"], + AccountAddress.fromStringStrict(ADDRESS_ZERO["longWith0x"].toString()).toString(), + ) + assertFailsWith("Address must be 32 bytes long") { + AccountAddress.fromStringStrict(ADDRESS_ZERO["longWithout0x"].toString()) + } + assertEquals( + ADDRESS_ZERO["shortWith0x"], + AccountAddress.fromStringStrict(ADDRESS_ZERO["shortWith0x"].toString()).toString(), + ) + assertFailsWith { + AccountAddress.fromStringStrict(ADDRESS_ZERO["shortWithout0x"].toString()) + } + + assertEquals( + ADDRESS_ONE["shortWith0x"], + AccountAddress.fromStringStrict(ADDRESS_ONE["longWith0x"].toString()).toString(), + ) + assertFailsWith { + AccountAddress.fromStringStrict(ADDRESS_ONE["longWithout0x"].toString()) + } + assertEquals( + ADDRESS_ONE["shortWith0x"], + AccountAddress.fromStringStrict(ADDRESS_ONE["shortWith0x"].toString()).toString(), + ) + assertFailsWith { + AccountAddress.fromStringStrict(ADDRESS_ONE["shortWithout0x"].toString()) + } + + assertEquals( + ADDRESS_F["shortWith0x"], + AccountAddress.fromStringStrict(ADDRESS_F["longWith0x"].toString()).toString(), + ) + assertFailsWith { + AccountAddress.fromStringStrict(ADDRESS_F["longWithout0x"].toString()) + } + assertEquals( + ADDRESS_F["shortWith0x"], + AccountAddress.fromStringStrict(ADDRESS_F["shortWith0x"].toString()).toString(), + ) + assertFailsWith { + AccountAddress.fromStringStrict(ADDRESS_F["shortWithout0x"].toString()) + } + + assertFailsWith { + AccountAddress.fromStringStrict(ADDRESS_F_PADDED_SHORT_FORM["shortWith0x"].toString()) + } + assertFailsWith { + AccountAddress.fromStringStrict(ADDRESS_F_PADDED_SHORT_FORM["shortWithout0x"].toString()) + } + + assertEquals( + ADDRESS_TEN["longWith0x"], + AccountAddress.fromStringStrict(ADDRESS_TEN["longWith0x"].toString()).toString(), + ) + assertFailsWith { + AccountAddress.fromStringStrict(ADDRESS_TEN["longWithout0x"].toString()) + } + assertFailsWith { + AccountAddress.fromStringStrict(ADDRESS_TEN["shortWith0x"].toString()) + } + assertFailsWith { + AccountAddress.fromStringStrict(ADDRESS_TEN["shortWithout0x"].toString()) + } + + assertEquals( + ADDRESS_OTHER["longWith0x"], + AccountAddress.fromStringStrict(ADDRESS_OTHER["longWith0x"].toString()).toString(), + ) + assertFailsWith { + AccountAddress.fromStringStrict(ADDRESS_OTHER["longWithout0x"].toString()) + } + } +} + +typealias Addresses = Map