From d79b5b3aaddf0cfe6d64786aa741be5b54afe247 Mon Sep 17 00:00:00 2001 From: Julink Date: Tue, 27 Aug 2024 18:22:10 +0200 Subject: [PATCH 1/3] feat: added library to extract NFT id from label + tests --- contracts/LabelUtils.sol | 53 +++++++++++++++++++++++ contracts/NFTResolver.sol | 33 +++++++++++--- test/NFTResolver.ts | 90 +++++++++++++++++++++------------------ 3 files changed, 129 insertions(+), 47 deletions(-) create mode 100644 contracts/LabelUtils.sol diff --git a/contracts/LabelUtils.sol b/contracts/LabelUtils.sol new file mode 100644 index 0000000..c4a440a --- /dev/null +++ b/contracts/LabelUtils.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.25; + +/** + * @title Library to perform ENS label manipulation + * @author ConsenSys Software Inc. + */ +library LabelUtils { + /** + * Extract the first label name from a dns encoded ens domain + * @param name the dns encoded ENS domain + * @return label as bytes + */ + function extractFirstLabel( + bytes memory name + ) external pure returns (bytes memory) { + uint256 idx = 0; + uint8 labelLength = uint8(name[idx]); + idx++; + bytes memory label = new bytes(labelLength); + for (uint256 i = 0; i < labelLength; i++) { + label[i] = name[idx + i]; + } + return label; + } + + /** + * Extract the numeric suffix from the dns encoded label + * @param label the dns encoded label + * @return number the numeric suffix + */ + function extractNumericSuffix( + bytes memory label + ) external pure returns (uint256) { + uint256 num = 0; + bool hasNumber = false; + + for (uint256 i = 0; i < label.length; i++) { + uint8 char = uint8(label[i]); + if (char >= 48 && char <= 57) { + // ASCII for '0' is 48 and '9' is 57 + num = num * 10 + (char - 48); + hasNumber = true; + } else if (hasNumber) { + // Break on first non-digit after starting to read numbers + break; + } + } + + require(hasNumber, "No numeric suffix found"); + return num; + } +} diff --git a/contracts/NFTResolver.sol b/contracts/NFTResolver.sol index e1827a0..4cfb523 100644 --- a/contracts/NFTResolver.sol +++ b/contracts/NFTResolver.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: Apache-2.0 pragma solidity 0.8.25; import {EVMFetcher} from "@consensys/linea-state-verifier/contracts/EVMFetcher.sol"; @@ -13,6 +13,7 @@ import "@ensdomains/ens-contracts/contracts/resolvers/profiles/IExtendedResolver import {ITargetResolver} from "./ITargetResolver.sol"; import {IAddrSetter} from "./IAddrSetter.sol"; import "@openzeppelin/contracts/utils/introspection/ERC165.sol"; +import {LabelUtils} from "./LabelUtils.sol"; contract NFTResolver is EVMFetchTarget, @@ -146,19 +147,38 @@ contract NFTResolver is ) external view returns (bytes memory result) { require(data.length >= 4, "param data too short"); + bytes32 node = abi.decode(data[4:], (bytes32)); + bool isBaseDomain = targets[node] != address(0); + + // If trying to resolve the base domain, we return the target contract as the address + if (isBaseDomain) { + return abi.encode(targets[node]); + } + (, address target) = _getTarget(name, 0); + bytes4 selector = bytes4(data); if (selector == IAddrResolver.addr.selector) { - bytes32 node = abi.decode(data[4:], (bytes32)); - // TODO: Replace node by NFT ID - return _addr(1, target); + // Get NFT Index from the + uint256 nftId = extractNFTId(name); + return _addr(nftId, target); } // None selector has been found it reverts revert("invalid selector"); } + /** + * Get the NFT Id from the ENS name's label + * @param name DNS encoded ENS name + * @return id the NFT id + */ + function extractNFTId(bytes calldata name) public pure returns (uint256) { + bytes memory firstLabel = LabelUtils.extractFirstLabel(name); + return LabelUtils.extractNumericSuffix(firstLabel); + } + /** * @dev Resolve and throws an EIP 3559 compliant error * @param name DNS encoded ENS name to query @@ -179,7 +199,7 @@ contract NFTResolver is ) private view returns (bytes memory) { EVMFetcher .newFetchRequest(verifier, target) - .getDynamic(OWNERS_SLOT) + .getStatic(OWNERS_SLOT) .element(tokenId) .fetch(this.addrCallback.selector, ""); // recordVersions } @@ -188,7 +208,8 @@ contract NFTResolver is bytes[] memory values, bytes memory ) external pure returns (bytes memory) { - return abi.encode(address(bytes20(values[1]))); + address addr = abi.decode(values[0], (address)); + return abi.encode(addr); } function supportsInterface( diff --git a/test/NFTResolver.ts b/test/NFTResolver.ts index a6042a0..d7f66fb 100644 --- a/test/NFTResolver.ts +++ b/test/NFTResolver.ts @@ -27,17 +27,20 @@ const labelhash = (label: string) => const encodeName = (name: string) => "0x" + packet.name.encode(name).toString("hex"); const nftId = 1; -const wrongNftId = 0; const domainName = "foos"; const baseDomain = `${domainName}.eth`; const node = ethers.namehash(baseDomain); const encodedname = encodeName(baseDomain); -const registrantAddr = "0x4a8e79E5258592f208ddba8A8a0d3ffEB051B10A"; +// TODO use: registrantAddr = "0x4a8e79E5258592f208ddba8A8a0d3ffEB051B10A"; +const registrantAddr = "0xDeaD1F5aF792afc125812E875A891b038f888258"; const subDomain = "foo1.foos.eth"; const subDomainNode = ethers.namehash(subDomain); const encodedSubDomain = encodeName(subDomain); +const wrongSubDomain = "foo.foos.eth"; +const wrongEncodedSubDomain = encodeName(wrongSubDomain); + const EMPTY_ADDRESS = "0x0000000000000000000000000000000000000000"; const EMPTY_BYTES32 = "0x0000000000000000000000000000000000000000000000000000000000000000"; @@ -93,7 +96,8 @@ describe("Crosschain Resolver", () => { signer = await l1Provider.getSigner(0); signerAddress = await signer.getAddress(); // The NFT contract deployed on Linea Sepolia - l2NFTContractAddress = "0x27c11E7d60bA46a55EBF1fA33E6c30eDeAb162B6"; + // TODO use: l2NFTContractAddress = "0x27c11E7d60bA46a55EBF1fA33E6c30eDeAb162B6"; + l2NFTContractAddress = "0x03f8B4b140249Dc7B2503C928E7258CCe1d91F1A"; const Rollup = await ethers.getContractFactory("RollupMock", signer); @@ -195,9 +199,7 @@ describe("Crosschain Resolver", () => { const publicResolverAddress = await publicResolver.getAddress(); await reverseRegistrar.setDefaultResolver(publicResolverAddress); - console.log("TEST1"); await l1Provider.send("evm_mine", []); - console.log("TEST2"); const Mimc = await ethers.getContractFactory("Mimc", signer); const mimc = await Mimc.deploy(); @@ -222,10 +224,13 @@ describe("Crosschain Resolver", () => { await rollup.getAddress() ); - const nftResolverFactory = await ethers.getContractFactory( - "NFTResolver", - signer - ); + const LabelUtils = await ethers.getContractFactory("LabelUtils", signer); + const labelUtils = await LabelUtils.deploy(); + + const nftResolverFactory = await ethers.getContractFactory("NFTResolver", { + libraries: { LabelUtils: await labelUtils.getAddress() }, + signer, + }); const verifierAddress = await verifier.getAddress(); target = await nftResolverFactory.deploy( verifierAddress, @@ -249,7 +254,6 @@ describe("Crosschain Resolver", () => { await target.setTarget(incorrectname, l2NFTContractAddress); throw "Should have reverted"; } catch (e: any) { - console.log(e); expect(e.reason).equal("Not authorized to set target for this node"); } @@ -270,21 +274,21 @@ describe("Crosschain Resolver", () => { expect(result[1]).to.equal(signerAddress); }); - it("should resolve empty ETH Address", async () => { + it("should revert if there are no numeric suffix in the queried name", async () => { await target.setTarget(encodedname, l2NFTContractAddress); - const addr = "0x0000000000000000000000000000000000000000"; const i = new ethers.Interface(["function addr(bytes32) returns(address)"]); const calldata = i.encodeFunctionData("addr", [node]); - const result2 = await target.resolve(encodedname, calldata, { - enableCcipRead: true, - }); - const decoded = i.decodeFunctionResult("addr", result2); - expect(decoded[0]).to.equal(addr); + + await expect( + target.resolve(wrongEncodedSubDomain, calldata, { + enableCcipRead: true, + }) + ).to.be.revertedWith("No numeric suffix found"); }); - it("should resolve ETH Address", async () => { + it.only("should resolve ETH Address for the subdomain", async () => { await target.setTarget(encodedname, l2NFTContractAddress); - const result = await l2NFTContract["addr(bytes32)"](subDomainNode); + const result = await l2NFTContract["ownerOf(uint256)"](nftId); expect(ethers.getAddress(result)).to.equal(registrantAddr); await l1Provider.send("evm_mine", []); @@ -301,39 +305,43 @@ describe("Crosschain Resolver", () => { it("should revert when the functions's selector is invalid", async () => { await target.setTarget(encodedname, l2NFTContractAddress); - const addr = "0x0000000000000000000000000000000000000000"; - const result = await l2NFTContract["addr(bytes32)"](node); - expect(result).to.equal(addr); - await l1Provider.send("evm_mine", []); const i = new ethers.Interface([ "function unknown(bytes32) returns(address)", ]); const calldata = i.encodeFunctionData("unknown", [node]); - try { - await target.resolve(encodedname, calldata, { + await expect( + target.resolve(encodedname, calldata, { enableCcipRead: true, - }); - throw "Should have reverted"; - } catch (error: any) { - expect(error.reason).to.equal("invalid selector"); - } + }) + ).to.be.revertedWith("invalid selector"); }); it("should revert if the calldata is too short", async () => { await target.setTarget(encodedname, l2NFTContractAddress); - const addr = "0x0000000000000000000000000000000000000000"; - const result = await l2NFTContract["addr(bytes32)"](node); - expect(result).to.equal(addr); - await l1Provider.send("evm_mine", []); const i = new ethers.Interface(["function addr(bytes32) returns(address)"]); const calldata = "0x"; - try { - await target.resolve(encodedname, calldata, { + await expect( + target.resolve(encodedSubDomain, calldata, { enableCcipRead: true, - }); - throw "Should have reverted"; - } catch (error: any) { - expect(error.reason).to.equal("param data too short"); - } + }) + ).to.be.revertedWith("param data too short"); + }); + + it("should return the numeric suffix from a given dns encoded name", async () => { + const result = await target.extractNFTId(encodedSubDomain); + expect(result).to.equal(nftId); + }); + + it.only("should resolve ETH Address for the base domain", async () => { + await target.setTarget(encodedname, l2NFTContractAddress); + const i = new ethers.Interface(["function addr(bytes32) returns(address)"]); + const calldata = i.encodeFunctionData("addr", [node]); + const result2 = await target.resolve(encodedname, calldata, { + enableCcipRead: true, + }); + const decoded = i.decodeFunctionResult("addr", result2); + expect(ethers.getAddress(decoded[0])).to.equal( + ethers.getAddress(l2NFTContractAddress) + ); }); }); From 12aa0ca2c9c1457ea87b6c5ffe01941e5e8fc01d Mon Sep 17 00:00:00 2001 From: Julink Date: Wed, 28 Aug 2024 10:06:29 +0200 Subject: [PATCH 2/3] feat: added usage of PublicResolver to resolve base domain + unit tests in CI --- .github/workflows/nft-resolver-tests.yml | 19 +++++++++++++ contracts/NFTResolver.sol | 34 +++++++++++++++++++++--- package.json | 6 +++-- test/NFTResolver.ts | 17 ++++++------ 4 files changed, 63 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/nft-resolver-tests.yml diff --git a/.github/workflows/nft-resolver-tests.yml b/.github/workflows/nft-resolver-tests.yml new file mode 100644 index 0000000..40bf21e --- /dev/null +++ b/.github/workflows/nft-resolver-tests.yml @@ -0,0 +1,19 @@ +name: Run nft-resolver tests + +on: + pull_request: + branches: + - main + +jobs: + nft-resolver-tests: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install Dependencies + run: npm install --frozen-lockfile + + - name: Run tests + run: npm run test diff --git a/contracts/NFTResolver.sol b/contracts/NFTResolver.sol index 4cfb523..10df9fd 100644 --- a/contracts/NFTResolver.sol +++ b/contracts/NFTResolver.sol @@ -28,6 +28,8 @@ contract NFTResolver is ENS public immutable ens; INameWrapper public immutable nameWrapper; uint256 public immutable l2ChainId; + // PublicResolver used to resolve a base domain such as xxx.eth when queried + address public immutable publicResolver; mapping(bytes32 => address) targets; uint256 constant OWNERS_SLOT = 2; // To check how old is the value/proof returned and is in the acceptable range @@ -55,12 +57,14 @@ contract NFTResolver is * @param _ens The ENS registry address * @param _nameWrapper The ENS name wrapper address * @param _l2ChainId The chainId at which the resolver resolves data from + * @param _publicResolver The PublicResolver address to use to resolve base domains */ constructor( IEVMVerifier _verifier, ENS _ens, INameWrapper _nameWrapper, - uint256 _l2ChainId + uint256 _l2ChainId, + address _publicResolver ) { require( address(_nameWrapper) != address(0), @@ -75,6 +79,7 @@ contract NFTResolver is ens = _ens; nameWrapper = _nameWrapper; l2ChainId = _l2ChainId; + publicResolver = _publicResolver; } /** @@ -150,9 +155,9 @@ contract NFTResolver is bytes32 node = abi.decode(data[4:], (bytes32)); bool isBaseDomain = targets[node] != address(0); - // If trying to resolve the base domain, we return the target contract as the address + // If trying to resolve the base domain, we use the PublicResolver if (isBaseDomain) { - return abi.encode(targets[node]); + return _resolve(name, data); } (, address target) = _getTarget(name, 0); @@ -193,6 +198,29 @@ contract NFTResolver is _writeDeferral(target); } + /** + * @dev The `PublicResolver` does not implement the `resolve(bytes,bytes)` method. + * This method completes the resolution request by staticcalling `PublicResolver` with the resolve request. + * Implementation matches the ENS `ExtendedResolver:resolve(bytes,bytes)` method with the exception that it `staticcall`s the + * the `rootResolver` instead of `address(this)`. + * @param data The ABI encoded data for the underlying resolution function (Eg, addr(bytes32), text(bytes32,string), etc). + * @return The return data, ABI encoded identically to the underlying function. + */ + function _resolve( + bytes memory, + bytes memory data + ) internal view returns (bytes memory) { + (bool success, bytes memory result) = publicResolver.staticcall(data); + if (success) { + return result; + } else { + // Revert with the reason provided by the call + assembly { + revert(add(result, 0x20), mload(result)) + } + } + } + function _addr( uint256 tokenId, address target diff --git a/package.json b/package.json index b7d15d6..fb27898 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,9 @@ "version": "1.0.0", "description": "NFT Resolver to resolve names based on NFT ownership on Linea", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "npx hardhat test", + "clean": "npx hardhat clean", + "compile": "npx hardhat compile" }, "repository": { "type": "git", @@ -30,4 +32,4 @@ "@ensdomains/ens-contracts": "^1.2.2", "@openzeppelin/contracts": "^4.1.0" } -} +} \ No newline at end of file diff --git a/test/NFTResolver.ts b/test/NFTResolver.ts index d7f66fb..8620361 100644 --- a/test/NFTResolver.ts +++ b/test/NFTResolver.ts @@ -39,6 +39,7 @@ const subDomainNode = ethers.namehash(subDomain); const encodedSubDomain = encodeName(subDomain); const wrongSubDomain = "foo.foos.eth"; +const wrongSubDomainNode = ethers.namehash(wrongSubDomain); const wrongEncodedSubDomain = encodeName(wrongSubDomain); const EMPTY_ADDRESS = "0x0000000000000000000000000000000000000000"; @@ -195,6 +196,7 @@ describe("Crosschain Resolver", () => { EMPTY_ADDRESS, reverseRegistrarAddress ); + await publicResolver.setAddr(node, signerAddress); const publicResolverAddress = await publicResolver.getAddress(); await reverseRegistrar.setDefaultResolver(publicResolverAddress); @@ -236,7 +238,8 @@ describe("Crosschain Resolver", () => { verifierAddress, ensAddress, "0x0000000000000000000000000000000000000001", - 59141 + 59141, + publicResolverAddress ); // Mine an empty block so we have something to prove against await l1Provider.send("evm_mine", []); @@ -277,7 +280,7 @@ describe("Crosschain Resolver", () => { it("should revert if there are no numeric suffix in the queried name", async () => { await target.setTarget(encodedname, l2NFTContractAddress); const i = new ethers.Interface(["function addr(bytes32) returns(address)"]); - const calldata = i.encodeFunctionData("addr", [node]); + const calldata = i.encodeFunctionData("addr", [wrongSubDomainNode]); await expect( target.resolve(wrongEncodedSubDomain, calldata, { @@ -286,7 +289,7 @@ describe("Crosschain Resolver", () => { ).to.be.revertedWith("No numeric suffix found"); }); - it.only("should resolve ETH Address for the subdomain", async () => { + it("should resolve ETH Address for the subdomain", async () => { await target.setTarget(encodedname, l2NFTContractAddress); const result = await l2NFTContract["ownerOf(uint256)"](nftId); expect(ethers.getAddress(result)).to.equal(registrantAddr); @@ -308,7 +311,7 @@ describe("Crosschain Resolver", () => { const i = new ethers.Interface([ "function unknown(bytes32) returns(address)", ]); - const calldata = i.encodeFunctionData("unknown", [node]); + const calldata = i.encodeFunctionData("unknown", [subDomainNode]); await expect( target.resolve(encodedname, calldata, { enableCcipRead: true, @@ -332,7 +335,7 @@ describe("Crosschain Resolver", () => { expect(result).to.equal(nftId); }); - it.only("should resolve ETH Address for the base domain", async () => { + it("should resolve ETH Address for the base domain using the PublicResolver", async () => { await target.setTarget(encodedname, l2NFTContractAddress); const i = new ethers.Interface(["function addr(bytes32) returns(address)"]); const calldata = i.encodeFunctionData("addr", [node]); @@ -340,8 +343,6 @@ describe("Crosschain Resolver", () => { enableCcipRead: true, }); const decoded = i.decodeFunctionResult("addr", result2); - expect(ethers.getAddress(decoded[0])).to.equal( - ethers.getAddress(l2NFTContractAddress) - ); + expect(ethers.getAddress(decoded[0])).to.equal(signerAddress); }); }); From 7571a854def0d46e04bf2952c8c0a622c16b89d9 Mon Sep 17 00:00:00 2001 From: Julink Date: Wed, 28 Aug 2024 10:13:01 +0200 Subject: [PATCH 3/3] chore: use test NFT contract + use npm cache in CI --- .github/workflows/nft-resolver-tests.yml | 13 +++++++++++++ test/NFTResolver.ts | 8 +++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/nft-resolver-tests.yml b/.github/workflows/nft-resolver-tests.yml index 40bf21e..4bfdb81 100644 --- a/.github/workflows/nft-resolver-tests.yml +++ b/.github/workflows/nft-resolver-tests.yml @@ -12,6 +12,19 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Cache node modules + id: cache-npm + uses: actions/cache@v4 + env: + cache-name: cache-node-modules + with: + path: ~/.npm + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}- + ${{ runner.os }}-build- + ${{ runner.os }}- + - name: Install Dependencies run: npm install --frozen-lockfile diff --git a/test/NFTResolver.ts b/test/NFTResolver.ts index 8620361..f08a702 100644 --- a/test/NFTResolver.ts +++ b/test/NFTResolver.ts @@ -32,8 +32,7 @@ const baseDomain = `${domainName}.eth`; const node = ethers.namehash(baseDomain); const encodedname = encodeName(baseDomain); -// TODO use: registrantAddr = "0x4a8e79E5258592f208ddba8A8a0d3ffEB051B10A"; -const registrantAddr = "0xDeaD1F5aF792afc125812E875A891b038f888258"; +const registrantAddr = "0x4a8e79E5258592f208ddba8A8a0d3ffEB051B10A"; const subDomain = "foo1.foos.eth"; const subDomainNode = ethers.namehash(subDomain); const encodedSubDomain = encodeName(subDomain); @@ -60,7 +59,7 @@ declare module "hardhat/types/runtime" { } } -describe("Crosschain Resolver", () => { +describe("NFT Resolver", () => { let l1Provider: BrowserProvider; let l2Provider: JsonRpcProvider; let l1SepoliaProvider: JsonRpcProvider; @@ -97,8 +96,7 @@ describe("Crosschain Resolver", () => { signer = await l1Provider.getSigner(0); signerAddress = await signer.getAddress(); // The NFT contract deployed on Linea Sepolia - // TODO use: l2NFTContractAddress = "0x27c11E7d60bA46a55EBF1fA33E6c30eDeAb162B6"; - l2NFTContractAddress = "0x03f8B4b140249Dc7B2503C928E7258CCe1d91F1A"; + l2NFTContractAddress = "0x27c11E7d60bA46a55EBF1fA33E6c30eDeAb162B6"; const Rollup = await ethers.getContractFactory("RollupMock", signer);