Parcourir la source

ERC-7930 (#5736)

Co-authored-by: Ernesto García <ernestognw@gmail.com>
Hadrien Croubois il y a 3 mois
Parent
commit
2909098755

+ 5 - 0
.changeset/rich-cows-repair.md

@@ -0,0 +1,5 @@
+---
+'openzeppelin-solidity': minor
+---
+
+`InteroperableAddress`: Add a library for formatting and parsing ERC-7930 interoperable addresses.

+ 2 - 1
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";

+ 4 - 1
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}}

+ 234 - 0
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
+        }
+    }
+}

+ 23 - 3
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",

+ 1 - 0
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",

+ 34 - 87
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 })),
 };

+ 25 - 22
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 () {
+    before(async function () {
+      this.mock = await ethers.deployContract('$CAIP2');
+    });
+
     it('local()', async function () {
-      const { caip2 } = await getLocalCAIP();
-      expect(await this.caip2.$local()).to.equal(caip2);
+      const { caip2 } = this.local;
+      expect(await this.mock.$local()).to.equal(caip2);
     });
 
-    for (const { namespace, reference, caip2 } of Object.values(CHAINS))
+    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();
 
+    before(async function () {
+      this.mock = await ethers.deployContract('$CAIP10');
+    });
+
     it(`local(${account})`, async function () {
-      const { caip10 } = await getLocalCAIP(account);
-      expect(await this.caip10.$local(ethers.Typed.address(account))).to.equal(caip10);
+      const caip10 = this.local.toCaip10(account);
+      expect(await this.mock.$local(ethers.Typed.address(account))).to.equal(caip10);
     });
 
-    for (const { account, caip2, caip10 } of Object.values(CHAINS))
+    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]);
       });
+    }
   });
 });

+ 99 - 0
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();
+    }
+}

+ 170 - 0
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,
+        ]);
+      });
+    }
+  });
+});