diff --git a/bun.lock b/bun.lock index b1001ed..51a8f15 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "offchain-resolver-example", diff --git a/lib/ens-contracts b/lib/ens-contracts index 289913d..be53b9c 160000 --- a/lib/ens-contracts +++ b/lib/ens-contracts @@ -1 +1 @@ -Subproject commit 289913d7e3923228675add09498d66920216fe9b +Subproject commit be53b9c25be5b2c7326f524bbd34a3939374ab1f diff --git a/src/IOffchainVerifier.sol b/src/IOffchainVerifier.sol new file mode 100755 index 0000000..e165061 --- /dev/null +++ b/src/IOffchainVerifier.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.13; + +interface IOffchainVerifierSigner { + function isOffchainSigner(address) external view returns (bool); +} + +interface IOffchainVerifier { + error CCIPReadExpired(uint64 expiry); + error CCIPReadUntrusted(address signed); + + /// @notice Verify `response` was signed by an authorized account. + function verifyResponse( + bytes calldata request, + bytes calldata response + ) external view returns (bytes memory); +} diff --git a/src/OffchainResolver.sol b/src/OffchainResolver.sol index 089663d..58048ee 100755 --- a/src/OffchainResolver.sol +++ b/src/OffchainResolver.sol @@ -3,44 +3,28 @@ pragma solidity >=0.8.13; import {Ownable} from "@oz/access/Ownable.sol"; import {ERC165} from "@oz/utils/introspection/ERC165.sol"; -import {ECDSA} from "@oz/utils/cryptography/ECDSA.sol"; import {IERC7996} from "@ens/utils/IERC7996.sol"; import {ResolverFeatures} from "@ens/resolvers/ResolverFeatures.sol"; import {IExtendedResolver} from "@ens/resolvers/profiles/IExtendedResolver.sol"; +import { + IVerifiableResolver +} from "@ens/resolvers/profiles/IVerifiableResolver.sol"; import {OffchainLookup} from "@ens/ccipRead/EIP3668.sol"; -import {IGatewayProvider} from "@ens/ccipRead/IGatewayProvider.sol"; +import {IOffchainVerifier} from "./IOffchainVerifier.sol"; contract OffchainResolver is Ownable, ERC165, IExtendedResolver, - IGatewayProvider, + IVerifiableResolver, IERC7996 { - error CCIPReadExpired(uint64 expiry); - error CCIPReadUntrusted(address signed); - - event SignerChanged(address signer, bool enabled); event GatewaysChanged(string[] gateways); + IOffchainVerifier _verifier; string[] _gateways; - /// @notice Determine if `signer` is a trusted signer. - mapping(address signer => bool enabled) public isSigner; - - constructor( - address owner, - address[] memory signers, - string[] memory gateways_ - ) Ownable(owner) { - for (uint256 i; i < signers.length; ++i) { - address signer = signers[i]; - isSigner[signer] = true; - emit SignerChanged(signer, true); - } - _gateways = gateways_; - emit GatewaysChanged(gateways_); - } + constructor(address owner) Ownable(owner) {} /// @inheritdoc ERC165 function supportsInterface( @@ -48,8 +32,8 @@ contract OffchainResolver is ) public view override returns (bool) { return interfaceId == type(IExtendedResolver).interfaceId || + interfaceId == type(IVerifiableResolver).interfaceId || interfaceId == type(IERC7996).interfaceId || - interfaceId == type(IGatewayProvider).interfaceId || super.supportsInterface(interfaceId); } @@ -60,22 +44,23 @@ contract OffchainResolver is return featureId == ResolverFeatures.RESOLVE_MULTICALL; } - /// @notice Set `signer` as an trusted signer. - function setSigner(address signer, bool enabled) external onlyOwner { - require(isSigner[signer] != enabled); - isSigner[signer] = enabled; - emit SignerChanged(signer, enabled); + /// @inheritdoc IVerifiableResolver + function verifierMetadata( + bytes calldata /*name*/ + ) external view returns (address verifier, string[] memory gateways) { + return (address(_verifier), _gateways); } /// @notice Set the gateways. - function setGateways(string[] memory gateways_) external onlyOwner { - _gateways = gateways_; - emit GatewaysChanged(gateways_); + function setGateways(string[] memory gateways) external onlyOwner { + _gateways = gateways; + emit GatewaysChanged(gateways); } - /// @inheritdoc IGatewayProvider - function gateways() external view returns (string[] memory) { - return _gateways; + /// @notice Set the verifier. + function setVerifier(IOffchainVerifier verifier) external onlyOwner { + _verifier = verifier; + emit VerifierChanged(hex"00", address(verifier)); } /// @inheritdoc IExtendedResolver @@ -97,35 +82,6 @@ contract OffchainResolver is bytes calldata response, bytes calldata request ) external view returns (bytes memory) { - return _verifyResponse(request, response); - } - - /// @dev Verify `signedResponse` was signed by `signer`. - function _verifyResponse( - bytes memory request, - bytes calldata response - ) internal view returns (bytes memory) { - (bytes memory answer, uint64 expiry, bytes memory sig) = abi.decode( - response, - (bytes, uint64, bytes) - ); - if (expiry < block.timestamp) { - revert CCIPReadExpired(expiry); - } - // standard "ens" offchain signing protocol - bytes32 hash = keccak256( - abi.encodePacked( - hex"1900", - address(this), - expiry, - keccak256(request), // original calldata, eg. msg.data - keccak256(answer) // response from server - ) - ); - address signed = ECDSA.recover(hash, sig); - if (!isSigner[signed]) { - revert CCIPReadUntrusted(signed); - } - return answer; + return _verifier.verifyResponse(request, response); } } diff --git a/src/OffchainVerifier.sol b/src/OffchainVerifier.sol new file mode 100755 index 0000000..cfef790 --- /dev/null +++ b/src/OffchainVerifier.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.13; + +import {Ownable} from "@oz/access/Ownable.sol"; +import {ERC165} from "@oz/utils/introspection/ERC165.sol"; +import {ECDSA} from "@oz/utils/cryptography/ECDSA.sol"; +import { + IOffchainVerifier, + IOffchainVerifierSigner +} from "./IOffchainVerifier.sol"; + +contract OffchainVerifier is ERC165, IOffchainVerifier { + event SignerChanged(address target, address signer, bool enabled); + + mapping(address target => mapping(address signer => bool)) _isSigner; + + /// @notice Set signer for target. + function setOffchainSigner( + address target, + address signer, + bool enabled + ) external { + if (target == address(0) || Ownable(target).owner() != msg.sender) { + revert Ownable.OwnableUnauthorizedAccount(target); + } + _isSigner[target][signer] = enabled; + emit SignerChanged(target, signer, enabled); + } + + function isOffchainSigner( + address target, + address signer + ) public view returns (bool) { + if (_isSigner[target][signer]) { + return true; + } + try + IOffchainVerifierSigner(target).isOffchainSigner(signer) + returns (bool enabled) { + return enabled; + } catch { + return false; + } + } + + /// @inheritdoc ERC165 + function supportsInterface( + bytes4 interfaceId + ) public view override returns (bool) { + return + interfaceId == type(IOffchainVerifier).interfaceId || + super.supportsInterface(interfaceId); + } + + /// @inheritdoc IOffchainVerifier + function verifyResponse( + bytes calldata request, + bytes calldata response + ) external view returns (bytes memory) { + (bytes memory answer, uint64 expiry, bytes memory sig) = abi.decode( + response, + (bytes, uint64, bytes) + ); + if (expiry < block.timestamp) { + revert CCIPReadExpired(expiry); + } + /// forge-lint: disable-next-item(asm-keccak256) + // standard "ens" offchain signing protocol + bytes32 hash = keccak256( + abi.encodePacked( + bytes2(0x1900), + msg.sender, + expiry, + keccak256(request), // original calldata, eg. msg.data + keccak256(answer) // response from server + ) + ); + address signed = ECDSA.recover(hash, sig); + if (!isOffchainSigner(msg.sender, signed)) { + revert CCIPReadUntrusted(signed); + } + return answer; + } +} diff --git a/test/e2e.test.ts b/test/e2e.test.ts index b546d3b..c87a600 100755 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -1,12 +1,5 @@ -import { - afterAll, - afterEach, - beforeAll, - describe, - expect, - test, -} from "bun:test"; -import { Foundry, type DeployedContract } from "@adraffy/blocksmith"; +import { afterAll, afterEach, describe, expect, test } from "bun:test"; +import { Foundry } from "@adraffy/blocksmith"; import { RESOLVE_ABI } from "@namestone/ezccip"; import { serve } from "@namestone/ezccip/serve"; import { dnsEncode, namehash, id as labelhash, ZeroHash } from "ethers"; @@ -17,95 +10,109 @@ const ETH = "0x51050ec063d393217B436747617aD1C2285Aeeee"; const BTC = "0xdeadfee5"; const CH = "0x1234"; -describe("e2e", () => { - let F: Foundry; - let S: Awaited>; - let OR: DeployedContract; - let UR: DeployedContract; - let ENS: DeployedContract; - beforeAll(async () => { - F = await Foundry.launch({ infoLog: false }); // enable to show anvil events - // deploy OffchainResolver - const { admin } = F.wallets; - OR = await F.deploy({ - file: "OffchainResolver", - args: [admin, [], []], - }); - // setup offchain server - S = await serve( - (name) => { - if (name === "raffy.eth") { - return { - addr(coinType) { - if (coinType === 0x8000_0000n) { - return ETH; // default address - } else if (coinType === 0n) { - return BTC; - } - }, - text(key) { - return `key is ${key}`; - }, - contenthash() { - return CH; - }, - // if not a standard profile - // by default, ezccip will return UnsupportedResolverProfile() - }; - } - // we dont know this name - // by default, ezccip will return UnreachableName() - }, - { protocol: "ens" } - ); - // ezccip generates a random key if not specified - // add this key as a trusted signer - await F.confirm(OR.setSigner(S.signer, true)); - // to support recursive ccip-read, we ignore the ccip sender, - // and instead sign relative to the contract we're supporting. - // note: ezccip automatically interprets the first address - // in an URL as the "origin" of the ccip request - await F.confirm(OR.setGateways([`${S.endpoint}/${OR.target}`])); - // deploy ENS registry - ENS = await F.deploy({ - import: "@ens/registry/ENSRegistry.sol", - }); - // setup "addr.reverse" to handle ReverseClaimer.claim() on UR - const FakeReverseRegistrar = await F.deploy(`contract X { - function claim(address) external pure returns (bytes32) {} - }`); - await F.confirm(ENS.setSubnodeOwner(ZeroHash, labelhash("reverse"), admin)); - await F.confirm( - ENS.setSubnodeOwner( - namehash("reverse"), - labelhash("addr"), - FakeReverseRegistrar - ) - ); - // deploy UniversalResolver - const BatchGatewayProvider = await F.deploy({ - import: "@ens/ccipRead/GatewayProvider.sol", - args: [admin, []], // no gateways are required since OffchainResolver supports ENSIP-22 - }); - UR = await F.deploy({ - import: "@ens/universalResolver/UniversalResolver.sol", - args: [admin, ENS, BatchGatewayProvider], - }); - // setup "raffy.eth" - await F.confirm(ENS.setSubnodeOwner(ZeroHash, labelhash("eth"), admin)); - await F.confirm( - ENS.setSubnodeRecord(namehash("eth"), labelhash("raffy"), admin, OR, 0) - ); +describe("e2e", async () => { + const F = await Foundry.launch({ infoLog: false }); // enable to show anvil events + afterAll(F.shutdown); + await F.parseArtifacts(); // all interfaces + + // deploy OffchainResolver + const { admin } = F.wallets; + const OffchainVerifier = await F.deploy({ + file: "OffchainVerifier", }); - afterAll(() => F?.shutdown); - afterAll(() => S?.shutdown); + const OR = await F.deploy({ + file: "OffchainResolver", + args: [admin], + }); + // setup offchain server + const S = await serve( + (name) => { + if (name === "raffy.eth") { + return { + addr(coinType) { + if (coinType === 0x8000_0000n) { + return ETH; // default address + } else if (coinType === 0n) { + return BTC; + } + }, + text(key) { + return `key is ${key}`; + }, + contenthash() { + return CH; + }, + // if not a standard profile + // by default, ezccip will return UnsupportedResolverProfile() + }; + } + // we dont know this name + // by default, ezccip will return UnreachableName() + }, + { protocol: "ens" } + ); + expect( + F.getEventResults( + await F.confirm(OR.setVerifier(OffchainVerifier)), + "VerifierChanged" + ) + ).toEqual<[[string, string]]>([["0x00", OffchainVerifier.target]]); + // ezccip generates a random key if not specified + // add this key as a trusted signer + expect( + F.getEventResults( + await F.confirm(OffchainVerifier.setOffchainSigner(OR, S.signer, true)), + "SignerChanged" + ) + ).toEqual<[[string, string, boolean]]>([[OR.target, S.signer, true]]); + // to support recursive ccip-read, we ignore the ccip sender, + // and instead sign relative to the contract we're supporting. + // note: ezccip automatically interprets the first address + // in an URL as the "origin" of the ccip request + const gateways = [`${S.endpoint}/${OR.target}`]; + expect( + F.getEventResults( + await F.confirm(OR.setGateways(gateways)), + "GatewaysChanged" + ) + ).toEqual<[[string[]]]>([[gateways]]); + // deploy ENS registry + const ENS = await F.deploy({ + import: "@ens/registry/ENSRegistry.sol", + }); + // setup "addr.reverse" to handle ReverseClaimer.claim() on UR + const FakeReverseRegistrar = await F.deploy(`contract X { + function claim(address) external pure returns (bytes32) {} + }`); + await F.confirm(ENS.setSubnodeOwner(ZeroHash, labelhash("reverse"), admin)); + await F.confirm( + ENS.setSubnodeOwner( + namehash("reverse"), + labelhash("addr"), + FakeReverseRegistrar + ) + ); + // deploy UniversalResolver + const BatchGatewayProvider = await F.deploy({ + import: "@ens/ccipRead/GatewayProvider.sol", + args: [admin, []], // no gateways are required since OffchainResolver supports ENSIP-22 + }); + const UR = await F.deploy({ + import: "@ens/universalResolver/UniversalResolver.sol", + args: [admin, ENS, BatchGatewayProvider], + }); + // setup "raffy.eth" + await F.confirm(ENS.setSubnodeOwner(ZeroHash, labelhash("eth"), admin)); + await F.confirm( + ENS.setSubnodeRecord(namehash("eth"), labelhash("raffy"), admin, OR, 0) + ); test("toggle signer", async () => { - expect(OR.isSigner(ETH)).resolves.toBeFalse(); - await F.confirm(OR.setSigner(ETH, true)); - expect(OR.isSigner(ETH)).resolves.toBeTrue(); - await F.confirm(OR.setSigner(ETH, false)); - expect(OR.isSigner(ETH)).resolves.toBeFalse(); + expect(OffchainVerifier.isOffchainSigner(OR, ETH)).resolves.toBeFalse(); + await F.confirm(OffchainVerifier.setOffchainSigner(OR, ETH, true)); + expect(OffchainVerifier.isOffchainSigner(OR, ETH)).resolves.toBeTrue(); + await F.confirm(OffchainVerifier.setOffchainSigner(OR, ETH, false)); + expect(OffchainVerifier.isOffchainSigner(OR, ETH)).resolves.toBeFalse(); }); test("UnreachableName", async () => { @@ -282,16 +289,15 @@ describe("e2e", () => { test("UR: multicall() w/o Multicall feature", async () => { // deploy a modified OffchainResolver disabled features - const HR = await F.deploy({ - sol: `import "@src/OffchainResolver.sol"; - contract HR is OffchainResolver { - constructor(address[] memory ss) OffchainResolver(msg.sender, ss, new string[](0)) {} - function supportsFeature(bytes4) external pure override returns (bool) { - return false; - } - }`, - args: [[S.signer]], - }); + const HR = await F.deploy(`import "@src/OffchainResolver.sol"; + contract HR is OffchainResolver(msg.sender) { + function supportsFeature(bytes4) external pure override returns (bool) { + return false; + } + } + `); + await F.confirm(HR.setVerifier(OffchainVerifier)); + await F.confirm(OffchainVerifier.setOffchainSigner(HR, S.signer, true)); await F.confirm(HR.setGateways([`${S.endpoint}/${HR.target}`])); await F.confirm(ENS.setResolver(namehash("raffy.eth"), HR)); // replace resolver in registry // setup local batch gateway to facilitate multicall