diff --git a/bun.lock b/bun.lock index 8bd5972f3..b647225b5 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,7 @@ "@ensdomains/solsha1": "0.0.3", "@nomicfoundation/hardhat-verify": "^2.0.4", "@openzeppelin/contracts": "4.9.3", - "@openzeppelin/contracts-v5": "npm:@openzeppelin/contracts@5.1.0", + "@openzeppelin/contracts-v5": "npm:@openzeppelin/contracts@5.3.0", "@unruggable/gateways": "^1.2.2", "clones-with-immutable-args": "Arachnid/clones-with-immutable-args#feature/create2", "dns-packet": "^5.3.0", @@ -218,7 +218,7 @@ "@openzeppelin/contracts": ["@openzeppelin/contracts@4.9.3", "", {}, "sha512-He3LieZ1pP2TNt5JbkPA4PNT9WC3gOTOlDcFGJW4Le4QKqwmiNJCRt44APfxMxvq7OugU/cqYuPcSBzOw38DAg=="], - "@openzeppelin/contracts-v5": ["@openzeppelin/contracts@5.1.0", "", {}, "sha512-p1ULhl7BXzjjbha5aqst+QMLY+4/LCWADXOCsmLHRM77AqiPjnd9vvUN9sosUfhL9JGKpZ0TjEGxgvnizmWGSA=="], + "@openzeppelin/contracts-v5": ["@openzeppelin/contracts@5.3.0", "", {}, "sha512-zj/KGoW7zxWUE8qOI++rUM18v+VeLTTzKs/DJFkSzHpQFPD/jKKF0TrMxBfGLl3kpdELCNccvB3zmofSzm4nlA=="], "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.3.15", "", { "dependencies": { "asn1js": "^3.0.5", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-QPeD8UA8axQREpgR5UTAfu2mqQmm97oUqahDtNdBcfj3qAnoXzFdQW+aNf/tD2WVXF8Fhmftxoj0eMIT++gX2w=="], diff --git a/contracts/resolvers/FallbackResolver.sol b/contracts/resolvers/FallbackResolver.sol new file mode 100644 index 000000000..028385958 --- /dev/null +++ b/contracts/resolvers/FallbackResolver.sol @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.13; + +import {ERC165} from "@openzeppelin/contracts-v5/utils/introspection/ERC165.sol"; +import {Clones} from "@openzeppelin/contracts-v5/proxy/Clones.sol"; + +import {CCIPReader} from "../ccipRead/CCIPReader.sol"; +import {ResolverCaller} from "../universalResolver/ResolverCaller.sol"; +import {IGatewayProvider} from "../ccipRead/IGatewayProvider.sol"; +import {BytesUtils} from "../utils/BytesUtils.sol"; +import {IERC7996} from "../utils/IERC7996.sol"; +import {ResolverFeatures} from "./ResolverFeatures.sol"; + +// resolver profiles +import {IExtendedResolver} from "./profiles/IExtendedResolver.sol"; +import {IMulticallable} from "./IMulticallable.sol"; + +contract FallbackResolver is + ERC165, + IERC7996, + ResolverCaller, + IExtendedResolver +{ + IGatewayProvider public immutable batchGatewayProvider; + + event Deployed(address); + + constructor( + IGatewayProvider _batchGatewayProvider + ) CCIPReader(DEFAULT_UNSAFE_CALL_GAS) { + batchGatewayProvider = _batchGatewayProvider; + } + + function deploy( + address[] memory _resolvers + ) external returns (FallbackResolver) { + bytes10 prefix; + address impl; + assembly { + extcodecopy(address(), 0, 0, 40) + prefix := mload(0) + impl := shr(96, mload(10)) + } + // check if we're the clone (ERC-1167) + if (prefix == bytes10(0x363d3d373d3d3d363d73)) { + return FallbackResolver(impl).deploy(_resolvers); + } + address clone = Clones.cloneWithImmutableArgs( + address(this), + abi.encode(_resolvers) + ); + emit Deployed(clone); + return FallbackResolver(clone); + } + + function resolvers() public view returns (address[] memory) { + return abi.decode(Clones.fetchCloneArgs(address(this)), (address[])); + } + + /// @inheritdoc ERC165 + function supportsInterface( + bytes4 interfaceId + ) public view override(ERC165) returns (bool) { + return + interfaceId == type(IExtendedResolver).interfaceId || + interfaceId == type(IERC7996).interfaceId || + super.supportsInterface(interfaceId); + } + + /// @inheritdoc IERC7996 + function supportsFeature(bytes4 featureId) external pure returns (bool) { + return featureId == ResolverFeatures.RESOLVE_MULTICALL; + } + + struct State { + uint256 resolverIndex; + bytes name; + bool multi; + bytes[] calls; + bytes[] answers; + } + + /// @inheritdoc IExtendedResolver + function resolve( + bytes memory name, + bytes calldata data + ) external view returns (bytes memory) { + State memory state; + state.name = name; + if (bytes4(data) == IMulticallable.multicall.selector) { + state.multi = true; + state.calls = abi.decode(data[4:], (bytes[])); + } else { + state.calls = new bytes[](1); + state.calls[0] = data; + } + state.answers = new bytes[](state.calls.length); + return _callNext(state); + } + + function _callNext( + State memory state + ) internal view returns (bytes memory) { + address[] memory v = resolvers(); + if (state.resolverIndex >= v.length) { + bool answered; + for (uint256 i; i < state.answers.length; i++) { + if (state.answers[i].length > 0) { + answered = true; + break; + } + } + if (answered) { + if (state.multi) { + return abi.encode(state.answers); + } else { + return state.answers[0]; + } + } else { + revert UnreachableName(state.name); + } + } + ccipRead( + address(this), + abi.encodeCall( + this.callResolver, + ( + v[state.resolverIndex++], + state.name, + state.multi + ? abi.encodeCall( + IMulticallable.multicall, + (state.calls) + ) + : state.calls[0], + batchGatewayProvider.gateways() + ) + ), + this.resolveCallback.selector, + this.resolveCallbackError.selector, + abi.encode(state) + ); + } + + function resolveCallback( + bytes calldata response, + bytes calldata extraData + ) external view returns (bytes memory) { + bytes memory unwrapped = abi.decode(response, (bytes)); + State memory state = abi.decode(extraData, (State)); + if (state.multi) { + bytes[] memory m = abi.decode(unwrapped, (bytes[])); + bytes[] memory calls = state.calls; + bytes[] memory answers = state.answers; + uint256 need; + uint256 next; + if (m.length == calls.length) { + for (uint256 i; i < m.length; i++) { + while (answers[next].length > 0) { + next++; + } + if (_isNullAnswer(m[i])) { + calls[need++] = calls[i]; + } else { + answers[next] = m[i]; + } + ++next; + } + if (need == 0) { + return abi.encode(state.answers); + } + assembly { + mstore(calls, need) // truncate + } + } + } else if (_isNullAnswer(unwrapped)) { + state.answers[0] = unwrapped; + } else { + return unwrapped; + } + return _callNext(state); + } + + function resolveCallbackError( + bytes calldata, + bytes calldata extraData + ) external view returns (bytes memory) { + return _callNext(abi.decode(extraData, (State))); + } + + function _isNullAnswer(bytes memory v) internal pure returns (bool) { + return + BytesUtils.isZeros(v) || + keccak256(v) == + 0x569e75fc77c1a856f6daaf9e69d8a9566ca34aa47f9133711ce065a571af0cfd; // abi.encode('') + } +} diff --git a/contracts/utils/BytesUtils.sol b/contracts/utils/BytesUtils.sol index ff01a3860..cb2b1e7df 100644 --- a/contracts/utils/BytesUtils.sol +++ b/contracts/utils/BytesUtils.sol @@ -321,4 +321,57 @@ library BytesUtils { } return type(uint256).max; } + + /// @dev Determine if `v` is all zeros. + /// @param v The bytes to search. + /// @return `true` if all zeros. + function isZeros(bytes memory v) internal pure returns (bool) { + uint256 ptr; + assembly { + ptr := add(v, 32) + } + return unsafeIsZeros(ptr, v.length); + } + + /// @dev Determine if `v[off:len]` is all zeros. + /// @param v The bytes to search. + /// @param off The offset to start searching. + /// @param len The number of bytes to search. + /// @return `true` if all zeros. + function isZeros( + bytes memory v, + uint256 off, + uint256 len + ) internal pure returns (bool) { + _checkBound(v, off + len); + uint256 ptr; + assembly { + ptr := add(v, 32) + } + return unsafeIsZeros(ptr + off, len); + } + + /// @dev Determine if `mem[ptr:ptr+len]` is all zeros. + function unsafeIsZeros( + uint256 ptr, + uint256 len + ) internal pure returns (bool ret) { + assembly { + let end := add(ptr, len) + ret := 1 // assume null + // prettier-ignore + for {} lt(ptr, end) {} { // while (ptr < end) + let x := mload(ptr) // remember last + ptr := add(ptr, 32) // step by word + if x { + ret := 0 // nonzero + if gt(ptr, end) { + // overshot, so shift and recheck + ret := iszero(shr(shl(3, sub(ptr, end)), x)) + } + break + } + } + } + } } diff --git a/contracts/utils/TestBytesUtils.sol b/contracts/utils/TestBytesUtils.sol index f3962b2f7..ef8711d2d 100644 --- a/contracts/utils/TestBytesUtils.sol +++ b/contracts/utils/TestBytesUtils.sol @@ -6,7 +6,7 @@ import {BytesUtils} from "../../contracts/utils/BytesUtils.sol"; contract TestBytesUtils { using BytesUtils for *; - function test_keccak() public pure { + function test_keccak() external pure { require( "".keccak(0, 0) == bytes32( @@ -30,7 +30,7 @@ contract TestBytesUtils { ); } - function test_equals() public pure { + function test_equals() external pure { require("hello".equals("hello"), "String equality"); require(!"hello".equals("goodbye"), "String inequality"); require("hello".equals(1, "ello"), "Substring to string equality"); @@ -48,7 +48,7 @@ contract TestBytesUtils { ); } - function test_compare_partial() public pure { + function test_compare_partial() external pure { require("xax".compare(1, 1, "xxbxx", 2, 1) < 0, "Compare same length"); require( "xax".compare(1, 1, "xxabxx", 2, 2) < 0, @@ -90,7 +90,7 @@ contract TestBytesUtils { ); } - function test_compare() public pure { + function test_compare() external pure { require("a".compare("a") == 0, "a == a"); require("a".compare("b") < 0, "a < b"); require("b".compare("a") > 0, "b > a"); @@ -109,7 +109,7 @@ contract TestBytesUtils { ); } - function test_copyBytes() public pure { + function test_copyBytes() external pure { bytes memory v = "0123456789abcdef0123456789abcdef"; { bytes memory u = new bytes(5); @@ -129,7 +129,7 @@ contract TestBytesUtils { } // this uses copyBytes() underneath - function test_substring() public pure { + function test_substring() external pure { bytes memory v = "abc"; require(keccak256(v.substring(0, 0)) == keccak256(""), "[]abc"); require(keccak256(v.substring(0, 2)) == keccak256("ab"), "[ab]c"); @@ -137,32 +137,32 @@ contract TestBytesUtils { require(keccak256(v.substring(0, 3)) == keccak256("abc"), "[abc]"); } - function testFail_substring_overflow() public pure { + function testFail_substring_overflow() external pure { "".substring(0, 1); } - function test_readUint8() public pure { + function test_readUint8() external pure { bytes memory v = "abc"; require(v.readUint8(0) == uint8(bytes1("a")), "0"); require(v.readUint8(1) == uint8(bytes1("b")), "1"); require(v.readUint8(2) == uint8(bytes1("c")), "2"); } - function test_readUint16() public pure { + function test_readUint16() external pure { bytes memory v = "abcd"; require(v.readUint16(0) == uint16(bytes2("ab")), "0"); require(v.readUint16(1) == uint16(bytes2("bc")), "1"); require(v.readUint16(2) == uint16(bytes2("cd")), "2"); } - function test_readUint32() public pure { + function test_readUint32() external pure { bytes memory v = "0123456789abc"; require(v.readUint32(0) == uint32(bytes4("0123")), "0"); require(v.readUint32(4) == uint32(bytes4("4567")), "4"); require(v.readUint32(8) == uint32(bytes4("89ab")), "8"); } - function test_readBytes20() public pure { + function test_readBytes20() external pure { bytes memory v = "0123456789abcdef0123456789abcdef"; bytes22 x = 0x30313233343536373839616263646566303132333435; require(v.readBytes20(0) == bytes20(x), "0"); @@ -170,7 +170,7 @@ contract TestBytesUtils { require(v.readBytes20(2) == bytes20(x << 16), "2"); } - function test_readBytes32() public pure { + function test_readBytes32() external pure { bytes memory v = "0123456789abcdef0123456789abcdef\x00\x00"; bytes32 x = 0x3031323334353637383961626364656630313233343536373839616263646566; require(v.readBytes32(0) == x, "0"); @@ -178,7 +178,7 @@ contract TestBytesUtils { require(v.readBytes32(2) == x << 16, "2"); } - function test_readBytesN() public pure { + function test_readBytesN() external pure { bytes memory v = "0123456789abcdef0123456789abcdef"; bytes32 x = 0x3031323334353637383961626364656630313233343536373839616263646566; require(v.readBytesN(0, 0) == 0, "0"); @@ -218,15 +218,15 @@ contract TestBytesUtils { require(v.readBytesN(31, 1) == bytes32(x) << 248, "31+1"); } - function testFail_readBytesN_overflow() public pure { + function testFail_readBytesN_overflow() external pure { "".readBytesN(0, 1); } - function testFail_readBytesN_largeN() public pure { + function testFail_readBytesN_largeN() external pure { new bytes(64).readBytesN(0, 33); } - function test_find() public pure { + function test_find() external pure { bytes memory v = "0123456789abcdef0123456789abcdef"; require(v.find(0, v.length, "0") == 0, "0"); require(v.find(1, v.length, "0") == 16, "2nd 0"); @@ -234,4 +234,15 @@ contract TestBytesUtils { require(v.find(0, v.length, "A") == type(uint256).max, "A"); require(v.find(0, 10, "a") == type(uint256).max, "a"); } + + function test_isZeros() external pure { + bytes memory v = new bytes(34); + v[33] = hex'01'; + require(v.isZeros(0, 0), "0,0"); + require(v.isZeros(0, 32), "0,32"); + require(v.isZeros(1, 32), "1,32"); + require(!v.isZeros(1, 33), "1,33"); + require(!v.isZeros(), "all"); + require("".isZeros(), "empty"); + } } diff --git a/package.json b/package.json index ab22f38ea..40a87a024 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "@ensdomains/solsha1": "0.0.3", "@nomicfoundation/hardhat-verify": "^2.0.4", "@openzeppelin/contracts": "4.9.3", - "@openzeppelin/contracts-v5": "npm:@openzeppelin/contracts@5.1.0", + "@openzeppelin/contracts-v5": "npm:@openzeppelin/contracts@5.3.0", "@unruggable/gateways": "^1.2.2", "clones-with-immutable-args": "Arachnid/clones-with-immutable-args#feature/create2", "dns-packet": "^5.3.0" diff --git a/test/resolvers/TestFallbackResolver.ts b/test/resolvers/TestFallbackResolver.ts new file mode 100644 index 000000000..d0efb0056 --- /dev/null +++ b/test/resolvers/TestFallbackResolver.ts @@ -0,0 +1,146 @@ +import { loadFixture } from '@nomicfoundation/hardhat-toolbox-viem/network-helpers.js' +import { expect } from 'chai' +import hre from 'hardhat' +import { type Address, decodeEventLog, getAddress, getContract } from 'viem' +import { dnsEncodeName } from '../fixtures/dnsEncodeName.js' +import { COIN_TYPE_ETH } from '../fixtures/ensip19.js' +import { serveBatchGateway } from '../fixtures/localBatchGateway.js' +import { writeContract } from 'viem/actions' +import { + bundleCalls, + KnownProfile, + makeResolutions, +} from '../utils/resolutions.js' +import { FEATURES } from '../utils/features.js' + +async function fixture() { + const client = await hre.viem.getPublicClient({ ccipRead: undefined }) + const [owner] = await hre.viem.getWalletClients() + const bg = await serveBatchGateway() + after(bg.shutdown) + const batchGatewayProvider = await hre.viem.deployContract( + 'GatewayProvider', + [owner.account.address, [bg.localBatchGatewayUrl]], + ) + const fallbackResolverImpl = await hre.viem.deployContract( + 'FallbackResolver', + [batchGatewayProvider.address], + ) + const ss1 = await hre.viem.deployContract('DummyShapeshiftResolver') + const ss2 = await hre.viem.deployContract('DummyShapeshiftResolver') + return { + owner, + client, + bg, + ss1, + ss2, + fallbackResolverImpl, + deployFallbackResolver, + } + + async function deployFallbackResolver( + resolvers: readonly Address[], + address = fallbackResolverImpl.address, + ) { + const { abi } = fallbackResolverImpl + const hash = await writeContract(client, { + account: owner.account, + address, + abi, + functionName: 'deploy', + args: [resolvers], + }) + const receipt = await client.waitForTransactionReceipt({ hash }) + const log = decodeEventLog({ + abi, + data: receipt.logs[0].data, + topics: receipt.logs[0].topics, + }) + return getContract({ + address: log.args[0], + abi, + client, + }) + } +} + +describe('FallbackResolver', () => { + it('resolves()', async () => { + const F = await loadFixture(fixture) + const v = [F.ss1.address, F.ss2.address] + const r = await F.deployFallbackResolver(v) + await expect(r.read.resolvers()).resolves.toStrictEqual( + v.map((x) => getAddress(x)), + ) + }) + + it('deploy() through clone', async () => { + const F = await loadFixture(fixture) + const r1 = await F.deployFallbackResolver([F.ss1.address]) + const a = F.ss2.address + const r2 = await F.deployFallbackResolver([a], r1.address) + await expect(r2.read.resolvers()).resolves.toStrictEqual([getAddress(a)]) + }) + + describe('resolve()', () => { + const kp: KnownProfile = { + name: 'test.eth', + addresses: [ + { + coinType: COIN_TYPE_ETH, + value: '0x8000000000000000000000000000000000000001', + }, + ], + texts: [{ key: 'url', value: 'https://ens.domains' }], + } + const resolutions = makeResolutions(kp) + for (let n = 1; n <= resolutions.length; n++) { + describe(`calls = ${n}`, () => { + const bundle = bundleCalls(resolutions.slice(0, n)) + // assume: 2 resolvers + for (let bits = 0, max = 1 << (3 * 2); bits < max; bits++) { + const extended = [0, 1].map((x) => !!(bits & (1 << x))) + const offchain = [2, 3].map((x) => !!(bits & (1 << x))) + const multi = [4, 5].map((x) => !!(bits & (1 << x))) + const parts: number[][] = [[], []] + for (let i = 0; i < n; i++) { + parts[(Math.random() * parts.length) | 0].push(i) + } + let title = '' + for (let i = 0; i < multi.length; i++) { + if (title) title += ' + ' + title += `#${i + 1}<` + if (extended[i]) title += 'E' + if (offchain[i]) title += 'O' + if (multi[i]) title += 'M' + title += `>[${parts[i].map((j) => bundle.resolutions[j].desc)}]` + } + it(title, async () => { + const F = await loadFixture(fixture) + const sss = [F.ss1, F.ss2] + for (let i = 0; i < sss.length; i++) { + const ss = sss[i] + await ss.write.setExtended([extended[i]]) + await ss.write.setOffchain([offchain[i]]) + await ss.write.setDeriveMulticall([multi[i]]) + await ss.write.setFeature([ + FEATURES.RESOLVER.RESOLVE_MULTICALL, + multi[i], + ]) + for (let j of parts[i]) { + const res = bundle.resolutions[j] + await ss.write.setResponse([res.call, res.answer]) + } + } + const r = await F.deployFallbackResolver(sss.map((x) => x.address)) + const answer = await r.read.resolve([ + dnsEncodeName(kp.name), + bundle.call, + ]) + bundle.expect(answer) + }) + } + }) + } + }) +})