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/resolvers/DelegatableResolver.sol b/contracts/resolvers/DelegatableResolver.sol new file mode 100644 index 00000000..92b0f3a7 --- /dev/null +++ b/contracts/resolvers/DelegatableResolver.sol @@ -0,0 +1,134 @@ +pragma solidity >=0.8.4; +import "./profiles/ABIResolver.sol"; +import "./profiles/AddrResolver.sol"; +import "./profiles/ContentHashResolver.sol"; +import "./profiles/DNSResolver.sol"; +import "./profiles/InterfaceResolver.sol"; +import "./profiles/NameResolver.sol"; +import "./profiles/PubkeyResolver.sol"; +import "./profiles/TextResolver.sol"; +import "./profiles/ExtendedResolver.sol"; +import "./Multicallable.sol"; +import "./IDelegatableResolver.sol"; +import {Clone} from "clones-with-immutable-args/src/Clone.sol"; + +/** + * A delegated resolver that allows the resolver owner to add an operator to update records of a node on behalf of the owner. + * address. + */ +contract DelegatableResolver is + Clone, + Multicallable, + ABIResolver, + AddrResolver, + ContentHashResolver, + DNSResolver, + InterfaceResolver, + NameResolver, + PubkeyResolver, + TextResolver, + ExtendedResolver +{ + using BytesUtils for bytes; + + // Logged when an operator is added or removed. + event Approval( + bytes32 indexed node, + address indexed operator, + bytes name, + bool approved + ); + + error NotAuthorized(bytes32 node); + + //node => (delegate => isAuthorised) + mapping(bytes32 => mapping(address => bool)) operators; + + /* + * Check to see if the operator has been approved by the owner for the node. + * @param name The ENS node to query + * @param offset The offset of the label to query recursively. Start from the 0 position and kepp adding the length of each label as it traverse. The function exits when len is 0. + * @param operator The address of the operator to query + * @return node The node of the name passed as an argument + * @return authorized The boolean state of whether the operator is approved to update record of the name + */ + function getAuthorisedNode( + bytes memory name, + uint256 offset, + address operator + ) public view returns (bytes32 node, bool authorized) { + uint256 len = name.readUint8(offset); + node = bytes32(0); + if (len > 0) { + bytes32 label = name.keccak(offset + 1, len); + (node, authorized) = getAuthorisedNode( + name, + offset + len + 1, + operator + ); + node = keccak256(abi.encodePacked(node, label)); + } else { + return ( + node, + authorized || operators[node][operator] || owner() == operator + ); + } + return (node, authorized || operators[node][operator]); + } + + /** + * @dev Approve an operator to be able to updated records on a node. + */ + function approve( + bytes memory name, + address operator, + bool approved + ) external { + (bytes32 node, bool authorized) = getAuthorisedNode( + name, + 0, + msg.sender + ); + if (!authorized) { + revert NotAuthorized(node); + } + operators[node][operator] = approved; + emit Approval(node, operator, name, approved); + } + + /* + * Returns the owner address passed set by the Factory + * @return address The owner address + */ + function owner() public view returns (address) { + return _getArgAddress(0); + } + + function isAuthorised(bytes32 node) internal view override returns (bool) { + return msg.sender == owner() || operators[node][msg.sender]; + } + + function supportsInterface( + bytes4 interfaceID + ) + public + view + virtual + override( + Multicallable, + ABIResolver, + AddrResolver, + ContentHashResolver, + DNSResolver, + InterfaceResolver, + NameResolver, + PubkeyResolver, + TextResolver + ) + returns (bool) + { + return + interfaceID == type(IDelegatableResolver).interfaceId || + super.supportsInterface(interfaceID); + } +} diff --git a/contracts/resolvers/DelegatableResolverFactory.sol b/contracts/resolvers/DelegatableResolverFactory.sol new file mode 100644 index 00000000..a134691a --- /dev/null +++ b/contracts/resolvers/DelegatableResolverFactory.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +import "./DelegatableResolver.sol"; +import {ClonesWithImmutableArgs} from "clones-with-immutable-args/src/ClonesWithImmutableArgs.sol"; + +/** + * A resolver factory that creates a dedicated resolver for each user + */ + +contract DelegatableResolverFactory { + using ClonesWithImmutableArgs for address; + + DelegatableResolver public implementation; + event NewDelegatableResolver(address resolver, address owner); + + constructor(DelegatableResolver _implementation) { + implementation = _implementation; + } + + /* + * Create the unique address unique to the owner + * @param address The address of the resolver owner + * @return address The address of the newly created Resolver + */ + function create( + address owner + ) external returns (DelegatableResolver clone) { + bytes memory data = abi.encodePacked(owner); + clone = DelegatableResolver(address(implementation).clone2(data)); + emit NewDelegatableResolver(address(clone), owner); + } + + /* + * Returns the unique address unique to the owner + * @param address The address of the resolver owner + * @return address The address of the newly created Resolver + */ + function predictAddress(address owner) external returns (address clone) { + bytes memory data = abi.encodePacked(owner); + clone = address(implementation).addressOfClone2(data); + } +} diff --git a/contracts/resolvers/IDelegatableResolver.sol b/contracts/resolvers/IDelegatableResolver.sol new file mode 100644 index 00000000..f01b0c35 --- /dev/null +++ b/contracts/resolvers/IDelegatableResolver.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.4; + +interface IDelegatableResolver { + function approve( + bytes memory name, + address operator, + bool approved + ) external; + + function getAuthorisedNode( + bytes memory name, + uint256 offset, + address operator + ) external returns (bytes32 node, bool authorized); + + function owner() external view returns (address); +} diff --git a/package.json b/package.json index 925fb889..50abc811 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@ensdomains/buffer": "^0.1.1", "@ensdomains/solsha1": "0.0.3", "@openzeppelin/contracts": "^4.1.0", + "clones-with-immutable-args": "wighawag/clones-with-immutable-args", "dns-packet": "^5.3.0" }, "directories": { diff --git a/test/resolvers/TestDelegatableResolver.js b/test/resolvers/TestDelegatableResolver.js new file mode 100644 index 00000000..a8a86d28 --- /dev/null +++ b/test/resolvers/TestDelegatableResolver.js @@ -0,0 +1,207 @@ +const DelegatableResolverFactory = artifacts.require( + 'DelegatableResolverFactory.sol', +) +const DelegatableResolver = artifacts.require('DelegatableResolver.sol') +const { encodeName, namehash } = require('../test-utils/ens') +const { exceptions } = require('../test-utils') +const { expect } = require('chai') + +contract('DelegatableResolver', function (accounts) { + let node + let encodedname + let resolver, operatorResolver + let signers + let deployer + let owner, ownerSigner + let operator, operatorSigner + let operator2, operator2Signer + let impl + + beforeEach(async () => { + signers = await ethers.getSigners() + deployer = await signers[0] + ownerSigner = await signers[1] + owner = await ownerSigner.getAddress() + operatorSigner = await signers[2] + operator = await operatorSigner.getAddress() + operator2Signer = await signers[3] + operator2 = await operator2Signer.getAddress() + node = namehash('eth') + encodedname = encodeName('eth') + impl = await DelegatableResolver.new() + factory = await DelegatableResolverFactory.new(impl.address) + const tx = await factory.create(owner) + const result = tx.logs[0].args.resolver + resolver = await (await ethers.getContractFactory('DelegatableResolver')) + .attach(result) + .connect(ownerSigner) + operatorResolver = await ( + await ethers.getContractFactory('DelegatableResolver') + ) + .attach(result) + .connect(operatorSigner) + }) + + describe('supportsInterface function', async () => { + it('supports known interfaces', async () => { + assert.equal(await resolver.supportsInterface('0x3b3b57de'), true) // IAddrResolver + assert.equal(await resolver.supportsInterface('0xf1cb7e06'), true) // IAddressResolver + assert.equal(await resolver.supportsInterface('0x691f3431'), true) // INameResolver + assert.equal(await resolver.supportsInterface('0x2203ab56'), true) // IABIResolver + assert.equal(await resolver.supportsInterface('0xc8690233'), true) // IPubkeyResolver + assert.equal(await resolver.supportsInterface('0x59d1d43c'), true) // ITextResolver + assert.equal(await resolver.supportsInterface('0xbc1c58d1'), true) // IContentHashResolver + assert.equal(await resolver.supportsInterface('0xa8fa5682'), true) // IDNSRecordResolver + assert.equal(await resolver.supportsInterface('0x5c98042b'), true) // IDNSZoneResolver + assert.equal(await resolver.supportsInterface('0x01ffc9a7'), true) // IInterfaceResolver + assert.equal(await resolver.supportsInterface('0x4fbf0433'), true) // IMulticallable + assert.equal(await resolver.supportsInterface('0x8295fc20'), true) // IDelegatable + }) + + it('does not support a random interface', async () => { + assert.equal(await resolver.supportsInterface('0x3b3b57df'), false) + }) + }) + + describe('factory', async () => { + it('predicts address', async () => { + const tx = await factory.create(operator) + const result = tx.logs[0].args.resolver + assert.equal(await factory.predictAddress.call(operator), result) + }) + + it('emits an event', async () => { + const tx = await factory.create(operator) + const log = tx.logs[0] + assert.equal(log.args.owner, operator) + }) + + it('does not allow duplicate contracts', async () => { + await expect(factory.create(owner)).to.be.revertedWith('CreateFail') + }) + }) + + describe('addr', async () => { + it('permits setting address by owner', async () => { + await resolver.functions['setAddr(bytes32,address)'](node, operator) + assert.equal(await resolver.functions['addr(bytes32)'](node), operator) + }) + + it('forbids setting new address by non-owners', async () => { + await exceptions.expectFailure( + operatorResolver.functions['setAddr(bytes32,address)'](node, operator), + ) + }) + + it('forbids approving wrong node', async () => { + encodedname = encodeName('a.b.c.eth') + const wrongnode = namehash('d.b.c.eth') + await resolver.approve(encodedname, operator, true) + await exceptions.expectFailure( + operatorResolver.functions['setAddr(bytes32,address)']( + wrongnode, + operator, + ), + ) + }) + }) + + describe('authorisations', async () => { + it('owner is the owner', async () => { + assert.equal(await resolver.owner(), owner) + }) + + it('owner is ahtorised to update any names', async () => { + assert.equal( + (await resolver.getAuthorisedNode(encodeName('a.b.c'), 0, owner)) + .authorized, + true, + ) + assert.equal( + (await resolver.getAuthorisedNode(encodeName('x.y.z'), 0, owner)) + .authorized, + true, + ) + }) + + it('approves multiple users', async () => { + await resolver.approve(encodedname, operator, true) + await resolver.approve(encodedname, operator2, true) + const result = await resolver.getAuthorisedNode(encodedname, 0, operator) + assert.equal(result.node, node) + assert.equal(result.authorized, true) + assert.equal( + (await resolver.getAuthorisedNode(encodedname, 0, operator2)) + .authorized, + true, + ) + }) + + it('approves subnames', async () => { + const subname = 'a.b.c.eth' + await resolver.approve(encodeName(subname), operator, true) + await operatorResolver.functions['setAddr(bytes32,address)']( + namehash(subname), + operator, + ) + }) + + it('only approves the subname and not its parent', async () => { + const subname = '1234.123' + const parentname = 'b.c.eth' + await resolver.approve(encodeName(subname), operator, true) + const result = await resolver.getAuthorisedNode( + encodeName(subname), + 0, + operator, + ) + assert.equal(result.node, namehash(subname)) + assert.equal(result.authorized, true) + const result2 = await resolver.getAuthorisedNode( + encodeName(parentname), + 0, + operator, + ) + assert.equal(result2.node, namehash(parentname)) + assert.equal(result2.authorized, false) + }) + + it('approves users to make changes', async () => { + await resolver.approve(encodedname, operator, true) + await operatorResolver.functions['setAddr(bytes32,address)']( + node, + operator, + ) + console.log('resolver.functions', resolver.functions['addr(bytes32)']) + assert.equal(await resolver.functions['addr(bytes32)'](node), operator) + }) + + it('approves to be revoked', async () => { + await resolver.approve(encodedname, operator, true) + operatorResolver.functions['setAddr(bytes32,address)'](node, operator2) + await resolver.approve(encodedname, operator, false) + await exceptions.expectFailure( + operatorResolver.functions['setAddr(bytes32,address)'](node, operator2), + ) + }) + + it('does not allow non owner to approve', async () => { + await expect( + operatorResolver.approve(encodedname, operator, true), + ).to.be.revertedWith('NotAuthorized') + }) + + it('emits an Approval log', async () => { + var tx = await ( + await resolver.approve(encodedname, operator, true) + ).wait() + const event = tx.events[0] + const args = event.args + assert.equal(event.event, 'Approval') + assert.equal(args.node, node) + assert.equal(args.operator, operator) + assert.equal(args.name, encodedname) + assert.equal(args.approved, true) + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index 91ac4da1..1b816c1a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3050,6 +3050,10 @@ clone@2.1.2, clone@^2.0.0: resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w== +clones-with-immutable-args@wighawag/clones-with-immutable-args: + version "1.1.2" + resolved "https://codeload.github.com/wighawag/clones-with-immutable-args/tar.gz/2df4bff4eef8c061b9a92a7edbbfe12dbf6bcdb0" + code-point-at@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"