Jelajahi Sumber

Bytes library and CAIP2/CAIP10 helpers (#5252)

Co-authored-by: cairo <cairoeth@protonmail.com>
Co-authored-by: Ernesto García <ernestognw@gmail.com>
Co-authored-by: Arr00 <13561405+arr00@users.noreply.github.com>
Hadrien Croubois 11 bulan lalu
induk
melakukan
fe6249ec2c

+ 5 - 0
.changeset/healthy-books-shout.md

@@ -0,0 +1,5 @@
+---
+'openzeppelin-solidity': minor
+---
+
+`CAIP2` and `CAIP10`: Add libraries for formatting and parsing CAIP-2 and CAIP-10 identifiers.

+ 5 - 0
.changeset/proud-planes-arrive.md

@@ -0,0 +1,5 @@
+---
+'openzeppelin-solidity': minor
+---
+
+`Bytes`: Add a library of common operation that operate on `bytes` objects.

+ 3 - 0
contracts/mocks/Stateless.sol

@@ -9,6 +9,9 @@ import {Arrays} from "../utils/Arrays.sol";
 import {AuthorityUtils} from "../access/manager/AuthorityUtils.sol";
 import {Base64} from "../utils/Base64.sol";
 import {BitMaps} from "../utils/structs/BitMaps.sol";
+import {Bytes} from "../utils/Bytes.sol";
+import {CAIP2} from "../utils/CAIP2.sol";
+import {CAIP10} from "../utils/CAIP10.sol";
 import {Checkpoints} from "../utils/structs/Checkpoints.sol";
 import {CircularBuffer} from "../utils/structs/CircularBuffer.sol";
 import {Clones} from "../proxy/Clones.sol";

+ 115 - 0
contracts/utils/Bytes.sol

@@ -0,0 +1,115 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.20;
+
+import {Math} from "./math/Math.sol";
+
+/**
+ * @dev Bytes operations.
+ */
+library Bytes {
+    /**
+     * @dev Forward search for `s` in `buffer`
+     * * If `s` is present in the buffer, returns the index of the first instance
+     * * If `s` is not present in the buffer, returns type(uint256).max
+     *
+     * NOTE: replicates the behavior of https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/indexOf[Javascript's `Array.indexOf`]
+     */
+    function indexOf(bytes memory buffer, bytes1 s) internal pure returns (uint256) {
+        return indexOf(buffer, s, 0);
+    }
+
+    /**
+     * @dev Forward search for `s` in `buffer` starting at position `pos`
+     * * If `s` is present in the buffer (at or after `pos`), returns the index of the next instance
+     * * If `s` is not present in the buffer (at or after `pos`), returns type(uint256).max
+     *
+     * NOTE: replicates the behavior of https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/indexOf[Javascript's `Array.indexOf`]
+     */
+    function indexOf(bytes memory buffer, bytes1 s, uint256 pos) internal pure returns (uint256) {
+        unchecked {
+            uint256 length = buffer.length;
+            for (uint256 i = pos; i < length; ++i) {
+                if (bytes1(_unsafeReadBytesOffset(buffer, i)) == s) {
+                    return i;
+                }
+            }
+            return type(uint256).max;
+        }
+    }
+
+    /**
+     * @dev Backward search for `s` in `buffer`
+     * * If `s` is present in the buffer, returns the index of the last instance
+     * * If `s` is not present in the buffer, returns type(uint256).max
+     *
+     * NOTE: replicates the behavior of https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/lastIndexOf[Javascript's `Array.lastIndexOf`]
+     */
+    function lastIndexOf(bytes memory buffer, bytes1 s) internal pure returns (uint256) {
+        return lastIndexOf(buffer, s, type(uint256).max);
+    }
+
+    /**
+     * @dev Backward search for `s` in `buffer` starting at position `pos`
+     * * If `s` is present in the buffer (at or before `pos`), returns the index of the previous instance
+     * * If `s` is not present in the buffer (at or before `pos`), returns type(uint256).max
+     *
+     * NOTE: replicates the behavior of https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/lastIndexOf[Javascript's `Array.lastIndexOf`]
+     */
+    function lastIndexOf(bytes memory buffer, bytes1 s, uint256 pos) internal pure returns (uint256) {
+        unchecked {
+            uint256 length = buffer.length;
+            // NOTE here we cannot do `i = Math.min(pos + 1, length)` because `pos + 1` could overflow
+            for (uint256 i = Math.min(pos, length - 1) + 1; i > 0; --i) {
+                if (bytes1(_unsafeReadBytesOffset(buffer, i - 1)) == s) {
+                    return i - 1;
+                }
+            }
+            return type(uint256).max;
+        }
+    }
+
+    /**
+     * @dev Copies the content of `buffer`, from `start` (included) to the end of `buffer` into a new bytes object in
+     * memory.
+     *
+     * NOTE: replicates the behavior of https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/slice[Javascript's `Array.slice`]
+     */
+    function slice(bytes memory buffer, uint256 start) internal pure returns (bytes memory) {
+        return slice(buffer, start, buffer.length);
+    }
+
+    /**
+     * @dev Copies the content of `buffer`, from `start` (included) to `end` (excluded) into a new bytes object in
+     * memory.
+     *
+     * NOTE: replicates the behavior of https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/slice[Javascript's `Array.slice`]
+     */
+    function slice(bytes memory buffer, uint256 start, uint256 end) internal pure returns (bytes memory) {
+        // sanitize
+        uint256 length = buffer.length;
+        end = Math.min(end, length);
+        start = Math.min(start, end);
+
+        // allocate and copy
+        bytes memory result = new bytes(end - start);
+        assembly ("memory-safe") {
+            mcopy(add(result, 0x20), add(buffer, add(start, 0x20)), sub(end, start))
+        }
+
+        return result;
+    }
+
+    /**
+     * @dev Reads a bytes32 from a bytes array without bounds checking.
+     *
+     * NOTE: making this function internal would mean it could be used with memory unsafe offset, and marking the
+     * assembly block as such would prevent some optimizations.
+     */
+    function _unsafeReadBytesOffset(bytes memory buffer, uint256 offset) private pure returns (bytes32 value) {
+        // This is not memory safe in the general case, but all calls to this private function are within bounds.
+        assembly ("memory-safe") {
+            value := mload(add(buffer, add(0x20, offset)))
+        }
+    }
+}

+ 49 - 0
contracts/utils/CAIP10.sol

@@ -0,0 +1,49 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.20;
+
+import {SafeCast} from "./math/SafeCast.sol";
+import {Bytes} from "./Bytes.sol";
+import {CAIP2} from "./CAIP2.sol";
+import {Strings} from "./Strings.sol";
+
+/**
+ * @dev Helper library to format and parse CAIP-10 identifiers
+ *
+ * https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-10.md[CAIP-10] defines account identifiers as:
+ * account_id:        chain_id + ":" + account_address
+ * chain_id:          [-a-z0-9]{3,8}:[-_a-zA-Z0-9]{1,32} (See {CAIP2})
+ * account_address:   [-.%a-zA-Z0-9]{1,128}
+ */
+library CAIP10 {
+    using SafeCast for uint256;
+    using Strings for address;
+    using Bytes for bytes;
+
+    /// @dev Return the CAIP-10 identifier for an account on the current (local) chain.
+    function local(address account) internal view returns (string memory) {
+        return format(CAIP2.local(), account.toChecksumHexString());
+    }
+
+    /**
+     * @dev Return the CAIP-10 identifier for a given caip2 chain and account.
+     *
+     * NOTE: This function does not verify that the inputs are properly formatted.
+     */
+    function format(string memory caip2, string memory account) internal pure returns (string memory) {
+        return string.concat(caip2, ":", account);
+    }
+
+    /**
+     * @dev Parse a CAIP-10 identifier into its components.
+     *
+     * NOTE: This function does not verify that the CAIP-10 input is properly formatted. The `caip2` return can be
+     * parsed using the {CAIP2} library.
+     */
+    function parse(string memory caip10) internal pure returns (string memory caip2, string memory account) {
+        bytes memory buffer = bytes(caip10);
+
+        uint256 pos = buffer.lastIndexOf(":");
+        return (string(buffer.slice(0, pos)), string(buffer.slice(pos + 1)));
+    }
+}

+ 47 - 0
contracts/utils/CAIP2.sol

@@ -0,0 +1,47 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.20;
+
+import {SafeCast} from "./math/SafeCast.sol";
+import {Bytes} from "./Bytes.sol";
+import {Strings} from "./Strings.sol";
+
+/**
+ * @dev Helper library to format and parse CAIP-2 identifiers
+ *
+ * https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md[CAIP-2] defines chain identifiers as:
+ * chain_id:    namespace + ":" + reference
+ * namespace:   [-a-z0-9]{3,8}
+ * reference:   [-_a-zA-Z0-9]{1,32}
+ */
+library CAIP2 {
+    using SafeCast for uint256;
+    using Strings for uint256;
+    using Bytes for bytes;
+
+    /// @dev Return the CAIP-2 identifier for the current (local) chain.
+    function local() internal view returns (string memory) {
+        return format("eip155", block.chainid.toString());
+    }
+
+    /**
+     * @dev Return the CAIP-2 identifier for a given namespace and reference.
+     *
+     * NOTE: This function does not verify that the inputs are properly formatted.
+     */
+    function format(string memory namespace, string memory ref) internal pure returns (string memory) {
+        return string.concat(namespace, ":", ref);
+    }
+
+    /**
+     * @dev Parse a CAIP-2 identifier into its components.
+     *
+     * NOTE: This function does not verify that the CAIP-2 input is properly formatted.
+     */
+    function parse(string memory caip2) internal pure returns (string memory namespace, string memory ref) {
+        bytes memory buffer = bytes(caip2);
+
+        uint256 pos = buffer.indexOf(":");
+        return (string(buffer.slice(0, pos)), string(buffer.slice(pos + 1)));
+    }
+}

+ 2 - 0
contracts/utils/README.adoc

@@ -31,6 +31,7 @@ Miscellaneous contracts and libraries containing utility functions you can use t
  * {Address}: Collection of functions for overloading Solidity's https://docs.soliditylang.org/en/latest/types.html#address[`address`] type.
  * {Arrays}: Collection of functions that operate on https://docs.soliditylang.org/en/latest/types.html#arrays[`arrays`].
  * {Base64}: On-chain base64 and base64URL encoding according to https://datatracker.ietf.org/doc/html/rfc4648[RFC-4648].
+ * {Bytes}: Common operations on bytes objects.
  * {Strings}: Common operations for strings formatting.
  * {ShortString}: Library to encode (and decode) short strings into (or from) a single bytes32 slot for optimizing costs. Short strings are limited to 31 characters.
  * {SlotDerivation}: Methods for deriving storage slot from ERC-7201 namespaces as well as from constructions such as mapping and arrays.
@@ -41,6 +42,7 @@ Miscellaneous contracts and libraries containing utility functions you can use t
  * {Packing}: A library for packing and unpacking multiple values into bytes32
  * {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 with the {Heap} library.
+ * {CAIP2}, {CAIP10}: Libraries for formatting and parsing CAIP-2 and CAIP-10 identifiers.
 
 [NOTE]
 ====

+ 109 - 0
test/helpers/chains.js

@@ -0,0 +1,109 @@
+// 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');
+
+// 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',
+};
+
+// 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 makeCAIP = ({ namespace, reference, account }) => ({
+  namespace,
+  reference,
+  account,
+  caip2: `${namespace}:${reference}`,
+  caip10: `${namespace}:${reference}:${account}`,
+  toCaip10: other => `${namespace}:${reference}:${ethers.getAddress(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)),
+      })),
+    ),
+    makeCAIP,
+  ),
+  getLocalCAIP: account =>
+    ethers.provider.getNetwork().then(({ chainId }) => makeCAIP({ namespace: 'eip155', reference: chainId, account })),
+};

+ 88 - 0
test/utils/Bytes.test.js

@@ -0,0 +1,88 @@
+const { ethers } = require('hardhat');
+const { expect } = require('chai');
+const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
+
+async function fixture() {
+  const mock = await ethers.deployContract('$Bytes');
+  return { mock };
+}
+
+const lorem = ethers.toUtf8Bytes(
+  'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
+);
+const present = lorem.at(1);
+const absent = 255;
+
+describe('Bytes', function () {
+  before(async function () {
+    Object.assign(this, await loadFixture(fixture));
+  });
+
+  describe('indexOf', function () {
+    it('first', async function () {
+      expect(await this.mock.$indexOf(lorem, ethers.toBeHex(present))).to.equal(lorem.indexOf(present));
+    });
+
+    it('from index', async function () {
+      for (const start in Array(lorem.length + 10).fill()) {
+        const index = lorem.indexOf(present, start);
+        const result = index === -1 ? ethers.MaxUint256 : index;
+        expect(await this.mock.$indexOf(lorem, ethers.toBeHex(present), ethers.Typed.uint256(start))).to.equal(result);
+      }
+    });
+
+    it('absent', async function () {
+      expect(await this.mock.$indexOf(lorem, ethers.toBeHex(absent))).to.equal(ethers.MaxUint256);
+    });
+  });
+
+  describe('lastIndexOf', function () {
+    it('first', async function () {
+      expect(await this.mock.$lastIndexOf(lorem, ethers.toBeHex(present))).to.equal(lorem.lastIndexOf(present));
+    });
+
+    it('from index', async function () {
+      for (const start in Array(lorem.length + 10).fill()) {
+        const index = lorem.lastIndexOf(present, start);
+        const result = index === -1 ? ethers.MaxUint256 : index;
+        expect(await this.mock.$lastIndexOf(lorem, ethers.toBeHex(present), ethers.Typed.uint256(start))).to.equal(
+          result,
+        );
+      }
+    });
+
+    it('absent', async function () {
+      expect(await this.mock.$lastIndexOf(lorem, ethers.toBeHex(absent))).to.equal(ethers.MaxUint256);
+    });
+  });
+
+  describe('slice', function () {
+    describe('slice(bytes, uint256)', function () {
+      for (const [descr, start] of Object.entries({
+        'start = 0': 0,
+        'start within bound': 10,
+        'start out of bound': 1000,
+      })) {
+        it(descr, async function () {
+          const result = ethers.hexlify(lorem.slice(start));
+          expect(await this.mock.$slice(lorem, start)).to.equal(result);
+        });
+      }
+    });
+
+    describe('slice(bytes, uint256, uint256)', function () {
+      for (const [descr, [start, end]] of Object.entries({
+        'start = 0': [0, 42],
+        'start and end within bound': [17, 42],
+        'end out of bound': [42, 1000],
+        'start = end': [17, 17],
+        'start > end': [42, 17],
+      })) {
+        it(descr, async function () {
+          const result = ethers.hexlify(lorem.slice(start, end));
+          expect(await this.mock.$slice(lorem, start, ethers.Typed.uint256(end))).to.equal(result);
+        });
+      }
+    });
+  });
+});

+ 53 - 0
test/utils/CAIP.test.js

@@ -0,0 +1,53 @@
+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 };
+}
+
+describe('CAIP utilities', function () {
+  beforeEach(async function () {
+    Object.assign(this, await loadFixture(fixture));
+  });
+
+  describe('CAIP-2', function () {
+    it('local()', async function () {
+      const { caip2 } = await getLocalCAIP();
+      expect(await this.caip2.$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);
+      });
+
+    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]);
+      });
+  });
+
+  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);
+    });
+
+    for (const { account, caip2, caip10 } of Object.values(CHAINS))
+      it(`format(${caip2}, ${account})`, async function () {
+        expect(await this.caip10.$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]);
+      });
+  });
+});