diff --git a/.changeset/rich-cows-repair.md b/.changeset/rich-cows-repair.md new file mode 100644 index 000000000..013f6529f --- /dev/null +++ b/.changeset/rich-cows-repair.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`InteroperableAddress`: Add a library for formatting and parsing ERC-7930 interoperable addresses. diff --git a/contracts/mocks/Stateless.sol b/contracts/mocks/Stateless.sol index baa2905cd..ed19bb4e1 100644 --- a/contracts/mocks/Stateless.sol +++ b/contracts/mocks/Stateless.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; +pragma solidity ^0.8.26; // We keep these imports and a dummy contract just to we can run the test suite after transpilation. @@ -30,6 +30,7 @@ import {ERC1967Utils} from "../proxy/ERC1967/ERC1967Utils.sol"; import {ERC4337Utils} from "../account/utils/draft-ERC4337Utils.sol"; import {ERC7579Utils} from "../account/utils/draft-ERC7579Utils.sol"; import {Heap} from "../utils/structs/Heap.sol"; +import {InteroperableAddress} from "../utils/draft-InteroperableAddress.sol"; import {Math} from "../utils/math/Math.sol"; import {MerkleProof} from "../utils/cryptography/MerkleProof.sol"; import {MessageHashUtils} from "../utils/cryptography/MessageHashUtils.sol"; diff --git a/contracts/utils/README.adoc b/contracts/utils/README.adoc index 231bccd97..8640e56fa 100644 --- a/contracts/utils/README.adoc +++ b/contracts/utils/README.adoc @@ -38,9 +38,10 @@ Miscellaneous contracts and libraries containing utility functions you can use t * {Panic}: A library to revert with https://docs.soliditylang.org/en/v0.8.20/control-structures.html#panic-via-assert-and-error-via-require[Solidity panic codes]. * {Comparators}: A library that contains comparator functions to use with the {Heap} library. * {CAIP2}, {CAIP10}: Libraries for formatting and parsing CAIP-2 and CAIP-10 identifiers. + * {InteroperableAddress}: Library for formatting and parsing ERC-7930 interoperable addresses. * {Blockhash}: A library for accessing historical block hashes beyond the standard 256 block limit utilizing EIP-2935's historical blockhash functionality. * {Time}: A library that provides helpers for manipulating time-related objects, including a `Delay` type. - + [NOTE] ==== Because Solidity does not support generic types, {EnumerableMap} and {EnumerableSet} are specialized to a limited number of key-value types. @@ -134,6 +135,8 @@ Ethereum contracts have no native concept of an interface, so applications must {{CAIP10}} +{{InteroperableAddress}} + {{Blockhash}} {{Time}} diff --git a/contracts/utils/draft-InteroperableAddress.sol b/contracts/utils/draft-InteroperableAddress.sol new file mode 100644 index 000000000..fad27f716 --- /dev/null +++ b/contracts/utils/draft-InteroperableAddress.sol @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +import {Math} from "./math/Math.sol"; +import {SafeCast} from "./math/SafeCast.sol"; +import {Bytes} from "./Bytes.sol"; +import {Calldata} from "./Calldata.sol"; + +/** + * @dev Helper library to format and parse https://ethereum-magicians.org/t/erc-7930-interoperable-addresses/23365[ERC-7930] interoperable + * addresses. + */ +library InteroperableAddress { + using SafeCast for uint256; + using Bytes for bytes; + + error InteroperableAddressParsingError(bytes); + error InteroperableAddressEmptyReferenceAndAddress(); + + /** + * @dev Format an ERC-7930 interoperable address (version 1) from its components `chainType`, `chainReference` + * and `addr`. This is a generic function that supports any chain type, chain reference and address supported by + * ERC-7390, including interoperable addresses with empty chain reference or empty address. + */ + function formatV1( + bytes2 chainType, + bytes memory chainReference, + bytes memory addr + ) internal pure returns (bytes memory) { + require(chainReference.length > 0 || addr.length > 0, InteroperableAddressEmptyReferenceAndAddress()); + return + abi.encodePacked( + bytes2(0x0001), + chainType, + chainReference.length.toUint8(), + chainReference, + addr.length.toUint8(), + addr + ); + } + + /** + * @dev Variant of {formatV1-bytes2-bytes-bytes-} specific to EVM chains. Returns the ERC-7930 interoperable + * address (version 1) for a given chainid and ethereum address. + */ + function formatEvmV1(uint256 chainid, address addr) internal pure returns (bytes memory) { + bytes memory chainReference = _toChainReference(chainid); + return abi.encodePacked(bytes4(0x00010000), uint8(chainReference.length), chainReference, uint8(20), addr); + } + + /** + * @dev Variant of {formatV1-bytes2-bytes-bytes-} that specifies an EVM chain without an address. + */ + function formatEvmV1(uint256 chainid) internal pure returns (bytes memory) { + bytes memory chainReference = _toChainReference(chainid); + return abi.encodePacked(bytes4(0x00010000), uint8(chainReference.length), chainReference, uint8(0)); + } + + /** + * @dev Variant of {formatV1-bytes2-bytes-bytes-} that specifies an EVM address without a chain reference. + */ + function formatEvmV1(address addr) internal pure returns (bytes memory) { + return abi.encodePacked(bytes6(0x000100000014), addr); + } + + /** + * @dev Parse a ERC-7930 interoperable address (version 1) into its different components. Reverts if the input is + * not following a version 1 of ERC-7930 + */ + function parseV1( + bytes memory self + ) internal pure returns (bytes2 chainType, bytes memory chainReference, bytes memory addr) { + bool success; + (success, chainType, chainReference, addr) = tryParseV1(self); + require(success, InteroperableAddressParsingError(self)); + } + + /** + * @dev Variant of {parseV1} that handles calldata slices to reduce memory copy costs. + */ + function parseV1Calldata( + bytes calldata self + ) internal pure returns (bytes2 chainType, bytes calldata chainReference, bytes calldata addr) { + bool success; + (success, chainType, chainReference, addr) = tryParseV1Calldata(self); + require(success, InteroperableAddressParsingError(self)); + } + + /** + * @dev Variant of {parseV1} that does not revert on invalid input. Instead, it returns `false` as the first + * return value to indicate parsing failure when the input does not follow version 1 of ERC-7930. + */ + function tryParseV1( + bytes memory self + ) internal pure returns (bool success, bytes2 chainType, bytes memory chainReference, bytes memory addr) { + unchecked { + success = true; + if (self.length < 0x06) return (false, 0x0000, _emptyBytesMemory(), _emptyBytesMemory()); + + bytes2 version = _readBytes2(self, 0x00); + if (version != bytes2(0x0001)) return (false, 0x0000, _emptyBytesMemory(), _emptyBytesMemory()); + chainType = _readBytes2(self, 0x02); + + uint8 chainReferenceLength = uint8(self[0x04]); + if (self.length < 0x06 + chainReferenceLength) + return (false, 0x0000, _emptyBytesMemory(), _emptyBytesMemory()); + chainReference = self.slice(0x05, 0x05 + chainReferenceLength); + + uint8 addrLength = uint8(self[0x05 + chainReferenceLength]); + if (self.length < 0x06 + chainReferenceLength + addrLength) + return (false, 0x0000, _emptyBytesMemory(), _emptyBytesMemory()); + addr = self.slice(0x06 + chainReferenceLength, 0x06 + chainReferenceLength + addrLength); + } + } + + /** + * @dev Variant of {tryParseV1} that handles calldata slices to reduce memory copy costs. + */ + function tryParseV1Calldata( + bytes calldata self + ) internal pure returns (bool success, bytes2 chainType, bytes calldata chainReference, bytes calldata addr) { + unchecked { + success = true; + if (self.length < 0x06) return (false, 0x0000, Calldata.emptyBytes(), Calldata.emptyBytes()); + + bytes2 version = _readBytes2Calldata(self, 0x00); + if (version != bytes2(0x0001)) return (false, 0x0000, Calldata.emptyBytes(), Calldata.emptyBytes()); + chainType = _readBytes2Calldata(self, 0x02); + + uint8 chainReferenceLength = uint8(self[0x04]); + if (self.length < 0x06 + chainReferenceLength) + return (false, 0x0000, Calldata.emptyBytes(), Calldata.emptyBytes()); + chainReference = self[0x05:0x05 + chainReferenceLength]; + + uint8 addrLength = uint8(self[0x05 + chainReferenceLength]); + if (self.length < 0x06 + chainReferenceLength + addrLength) + return (false, 0x0000, Calldata.emptyBytes(), Calldata.emptyBytes()); + addr = self[0x06 + chainReferenceLength:0x06 + chainReferenceLength + addrLength]; + } + } + + /** + * @dev Parse a ERC-7930 interoperable address (version 1) corresponding to an EIP-155 chain. The `chainId` and + * `addr` return values will be zero if the input doesn't include a chainReference or an address, respectively. + * + * Requirements: + * + * * The input must be a valid ERC-7930 interoperable address (version 1) + * * The underlying chainType must be "eip-155" + */ + function parseEvmV1(bytes memory self) internal pure returns (uint256 chainId, address addr) { + bool success; + (success, chainId, addr) = tryParseEvmV1(self); + require(success, InteroperableAddressParsingError(self)); + } + + /** + * @dev Variant of {parseEvmV1} that handles calldata slices to reduce memory copy costs. + */ + function parseEvmV1Calldata(bytes calldata self) internal pure returns (uint256 chainId, address addr) { + bool success; + (success, chainId, addr) = tryParseEvmV1Calldata(self); + require(success, InteroperableAddressParsingError(self)); + } + + /** + * @dev Variant of {parseEvmV1} that does not revert on invalid input. Instead, it returns `false` as the first + * return value to indicate parsing failure when the input does not follow version 1 of ERC-7930. + */ + function tryParseEvmV1(bytes memory self) internal pure returns (bool success, uint256 chainId, address addr) { + (bool success_, bytes2 chainType_, bytes memory chainReference_, bytes memory addr_) = tryParseV1(self); + return + (success_ && + chainType_ == 0x0000 && + chainReference_.length < 33 && + (addr_.length == 0 || addr_.length == 20)) + ? ( + true, + uint256(bytes32(chainReference_)) >> (256 - 8 * chainReference_.length), + address(bytes20(addr_)) + ) + : (false, 0, address(0)); + } + + /** + * @dev Variant of {tryParseEvmV1} that handles calldata slices to reduce memory copy costs. + */ + function tryParseEvmV1Calldata( + bytes calldata self + ) internal pure returns (bool success, uint256 chainId, address addr) { + (bool success_, bytes2 chainType_, bytes calldata chainReference_, bytes calldata addr_) = tryParseV1Calldata( + self + ); + return + (success_ && + chainType_ == 0x0000 && + chainReference_.length < 33 && + (addr_.length == 0 || addr_.length == 20)) + ? ( + true, + uint256(bytes32(chainReference_)) >> (256 - 8 * chainReference_.length), + address(bytes20(addr_)) + ) + : (false, 0, address(0)); + } + + function _toChainReference(uint256 chainid) private pure returns (bytes memory) { + unchecked { + // length fits in a uint8: log256(type(uint256).max) is 31 + uint256 length = Math.log256(chainid) + 1; + return abi.encodePacked(chainid).slice(32 - length); + } + } + + function _readBytes2(bytes memory buffer, uint256 offset) private pure returns (bytes2 value) { + // This is not memory safe in the general case, but all calls to this private function are within bounds. + assembly ("memory-safe") { + value := shl(240, shr(240, mload(add(add(buffer, 0x20), offset)))) + } + } + + function _readBytes2Calldata(bytes calldata buffer, uint256 offset) private pure returns (bytes2 value) { + assembly ("memory-safe") { + value := shl(240, shr(240, calldataload(add(buffer.offset, offset)))) + } + } + + function _emptyBytesMemory() private pure returns (bytes memory result) { + assembly ("memory-safe") { + result := 0x60 // mload(0x60) is always 0 + } + } +} diff --git a/package-lock.json b/package-lock.json index 2a427c695..6ab071101 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "hardhat-gas-reporter": "^2.1.0", "hardhat-ignore-warnings": "^0.2.11", "husky": "^9.1.7", + "interoperable-addresses": "^0.1.3", "lint-staged": "^16.0.0", "lodash.startcase": "^4.4.0", "micromatch": "^4.0.2", @@ -2189,9 +2190,9 @@ } }, "node_modules/@scure/base": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.5.tgz", - "integrity": "sha512-9rE6EOVeIQzt5TSu4v+K523F8u6DhBsoZWPGKlnCshhlDhy0kJzUX4V+tr2dWmzF1GdekvThABoEQBGBQI7xZw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", "dev": true, "license": "MIT", "funding": { @@ -6272,6 +6273,18 @@ "dev": true, "license": "ISC" }, + "node_modules/interoperable-addresses": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/interoperable-addresses/-/interoperable-addresses-0.1.3.tgz", + "integrity": "sha512-0URwTYBZzjg+BsjEssrvxQPB2XHYVIXLcMjUR0Bd4IoINQHgmR5nbHAOSsCAAR32FsrN1VLZNaqPl8ijQzIMVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@scure/base": "^1.2.6", + "ethereum-cryptography": "^3.0.0", + "typed-regex": "^0.0.8" + } + }, "node_modules/interpret": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", @@ -10093,6 +10106,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typed-regex": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/typed-regex/-/typed-regex-0.0.8.tgz", + "integrity": "sha512-1XkGm1T/rUngbFROIOw9wPnMAKeMsRoc+c9O6GwOHz6aH/FrJFtcyd2sHASbT0OXeGLot5N1shPNpwHGTv9RdQ==", + "dev": true, + "license": "MIT" + }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", diff --git a/package.json b/package.json index be69d10db..c9e949038 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "hardhat-gas-reporter": "^2.1.0", "hardhat-ignore-warnings": "^0.2.11", "husky": "^9.1.7", + "interoperable-addresses": "^0.1.3", "lint-staged": "^16.0.0", "lodash.startcase": "^4.4.0", "micromatch": "^4.0.2", diff --git a/test/helpers/chains.js b/test/helpers/chains.js index 3711a8125..b0cbf35a0 100644 --- a/test/helpers/chains.js +++ b/test/helpers/chains.js @@ -1,109 +1,56 @@ -// NOTE: this file defines some examples of CAIP-2 and CAIP-10 identifiers. // The following listing does not pretend to be exhaustive or even accurate. It SHOULD NOT be used in production. const { ethers } = require('hardhat'); const { mapValues } = require('./iterate'); +const { addressCoder } = require('interoperable-addresses'); + // EVM (https://axelarscan.io/resources/chains?type=evm) const ethereum = { - Ethereum: '1', - optimism: '10', - binance: '56', - Polygon: '137', - Fantom: '250', - fraxtal: '252', - filecoin: '314', - Moonbeam: '1284', - centrifuge: '2031', - kava: '2222', - mantle: '5000', - base: '8453', - immutable: '13371', - arbitrum: '42161', - celo: '42220', - Avalanche: '43114', - linea: '59144', - blast: '81457', - scroll: '534352', - aurora: '1313161554', + Ethereum: 1n, + optimism: 10n, + binance: 56n, + Polygon: 137n, + Fantom: 250n, + fraxtal: 252n, + filecoin: 314n, + Moonbeam: 1284n, + centrifuge: 2031n, + kava: 2222n, + mantle: 5000n, + base: 8453n, + immutable: 13371n, + arbitrum: 42161n, + celo: 42220n, + Avalanche: 43114n, + linea: 59144n, + blast: 81457n, + scroll: 534352n, + aurora: 1313161554n, }; -// Cosmos (https://axelarscan.io/resources/chains?type=cosmos) -const cosmos = { - Axelarnet: 'axelar-dojo-1', - osmosis: 'osmosis-1', - cosmoshub: 'cosmoshub-4', - juno: 'juno-1', - 'e-money': 'emoney-3', - injective: 'injective-1', - crescent: 'crescent-1', - kujira: 'kaiyo-1', - 'secret-snip': 'secret-4', - secret: 'secret-4', - sei: 'pacific-1', - stargaze: 'stargaze-1', - assetmantle: 'mantle-1', - fetch: 'fetchhub-4', - ki: 'kichain-2', - evmos: 'evmos_9001-2', - aura: 'xstaxy-1', - comdex: 'comdex-1', - persistence: 'core-1', - regen: 'regen-1', - umee: 'umee-1', - agoric: 'agoric-3', - xpla: 'dimension_37-1', - acre: 'acre_9052-1', - stride: 'stride-1', - carbon: 'carbon-1', - sommelier: 'sommelier-3', - neutron: 'neutron-1', - rebus: 'reb_1111-1', - archway: 'archway-1', - provenance: 'pio-mainnet-1', - ixo: 'ixo-5', - migaloo: 'migaloo-1', - teritori: 'teritori-1', - haqq: 'haqq_11235-1', - celestia: 'celestia', - ojo: 'agamotto', - chihuahua: 'chihuahua-1', - saga: 'ssc-1', - dymension: 'dymension_1100-1', - fxcore: 'fxcore', - c4e: 'perun-1', - bitsong: 'bitsong-2b', - nolus: 'pirin-1', - lava: 'lava-mainnet-1', - 'terra-2': 'phoenix-1', - terra: 'columbus-5', +const solana = { + Mainnet: '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d', }; -const makeCAIP = ({ namespace, reference, account }) => ({ +const format = ({ namespace, reference }) => ({ namespace, - reference, - account, + reference: reference.toString(), caip2: `${namespace}:${reference}`, - caip10: `${namespace}:${reference}:${account}`, + erc7930: addressCoder.encode({ chainType: namespace, reference }), toCaip10: other => `${namespace}:${reference}:${ethers.getAddress(other.target ?? other.address ?? other)}`, + toErc7930: other => + addressCoder.encode({ chainType: namespace, reference, address: other.target ?? other.address ?? other }), }); module.exports = { CHAINS: mapValues( Object.assign( - mapValues(ethereum, reference => ({ - namespace: 'eip155', - reference, - account: ethers.Wallet.createRandom().address, - })), - mapValues(cosmos, reference => ({ - namespace: 'cosmos', - reference, - account: ethers.encodeBase58(ethers.randomBytes(32)), - })), + mapValues(ethereum, reference => ({ namespace: 'eip155', reference })), + mapValues(solana, reference => ({ namespace: 'solana', reference })), ), - makeCAIP, + format, ), - getLocalCAIP: account => - ethers.provider.getNetwork().then(({ chainId }) => makeCAIP({ namespace: 'eip155', reference: chainId, account })), + getLocalChain: () => + ethers.provider.getNetwork().then(({ chainId }) => format({ namespace: 'eip155', reference: chainId })), }; diff --git a/test/utils/CAIP.test.js b/test/utils/CAIP.test.js index cd5995cad..bb0bae023 100644 --- a/test/utils/CAIP.test.js +++ b/test/utils/CAIP.test.js @@ -1,53 +1,56 @@ const { ethers } = require('hardhat'); const { expect } = require('chai'); -const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); -const { CHAINS, getLocalCAIP } = require('../helpers/chains'); - -async function fixture() { - const caip2 = await ethers.deployContract('$CAIP2'); - const caip10 = await ethers.deployContract('$CAIP10'); - return { caip2, caip10 }; -} +const { CHAINS, getLocalChain } = require('../helpers/chains'); describe('CAIP utilities', function () { - beforeEach(async function () { - Object.assign(this, await loadFixture(fixture)); + before(async function () { + this.local = await getLocalChain(); }); describe('CAIP-2', function () { - it('local()', async function () { - const { caip2 } = await getLocalCAIP(); - expect(await this.caip2.$local()).to.equal(caip2); + before(async function () { + this.mock = await ethers.deployContract('$CAIP2'); }); - for (const { namespace, reference, caip2 } of Object.values(CHAINS)) + it('local()', async function () { + const { caip2 } = this.local; + expect(await this.mock.$local()).to.equal(caip2); + }); + + for (const { namespace, reference, caip2 } of Object.values(CHAINS)) { it(`format(${namespace}, ${reference})`, async function () { - expect(await this.caip2.$format(namespace, reference)).to.equal(caip2); + expect(await this.mock.$format(namespace, reference)).to.equal(caip2); }); - for (const { namespace, reference, caip2 } of Object.values(CHAINS)) it(`parse(${caip2})`, async function () { - expect(await this.caip2.$parse(caip2)).to.deep.equal([namespace, reference]); + expect(await this.mock.$parse(caip2)).to.deep.equal([namespace, reference]); }); + } }); describe('CAIP-10', function () { const { address: account } = ethers.Wallet.createRandom(); - it(`local(${account})`, async function () { - const { caip10 } = await getLocalCAIP(account); - expect(await this.caip10.$local(ethers.Typed.address(account))).to.equal(caip10); + before(async function () { + this.mock = await ethers.deployContract('$CAIP10'); }); - for (const { account, caip2, caip10 } of Object.values(CHAINS)) + it(`local(${account})`, async function () { + const caip10 = this.local.toCaip10(account); + expect(await this.mock.$local(ethers.Typed.address(account))).to.equal(caip10); + }); + + for (const { caip2, toCaip10 } of Object.values(CHAINS)) { + const caip10 = toCaip10(account); + it(`format(${caip2}, ${account})`, async function () { - expect(await this.caip10.$format(ethers.Typed.string(caip2), ethers.Typed.string(account))).to.equal(caip10); + expect(await this.mock.$format(ethers.Typed.string(caip2), ethers.Typed.string(account))).to.equal(caip10); }); - for (const { account, caip2, caip10 } of Object.values(CHAINS)) it(`parse(${caip10})`, async function () { - expect(await this.caip10.$parse(caip10)).to.deep.equal([caip2, account]); + expect(await this.mock.$parse(caip10)).to.deep.equal([caip2, account]); }); + } }); }); diff --git a/test/utils/draft-InteroperableAddress.t.sol b/test/utils/draft-InteroperableAddress.t.sol new file mode 100644 index 000000000..8fdf400d1 --- /dev/null +++ b/test/utils/draft-InteroperableAddress.t.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {InteroperableAddress} from "../../contracts/utils/draft-InteroperableAddress.sol"; + +contract InteroperableAddressTest is Test { + using InteroperableAddress for bytes; + + function testFormatParse(bytes2 chainType, bytes calldata chainReference, bytes calldata addr) public view { + vm.assume(chainReference.length > 0 || addr.length > 0); + { + (bytes2 chainType_, bytes memory chainReference_, bytes memory addr_) = InteroperableAddress + .formatV1(chainType, chainReference, addr) + .parseV1(); + assertEq(chainType, chainType_); + assertEq(chainReference, chainReference_); + assertEq(addr, addr_); + } + { + (bool success, bytes2 chainType_, bytes memory chainReference_, bytes memory addr_) = InteroperableAddress + .formatV1(chainType, chainReference, addr) + .tryParseV1(); + assertTrue(success); + assertEq(chainType, chainType_); + assertEq(chainReference, chainReference_); + assertEq(addr, addr_); + } + { + (bytes2 chainType_, bytes memory chainReference_, bytes memory addr_) = this.parseV1Calldata( + InteroperableAddress.formatV1(chainType, chainReference, addr) + ); + assertEq(chainType, chainType_); + assertEq(chainReference, chainReference_); + assertEq(addr, addr_); + } + { + (bool success, bytes2 chainType_, bytes memory chainReference_, bytes memory addr_) = this + .tryParseV1Calldata(InteroperableAddress.formatV1(chainType, chainReference, addr)); + assertTrue(success); + assertEq(chainType, chainType_); + assertEq(chainReference, chainReference_); + assertEq(addr, addr_); + } + } + + function testFormatParseEVM(uint256 chainid, address addr) public view { + { + (uint256 chainid_, address addr_) = InteroperableAddress.formatEvmV1(chainid, addr).parseEvmV1(); + assertEq(chainid, chainid_); + assertEq(addr, addr_); + } + { + (bool success, uint256 chainid_, address addr_) = InteroperableAddress + .formatEvmV1(chainid, addr) + .tryParseEvmV1(); + assertTrue(success); + assertEq(chainid, chainid_); + assertEq(addr, addr_); + } + { + (uint256 chainid_, address addr_) = this.parseEvmV1Calldata( + InteroperableAddress.formatEvmV1(chainid, addr) + ); + assertEq(chainid, chainid_); + assertEq(addr, addr_); + } + { + (bool success, uint256 chainid_, address addr_) = this.tryParseEvmV1Calldata( + InteroperableAddress.formatEvmV1(chainid, addr) + ); + assertTrue(success); + assertEq(chainid, chainid_); + assertEq(addr, addr_); + } + } + + function parseV1Calldata( + bytes calldata self + ) external pure returns (bytes2 chainType, bytes calldata chainReference, bytes calldata addr) { + return self.parseV1Calldata(); + } + + function tryParseV1Calldata( + bytes calldata self + ) external pure returns (bool success, bytes2 chainType, bytes calldata chainReference, bytes calldata addr) { + return self.tryParseV1Calldata(); + } + + function parseEvmV1Calldata(bytes calldata self) external pure returns (uint256 chainid, address addr) { + return self.parseEvmV1Calldata(); + } + + function tryParseEvmV1Calldata( + bytes calldata self + ) external pure returns (bool success, uint256 chainid, address addr) { + return self.tryParseEvmV1Calldata(); + } +} diff --git a/test/utils/draft-InteroperableAddress.test.js b/test/utils/draft-InteroperableAddress.test.js new file mode 100644 index 000000000..f7d5a1d9b --- /dev/null +++ b/test/utils/draft-InteroperableAddress.test.js @@ -0,0 +1,170 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { addressCoder, nameCoder } = require('interoperable-addresses'); +const { CAIP350, chainTypeCoder } = require('interoperable-addresses/dist/CAIP350'); + +const { getLocalChain } = require('../helpers/chains'); + +async function fixture() { + const mock = await ethers.deployContract('$InteroperableAddress'); + return { mock }; +} + +describe('ERC7390', function () { + before(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + it('formatEvmV1 address on the local chain', async function () { + const { reference: chainid, toErc7930 } = await getLocalChain(); + await expect( + this.mock.$formatEvmV1(ethers.Typed.uint256(chainid), ethers.Typed.address(this.mock)), + ).to.eventually.equal(toErc7930(this.mock)); + }); + + it('formatV1 fails if both reference and address are empty', async function () { + await expect(this.mock.$formatV1('0x0000', '0x', '0x')).to.be.revertedWithCustomError( + this.mock, + 'InteroperableAddressEmptyReferenceAndAddress', + ); + }); + + describe('reference examples', function () { + for (const { title, name } of [ + { + title: 'Example 1: Ethereum mainnet address', + name: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045@eip155:1#4CA88C9C', + }, + { + title: 'Example 2: Solana mainnet address', + name: 'MJKqp326RZCHnAAbew9MDdui3iCKWco7fsK9sVuZTX2@solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d#88835C11', + }, + { + title: 'Example 3: EVM address without chainid', + name: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045@eip155#B26DB7CB', + }, + { + title: 'Example 4: Solana mainnet network, no address', + name: '@solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d#2EB18670', + }, + { + title: 'Example 5: Arbitrum One address', + name: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045@eip155:42161#D2E02854', + }, + { + title: 'Example 6: Ethereum mainnet, no address', + name: '@eip155:1#F54D4FBF', + }, + ]) { + const { chainType, reference, address } = nameCoder.decode(name, true); + const binary = addressCoder.encode({ chainType, reference, address }); + + it(title, async function () { + const expected = [ + chainTypeCoder.decode(chainType), + CAIP350[chainType].reference.decode(reference), + CAIP350[chainType].address.decode(address), + ].map(ethers.hexlify); + + await expect(this.mock.$parseV1(binary)).to.eventually.deep.equal(expected); + await expect(this.mock.$parseV1Calldata(binary)).to.eventually.deep.equal(expected); + await expect(this.mock.$tryParseV1(binary)).to.eventually.deep.equal([true, ...expected]); + await expect(this.mock.$tryParseV1Calldata(binary)).to.eventually.deep.equal([true, ...expected]); + await expect(this.mock.$formatV1(...expected)).to.eventually.equal(binary); + + if (chainType == 'eip155') { + await expect(this.mock.$parseEvmV1(binary)).to.eventually.deep.equal([ + reference ?? 0n, + address ?? ethers.ZeroAddress, + ]); + await expect(this.mock.$parseEvmV1Calldata(binary)).to.eventually.deep.equal([ + reference ?? 0n, + address ?? ethers.ZeroAddress, + ]); + await expect(this.mock.$tryParseEvmV1(binary)).to.eventually.deep.equal([ + true, + reference ?? 0n, + address ?? ethers.ZeroAddress, + ]); + await expect(this.mock.$tryParseEvmV1Calldata(binary)).to.eventually.deep.equal([ + true, + reference ?? 0n, + address ?? ethers.ZeroAddress, + ]); + + if (!address) { + await expect(this.mock.$formatEvmV1(ethers.Typed.uint256(reference))).to.eventually.equal( + binary.toLowerCase(), + ); + } else if (!reference) { + await expect(this.mock.$formatEvmV1(ethers.Typed.address(address))).to.eventually.equal( + binary.toLowerCase(), + ); + } else { + await expect( + this.mock.$formatEvmV1(ethers.Typed.uint256(reference), ethers.Typed.address(address)), + ).to.eventually.equal(binary.toLowerCase()); + } + } + }); + } + }); + + describe('invalid format', function () { + for (const [title, binary] of Object.entries({ + // version 2 + some data + 'unsupported version': '0x00020000010100', + // version + ref: missing chainReferenceLength and addressLength + 'too short (case 1)': '0x00010000', + // version + ref + chainReference: missing addressLength + 'too short (case 2)': '0x000100000101', + // version + ref + chainReference + addressLength + part of the address: missing 2 bytes of the address + 'too short (case 3)': '0x00010000010114d8da6bf26964af9d7eed9e03e53415d37aa9', + })) { + it(title, async function () { + await expect(this.mock.$parseV1(binary)) + .to.be.revertedWithCustomError(this.mock, 'InteroperableAddressParsingError') + .withArgs(binary); + await expect(this.mock.$parseV1Calldata(binary)) + .to.be.revertedWithCustomError(this.mock, 'InteroperableAddressParsingError') + .withArgs(binary); + await expect(this.mock.$parseEvmV1(binary)) + .to.be.revertedWithCustomError(this.mock, 'InteroperableAddressParsingError') + .withArgs(binary); + await expect(this.mock.$parseEvmV1Calldata(binary)) + .to.be.revertedWithCustomError(this.mock, 'InteroperableAddressParsingError') + .withArgs(binary); + await expect(this.mock.$tryParseV1(binary)).to.eventually.deep.equal([false, '0x0000', '0x', '0x']); + await expect(this.mock.$tryParseV1Calldata(binary)).to.eventually.deep.equal([false, '0x0000', '0x', '0x']); + await expect(this.mock.$tryParseEvmV1(binary)).to.eventually.deep.equal([false, 0n, ethers.ZeroAddress]); + await expect(this.mock.$tryParseEvmV1Calldata(binary)).to.eventually.deep.equal([ + false, + 0n, + ethers.ZeroAddress, + ]); + }); + } + + for (const [title, binary] of Object.entries({ + 'not an evm format: chainid too long': + '0x00010000212dc7f03c13ad47809e88339107c33a612043d704c1c9693a74996e7f9c6bee8f2314d8da6bf26964af9d7eed9e03e53415d37aa96045', + 'not an evm format: address in not 20 bytes': '0x00010000010112d8da6bf26964af9d7eed9e03e53415d37aa9', + })) { + it(title, async function () { + await expect(this.mock.$parseEvmV1(binary)) + .to.be.revertedWithCustomError(this.mock, 'InteroperableAddressParsingError') + .withArgs(binary); + await expect(this.mock.$parseEvmV1Calldata(binary)) + .to.be.revertedWithCustomError(this.mock, 'InteroperableAddressParsingError') + .withArgs(binary); + await expect(this.mock.$tryParseEvmV1(binary)).to.eventually.deep.equal([false, 0n, ethers.ZeroAddress]); + await expect(this.mock.$tryParseEvmV1Calldata(binary)).to.eventually.deep.equal([ + false, + 0n, + ethers.ZeroAddress, + ]); + }); + } + }); +});