From b59ff2bb1d78befa5c362140c2110c5d72c33198 Mon Sep 17 00:00:00 2001 From: tate Date: Wed, 18 Oct 2023 09:40:00 +1100 Subject: [PATCH 1/2] feat: contract resolver proxy (wip) --- README.md | 4 +- contracts/utils/ContractResolverProxy.sol | 47 ++ .../utils/UniversalResolverNoMulticall.sol | 299 ++++++++ test/utils/TestContractResolverProxy.ts | 93 +++ .../utils/TestUniversalResolverNoMulticall.js | 696 ++++++++++++++++++ 5 files changed, 1137 insertions(+), 2 deletions(-) create mode 100644 contracts/utils/ContractResolverProxy.sol create mode 100644 contracts/utils/UniversalResolverNoMulticall.sol create mode 100644 test/utils/TestContractResolverProxy.ts create mode 100644 test/utils/TestUniversalResolverNoMulticall.js diff --git a/README.md b/README.md index e943715a..8a64dd69 100644 --- a/README.md +++ b/README.md @@ -176,8 +176,8 @@ yarn pub 5. Create a "Release Candidate" [release](https://docs.github.com/en/repositories/releasing-projects-on-github/about-releases) on GitHub. This will be of the form `v1.2.3-RC0`. This tagged commit is now subject to our bug bounty. 6. Have the tagged commit audited if necessary 7. If changes are required, make the changes and then once ready for review create another GitHub release with an incremented RC value `v1.2.3-RC0` -> `v.1.2.3-RC1`. Repeat as necessary. -8. Deploy to testnet. Open a pull request to merge the deploy artifacts into -the `feature` branch. Get someone to review and approve the deployment and then merge. You now MUST merge this branch into `staging` branch. +8. Deploy to testnet. Open a pull request to merge the deploy artifacts into + the `feature` branch. Get someone to review and approve the deployment and then merge. You now MUST merge this branch into `staging` branch. 9. Create GitHub release of the form `v1.2.3-testnet` from the commit that has the new deployment artifacts. 10. If any further changes are needed, you can either make them on the existing feature branch that is in sync or create a new branch, and follow steps 1 -> 9. Repeat as necessary. 11. Make Deployment to mainnet from `staging`. Commit build artifacts. You now MUST merge this branch into `main`. diff --git a/contracts/utils/ContractResolverProxy.sol b/contracts/utils/ContractResolverProxy.sol new file mode 100644 index 00000000..4eb0f3b4 --- /dev/null +++ b/contracts/utils/ContractResolverProxy.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.17 <0.9.0; + +import {ERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; +import {UniversalResolverNoMulticall} from "./UniversalResolverNoMulticall.sol"; +import {IAddrResolver} from "../resolvers/Resolver.sol"; +import {BytesUtils} from "../wrapper/BytesUtils.sol"; + +error AddressNotFound(); + +contract ContractResolverProxy is ERC165 { + using BytesUtils for bytes; + + UniversalResolverNoMulticall public immutable ur; + + constructor(address _ur) { + ur = UniversalResolverNoMulticall(_ur); + } + + function resolve( + bytes calldata name, + bytes memory data + ) external view returns (bytes memory) { + bytes32 namehash = name.namehash(0); + (bytes memory resolvedAddressData, ) = ur.resolve( + name, + abi.encodeCall(IAddrResolver.addr, namehash) + ); + if (resolvedAddressData.length == 0) { + revert AddressNotFound(); + } + + address addr = abi.decode(resolvedAddressData, (address)); + if (addr == address(0)) { + revert AddressNotFound(); + } + + (bool success, bytes memory ret) = addr.staticcall(data); + if (!success) { + assembly { + revert(add(ret, 32), returndatasize()) + } + } + + return ret; + } +} diff --git a/contracts/utils/UniversalResolverNoMulticall.sol b/contracts/utils/UniversalResolverNoMulticall.sol new file mode 100644 index 00000000..627493c8 --- /dev/null +++ b/contracts/utils/UniversalResolverNoMulticall.sol @@ -0,0 +1,299 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.17 <0.9.0; + +import {ERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {LowLevelCallUtils} from "./LowLevelCallUtils.sol"; +import {ENS} from "../registry/ENS.sol"; +import {IExtendedResolver} from "../resolvers/profiles/IExtendedResolver.sol"; +import {Resolver, INameResolver, IAddrResolver} from "../resolvers/Resolver.sol"; +import {NameEncoder} from "./NameEncoder.sol"; +import {BytesUtils} from "../wrapper/BytesUtils.sol"; +import {HexUtils} from "./HexUtils.sol"; + +error OffchainLookup( + address sender, + string[] urls, + bytes callData, + bytes4 callbackFunction, + bytes extraData +); + +error ResolverNotFound(); + +error ResolverWildcardNotSupported(); + +/** + * The Universal Resolver is a contract that handles the work of resolving a name entirely onchain, + * making it possible to make a single smart contract call to resolve an ENS name. + */ +contract UniversalResolverNoMulticall is ERC165 { + using Address for address; + using NameEncoder for string; + using BytesUtils for bytes; + using HexUtils for bytes; + + ENS public immutable registry; + + constructor(address _registry) { + registry = ENS(_registry); + } + + /** + * @dev Performs ENS name resolution for the supplied name and resolution data. + * @param name The name to resolve, in normalised and DNS-encoded form. + * @param data The resolution data, as specified in ENSIP-10. + * @return The result of resolving the name. + */ + function resolve( + bytes calldata name, + bytes memory data + ) external view returns (bytes memory, address) { + return + _resolveWithCallbackSelector( + name, + data, + this.resolveCallback.selector + ); + } + + /** + * @dev Callback function for `resolve`. + * @param response Response data returned by the target address that invoked the inner `OffchainData` revert. + * @param extraData Extra data encoded by `callWithOffchainLookupPropagation` to allow completing the request. + */ + function resolveCallback( + bytes calldata response, + bytes calldata extraData + ) external view returns (bytes memory, address) { + ( + address target, + bytes4 innerCallbackFunction, + bytes memory innerExtraData + ) = abi.decode(extraData, (address, bytes4, bytes)); + return + abi.decode( + target.functionStaticCall( + abi.encodeWithSelector( + innerCallbackFunction, + response, + innerExtraData + ) + ), + (bytes, address) + ); + } + + /** + * @dev Performs ENS name reverse resolution for the supplied reverse name. + * @param reverseName The reverse name to resolve, in normalised and DNS-encoded form. e.g. b6E040C9ECAaE172a89bD561c5F73e1C48d28cd9.addr.reverse + * @return The resolved name, the resolved address, the reverse resolver address, and the resolver address. + */ + function reverse( + bytes calldata reverseName + ) external view returns (string memory, address, address, address) { + ( + bytes memory reverseResolvedData, + address reverseResolverAddress + ) = _resolveWithCallbackSelector( + reverseName, + abi.encodeCall(INameResolver.name, reverseName.namehash(0)), + this.reverseCallback.selector + ); + + return + _getForwardDataFromReverse( + reverseResolvedData, + reverseResolverAddress + ); + } + + function reverseCallback( + bytes calldata response, + bytes calldata extraData + ) external view returns (string memory, address, address, address) { + ( + bytes memory reverseResolvedData, + address _reverseResolverAddress + ) = this.resolveCallback(response, extraData); + + return + _getForwardDataFromReverse( + reverseResolvedData, + _reverseResolverAddress + ); + } + + function _resolveWithCallbackSelector( + bytes calldata name, + bytes memory data, + bytes4 callbackFunction + ) public view returns (bytes memory, address) { + (Resolver resolver, , uint256 finalOffset) = findResolver(name); + if (address(resolver) == address(0)) { + revert ResolverNotFound(); + } + + try + resolver.supportsInterface(type(IExtendedResolver).interfaceId) + returns (bool supported) { + if (supported) { + return ( + _callWithOffchainLookupPropagation( + address(resolver), + abi.encodeCall(IExtendedResolver.resolve, (name, data)), + callbackFunction + ), + address(resolver) + ); + } + } catch {} + + if (finalOffset != 0) { + revert ResolverWildcardNotSupported(); + } + + return ( + _callWithOffchainLookupPropagation( + address(resolver), + data, + callbackFunction + ), + address(resolver) + ); + } + + /** + * @dev Finds a resolver by recursively querying the registry, starting at the longest name and progressively + * removing labels until it finds a result. + * @param name The name to resolve, in DNS-encoded and normalised form. + * @return resolver The Resolver responsible for this name. + * @return namehash The namehash of the full name. + * @return finalOffset The offset of the first label with a resolver. + */ + function findResolver( + bytes calldata name + ) public view returns (Resolver, bytes32, uint256) { + ( + address resolver, + bytes32 namehash, + uint256 finalOffset + ) = _findResolver(name, 0); + return (Resolver(resolver), namehash, finalOffset); + } + + function _findResolver( + bytes calldata name, + uint256 offset + ) internal view returns (address, bytes32, uint256) { + uint256 labelLength = uint256(uint8(name[offset])); + if (labelLength == 0) { + return (address(0), bytes32(0), offset); + } + uint256 nextLabel = offset + labelLength + 1; + bytes32 labelHash; + if ( + labelLength == 66 && + // 0x5b == '[' + name[offset + 1] == 0x5b && + // 0x5d == ']' + name[nextLabel - 1] == 0x5d + ) { + // Encrypted label + (labelHash, ) = bytes(name[offset + 2:nextLabel - 1]) + .hexStringToBytes32(0, 64); + } else { + labelHash = keccak256(name[offset + 1:nextLabel]); + } + ( + address parentresolver, + bytes32 parentnode, + uint256 parentoffset + ) = _findResolver(name, nextLabel); + bytes32 node = keccak256(abi.encodePacked(parentnode, labelHash)); + address resolver = registry.resolver(node); + if (resolver != address(0)) { + return (resolver, node, offset); + } + return (parentresolver, node, parentoffset); + } + + function _getForwardDataFromReverse( + bytes memory reverseResolvedData, + address reverseResolverAddress + ) internal view returns (string memory, address, address, address) { + string memory resolvedName = abi.decode(reverseResolvedData, (string)); + + (bytes memory encodedName, bytes32 namehash) = resolvedName + .dnsEncodeName(); + + (bytes memory resolvedData, address resolverAddress) = this + ._resolveWithCallbackSelector( + encodedName, + abi.encodeCall(IAddrResolver.addr, namehash), + this.reverseCallback.selector + ); + + return ( + resolvedName, + abi.decode(resolvedData, (address)), + reverseResolverAddress, + resolverAddress + ); + } + + /** + * @dev Makes a call to `target` with `data`. If the call reverts with an `OffchainLookup` error, wraps + * the error with the data necessary to continue the request where it left off. + * @param target The address to call. + * @param data The data to call `target` with. + * @param callbackFunction The function ID of a function on this contract to use as an EIP 3668 callback. + * This function's `extraData` argument will be passed `(address target, bytes4 innerCallback, bytes innerExtraData)`. + * @return ret If `target` did not revert, contains the return data from the call to `target`. + */ + function _callWithOffchainLookupPropagation( + address target, + bytes memory data, + bytes4 callbackFunction + ) internal view returns (bytes memory ret) { + bool result = LowLevelCallUtils.functionStaticCall(target, data); + uint256 size = LowLevelCallUtils.returnDataSize(); + + if (result) { + return LowLevelCallUtils.readReturnData(0, size); + } + + // Failure + if (size >= 4) { + bytes memory errorId = LowLevelCallUtils.readReturnData(0, 4); + if (bytes4(errorId) == OffchainLookup.selector) { + // Offchain lookup. Decode the revert message and create our own that nests it. + bytes memory revertData = LowLevelCallUtils.readReturnData( + 4, + size - 4 + ); + ( + address sender, + string[] memory urls, + bytes memory callData, + bytes4 innerCallbackFunction, + bytes memory extraData + ) = abi.decode( + revertData, + (address, string[], bytes, bytes4, bytes) + ); + if (sender == target) { + revert OffchainLookup( + address(this), + urls, + callData, + callbackFunction, + abi.encode(sender, innerCallbackFunction, extraData) + ); + } + } + } + + LowLevelCallUtils.propagateRevert(); + } +} diff --git a/test/utils/TestContractResolverProxy.ts b/test/utils/TestContractResolverProxy.ts new file mode 100644 index 00000000..4d64cf9e --- /dev/null +++ b/test/utils/TestContractResolverProxy.ts @@ -0,0 +1,93 @@ +import * as packet from 'dns-packet' +import { Contract } from 'ethers' +import { namehash, solidityKeccak256 } from 'ethers/lib/utils' +import { contract, ethers, expect } from 'hardhat' + +const ROOT_NODE = + '0x0000000000000000000000000000000000000000000000000000000000000000' + +const hexEncodeName = (name: string) => + '0x' + packet.name.encode(name).toString('hex') + +const labelhash = (label: string) => solidityKeccak256(['string'], [label]) + +contract('ContractResolverProxy', (accounts) => { + let ensRegistry: Contract + let universalResolver: Contract + let contractResolverProxy: Contract + let ownedResolver: Contract + + beforeEach(async () => { + const ENSRegistry = await ethers.getContractFactory('ENSRegistry') + const UniversalResolver = await ethers.getContractFactory( + 'UniversalResolverNoMulticall', + ) + const ContractResolverProxy = await ethers.getContractFactory( + 'ContractResolverProxy', + ) + const OwnedResolver = await ethers.getContractFactory('OwnedResolver') + + ensRegistry = await ENSRegistry.deploy() + await ensRegistry.deployed() + + universalResolver = await UniversalResolver.deploy(ensRegistry.address) + await universalResolver.deployed() + + contractResolverProxy = await ContractResolverProxy.deploy( + universalResolver.address, + ) + await contractResolverProxy.deployed() + + ownedResolver = await OwnedResolver.deploy() + await ownedResolver.deployed() + + await ensRegistry.setSubnodeOwner( + ROOT_NODE, + labelhash('eth'), + accounts[0], + { + from: accounts[0], + }, + ) + + await ensRegistry.setSubnodeOwner( + namehash('eth'), + labelhash('ens'), + accounts[0], + { from: accounts[0] }, + ) + + await ensRegistry.setSubnodeRecord( + namehash('ens.eth'), + labelhash('registry'), + accounts[0], + ownedResolver.address, + 0, + { from: accounts[0] }, + ) + + await ownedResolver['setAddr(bytes32,address)']( + namehash('registry.ens.eth'), + ensRegistry.address, + { + from: accounts[0], + }, + ) + }) + + it('works', async () => { + const data = ensRegistry.interface.encodeFunctionData('owner', [ + namehash('registry.ens.eth'), + ]) + const result = await contractResolverProxy.resolve( + hexEncodeName('registry.ens.eth'), + data, + ) + + const [returnAddress] = ensRegistry.interface.decodeFunctionResult( + 'owner', + result, + ) + expect(returnAddress).to.equal(accounts[0]) + }) +}) diff --git a/test/utils/TestUniversalResolverNoMulticall.js b/test/utils/TestUniversalResolverNoMulticall.js new file mode 100644 index 00000000..ea132bc5 --- /dev/null +++ b/test/utils/TestUniversalResolverNoMulticall.js @@ -0,0 +1,696 @@ +const { solidity } = require('ethereum-waffle') +const { use, expect } = require('chai') +const namehash = require('eth-ens-namehash') +const sha3 = require('web3-utils').sha3 +const { Contract } = require('ethers') +const { ethers } = require('hardhat') +const { dns } = require('../test-utils') +const { deploy } = require('../test-utils/contracts') + +use(solidity) + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' +const EMPTY_BYTES32 = + '0x0000000000000000000000000000000000000000000000000000000000000000' + +contract('UniversalResolver', function (accounts) { + let LegacyResolver + let ens, + publicResolver, + /** + * @type {Contract} + */ + universalResolver, + dummyOffchainResolver, + nameWrapper, + reverseRegistrar, + reverseNode, + batchGateway, + dummyOldResolver, + dummyRevertResolver + + before(async () => { + batchGateway = (await ethers.getContractAt('BatchGateway', ZERO_ADDRESS)) + .interface + LegacyResolver = await ethers.getContractFactory('LegacyResolver') + }) + + beforeEach(async () => { + node = namehash.hash('eth') + ens = await deploy('ENSRegistry') + nameWrapper = await deploy('DummyNameWrapper') + reverseRegistrar = await deploy('ReverseRegistrar', ens.address) + reverseNode = accounts[0].toLowerCase().substring(2) + '.addr.reverse' + oldResolverReverseNode = + accounts[10].toLowerCase().substring(2) + '.addr.reverse' + await ens.setSubnodeOwner(EMPTY_BYTES32, sha3('reverse'), accounts[0], { + from: accounts[0], + }) + await ens.setSubnodeOwner( + namehash.hash('reverse'), + sha3('addr'), + reverseRegistrar.address, + { from: accounts[0] }, + ) + publicResolver = await deploy( + 'PublicResolver', + ens.address, + nameWrapper.address, + ZERO_ADDRESS, + ZERO_ADDRESS, + ) + universalResolver = await deploy( + 'UniversalResolverNoMulticall', + ens.address, + ) + dummyOffchainResolver = await deploy('DummyOffchainResolver') + dummyOldResolver = await deploy('DummyOldResolver') + dummyRevertResolver = await deploy('DummyRevertResolver') + + await ens.setSubnodeOwner(EMPTY_BYTES32, sha3('eth'), accounts[0], { + from: accounts[0], + }) + await ens.setSubnodeOwner(namehash.hash('eth'), sha3('test'), accounts[0], { + from: accounts[0], + }) + await ens.setResolver(namehash.hash('test.eth'), publicResolver.address, { + from: accounts[0], + }) + await ens.setSubnodeOwner( + namehash.hash('test.eth'), + sha3('sub'), + accounts[0], + { from: accounts[0] }, + ) + await ens.setResolver(namehash.hash('sub.test.eth'), accounts[1], { + from: accounts[0], + }) + await publicResolver.functions['setAddr(bytes32,address)']( + namehash.hash('test.eth'), + accounts[1], + { from: accounts[0] }, + ) + await publicResolver.functions['setText(bytes32,string,string)']( + namehash.hash('test.eth'), + 'foo', + 'bar', + { from: accounts[0] }, + ) + await ens.setSubnodeOwner( + namehash.hash('test.eth'), + sha3('offchain'), + accounts[0], + { from: accounts[0] }, + ) + await ens.setSubnodeOwner( + namehash.hash('test.eth'), + sha3('no-resolver'), + accounts[0], + { from: accounts[0] }, + ) + await ens.setSubnodeOwner( + namehash.hash('test.eth'), + sha3('revert-resolver'), + accounts[0], + { from: accounts[0] }, + ) + let name = 'test.eth' + for (let i = 0; i < 5; i += 1) { + const parent = name + const label = `sub${i}` + await ens.setSubnodeOwner( + namehash.hash(parent), + sha3(label), + accounts[0], + { from: accounts[0] }, + ) + name = `${label}.${parent}` + } + await ens.setResolver( + namehash.hash('offchain.test.eth'), + dummyOffchainResolver.address, + { from: accounts[0] }, + ) + await ens.setResolver( + namehash.hash('revert-resolver.test.eth'), + dummyRevertResolver.address, + { from: accounts[0] }, + ) + + await reverseRegistrar.claim(accounts[0], { + from: accounts[0], + }) + await ens.setResolver(namehash.hash(reverseNode), publicResolver.address, { + from: accounts[0], + }) + await publicResolver.setName(namehash.hash(reverseNode), 'test.eth') + + const oldResolverSigner = await ethers.getSigner(accounts[10]) + const _reverseRegistrar = reverseRegistrar.connect(oldResolverSigner) + const _ens = ens.connect(oldResolverSigner) + + await _reverseRegistrar.claim(accounts[10]) + await _ens.setResolver( + namehash.hash(oldResolverReverseNode), + dummyOldResolver.address, + ) + }) + + const resolveCallbackSig = ethers.utils.hexDataSlice( + ethers.utils.id('resolveCallback(bytes,bytes)'), + 0, + 4, + ) + + describe('findResolver()', () => { + it('should find an exact match resolver', async () => { + const result = await universalResolver.findResolver( + dns.hexEncodeName('test.eth'), + ) + expect(result['0']).to.equal(publicResolver.address) + }) + + it('should find a resolver on a parent name', async () => { + const result = await universalResolver.findResolver( + dns.hexEncodeName('foo.test.eth'), + ) + expect(result['0']).to.equal(publicResolver.address) + }) + + it('should choose the resolver closest to the leaf', async () => { + const result = await universalResolver.findResolver( + dns.hexEncodeName('sub.test.eth'), + ) + expect(result['0']).to.equal(accounts[1]) + }) + it('should allow encrypted labels', async () => { + const result = await universalResolver.callStatic.findResolver( + dns.hexEncodeName( + '[9c22ff5f21f0b81b113e63f7db6da94fedef11b2119b4088b89664fb9a3cb658].eth', + ), + ) + expect(result['0']).to.equal(publicResolver.address) + }) + it('should return the final offset for the found resolver', async () => { + const result = await universalResolver.findResolver( + dns.hexEncodeName('foo.test.eth'), + ) + expect(result['2']).to.equal(4) + }) + it('should find a resolver many levels up', async () => { + const result = await universalResolver.findResolver( + dns.hexEncodeName('sub4.sub3.sub2.sub1.sub0.test.eth'), + ) + expect(result['0']).to.equal(publicResolver.address) + expect(result['2']).to.equal(25) + }) + }) + + describe('resolve()', () => { + it('should resolve a record via legacy methods', async () => { + const data = publicResolver.interface.encodeFunctionData( + 'addr(bytes32)', + [namehash.hash('test.eth')], + ) + + const result = await universalResolver.resolve( + dns.hexEncodeName('test.eth'), + data, + ) + const [ret] = ethers.utils.defaultAbiCoder.decode( + ['address'], + result['0'], + ) + expect(ret).to.equal(accounts[1]) + }) + + it('should throw if a resolver is not set on the queried name', async () => { + const data = publicResolver.interface.encodeFunctionData( + 'addr(bytes32)', + [namehash.hash('no-resolver.test.other')], + ) + + try { + await universalResolver.resolve( + dns.hexEncodeName('no-resolver.test.other'), + data, + ) + expect(false).to.be.true + } catch (e) { + expect(e.errorName).to.equal('ResolverNotFound') + } + }) + + it('should return with revert data if resolver reverts', async () => { + const data = publicResolver.interface.encodeFunctionData( + 'addr(bytes32)', + [namehash.hash('revert-resolver.test.eth')], + ) + + try { + await universalResolver.resolve( + dns.hexEncodeName('revert-resolver.test.eth'), + data, + ) + expect(false).to.be.true + } catch (e) { + expect(e.errorSignature).to.equal('Error(string)') + expect(e.reason).to.equal('Not Supported') + } + }) + + it('should throw if a resolver is not set on the queried name, and the found resolver does not support resolve()', async () => { + const data = publicResolver.interface.encodeFunctionData( + 'addr(bytes32)', + [namehash.hash('no-resolver.test.eth')], + ) + + try { + await universalResolver.resolve( + dns.hexEncodeName('no-resolver.test.eth'), + data, + ) + expect(false).to.be.true + } catch (e) { + expect(e.errorName).to.equal('ResolverWildcardNotSupported') + } + }) + + it('should resolve a record if `supportsInterface` throws', async () => { + const legacyResolver = await LegacyResolver.deploy() + await ens.setSubnodeOwner( + namehash.hash('eth'), + sha3('test2'), + accounts[0], + { from: accounts[0] }, + ) + await ens.setResolver( + namehash.hash('test2.eth'), + legacyResolver.address, + { from: accounts[0] }, + ) + const data = publicResolver.interface.encodeFunctionData( + 'addr(bytes32)', + [namehash.hash('test.eth')], + ) + const result = await universalResolver.resolve( + dns.hexEncodeName('test2.eth'), + data, + ) + const [ret] = ethers.utils.defaultAbiCoder.decode( + ['address'], + result['0'], + ) + expect(ret).to.equal(legacyResolver.address) + }) + + it('should return a revert if the resolver reverts with OffchainLookup', async () => { + const data = publicResolver.interface.encodeFunctionData( + 'addr(bytes32)', + [namehash.hash('offchain.test.eth')], + ) + + const urls = ['https://example.com/'] + + // OffchainLookup(address sender, string[] urls, bytes callData, bytes4 callbackFunction, bytes extraData) + // This is the extraData value the universal resolver should encode + const extraData = ethers.utils.defaultAbiCoder.encode( + ['bool', 'address', 'string[]', 'bytes', '(bytes4,bytes)[]'], + [ + false, + dummyOffchainResolver.address, + ['https://example.com/'], + '0x', + [[resolveCallbackSig, data]], + ], + ) + + const callData = batchGateway.encodeFunctionData('query', [ + [[dummyOffchainResolver.address, ['https://example.com/'], data]], + ]) + + try { + await universalResolver.resolve( + dns.hexEncodeName('offchain.test.eth'), + data, + ) + } catch (e) { + expect(e.errorName).to.equal('OffchainLookup') + expect(e.errorArgs.sender).to.equal(universalResolver.address) + expect(e.errorArgs.urls).to.deep.equal(['https://example.com/']) + expect(e.errorArgs.callData).to.equal(callData) + expect(e.errorArgs.callbackFunction).to.equal( + ethers.utils.hexDataSlice( + ethers.utils.id('resolveSingleCallback(bytes,bytes)'), + 0, + 4, + ), + ) + expect(e.errorArgs.extraData).to.equal(extraData) + } + }) + it('should use custom gateways when specified', async () => { + const data = publicResolver.interface.encodeFunctionData( + 'addr(bytes32)', + [namehash.hash('offchain.test.eth')], + ) + try { + await universalResolver['resolve(bytes,bytes,string[])']( + dns.hexEncodeName('offchain.test.eth'), + data, + ['https://custom-offchain-resolver.local/'], + ) + } catch (e) { + expect(e.errorArgs.urls).to.deep.equal([ + 'https://custom-offchain-resolver.local/', + ]) + } + }) + + describe('batch', () => { + it('should resolve multiple records onchain', async () => { + const textData = publicResolver.interface.encodeFunctionData( + 'text(bytes32,string)', + [namehash.hash('test.eth'), 'foo'], + ) + const addrData = publicResolver.interface.encodeFunctionData( + 'addr(bytes32)', + [namehash.hash('test.eth')], + ) + const [[textEncoded, addrEncoded]] = await universalResolver[ + 'resolve(bytes,bytes[])' + ](dns.hexEncodeName('test.eth'), [textData, addrData]) + const [textRet] = publicResolver.interface.decodeFunctionResult( + 'text(bytes32,string)', + textEncoded, + ) + const [addrRet] = publicResolver.interface.decodeFunctionResult( + 'addr(bytes32)', + addrEncoded, + ) + expect(textRet).to.equal('bar') + expect(addrRet).to.equal(accounts[1]) + }) + it('should resolve multiple records offchain', async () => { + const textData = publicResolver.interface.encodeFunctionData( + 'text(bytes32,string)', + [namehash.hash('offchain.test.eth'), 'foo'], + ) + const addrData = publicResolver.interface.encodeFunctionData( + 'addr(bytes32)', + [namehash.hash('offchain.test.eth')], + ) + const callData = batchGateway.encodeFunctionData('query', [ + [ + [dummyOffchainResolver.address, ['https://example.com/'], textData], + [dummyOffchainResolver.address, ['https://example.com/'], addrData], + ], + ]) + const extraData = ethers.utils.defaultAbiCoder.encode( + ['bool', 'address', 'string[]', 'bytes', '(bytes4,bytes)[]'], + [ + false, + dummyOffchainResolver.address, + ['http://universal-offchain-resolver.local/'], + '0x', + [ + [resolveCallbackSig, textData], + [resolveCallbackSig, addrData], + ], + ], + ) + try { + await universalResolver['resolve(bytes,bytes[])']( + dns.hexEncodeName('offchain.test.eth'), + [textData, addrData], + ) + } catch (e) { + expect(e.errorName).to.equal('OffchainLookup') + expect(e.errorArgs.callData).to.equal(callData) + expect(e.errorArgs.callbackFunction).to.equal(resolveCallbackSig) + expect(e.errorArgs.extraData).to.equal(extraData) + } + }) + }) + }) + + describe('resolveSingleCallback', () => { + it('should resolve a record via a callback from offchain lookup', async () => { + const addrData = publicResolver.interface.encodeFunctionData( + 'addr(bytes32)', + [namehash.hash('offchain.test.eth')], + ) + const extraData = ethers.utils.defaultAbiCoder.encode( + ['bool', 'address', 'string[]', 'bytes', '(bytes4,bytes)[]'], + [ + false, + dummyOffchainResolver.address, + ['http://universal-offchain-resolver.local/'], + '0x', + [[resolveCallbackSig, addrData]], + ], + ) + const responses = batchGateway.encodeFunctionResult('query', [ + [false], + [addrData], + ]) + + const [encodedAddr, resolverAddress] = + await universalResolver.callStatic.resolveSingleCallback( + responses, + extraData, + ) + expect(resolverAddress).to.equal(dummyOffchainResolver.address) + const [addrRet] = publicResolver.interface.decodeFunctionResult( + 'addr(bytes32)', + encodedAddr, + ) + expect(addrRet).to.equal(dummyOffchainResolver.address) + }) + }) + describe('resolveCallback', () => { + it('should resolve records via a callback from offchain lookup', async () => { + const addrData = publicResolver.interface.encodeFunctionData( + 'addr(bytes32)', + [namehash.hash('offchain.test.eth')], + ) + const textData = publicResolver.interface.encodeFunctionData( + 'text(bytes32,string)', + [namehash.hash('offchain.test.eth'), 'foo'], + ) + const extraData = ethers.utils.defaultAbiCoder.encode( + ['bool', 'address', 'string[]', 'bytes', '(bytes4,bytes)[]'], + [ + false, + dummyOffchainResolver.address, + ['http://universal-offchain-resolver.local/'], + '0x', + [ + [resolveCallbackSig, addrData], + [resolveCallbackSig, textData], + ], + ], + ) + const responses = batchGateway.encodeFunctionResult('query', [ + [false, false], + [addrData, textData], + ]) + const [[encodedRes, encodedResTwo], resolverAddress] = + await universalResolver.callStatic.resolveCallback(responses, extraData) + expect(resolverAddress).to.equal(dummyOffchainResolver.address) + const [addrRet] = publicResolver.interface.decodeFunctionResult( + 'addr(bytes32)', + encodedRes, + ) + const [addrRetTwo] = publicResolver.interface.decodeFunctionResult( + 'addr(bytes32)', + encodedResTwo, + ) + expect(addrRet).to.equal(dummyOffchainResolver.address) + expect(addrRetTwo).to.equal(dummyOffchainResolver.address) + }) + it('should not revert if there is an error in a call', async () => { + const extraData = ethers.utils.defaultAbiCoder.encode( + ['bool', 'address', 'string[]', 'bytes', '(bytes4,bytes)[]'], + [ + false, + dummyOffchainResolver.address, + ['http://universal-offchain-resolver.local/'], + '0x', + [[resolveCallbackSig, '0x']], + ], + ) + const responses = batchGateway.encodeFunctionResult('query', [ + [true], + ['0x'], + ]) + const [[encodedRes], resolverAddress] = + await universalResolver.callStatic.resolveCallback(responses, extraData) + expect(resolverAddress).to.equal(dummyOffchainResolver.address) + expect(encodedRes).to.equal('0x') + }) + it('should allow response at non-0 extraData index', async () => { + const addrData = publicResolver.interface.encodeFunctionData( + 'addr(bytes32)', + [namehash.hash('offchain.test.eth')], + ) + const textData = publicResolver.interface.encodeFunctionData( + 'text(bytes32,string)', + [namehash.hash('offchain.test.eth'), 'foo'], + ) + const extraData = ethers.utils.defaultAbiCoder.encode( + ['bool', 'address', 'string[]', 'bytes', '(bytes4,bytes)[]'], + [ + false, + dummyOffchainResolver.address, + ['http://universal-offchain-resolver.local/'], + '0x', + [ + ['0x00000000', addrData], + [resolveCallbackSig, textData], + ], + ], + ) + const responses = batchGateway.encodeFunctionResult('query', [ + [false], + [textData], + ]) + const [[encodedRes, encodedResTwo], resolverAddress] = + await universalResolver.callStatic.resolveCallback(responses, extraData) + const [addrRet] = ethers.utils.defaultAbiCoder.decode( + ['bytes'], + encodedRes, + ) + const [addrRetTwo] = publicResolver.interface.decodeFunctionResult( + 'addr(bytes32)', + encodedResTwo, + ) + expect(ethers.utils.toUtf8String(addrRet)).to.equal('onchain') + expect(addrRetTwo).to.equal(dummyOffchainResolver.address) + expect(resolverAddress).to.equal(dummyOffchainResolver.address) + }) + it('should gracefully handle a non-existent function on an offchain resolver', async () => { + const addrData = publicResolver.interface.encodeFunctionData( + 'addr(bytes32,uint256)', + [namehash.hash('offchain.test.eth'), 60], + ) + const textData = publicResolver.interface.encodeFunctionData( + 'text(bytes32,string)', + [namehash.hash('offchain.test.eth'), 'foo'], + ) + const extraData = ethers.utils.defaultAbiCoder.encode( + ['bool', 'address', 'string[]', 'bytes', '(bytes4,bytes)[]'], + [ + false, + dummyOffchainResolver.address, + ['http://universal-offchain-resolver.local/'], + '0x', + [ + ['0x00000000', addrData], + [resolveCallbackSig, textData], + ], + ], + ) + const responses = batchGateway.encodeFunctionResult('query', [ + [false], + [textData], + ]) + const [[addr, text], resolver] = + await universalResolver.callStatic.resolveCallback(responses, extraData) + const [addrRetFromText] = publicResolver.interface.decodeFunctionResult( + 'addr(bytes32)', + text, + ) + expect(addr).to.equal('0x') + expect(addrRetFromText).to.equal(dummyOffchainResolver.address) + expect(resolver).to.equal(dummyOffchainResolver.address) + }) + }) + describe('reverseCallback', () => { + it('should revert with metadata for initial forward resolution if required', async () => { + const extraData = ethers.utils.defaultAbiCoder.encode( + ['bool', 'address', 'string[]', 'bytes', '(bytes4,bytes)[]'], + [ + false, + dummyOffchainResolver.address, + ['http://universal-offchain-resolver.local/'], + '0x', + [[resolveCallbackSig, '0x691f3431']], + ], + ) + const responses = batchGateway.encodeFunctionResult('query', [ + [false], + ['0x691f3431'], + ]) + try { + await universalResolver.callStatic.reverseCallback(responses, extraData) + } catch (e) { + expect(e.errorName).to.equal('OffchainLookup') + const extraDataReturned = ethers.utils.defaultAbiCoder.decode( + ['bool', 'address', 'string[]', 'bytes', '(bytes4,bytes)[]'], + e.errorArgs.extraData, + ) + const metaData = ethers.utils.defaultAbiCoder.decode( + ['string', 'address'], + extraDataReturned[3], + ) + expect(metaData[0]).to.equal('offchain.test.eth') + expect(metaData[1]).to.equal(dummyOffchainResolver.address) + } + }) + it('should resolve address record via a callback from offchain lookup', async () => { + const metaData = ethers.utils.defaultAbiCoder.encode( + ['string', 'address'], + ['offchain.test.eth', dummyOffchainResolver.address], + ) + const extraData = ethers.utils.defaultAbiCoder.encode( + ['bool', 'address', 'string[]', 'bytes', '(bytes4,bytes)[]'], + [ + false, + dummyOffchainResolver.address, + ['http://universal-offchain-resolver.local/'], + metaData, + [[resolveCallbackSig, '0x']], + ], + ) + const responses = batchGateway.encodeFunctionResult('query', [ + [false], + ['0x'], + ]) + const [name, a1, a2, a3] = await universalResolver.reverseCallback( + responses, + extraData, + ) + expect(name).to.equal('offchain.test.eth') + expect(a1).to.equal(dummyOffchainResolver.address) + expect(a2).to.equal(dummyOffchainResolver.address) + expect(a3).to.equal(dummyOffchainResolver.address) + }) + }) + + describe('reverse()', () => { + const makeEstimateAndResult = async (contract, func, ...args) => ({ + estimate: await contract.estimateGas[func](...args), + result: await contract.functions[func](...args), + }) + it('should resolve a reverse record with name and resolver address', async () => { + const { estimate, result } = await makeEstimateAndResult( + universalResolver, + 'reverse(bytes)', + dns.hexEncodeName(reverseNode), + ) + console.log('GAS ESTIMATE:', estimate) + expect(result['0']).to.equal('test.eth') + expect(result['1']).to.equal(accounts[1]) + expect(result['2']).to.equal(publicResolver.address) + expect(result['3']).to.equal(publicResolver.address) + }) + it('should not use all the gas on a revert', async () => { + const estimate = await universalResolver.estimateGas['reverse(bytes)']( + dns.hexEncodeName(oldResolverReverseNode), + { gasLimit: 8000000 }, + ) + expect(estimate.lt(200000)).to.be.true + }) + }) +}) From 740dc6c63359046a54b4c101c9fccc3ce9c6ae9d Mon Sep 17 00:00:00 2001 From: tate Date: Fri, 27 Oct 2023 10:48:17 +1100 Subject: [PATCH 2/2] name changed to CallByNameProxy --- ...ractResolverProxy.sol => CallByNameProxy.sol} | 2 +- ...ctResolverProxy.ts => TestCallByNameProxy.ts} | 16 ++++++---------- 2 files changed, 7 insertions(+), 11 deletions(-) rename contracts/utils/{ContractResolverProxy.sol => CallByNameProxy.sol} (96%) rename test/utils/{TestContractResolverProxy.ts => TestCallByNameProxy.ts} (84%) diff --git a/contracts/utils/ContractResolverProxy.sol b/contracts/utils/CallByNameProxy.sol similarity index 96% rename from contracts/utils/ContractResolverProxy.sol rename to contracts/utils/CallByNameProxy.sol index 4eb0f3b4..87a702c8 100644 --- a/contracts/utils/ContractResolverProxy.sol +++ b/contracts/utils/CallByNameProxy.sol @@ -8,7 +8,7 @@ import {BytesUtils} from "../wrapper/BytesUtils.sol"; error AddressNotFound(); -contract ContractResolverProxy is ERC165 { +contract CallByNameProxy is ERC165 { using BytesUtils for bytes; UniversalResolverNoMulticall public immutable ur; diff --git a/test/utils/TestContractResolverProxy.ts b/test/utils/TestCallByNameProxy.ts similarity index 84% rename from test/utils/TestContractResolverProxy.ts rename to test/utils/TestCallByNameProxy.ts index 4d64cf9e..6329f5b7 100644 --- a/test/utils/TestContractResolverProxy.ts +++ b/test/utils/TestCallByNameProxy.ts @@ -11,10 +11,10 @@ const hexEncodeName = (name: string) => const labelhash = (label: string) => solidityKeccak256(['string'], [label]) -contract('ContractResolverProxy', (accounts) => { +contract('CallByNameProxy', (accounts) => { let ensRegistry: Contract let universalResolver: Contract - let contractResolverProxy: Contract + let callByNameProxy: Contract let ownedResolver: Contract beforeEach(async () => { @@ -22,9 +22,7 @@ contract('ContractResolverProxy', (accounts) => { const UniversalResolver = await ethers.getContractFactory( 'UniversalResolverNoMulticall', ) - const ContractResolverProxy = await ethers.getContractFactory( - 'ContractResolverProxy', - ) + const CallByNameProxy = await ethers.getContractFactory('CallByNameProxy') const OwnedResolver = await ethers.getContractFactory('OwnedResolver') ensRegistry = await ENSRegistry.deploy() @@ -33,10 +31,8 @@ contract('ContractResolverProxy', (accounts) => { universalResolver = await UniversalResolver.deploy(ensRegistry.address) await universalResolver.deployed() - contractResolverProxy = await ContractResolverProxy.deploy( - universalResolver.address, - ) - await contractResolverProxy.deployed() + callByNameProxy = await CallByNameProxy.deploy(universalResolver.address) + await callByNameProxy.deployed() ownedResolver = await OwnedResolver.deploy() await ownedResolver.deployed() @@ -79,7 +75,7 @@ contract('ContractResolverProxy', (accounts) => { const data = ensRegistry.interface.encodeFunctionData('owner', [ namehash('registry.ens.eth'), ]) - const result = await contractResolverProxy.resolve( + const result = await callByNameProxy.resolve( hexEncodeName('registry.ens.eth'), data, )