From 8c58736eb60153ebdd12f48004b63ec9139f7026 Mon Sep 17 00:00:00 2001 From: tate Date: Tue, 28 May 2024 09:55:25 +1000 Subject: [PATCH] checkpoint --- bun.lockb | Bin 577740 -> 577740 bytes .../test}/mocks/DummyOffchainResolver.sol | 0 .../test}/mocks/LegacyResolver.sol | 0 .../test}/mocks/MockERC20.sol | 0 .../test}/mocks/MockOffchainResolver.sol | 0 .../mocks/MockReverseClaimerImplementer.sol | 0 .../test}/mocks/StringUtilsTest.sol | 0 package.json | 4 +- test/ethregistrar/TestBaseRegistrar.ts | 57 +- .../TestEthRegistrarController.ts | 16 +- test/fixtures/constants.ts | 15 + test/fixtures/utils.ts | 5 + test/utils/TestERC20Recoverable.ts | 56 + test/utils/TestHexUtils.ts | 201 +++ test/utils/TestNameEncoder.ts | 45 + test/utils/TestStringUtils.ts | 35 + test/utils/TestUniversalResolver.ts | 1256 +++++++++++++++++ test/wrapper/TestBytesUtils.ts | 89 ++ test/wrapper/TestTestUnwrap.ts | 250 ++++ 19 files changed, 1983 insertions(+), 46 deletions(-) rename {test/utils => contracts/test}/mocks/DummyOffchainResolver.sol (100%) rename {test/utils => contracts/test}/mocks/LegacyResolver.sol (100%) rename {test/utils => contracts/test}/mocks/MockERC20.sol (100%) rename {test/utils => contracts/test}/mocks/MockOffchainResolver.sol (100%) rename {test/reverseRegistrar => contracts/test}/mocks/MockReverseClaimerImplementer.sol (100%) rename {test/utils => contracts/test}/mocks/StringUtilsTest.sol (100%) create mode 100644 test/fixtures/constants.ts create mode 100644 test/fixtures/utils.ts create mode 100644 test/utils/TestERC20Recoverable.ts create mode 100644 test/utils/TestHexUtils.ts create mode 100644 test/utils/TestNameEncoder.ts create mode 100644 test/utils/TestStringUtils.ts create mode 100644 test/utils/TestUniversalResolver.ts create mode 100644 test/wrapper/TestBytesUtils.ts create mode 100644 test/wrapper/TestTestUnwrap.ts diff --git a/bun.lockb b/bun.lockb index 680da88595daa022c54cbaf997d890db1f9d8d9b..4d410dcbb31f5001fe58a7f2a3289be73363eca5 100755 GIT binary patch delta 173 zcmV;e08;&gliS b0X2t$>IH{_>IS!h>IZV+0X4Vv%Lvy5r0PwP delta 173 zcmV;e08;IH{_>IS!h>IZV+0W`Pu%Lvy5!R%8) diff --git a/test/utils/mocks/DummyOffchainResolver.sol b/contracts/test/mocks/DummyOffchainResolver.sol similarity index 100% rename from test/utils/mocks/DummyOffchainResolver.sol rename to contracts/test/mocks/DummyOffchainResolver.sol diff --git a/test/utils/mocks/LegacyResolver.sol b/contracts/test/mocks/LegacyResolver.sol similarity index 100% rename from test/utils/mocks/LegacyResolver.sol rename to contracts/test/mocks/LegacyResolver.sol diff --git a/test/utils/mocks/MockERC20.sol b/contracts/test/mocks/MockERC20.sol similarity index 100% rename from test/utils/mocks/MockERC20.sol rename to contracts/test/mocks/MockERC20.sol diff --git a/test/utils/mocks/MockOffchainResolver.sol b/contracts/test/mocks/MockOffchainResolver.sol similarity index 100% rename from test/utils/mocks/MockOffchainResolver.sol rename to contracts/test/mocks/MockOffchainResolver.sol diff --git a/test/reverseRegistrar/mocks/MockReverseClaimerImplementer.sol b/contracts/test/mocks/MockReverseClaimerImplementer.sol similarity index 100% rename from test/reverseRegistrar/mocks/MockReverseClaimerImplementer.sol rename to contracts/test/mocks/MockReverseClaimerImplementer.sol diff --git a/test/utils/mocks/StringUtilsTest.sol b/contracts/test/mocks/StringUtilsTest.sol similarity index 100% rename from test/utils/mocks/StringUtilsTest.sol rename to contracts/test/mocks/StringUtilsTest.sol diff --git a/package.json b/package.json index 13146952..d2c5afcc 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "compile": "NODE_OPTIONS=\"--experimental-loader ts-node/esm/transpile-only\" hardhat compile", - "test": "NODE_OPTIONS=\"--experimental-loader ts-node/esm/transpile-only\" TS_NODE_PREFER_TS_EXTS=true hardhat test ./test/root/TestRoot.ts", + "test": "NODE_OPTIONS=\"--experimental-loader ts-node/esm/transpile-only\" TS_NODE_PREFER_TS_EXTS=true hardhat test ./test/wrapper/TestTestUnwrap.ts", "test:parallel": "NODE_OPTIONS=\"--experimental-loader ts-node/esm/transpile-only\" TS_NODE_PREFER_TS_EXTS=true hardhat test ./test/**/Test*.ts --parallel", "test:local": "hardhat --network localhost test", "test:deploy": "hardhat --network hardhat deploy", @@ -28,7 +28,7 @@ "main": "index.js", "devDependencies": { "@ensdomains/dnsprovejs": "^0.3.7", - "@ensdomains/hardhat-chai-matchers-viem": "^0.0.4", + "@ensdomains/hardhat-chai-matchers-viem": "^0.0.5", "@ensdomains/test-utils": "^1.3.0", "@nomicfoundation/hardhat-toolbox-viem": "^3.0.0", "@openzeppelin/test-helpers": "^0.5.11", diff --git a/test/ethregistrar/TestBaseRegistrar.ts b/test/ethregistrar/TestBaseRegistrar.ts index 04b47735..49098b83 100644 --- a/test/ethregistrar/TestBaseRegistrar.ts +++ b/test/ethregistrar/TestBaseRegistrar.ts @@ -1,7 +1,8 @@ import { loadFixture } from '@nomicfoundation/hardhat-toolbox-viem/network-helpers.js' import { expect } from 'chai' import hre from 'hardhat' -import { hexToBigInt, labelhash, namehash, zeroAddress, zeroHash } from 'viem' +import { labelhash, namehash, zeroAddress, zeroHash } from 'viem' +import { toLabelId } from '../fixtures/utils.js' const getAccounts = async () => { const [ownerClient, controllerClient, registrantClient, otherClient] = @@ -40,7 +41,7 @@ async function fixture() { async function fixtureWithRegistration() { const existing = await loadFixture(fixture) await existing.baseRegistrar.write.register( - [labelId('newname'), existing.registrantAccount.address, 86400n], + [toLabelId('newname'), existing.registrantAccount.address, 86400n], { account: existing.controllerAccount, }, @@ -48,8 +49,6 @@ async function fixtureWithRegistration() { return existing } -const labelId = (label: string) => hexToBigInt(labelhash(label)) - describe('BaseRegistrar', () => { it('should allow new registrations', async () => { const { @@ -61,7 +60,7 @@ describe('BaseRegistrar', () => { } = await loadFixture(fixture) const hash = await baseRegistrar.write.register( - [labelId('newname'), registrantAccount.address, 86400n], + [toLabelId('newname'), registrantAccount.address, 86400n], { account: controllerAccount, }, @@ -73,10 +72,10 @@ describe('BaseRegistrar', () => { ensRegistry.read.owner([namehash('newname.eth')]), ).resolves.toEqualAddress(registrantAccount.address) await expect( - baseRegistrar.read.ownerOf([labelId('newname')]), + baseRegistrar.read.ownerOf([toLabelId('newname')]), ).resolves.toEqualAddress(registrantAccount.address) await expect( - baseRegistrar.read.nameExpires([labelId('newname')]), + baseRegistrar.read.nameExpires([toLabelId('newname')]), ).resolves.toEqual(block.timestamp + 86400n) }) @@ -90,7 +89,7 @@ describe('BaseRegistrar', () => { } = await loadFixture(fixture) const hash = await baseRegistrar.write.registerOnly( - [labelId('silentname'), registrantAccount.address, 86400n], + [toLabelId('silentname'), registrantAccount.address, 86400n], { account: controllerAccount, }, @@ -102,10 +101,10 @@ describe('BaseRegistrar', () => { ensRegistry.read.owner([namehash('silentname.eth')]), ).resolves.toEqualAddress(zeroAddress) await expect( - baseRegistrar.read.ownerOf([labelId('silentname')]), + baseRegistrar.read.ownerOf([toLabelId('silentname')]), ).resolves.toEqualAddress(registrantAccount.address) await expect( - baseRegistrar.read.nameExpires([labelId('silentname')]), + baseRegistrar.read.nameExpires([toLabelId('silentname')]), ).resolves.toEqual(block.timestamp + 86400n) }) @@ -115,15 +114,15 @@ describe('BaseRegistrar', () => { ) const oldExpires = await baseRegistrar.read.nameExpires([ - labelId('newname'), + toLabelId('newname'), ]) - await baseRegistrar.write.renew([labelId('newname'), 86400n], { + await baseRegistrar.write.renew([toLabelId('newname'), 86400n], { account: controllerAccount, }) await expect( - baseRegistrar.read.nameExpires([labelId('newname')]), + baseRegistrar.read.nameExpires([toLabelId('newname')]), ).resolves.toEqual(oldExpires + 86400n) }) @@ -131,7 +130,7 @@ describe('BaseRegistrar', () => { const { baseRegistrar, otherAccount } = await loadFixture(fixture) await expect(baseRegistrar) - .write('register', [labelId('foo'), otherAccount.address, 86400n], { + .write('register', [toLabelId('foo'), otherAccount.address, 86400n], { account: otherAccount, }) .toBeRevertedWithoutReason() @@ -141,7 +140,7 @@ describe('BaseRegistrar', () => { const { baseRegistrar, otherAccount } = await loadFixture(fixture) await expect(baseRegistrar) - .write('renew', [labelId('foo'), 86400n], { + .write('renew', [toLabelId('foo'), 86400n], { account: otherAccount, }) .toBeRevertedWithoutReason() @@ -154,7 +153,7 @@ describe('BaseRegistrar', () => { await expect(baseRegistrar) .write( 'register', - [labelId('newname'), registrantAccount.address, 86400n], + [toLabelId('newname'), registrantAccount.address, 86400n], { account: controllerAccount, }, @@ -166,7 +165,7 @@ describe('BaseRegistrar', () => { const { baseRegistrar, controllerAccount } = await loadFixture(fixture) await expect(baseRegistrar) - .write('renew', [labelId('newname'), 86400n], { + .write('renew', [toLabelId('newname'), 86400n], { account: controllerAccount, }) .toBeRevertedWithoutReason() @@ -181,7 +180,7 @@ describe('BaseRegistrar', () => { account: registrantAccount, }) await baseRegistrar.write.reclaim( - [labelId('newname'), registrantAccount.address], + [toLabelId('newname'), registrantAccount.address], { account: registrantAccount, }, @@ -201,7 +200,7 @@ describe('BaseRegistrar', () => { }) await expect(baseRegistrar) - .write('reclaim', [labelId('newname'), registrantAccount.address], { + .write('reclaim', [toLabelId('newname'), registrantAccount.address], { account: otherAccount, }) .toBeRevertedWithoutReason() @@ -212,21 +211,21 @@ describe('BaseRegistrar', () => { await loadFixture(fixtureWithRegistration) await baseRegistrar.write.transferFrom( - [registrantAccount.address, otherAccount.address, labelId('newname')], + [registrantAccount.address, otherAccount.address, toLabelId('newname')], { account: registrantAccount, }, ) await expect( - baseRegistrar.read.ownerOf([labelId('newname')]), + baseRegistrar.read.ownerOf([toLabelId('newname')]), ).resolves.toEqualAddress(otherAccount.address) await expect( ensRegistry.read.owner([namehash('newname.eth')]), ).resolves.toEqualAddress(registrantAccount.address) await baseRegistrar.write.transferFrom( - [otherAccount.address, registrantAccount.address, labelId('newname')], + [otherAccount.address, registrantAccount.address, toLabelId('newname')], { account: otherAccount, }, @@ -241,7 +240,7 @@ describe('BaseRegistrar', () => { await expect(baseRegistrar) .write( 'transferFrom', - [otherAccount.address, otherAccount.address, labelId('newname')], + [otherAccount.address, otherAccount.address, toLabelId('newname')], { account: otherAccount, }, @@ -260,7 +259,7 @@ describe('BaseRegistrar', () => { await expect(baseRegistrar) .write( 'transferFrom', - [registrantAccount.address, otherAccount.address, labelId('newname')], + [registrantAccount.address, otherAccount.address, toLabelId('newname')], { account: registrantAccount, }, @@ -268,7 +267,7 @@ describe('BaseRegistrar', () => { .toBeRevertedWithoutReason() await expect(baseRegistrar) - .write('reclaim', [labelId('newname'), registrantAccount.address], { + .write('reclaim', [toLabelId('newname'), registrantAccount.address], { account: registrantAccount, }) .toBeRevertedWithoutReason() @@ -283,7 +282,7 @@ describe('BaseRegistrar', () => { await testClient.increaseTime({ seconds: 86400 + 3600 }) await testClient.mine({ blocks: 1 }) - await baseRegistrar.write.renew([labelId('newname'), 86400n], { + await baseRegistrar.write.renew([toLabelId('newname'), 86400n], { account: controllerAccount, }) }) @@ -301,18 +300,18 @@ describe('BaseRegistrar', () => { await testClient.mine({ blocks: 1 }) await expect(baseRegistrar) - .read('ownerOf', [labelId('newname')]) + .read('ownerOf', [toLabelId('newname')]) .toBeRevertedWithoutReason() await baseRegistrar.write.register( - [labelId('newname'), otherAccount.address, 86400n], + [toLabelId('newname'), otherAccount.address, 86400n], { account: controllerAccount, }, ) await expect( - baseRegistrar.read.ownerOf([labelId('newname')]), + baseRegistrar.read.ownerOf([toLabelId('newname')]), ).resolves.toEqualAddress(otherAccount.address) }) diff --git a/test/ethregistrar/TestEthRegistrarController.ts b/test/ethregistrar/TestEthRegistrarController.ts index d175326a..d0a11bc7 100644 --- a/test/ethregistrar/TestEthRegistrarController.ts +++ b/test/ethregistrar/TestEthRegistrarController.ts @@ -10,6 +10,7 @@ import { zeroAddress, zeroHash, } from 'viem' +import { DAY, FUSES } from '../fixtures/constants.js' import { getReverseNode } from '../fixtures/getReverseNode.js' import { commitName, @@ -18,25 +19,10 @@ import { registerName, } from '../fixtures/registerName.js' -const DAY = 24n * 60n * 60n const REGISTRATION_TIME = 28n * DAY const BUFFERED_REGISTRATION_COST = REGISTRATION_TIME + 3n * DAY const GRACE_PERIOD = 90n * DAY -const FUSES = { - CAN_DO_EVERYTHING: 0, - CANNOT_UNWRAP: 1, - CANNOT_BURN_FUSES: 2, - CANNOT_TRANSFER: 4, - CANNOT_SET_RESOLVER: 8, - CANNOT_SET_TTL: 16, - CANNOT_CREATE_SUBDOMAIN: 32, - CANNOT_APPROVE: 64, - PARENT_CANNOT_CONTROL: 2 ** 16, - IS_DOT_ETH: 2 ** 17, - CAN_EXTEND_EXPIRY: 2 ** 18, -} as const - const getAccounts = async () => { const [ownerClient, registrantClient, otherClient] = await hre.viem.getWalletClients() diff --git a/test/fixtures/constants.ts b/test/fixtures/constants.ts new file mode 100644 index 00000000..71d440fd --- /dev/null +++ b/test/fixtures/constants.ts @@ -0,0 +1,15 @@ +export const FUSES = { + CAN_DO_EVERYTHING: 0, + CANNOT_UNWRAP: 1, + CANNOT_BURN_FUSES: 2, + CANNOT_TRANSFER: 4, + CANNOT_SET_RESOLVER: 8, + CANNOT_SET_TTL: 16, + CANNOT_CREATE_SUBDOMAIN: 32, + CANNOT_APPROVE: 64, + PARENT_CANNOT_CONTROL: 2 ** 16, + IS_DOT_ETH: 2 ** 17, + CAN_EXTEND_EXPIRY: 2 ** 18, +} as const + +export const DAY = 24n * 60n * 60n diff --git a/test/fixtures/utils.ts b/test/fixtures/utils.ts new file mode 100644 index 00000000..37b5e513 --- /dev/null +++ b/test/fixtures/utils.ts @@ -0,0 +1,5 @@ +import { hexToBigInt, labelhash, namehash, type Hex } from 'viem' + +export const toTokenId = (hash: Hex) => hexToBigInt(hash) +export const toLabelId = (label: string) => toTokenId(labelhash(label)) +export const toNameId = (name: string) => toTokenId(namehash(name)) diff --git a/test/utils/TestERC20Recoverable.ts b/test/utils/TestERC20Recoverable.ts new file mode 100644 index 00000000..d94a25b8 --- /dev/null +++ b/test/utils/TestERC20Recoverable.ts @@ -0,0 +1,56 @@ +import { loadFixture } from '@nomicfoundation/hardhat-toolbox-viem/network-helpers.js' +import { expect } from 'chai' +import hre from 'hardhat' + +async function fixture() { + const accounts = await hre.viem + .getWalletClients() + .then((clients) => clients.map((c) => c.account)) + const erc20Recoverable = await hre.viem.deployContract('ERC20Recoverable', []) + const erc20Token = await hre.viem.deployContract('MockERC20', [ + 'Ethereum Name Service Token', + 'ENS', + [], + ]) + + return { erc20Recoverable, erc20Token, accounts } +} + +describe('ERC20Recoverable', () => { + it('should recover ERC20 token', async () => { + const { erc20Recoverable, erc20Token, accounts } = await loadFixture( + fixture, + ) + + await erc20Token.write.transfer([erc20Recoverable.address, 1000n]) + await expect( + erc20Token.read.balanceOf([erc20Recoverable.address]), + ).resolves.toEqual(1000n) + + await erc20Recoverable.write.recoverFunds([ + erc20Token.address, + accounts[0].address, + 1000n, + ]) + await expect( + erc20Token.read.balanceOf([erc20Recoverable.address]), + ).resolves.toEqual(0n) + }) + + it('should not allow non-owner to call', async () => { + const { erc20Recoverable, erc20Token, accounts } = await loadFixture( + fixture, + ) + + await erc20Token.write.transfer([erc20Recoverable.address, 1000n]) + await expect( + erc20Token.read.balanceOf([erc20Recoverable.address]), + ).resolves.toEqual(1000n) + + await expect(erc20Recoverable) + .write('recoverFunds', [erc20Token.address, accounts[1].address, 1000n], { + account: accounts[1], + }) + .toBeRevertedWithString('Ownable: caller is not the owner') + }) +}) diff --git a/test/utils/TestHexUtils.ts b/test/utils/TestHexUtils.ts new file mode 100644 index 00000000..6be09d0c --- /dev/null +++ b/test/utils/TestHexUtils.ts @@ -0,0 +1,201 @@ +import { loadFixture } from '@nomicfoundation/hardhat-toolbox-viem/network-helpers.js' +import { expect } from 'chai' +import hre from 'hardhat' +import { stringToHex, zeroAddress, zeroHash } from 'viem' + +async function fixture() { + const hexUtils = await hre.viem.deployContract('TestHexUtils', []) + + return { hexUtils } +} + +describe('HexUtils', () => { + describe('hexToBytes()', () => { + it('converts a hex string to bytes', async () => { + const { hexUtils } = await loadFixture(fixture) + + await expect( + hexUtils.read.hexToBytes([ + stringToHex( + '5cee339e13375638553bdf5a6e36ba80fb9f6a4f0783680884d92b558aa471da', + ), + 0n, + 64n, + ]), + ).resolves.toMatchObject([ + '0x5cee339e13375638553bdf5a6e36ba80fb9f6a4f0783680884d92b558aa471da', + true, + ]) + }) + + it('handles short strings', async () => { + const { hexUtils } = await loadFixture(fixture) + + await expect( + hexUtils.read.hexToBytes([stringToHex('5cee'), 0n, 4n]), + ).resolves.toMatchObject(['0x5cee', true]) + }) + + it('handles long strings', async () => { + const { hexUtils } = await loadFixture(fixture) + + await expect( + hexUtils.read.hexToBytes([ + stringToHex( + '5cee339e13375638553bdf5a6e36ba80fb9f6a4f0783680884d92b558aa471da010203', + ), + 0n, + 70n, + ]), + ).resolves.toMatchObject([ + '0x5cee339e13375638553bdf5a6e36ba80fb9f6a4f0783680884d92b558aa471da010203', + true, + ]) + }) + }) + + describe('hexStringToBytes32()', () => { + it('converts a hex string to bytes32', async () => { + const { hexUtils } = await loadFixture(fixture) + + await expect( + hexUtils.read.hexStringToBytes32([ + stringToHex( + '5cee339e13375638553bdf5a6e36ba80fb9f6a4f0783680884d92b558aa471da', + ), + 0n, + 64n, + ]), + ).resolves.toMatchObject([ + '0x5cee339e13375638553bdf5a6e36ba80fb9f6a4f0783680884d92b558aa471da', + true, + ]) + }) + + it('uses the correct index to read from', async () => { + const { hexUtils } = await loadFixture(fixture) + + await expect( + hexUtils.read.hexStringToBytes32([ + stringToHex( + 'zzzzz0x5cee339e13375638553bdf5a6e36ba80fb9f6a4f0783680884d92b558aa471da', + ), + 7n, + 71n, + ]), + ).resolves.toMatchObject([ + '0x5cee339e13375638553bdf5a6e36ba80fb9f6a4f0783680884d92b558aa471da', + true, + ]) + }) + + it('correctly parses all the hex characters', async () => { + const { hexUtils } = await loadFixture(fixture) + + await expect( + hexUtils.read.hexStringToBytes32([ + stringToHex('0123456789abcdefABCDEF0123456789abcdefABCD'), + 0n, + 40n, + ]), + ).resolves.toMatchObject([ + '0x0000000000000000000000000123456789abcdefabcdef0123456789abcdefab', + true, + ]) + }) + + it('returns invalid when the string contains non-hex characters', async () => { + const { hexUtils } = await loadFixture(fixture) + + await expect( + hexUtils.read.hexStringToBytes32([ + stringToHex( + 'zcee339e13375638553bdf5a6e36ba80fb9f6a4f0783680884d92b558aa471da', + ), + 0n, + 64n, + ]), + ).resolves.toMatchObject([zeroHash, false]) + }) + + it('reverts when the string is too short', async () => { + const { hexUtils } = await loadFixture(fixture) + + await expect(hexUtils) + .read('hexStringToBytes32', [ + stringToHex( + '5cee339e13375638553bdf5a6e36ba80fb9f6a4f0783680884d92b558aa471da', + ), + 1n, + 65n, + ]) + .toBeRevertedWithoutReason() + }) + }) + + describe('hexToAddress()', async () => { + it('converts a hex string to an address', async () => { + const { hexUtils } = await loadFixture(fixture) + + await expect( + hexUtils.read.hexToAddress([ + stringToHex( + '5cee339e13375638553bdf5a6e36ba80fb9f6a4f0783680884d92b558aa471da', + ), + 0n, + 40n, + ]), + ).resolves.toMatchObject([ + '0x5ceE339e13375638553bdF5a6e36BA80fB9f6a4F', + true, + ]) + }) + + it('does not allow sizes smaller than 40 characters', async () => { + const { hexUtils } = await loadFixture(fixture) + + await expect( + hexUtils.read.hexToAddress([ + stringToHex( + '5cee339e13375638553bdf5a6e36ba80fb9f6a4f0783680884d92b558aa471da', + ), + 0n, + 39n, + ]), + ).resolves.toMatchObject([zeroAddress, false]) + }) + }) + + describe('special cases for hexStringToBytes32()', () => { + const hex32Bytes = + '5cee339e13375638553bdf5a6e36ba80fb9f6a4f0783680884d92b558aa471da' + + it('odd length 1', async () => { + const { hexUtils } = await loadFixture(fixture) + + await expect(hexUtils) + .read('hexStringToBytes32', [stringToHex(hex32Bytes), 0n, 63n]) + .toBeRevertedWithString('Invalid string length') + }) + + it('odd length 2', async () => { + const { hexUtils } = await loadFixture(fixture) + + await expect(hexUtils) + .read('hexStringToBytes32', [stringToHex(hex32Bytes + '00'), 1n, 64n]) + .toBeRevertedWithString('Invalid string length') + }) + + it('exceed length', async () => { + const { hexUtils } = await loadFixture(fixture) + + await expect(hexUtils) + .read('hexStringToBytes32', [ + stringToHex(hex32Bytes + '1234'), + 0n, + 64n + 4n, + ]) + .toBeRevertedWithoutReason() + }) + }) +}) diff --git a/test/utils/TestNameEncoder.ts b/test/utils/TestNameEncoder.ts new file mode 100644 index 00000000..2d246868 --- /dev/null +++ b/test/utils/TestNameEncoder.ts @@ -0,0 +1,45 @@ +import { loadFixture } from '@nomicfoundation/hardhat-toolbox-viem/network-helpers.js' +import { expect } from 'chai' +import hre from 'hardhat' +import { namehash } from 'viem' +import { dnsEncodeName } from '../fixtures/dnsEncodeName.js' + +async function fixture() { + const nameEncoder = await hre.viem.deployContract('TestNameEncoder', []) + + return { nameEncoder } +} + +describe('NameEncoder', () => { + describe('encodeName()', () => { + it('should encode a name', async () => { + const { nameEncoder } = await loadFixture(fixture) + + await expect( + nameEncoder.read.encodeName(['foo.eth']), + ).resolves.toMatchObject([dnsEncodeName('foo.eth'), namehash('foo.eth')]) + }) + + it('should encode an empty name', async () => { + const { nameEncoder } = await loadFixture(fixture) + + await expect(nameEncoder.read.encodeName([''])).resolves.toMatchObject([ + `${dnsEncodeName( + '', + )}00` /* uhhh idk if its meant to be like this but leaving it for now */, + namehash(''), + ]) + }) + + it('should encode a long name', async () => { + const { nameEncoder } = await loadFixture(fixture) + + await expect( + nameEncoder.read.encodeName(['something.else.test.eth']), + ).resolves.toMatchObject([ + dnsEncodeName('something.else.test.eth'), + namehash('something.else.test.eth'), + ]) + }) + }) +}) diff --git a/test/utils/TestStringUtils.ts b/test/utils/TestStringUtils.ts new file mode 100644 index 00000000..a76591ae --- /dev/null +++ b/test/utils/TestStringUtils.ts @@ -0,0 +1,35 @@ +import { loadFixture } from '@nomicfoundation/hardhat-toolbox-viem/network-helpers.js' +import { expect } from 'chai' +import hre from 'hardhat' + +async function fixture() { + const stringUtils = await hre.viem.deployContract('StringUtilsTest', []) + + return { stringUtils } +} + +describe('StringUtils', () => { + it('should escape double quote correctly based on JSON standard', async () => { + const { stringUtils } = await loadFixture(fixture) + + await expect( + stringUtils.read.testEscape(['My ENS is, "tanrikulu.eth"']), + ).resolves.toEqual('My ENS is, \\"tanrikulu.eth\\"') + }) + + it('should escape backslash correctly based on JSON standard', async () => { + const { stringUtils } = await loadFixture(fixture) + + await expect( + stringUtils.read.testEscape(['Path\\to\\file']), + ).resolves.toEqual('Path\\\\to\\\\file') + }) + + it('should escape new line character correctly based on JSON standard', async () => { + const { stringUtils } = await loadFixture(fixture) + + await expect( + stringUtils.read.testEscape(['Line 1\nLine 2']), + ).resolves.toEqual('Line 1\\nLine 2') + }) +}) diff --git a/test/utils/TestUniversalResolver.ts b/test/utils/TestUniversalResolver.ts new file mode 100644 index 00000000..9e9dd54e --- /dev/null +++ b/test/utils/TestUniversalResolver.ts @@ -0,0 +1,1256 @@ +import { loadFixture } from '@nomicfoundation/hardhat-toolbox-viem/network-helpers.js' +import { expect } from 'chai' +import hre from 'hardhat' +import { + decodeFunctionResult, + encodeAbiParameters, + encodeErrorResult, + encodeFunctionData, + encodeFunctionResult, + getAddress, + getContract, + labelhash, + namehash, + parseAbiItem, + toFunctionSelector, + zeroAddress, + zeroHash, + type Address, + type Hex, + type ReadContractReturnType, +} from 'viem' +import { dnsEncodeName } from '../fixtures/dnsEncodeName.js' +import { + getReverseNode, + getReverseNodeHash, +} from '../fixtures/getReverseNode.js' + +// OffchainLookup(address sender, string[] urls, bytes callData, bytes4 callbackFunction, bytes extraData) +// This is the extraData value the universal resolver should encode +const encodeExtraData = ({ + isWildcard, + resolver, + gateways, + metadata, + extraDatas, +}: { + isWildcard: boolean + resolver: Address + gateways: string[] + metadata: Hex + extraDatas: { + callbackFunction: Hex + data: Hex + }[] +}) => + encodeAbiParameters( + [ + { name: 'isWildcard', type: 'bool' }, + { name: 'resolver', type: 'address' }, + { name: 'gateways', type: 'string[]' }, + { name: 'metadata', type: 'bytes' }, + { + name: 'extraDatas', + type: 'tuple[]', + components: [ + { name: 'callbackFunction', type: 'bytes4' }, + { name: 'data', type: 'bytes' }, + ], + }, + ], + [isWildcard, resolver, gateways, metadata, extraDatas], + ) + +async function fixture() { + const accounts = await hre.viem + .getWalletClients() + .then((clients) => clients.map((c) => c.account)) + const ensRegistry = await hre.viem.deployContract('ENSRegistry', []) + const nameWrapper = await hre.viem.deployContract('DummyNameWrapper', []) + const reverseRegistrar = await hre.viem.deployContract('ReverseRegistrar', [ + ensRegistry.address, + ]) + + await ensRegistry.write.setSubnodeOwner([ + zeroHash, + labelhash('reverse'), + accounts[0].address, + ]) + await ensRegistry.write.setSubnodeOwner([ + namehash('reverse'), + labelhash('addr'), + reverseRegistrar.address, + ]) + + const publicResolver = await hre.viem.deployContract('PublicResolver', [ + ensRegistry.address, + nameWrapper.address, + zeroAddress, + zeroAddress, + ]) + const universalResolver = await hre.viem.deployContract('UniversalResolver', [ + ensRegistry.address, + ['http://universal-offchain-resolver.local'], + ]) + const offchainResolver = await hre.viem.deployContract( + 'DummyOffchainResolver', + [], + ) + const oldResolver = await hre.viem.deployContract('DummyOldResolver', []) + const revertResolver = await hre.viem.deployContract( + 'DummyRevertResolver', + [], + ) + const legacyResolver = await hre.viem.deployContract('LegacyResolver', []) + + const createTestEthSub = async ({ + label, + resolverAddress = zeroAddress, + }: { + label: string + resolverAddress?: Address + }) => { + await ensRegistry.write.setSubnodeRecord([ + namehash('test.eth'), + labelhash(label), + accounts[0].address, + resolverAddress, + 0n, + ]) + } + + await ensRegistry.write.setSubnodeOwner([ + zeroHash, + labelhash('eth'), + accounts[0].address, + ]) + await ensRegistry.write.setSubnodeRecord([ + namehash('eth'), + labelhash('test'), + accounts[0].address, + publicResolver.address, + 0n, + ]) + await ensRegistry.write.setSubnodeRecord([ + namehash('eth'), + labelhash('legacy-resolver'), + accounts[0].address, + legacyResolver.address, + 0n, + ]) + await ensRegistry.write.setSubnodeRecord([ + namehash('test.eth'), + labelhash('sub'), + accounts[0].address, + accounts[1].address, + 0n, + ]) + await createTestEthSub({ + label: 'offchain', + resolverAddress: offchainResolver.address, + }) + await createTestEthSub({ + label: 'no-resolver', + }) + await createTestEthSub({ + label: 'revert-resolver', + resolverAddress: revertResolver.address, + }) + await createTestEthSub({ + label: 'non-contract-resolver', + resolverAddress: accounts[0].address, + }) + + let name = 'test.eth' + for (let i = 0; i < 5; i += 1) { + const parent = name + const label = `sub${i}` + await ensRegistry.write.setSubnodeOwner([ + namehash(parent), + labelhash(label), + accounts[0].address, + ]) + name = `${label}.${name}` + } + + await publicResolver.write.setAddr([ + namehash('test.eth'), + accounts[1].address, + ]) + await publicResolver.write.setText([namehash('test.eth'), 'foo', 'bar']) + + await reverseRegistrar.write.claim([accounts[0].address]) + await ensRegistry.write.setResolver([ + getReverseNodeHash(accounts[0].address), + publicResolver.address, + ]) + await publicResolver.write.setName([ + getReverseNodeHash(accounts[0].address), + 'test.eth', + ]) + + await reverseRegistrar.write.claim([accounts[10].address], { + account: accounts[10], + }) + await ensRegistry.write.setResolver( + [getReverseNodeHash(accounts[10].address), oldResolver.address], + { account: accounts[10] }, + ) + + const batchGatewayAbi = await hre.artifacts + .readArtifact('BatchGateway') + .then(({ abi }) => abi) + + return { + ensRegistry, + nameWrapper, + reverseRegistrar, + publicResolver, + universalResolver, + offchainResolver, + oldResolver, + revertResolver, + legacyResolver, + accounts, + batchGatewayAbi, + } +} + +describe('UniversalResolver', () => { + describe('findResolver()', () => { + it('should find an exact match resolver', async () => { + const { universalResolver, publicResolver } = await loadFixture(fixture) + + await expect( + universalResolver.read.findResolver([dnsEncodeName('test.eth')]), + ).resolves.toMatchObject([ + getAddress(publicResolver.address), + namehash('test.eth'), + 0n, + ]) + }) + + it('should find a resolver on a parent name', async () => { + const { universalResolver, publicResolver } = await loadFixture(fixture) + + await expect( + universalResolver.read.findResolver([dnsEncodeName('foo.test.eth')]), + ).resolves.toMatchObject([ + getAddress(publicResolver.address), + namehash('foo.test.eth'), + 4n, + ]) + }) + + it('should choose the resolver closest to the leaf', async () => { + const { universalResolver, accounts } = await loadFixture(fixture) + + await expect( + universalResolver.read.findResolver([dnsEncodeName('sub.test.eth')]), + ).resolves.toMatchObject([ + getAddress(accounts[1].address), + namehash('sub.test.eth'), + 0n, + ]) + }) + + it('should allow encoded labels', async () => { + const { universalResolver, publicResolver } = await loadFixture(fixture) + + await expect( + universalResolver.read.findResolver([ + dnsEncodeName( + '[9c22ff5f21f0b81b113e63f7db6da94fedef11b2119b4088b89664fb9a3cb658].eth', + ), + ]), + ).resolves.toMatchObject([ + getAddress(publicResolver.address), + namehash('test.eth'), + 0n, + ]) + }) + + it('should find a resolver many levels up', async () => { + const { universalResolver, publicResolver } = await loadFixture(fixture) + + await expect( + universalResolver.read.findResolver([ + dnsEncodeName('sub4.sub3.sub2.sub1.sub0.test.eth'), + ]), + ).resolves.toMatchObject([ + getAddress(publicResolver.address), + namehash('sub4.sub3.sub2.sub1.sub0.test.eth'), + 25n, + ]) + }) + }) + + describe('resolve()', () => { + it('should resolve a record via legacy methods', async () => { + const { universalResolver, publicResolver, accounts } = await loadFixture( + fixture, + ) + + const args = [namehash('test.eth')] as [Hex] + + const data = encodeFunctionData({ + abi: publicResolver.abi, + functionName: 'addr', + args, + }) + + const [result] = (await universalResolver.read.resolve([ + dnsEncodeName('test.eth'), + data, + ])) as ReadContractReturnType< + (typeof universalResolver)['abi'], + 'resolve', + [Hex, Hex] + > + + const decodedAddress = decodeFunctionResult< + (typeof publicResolver)['abi'], + 'addr', + typeof args + >({ + abi: publicResolver.abi, + functionName: 'addr', + data: result, + args: [namehash('test.eth')], + }) + expect(decodedAddress).toEqualAddress(accounts[1].address) + }) + + it('should throw if a resolver is not set on the queried name', async () => { + const { universalResolver, publicResolver } = await loadFixture(fixture) + + const data = encodeFunctionData({ + abi: publicResolver.abi, + functionName: 'addr', + args: [namehash('no-resolver.test.other')], + }) + + await expect(universalResolver) + .read('resolve', [dnsEncodeName('no-resolver.test.other'), data]) + .toBeRevertedWithCustomError('ResolverNotFound') + }) + + it('should throw if a resolver is not a contract', async () => { + const { universalResolver, publicResolver } = await loadFixture(fixture) + + const data = encodeFunctionData({ + abi: publicResolver.abi, + functionName: 'addr', + args: [namehash('non-contract-resolver.test.eth')], + }) + + await expect(universalResolver) + .read('resolve', [ + dnsEncodeName('non-contract-resolver.test.eth'), + data, + ]) + .toBeRevertedWithCustomError('ResolverNotContract') + }) + + it('should throw with revert data if resolver reverts', async () => { + const { universalResolver, publicResolver } = await loadFixture(fixture) + + const data = encodeFunctionData({ + abi: publicResolver.abi, + functionName: 'addr', + args: [namehash('revert-resolver.test.eth')], + }) + + const notSupportedError = encodeErrorResult({ + abi: [{ type: 'error', inputs: [{ type: 'string' }], name: 'Error' }], + errorName: 'Error', + args: ['Not Supported'], + }) + + await expect(universalResolver) + .read('resolve', [dnsEncodeName('revert-resolver.test.eth'), data]) + .toBeRevertedWithCustomError('ResolverError') + .withArgs(notSupportedError) + }) + + it('should throw if a resolver is not set on the queried name, and the found resolver does not support resolve()', async () => { + const { universalResolver, publicResolver } = await loadFixture(fixture) + + const data = encodeFunctionData({ + abi: publicResolver.abi, + functionName: 'addr', + args: [namehash('no-resolver.test.eth')], + }) + + await expect(universalResolver) + .read('resolve', [dnsEncodeName('no-resolver.test.eth'), data]) + .toBeRevertedWithCustomError('ResolverWildcardNotSupported') + }) + + it('should resolve a record if supportsInterface() throws', async () => { + const { universalResolver, publicResolver, legacyResolver } = + await loadFixture(fixture) + + const args = [namehash('legacy-resolver.eth')] as [Hex] + + const data = encodeFunctionData({ + abi: publicResolver.abi, + functionName: 'addr', + args, + }) + + const [result, resolverAddress] = (await universalResolver.read.resolve([ + dnsEncodeName('legacy-resolver.eth'), + data, + ])) as ReadContractReturnType< + (typeof universalResolver)['abi'], + 'resolve', + [Hex, Hex] + > + + const decodedAddress = decodeFunctionResult< + (typeof publicResolver)['abi'], + 'addr', + typeof args + >({ + abi: publicResolver.abi, + functionName: 'addr', + data: result, + args, + }) + expect(decodedAddress).toEqualAddress(legacyResolver.address) + expect(resolverAddress).toEqualAddress(legacyResolver.address) + }) + + it('should not run out of gas if calling a non-existent function on a legacy resolver', async () => { + const { universalResolver, publicResolver, legacyResolver } = + await loadFixture(fixture) + + const args = [namehash('legacy-resolver.eth'), 60n] as [Hex, bigint] + + const data = encodeFunctionData({ + abi: publicResolver.abi, + functionName: 'addr', + args, + }) + + await expect(universalResolver) + .read('resolve', [dnsEncodeName('legacy-resolver.eth'), data]) + .toBeRevertedWithCustomError('ResolverError') + .withArgs('0x') + }) + + it('should return a wrapped revert if the resolver reverts with OffchainLookup', async () => { + const { + universalResolver, + publicResolver, + offchainResolver, + batchGatewayAbi, + } = await loadFixture(fixture) + + const callData = encodeFunctionData({ + abi: publicResolver.abi, + functionName: 'addr', + args: [namehash('offchain.test.eth')], + }) + + const extraData = encodeExtraData({ + isWildcard: false, + resolver: offchainResolver.address, + gateways: ['http://universal-offchain-resolver.local'], + metadata: '0x', + extraDatas: [ + { + callbackFunction: toFunctionSelector( + 'function resolveCallback(bytes,bytes)', + ), + data: callData, + }, + ], + }) + + const queryCalldata = encodeFunctionData({ + abi: batchGatewayAbi, + functionName: 'query', + args: [ + [ + { + sender: offchainResolver.address, + urls: ['https://example.com/'], + callData, + }, + ], + ], + }) + + await expect(universalResolver) + .read('resolve', [dnsEncodeName('offchain.test.eth'), callData]) + .toBeRevertedWithCustomError('OffchainLookup') + .withArgs( + getAddress(universalResolver.address), + ['http://universal-offchain-resolver.local'], + queryCalldata, + toFunctionSelector('function resolveSingleCallback(bytes,bytes)'), + extraData, + ) + }) + + it('should use custom gateways when specified', async () => { + const { universalResolver, publicResolver } = await loadFixture(fixture) + + const args = [namehash('offchain.test.eth'), 60n] as [Hex, bigint] + + const data = encodeFunctionData({ + abi: publicResolver.abi, + functionName: 'addr', + args, + }) + + await expect(universalResolver) + .read('resolve', [ + dnsEncodeName('offchain.test.eth'), + data, + ['https://custom.local'], + ]) + .toBeRevertedWithCustomError('OffchainLookup') + .withArgs( + expect.anyValue, + ['https://custom.local'], + expect.anyValue, + expect.anyValue, + expect.anyValue, + ) + }) + + it('should return a wrapped revert with resolve() wrapped calls in extraData when combining onchain and offchain', async () => { + const { + universalResolver, + publicResolver, + offchainResolver, + batchGatewayAbi, + } = await loadFixture(fixture) + + const addrCall = encodeFunctionData({ + abi: publicResolver.abi, + functionName: 'addr', + args: [namehash('offchain.test.eth')], + }) + const onchainDataCall = '0x12345678' + + const extraData = encodeExtraData({ + isWildcard: false, + resolver: offchainResolver.address, + gateways: ['http://universal-offchain-resolver.local'], + metadata: '0x', + extraDatas: [ + { + callbackFunction: toFunctionSelector( + 'function resolveCallback(bytes,bytes)', + ), + data: addrCall, + }, + { + callbackFunction: '0x00000000', + data: encodeFunctionData({ + abi: universalResolver.abi, + functionName: 'resolve', + args: [dnsEncodeName('offchain.test.eth'), onchainDataCall], + }), + }, + ], + }) + + const queryCalldata = encodeFunctionData({ + abi: batchGatewayAbi, + functionName: 'query', + args: [ + [ + { + sender: offchainResolver.address, + urls: ['https://example.com/'], + callData: addrCall, + }, + ], + ], + }) + + await expect(universalResolver) + .read('resolve', [ + dnsEncodeName('offchain.test.eth'), + [addrCall, onchainDataCall], + ]) + .toBeRevertedWithCustomError('OffchainLookup') + .withArgs( + getAddress(universalResolver.address), + ['http://universal-offchain-resolver.local'], + queryCalldata, + toFunctionSelector('function resolveCallback(bytes,bytes)'), + extraData, + ) + }) + }) + + describe('batch', () => { + it('should resolve multiple records onchain', async () => { + const { universalResolver, publicResolver, accounts } = await loadFixture( + fixture, + ) + const addrArgs = [namehash('test.eth')] as [Hex] + const textData = encodeFunctionData({ + abi: publicResolver.abi, + functionName: 'text', + args: [namehash('test.eth'), 'foo'], + }) + const addrData = encodeFunctionData({ + abi: publicResolver.abi, + functionName: 'addr', + args: addrArgs, + }) + + const [[textResultEncoded, addrResultEncoded]] = + (await universalResolver.read.resolve([ + dnsEncodeName('test.eth'), + [textData, addrData], + ])) as ReadContractReturnType< + (typeof universalResolver)['abi'], + 'resolve', + [Hex, Hex[]] + > + + expect(textResultEncoded.success).toBe(true) + expect(addrResultEncoded.success).toBe(true) + + const textResult = decodeFunctionResult({ + abi: publicResolver.abi, + functionName: 'text', + data: textResultEncoded.returnData, + args: [namehash('test.eth'), 'foo'], + }) + const addrResult = decodeFunctionResult< + (typeof publicResolver)['abi'], + 'addr', + typeof addrArgs + >({ + abi: publicResolver.abi, + functionName: 'addr', + data: addrResultEncoded.returnData, + args: addrArgs, + }) + + expect(textResult).toEqual('bar') + expect(addrResult).toEqualAddress(accounts[1].address) + }) + + it('should resolve multiple records offchain', async () => { + const { + universalResolver, + publicResolver, + offchainResolver, + batchGatewayAbi, + } = await loadFixture(fixture) + + const addrArgs = [namehash('offchain.test.eth')] as [Hex] + const textData = encodeFunctionData({ + abi: publicResolver.abi, + functionName: 'text', + args: [namehash('offchain.test.eth'), 'foo'], + }) + const addrData = encodeFunctionData({ + abi: publicResolver.abi, + functionName: 'addr', + args: addrArgs, + }) + + const extraData = encodeExtraData({ + isWildcard: false, + resolver: offchainResolver.address, + gateways: ['http://universal-offchain-resolver.local'], + metadata: '0x', + extraDatas: [ + { + callbackFunction: toFunctionSelector( + 'function resolveCallback(bytes,bytes)', + ), + data: textData, + }, + { + callbackFunction: toFunctionSelector( + 'function resolveCallback(bytes,bytes)', + ), + data: addrData, + }, + ], + }) + + const queryCalldata = encodeFunctionData({ + abi: batchGatewayAbi, + functionName: 'query', + args: [ + [ + { + sender: offchainResolver.address, + urls: ['https://example.com/'], + callData: textData, + }, + { + sender: offchainResolver.address, + urls: ['https://example.com/'], + callData: addrData, + }, + ], + ], + }) + + await expect(universalResolver) + .read('resolve', [ + dnsEncodeName('offchain.test.eth'), + [textData, addrData], + ]) + .toBeRevertedWithCustomError('OffchainLookup') + .withArgs( + getAddress(universalResolver.address), + ['http://universal-offchain-resolver.local'], + queryCalldata, + toFunctionSelector('function resolveCallback(bytes,bytes)'), + extraData, + ) + }) + }) + + describe('resolveSingleCallback()', () => { + it('should resolve a record via a callback from offchain lookup', async () => { + const { + universalResolver, + publicResolver, + offchainResolver, + batchGatewayAbi, + } = await loadFixture(fixture) + + const addrArgs = [namehash('offchain.test.eth')] as [Hex] + const addrData = encodeFunctionData({ + abi: publicResolver.abi, + functionName: 'addr', + args: addrArgs, + }) + + const extraData = encodeExtraData({ + isWildcard: false, + resolver: offchainResolver.address, + gateways: ['http://universal-offchain-resolver.local'], + metadata: '0x', + extraDatas: [ + { + callbackFunction: toFunctionSelector( + 'function resolveCallback(bytes,bytes)', + ), + data: addrData, + }, + ], + }) + + const response = encodeFunctionResult({ + abi: batchGatewayAbi, + functionName: 'query', + result: [[false], [addrData]], + }) + + const [encodedAddr, resolverAddress] = + await universalResolver.read.resolveSingleCallback([ + response, + extraData, + ]) + + expect(resolverAddress).toEqualAddress(offchainResolver.address) + + const decodedAddress = decodeFunctionResult< + (typeof publicResolver)['abi'], + 'addr', + typeof addrArgs + >({ + abi: publicResolver.abi, + functionName: 'addr', + data: encodedAddr, + args: addrArgs, + }) + expect(decodedAddress).toEqualAddress(offchainResolver.address) + }) + + it('should propagate HttpError', async () => { + const { universalResolver, offchainResolver, batchGatewayAbi } = + await loadFixture(fixture) + + const publicClient = await hre.viem.getPublicClient() + + const universalResolverWithHttpError = getContract({ + abi: [ + ...universalResolver.abi, + parseAbiItem('error HttpError((uint16,string)[])'), + ], + address: universalResolver.address, + client: publicClient, + }) + + const errorData = encodeErrorResult({ + abi: universalResolverWithHttpError.abi, + errorName: 'HttpError', + args: [[[404, 'Not Found']]], + }) + + const extraData = encodeExtraData({ + isWildcard: false, + resolver: offchainResolver.address, + gateways: ['http://universal-offchain-resolver.local'], + metadata: '0x', + extraDatas: [ + { + callbackFunction: toFunctionSelector( + 'function resolveCallback(bytes,bytes)', + ), + data: errorData, + }, + ], + }) + + const response = encodeFunctionResult({ + abi: batchGatewayAbi, + functionName: 'query', + result: [[true], [errorData]], + }) + + await expect(universalResolverWithHttpError) + .read('resolveSingleCallback', [response, extraData]) + .toBeRevertedWithCustomError('HttpError') + .withArgs([[404, 'Not Found']]) + }) + }) + + describe('resolveCallback', () => { + it('should resolve records via a callback from offchain lookup', async () => { + const { + universalResolver, + publicResolver, + offchainResolver, + batchGatewayAbi, + } = await loadFixture(fixture) + + const addrArgs = [namehash('offchain.test.eth')] as [Hex] + const addrData = encodeFunctionData({ + abi: publicResolver.abi, + functionName: 'addr', + args: addrArgs, + }) + + const extraData = encodeExtraData({ + isWildcard: false, + resolver: offchainResolver.address, + gateways: ['http://universal-offchain-resolver.local'], + metadata: '0x', + extraDatas: [ + { + callbackFunction: toFunctionSelector( + 'function resolveCallback(bytes,bytes)', + ), + data: addrData, + }, + { + callbackFunction: toFunctionSelector( + 'function resolveCallback(bytes,bytes)', + ), + data: addrData, + }, + ], + }) + + const response = encodeFunctionResult({ + abi: batchGatewayAbi, + functionName: 'query', + result: [ + [false, false], + [addrData, addrData], + ], + }) + + const [[encodedAddr, encodedAddrTwo], resolverAddress] = + await universalResolver.read.resolveCallback([response, extraData]) + + expect(resolverAddress).toEqualAddress(offchainResolver.address) + + expect(encodedAddr.success).toBe(true) + expect(encodedAddrTwo.success).toBe(true) + + const addrResult = decodeFunctionResult< + (typeof publicResolver)['abi'], + 'addr', + typeof addrArgs + >({ + abi: publicResolver.abi, + functionName: 'addr', + data: encodedAddr.returnData, + args: addrArgs, + }) + const addrResultTwo = decodeFunctionResult< + (typeof publicResolver)['abi'], + 'addr', + typeof addrArgs + >({ + abi: publicResolver.abi, + functionName: 'addr', + data: encodedAddrTwo.returnData, + args: addrArgs, + }) + + expect(addrResult).toEqualAddress(offchainResolver.address) + expect(addrResultTwo).toEqualAddress(offchainResolver.address) + }) + + it('should not revert if there is an error in a call', async () => { + const { universalResolver, offchainResolver, batchGatewayAbi } = + await loadFixture(fixture) + + const extraData = encodeExtraData({ + isWildcard: false, + resolver: offchainResolver.address, + gateways: ['http://universal-offchain-resolver.local'], + metadata: '0x', + extraDatas: [ + { + callbackFunction: toFunctionSelector( + 'function resolveCallback(bytes,bytes)', + ), + data: '0x', + }, + ], + }) + const response = encodeFunctionResult({ + abi: batchGatewayAbi, + functionName: 'query', + result: [[true], ['0x']], + }) + + const [[encodedResult], resolverAddress] = + await universalResolver.read.resolveCallback([response, extraData]) + + expect(resolverAddress).toEqualAddress(offchainResolver.address) + expect(encodedResult.success).toBe(false) + expect(encodedResult.returnData).toEqual('0x') + }) + + it('should allow response at non-0 extraData index', async () => { + const { + universalResolver, + offchainResolver, + publicResolver, + batchGatewayAbi, + } = await loadFixture(fixture) + + const onchainCall = encodeFunctionData({ + abi: universalResolver.abi, + functionName: 'resolve', + args: [dnsEncodeName('offchain.test.eth'), '0x12345678'], + }) + const textData = encodeFunctionData({ + abi: publicResolver.abi, + functionName: 'text', + args: [namehash('offchain.test.eth'), 'foo'], + }) + + const extraData = encodeExtraData({ + isWildcard: false, + resolver: offchainResolver.address, + gateways: ['http://universal-offchain-resolver.local'], + metadata: '0x', + extraDatas: [ + { + callbackFunction: '0x00000000', + data: onchainCall, + }, + { + callbackFunction: toFunctionSelector( + 'function resolveCallback(bytes,bytes)', + ), + data: textData, + }, + ], + }) + const response = encodeFunctionResult({ + abi: batchGatewayAbi, + functionName: 'query', + result: [[false], [textData]], + }) + + const [[encodedResult, encodedResultTwo], resolverAddress] = + await universalResolver.read.resolveCallback([response, extraData]) + + expect(resolverAddress).toEqualAddress(offchainResolver.address) + expect(encodedResult.success).toBe(true) + expect(encodedResultTwo.success).toBe(true) + + const decodedResult = decodeFunctionResult({ + abi: publicResolver.abi, + functionName: 'text', + data: encodedResult.returnData, + args: [namehash('offchain.test.eth'), 'foo'], + }) + + const decodedResultTwo = decodeFunctionResult< + (typeof publicResolver)['abi'], + 'addr', + [Hex] + >({ + abi: publicResolver.abi, + functionName: 'addr', + data: encodedResultTwo.returnData, + args: [namehash('offchain.test.eth')], + }) + + expect(decodedResult).toEqual('foo') + expect(decodedResultTwo).toEqualAddress(offchainResolver.address) + }) + + it('should handle a non-existent function on an offchain resolver', async () => { + const { + universalResolver, + offchainResolver, + publicResolver, + batchGatewayAbi, + } = await loadFixture(fixture) + + const addrData = encodeFunctionData({ + abi: publicResolver.abi, + functionName: 'addr', + args: [namehash('offchain.test.eth'), 60n], + }) + const textData = encodeFunctionData({ + abi: publicResolver.abi, + functionName: 'text', + args: [namehash('offchain.test.eth'), 'foo'], + }) + const extraData = encodeExtraData({ + isWildcard: false, + resolver: offchainResolver.address, + gateways: ['http://universal-offchain-resolver.local'], + metadata: '0x', + extraDatas: [ + { + callbackFunction: '0x00000000', + data: addrData, + }, + { + callbackFunction: toFunctionSelector( + 'function resolveCallback(bytes,bytes)', + ), + data: textData, + }, + ], + }) + + const response = encodeFunctionResult({ + abi: batchGatewayAbi, + functionName: 'query', + result: [[false], [textData]], + }) + + const [[addr, text], resolverAddress] = + await universalResolver.read.resolveCallback([response, extraData]) + expect(text.success).toBe(true) + expect(resolverAddress).toEqualAddress(offchainResolver.address) + + const addrRetFromText = decodeFunctionResult< + (typeof publicResolver)['abi'], + 'addr', + [Hex] + >({ + abi: publicResolver.abi, + functionName: 'addr', + data: text.returnData, + args: [namehash('offchain.test.eth')], + }) + + expect(addr.success).toBe(false) + expect(addr.returnData).toEqual('0x') + expect(addrRetFromText).toEqualAddress(offchainResolver.address) + }) + }) + + describe('reverseCallback', () => { + it('should revert with metadata for initial forward resolution if required', async () => { + const { universalResolver, offchainResolver, batchGatewayAbi } = + await loadFixture(fixture) + + const metadata = encodeAbiParameters( + [{ type: 'string' }, { type: 'address' }], + ['offchain.test.eth', offchainResolver.address], + ) + const addrCall = encodeFunctionData({ + abi: offchainResolver.abi, + functionName: 'addr', + args: [namehash('offchain.test.eth')], + }) + + const extraData = encodeExtraData({ + isWildcard: false, + resolver: offchainResolver.address, + gateways: ['http://universal-offchain-resolver.local'], + metadata: '0x', + extraDatas: [ + { + callbackFunction: toFunctionSelector( + 'function resolveCallback(bytes,bytes)', + ), + data: '0x691f3431', + }, + ], + }) + const extraDataForResult = encodeExtraData({ + isWildcard: false, + resolver: offchainResolver.address, + gateways: ['http://universal-offchain-resolver.local'], + metadata, + extraDatas: [ + { + callbackFunction: toFunctionSelector( + 'function resolveCallback(bytes,bytes)', + ), + data: addrCall, + }, + ], + }) + const response = encodeFunctionResult({ + abi: batchGatewayAbi, + functionName: 'query', + result: [[false], ['0x691f3431']], + }) + + await expect(universalResolver) + .read('reverseCallback', [response, extraData]) + .toBeRevertedWithCustomError('OffchainLookup') + .withArgs( + getAddress(universalResolver.address), + ['http://universal-offchain-resolver.local'], + expect.anyValue, + toFunctionSelector('function reverseCallback(bytes,bytes)'), + extraDataForResult, + ) + }) + + it('should resolve address record via a callback from offchain lookup', async () => { + const { universalResolver, offchainResolver, batchGatewayAbi } = + await loadFixture(fixture) + + const metadata = encodeAbiParameters( + [{ type: 'string' }, { type: 'address' }], + ['offchain.test.eth', offchainResolver.address], + ) + const extraData = encodeExtraData({ + isWildcard: false, + resolver: offchainResolver.address, + gateways: ['http://universal-offchain-resolver.local'], + metadata, + extraDatas: [ + { + callbackFunction: toFunctionSelector( + 'function resolveCallback(bytes,bytes)', + ), + data: '0x', + }, + ], + }) + const response = encodeFunctionResult({ + abi: batchGatewayAbi, + functionName: 'query', + result: [[false], ['0x']], + }) + + const [name, a1, a2, a3] = await universalResolver.read.reverseCallback([ + response, + extraData, + ]) + + expect(name).toEqual('offchain.test.eth') + expect(a1).toEqualAddress(offchainResolver.address) + expect(a2).toEqualAddress(offchainResolver.address) + expect(a3).toEqualAddress(offchainResolver.address) + }) + + it('should propagate HttpError', async () => { + const { universalResolver, offchainResolver, batchGatewayAbi } = + await loadFixture(fixture) + + const publicClient = await hre.viem.getPublicClient() + + const universalResolverWithHttpError = getContract({ + abi: [ + ...universalResolver.abi, + parseAbiItem('error HttpError((uint16,string)[])'), + ], + address: universalResolver.address, + client: publicClient, + }) + + const errorData = encodeErrorResult({ + abi: universalResolverWithHttpError.abi, + errorName: 'HttpError', + args: [[[404, 'Not Found']]], + }) + + const metadata = encodeAbiParameters( + [{ type: 'string' }, { type: 'address' }], + ['offchain.test.eth', offchainResolver.address], + ) + const extraData = encodeExtraData({ + isWildcard: false, + resolver: offchainResolver.address, + gateways: ['http://universal-offchain-resolver.local'], + metadata, + extraDatas: [ + { + callbackFunction: toFunctionSelector( + 'function resolveCallback(bytes,bytes)', + ), + data: errorData, + }, + ], + }) + const response = encodeFunctionResult({ + abi: batchGatewayAbi, + functionName: 'query', + result: [[true], [errorData]], + }) + + await expect(universalResolverWithHttpError) + .read('reverseCallback', [response, extraData]) + .toBeRevertedWithCustomError('HttpError') + .withArgs([[404, 'Not Found']]) + }) + }) + + describe('reverse()', () => { + it('should resolve a reverse record with name and resolver address', async () => { + const { universalResolver, accounts, publicResolver } = await loadFixture( + fixture, + ) + + const [name, resolvedAddress, reverseResolverAddress, resolverAddress] = + (await universalResolver.read.reverse([ + dnsEncodeName(getReverseNode(accounts[0].address)), + ])) as ReadContractReturnType< + (typeof universalResolver)['abi'], + 'reverse', + [Hex] + > + + expect(name).toEqual('test.eth') + expect(resolvedAddress).toEqualAddress(accounts[1].address) + expect(reverseResolverAddress).toEqualAddress(publicResolver.address) + expect(resolverAddress).toEqualAddress(publicResolver.address) + }) + + it('should not use all the gas on a internal resolver revert', async () => { + const { universalResolver, accounts } = await loadFixture(fixture) + + await expect(universalResolver) + .read('reverse', [dnsEncodeName(getReverseNode(accounts[10].address))]) + .not.toBeReverted() + }) + }) +}) diff --git a/test/wrapper/TestBytesUtils.ts b/test/wrapper/TestBytesUtils.ts new file mode 100644 index 00000000..4d44a19c --- /dev/null +++ b/test/wrapper/TestBytesUtils.ts @@ -0,0 +1,89 @@ +import { loadFixture } from '@nomicfoundation/hardhat-toolbox-viem/network-helpers.js' +import { expect } from 'chai' +import hre from 'hardhat' +import { labelhash, namehash, zeroHash } from 'viem' +import { dnsEncodeName } from '../fixtures/dnsEncodeName.js' + +async function fixture() { + const bytesUtils = await hre.viem.deployContract('TestBytesUtils', []) + + return { bytesUtils } +} + +describe('BytesUtils', () => { + describe('readLabel()', () => { + it('reads the first label from a name', async () => { + const { bytesUtils } = await loadFixture(fixture) + + await expect( + bytesUtils.read.readLabel([dnsEncodeName('test.tld'), 0n]), + ).resolves.toMatchObject([labelhash('test'), 5n]) + }) + + it('reads subsequent labels from a name', async () => { + const { bytesUtils } = await loadFixture(fixture) + + await expect( + bytesUtils.read.readLabel([dnsEncodeName('test.tld'), 5n]), + ).resolves.toMatchObject([labelhash('tld'), 9n]) + }) + + it('reads the terminator from a name', async () => { + const { bytesUtils } = await loadFixture(fixture) + + await expect( + bytesUtils.read.readLabel([dnsEncodeName('test.tld'), 9n]), + ).resolves.toMatchObject([zeroHash, 10n]) + }) + + it('reverts when given an empty string', async () => { + const { bytesUtils } = await loadFixture(fixture) + + await expect(bytesUtils) + .read('readLabel', ['0x', 0n]) + .toBeRevertedWithString('readLabel: Index out of bounds') + }) + + it('reverts when given an index after the end of the string', async () => { + const { bytesUtils } = await loadFixture(fixture) + + await expect(bytesUtils) + .read('readLabel', [dnsEncodeName('test.tld'), 10n]) + .toBeRevertedWithString('readLabel: Index out of bounds') + }) + }) + + describe('namehash()', () => { + it('hashes the empty name to 0', async () => { + const { bytesUtils } = await loadFixture(fixture) + + await expect( + bytesUtils.read.namehash([dnsEncodeName('.'), 0n]), + ).resolves.toEqual(namehash('')) + }) + + it('hashes .eth correctly', async () => { + const { bytesUtils } = await loadFixture(fixture) + + await expect( + bytesUtils.read.namehash([dnsEncodeName('eth'), 0n]), + ).resolves.toEqual(namehash('eth')) + }) + + it('hashes a 2LD correctly', async () => { + const { bytesUtils } = await loadFixture(fixture) + + await expect( + bytesUtils.read.namehash([dnsEncodeName('test.tld'), 0n]), + ).resolves.toEqual(namehash('test.tld')) + }) + + it('hashes partial names correctly', async () => { + const { bytesUtils } = await loadFixture(fixture) + + await expect( + bytesUtils.read.namehash([dnsEncodeName('test.tld'), 5n]), + ).resolves.toEqual(namehash('tld')) + }) + }) +}) diff --git a/test/wrapper/TestTestUnwrap.ts b/test/wrapper/TestTestUnwrap.ts new file mode 100644 index 00000000..dea9eb9f --- /dev/null +++ b/test/wrapper/TestTestUnwrap.ts @@ -0,0 +1,250 @@ +import { loadFixture } from '@nomicfoundation/hardhat-toolbox-viem/network-helpers.js' +import { expect } from 'chai' +import hre from 'hardhat' +import { labelhash, namehash, zeroAddress, zeroHash } from 'viem' +import { DAY, FUSES } from '../fixtures/constants.js' +import { dnsEncodeName } from '../fixtures/dnsEncodeName.js' +import { toTokenId } from '../fixtures/utils.js' + +async function fixture() { + const accounts = await hre.viem + .getWalletClients() + .then((clients) => clients.map((c) => c.account)) + const ensRegistry = await hre.viem.deployContract('ENSRegistry', []) + const baseRegistrar = await hre.viem.deployContract( + 'BaseRegistrarImplementation', + [ensRegistry.address, namehash('eth')], + ) + + await baseRegistrar.write.addController([accounts[0].address]) + await baseRegistrar.write.addController([accounts[1].address]) + + const reverseRegistrar = await hre.viem.deployContract('ReverseRegistrar', [ + ensRegistry.address, + ]) + + await ensRegistry.write.setSubnodeOwner([ + zeroHash, + labelhash('reverse'), + accounts[0].address, + ]) + await ensRegistry.write.setSubnodeOwner([ + namehash('reverse'), + labelhash('addr'), + reverseRegistrar.address, + ]) + + const metadataService = await hre.viem.deployContract( + 'StaticMetadataService', + ['https://ens.domains/'], + ) + const nameWrapper = await hre.viem.deployContract('NameWrapper', [ + ensRegistry.address, + baseRegistrar.address, + metadataService.address, + ]) + const testUnwrap = await hre.viem.deployContract('TestUnwrap', [ + ensRegistry.address, + baseRegistrar.address, + ]) + + await ensRegistry.write.setSubnodeOwner([ + zeroHash, + labelhash('eth'), + baseRegistrar.address, + ]) + + // set the upgradeContract of the NameWrapper contract + await nameWrapper.write.setUpgradeContract([testUnwrap.address]) + + return { + ensRegistry, + baseRegistrar, + reverseRegistrar, + metadataService, + nameWrapper, + testUnwrap, + accounts, + } +} + +describe('TestUnwrap', () => { + describe('wrapFromUpgrade()', () => { + describe('.eth', () => { + const encodedName = dnsEncodeName('wrapped.eth') + const label = 'wrapped' + const labelHash = labelhash(label) + const nameHash = namehash('wrapped.eth') + + async function fixtureWithTestEthRegistered() { + const initial = await loadFixture(fixture) + const { ensRegistry, baseRegistrar, nameWrapper, accounts } = initial + + await baseRegistrar.write.register([ + toTokenId(labelHash), + accounts[0].address, + 1n * DAY, + ]) + await baseRegistrar.write.setApprovalForAll([nameWrapper.address, true]) + + await expect( + nameWrapper.read.ownerOf([toTokenId(nameHash)]), + ).resolves.toEqual(zeroAddress) + + await nameWrapper.write.wrapETH2LD([ + label, + accounts[0].address, + FUSES.CAN_DO_EVERYTHING, + zeroAddress, + ]) + + // make sure reclaim claimed ownership for the wrapper in registry + + await expect( + ensRegistry.read.owner([nameHash]), + ).resolves.toEqualAddress(nameWrapper.address) + await expect( + baseRegistrar.read.ownerOf([toTokenId(labelHash)]), + ).resolves.toEqualAddress(nameWrapper.address) + await expect( + nameWrapper.read.ownerOf([toTokenId(nameHash)]), + ).resolves.toEqualAddress(accounts[0].address) + + return initial + } + + it('allows unwrapping from an approved NameWrapper', async () => { + const { + ensRegistry, + baseRegistrar, + nameWrapper, + testUnwrap, + accounts, + } = await loadFixture(fixtureWithTestEthRegistered) + + await testUnwrap.write.setWrapperApproval([nameWrapper.address, true]) + + await nameWrapper.write.upgrade([encodedName, '0x']) + + await expect( + ensRegistry.read.owner([nameHash]), + ).resolves.toEqualAddress(accounts[0].address) + await expect( + baseRegistrar.read.ownerOf([toTokenId(labelHash)]), + ).resolves.toEqualAddress(accounts[0].address) + await expect( + nameWrapper.read.ownerOf([toTokenId(nameHash)]), + ).resolves.toEqualAddress(zeroAddress) + }) + + it('does not allow unwrapping from an unapproved NameWrapper', async () => { + const { nameWrapper } = await loadFixture(fixtureWithTestEthRegistered) + + await expect(nameWrapper) + .write('upgrade', [encodedName, '0x']) + .toBeRevertedWithString('Unauthorised') + }) + + it('does not allow unwrapping from an unapproved sender', async () => { + const { nameWrapper, testUnwrap, accounts } = await loadFixture( + fixtureWithTestEthRegistered, + ) + + await testUnwrap.write.setWrapperApproval([nameWrapper.address, true]) + + await expect(testUnwrap) + .write('wrapFromUpgrade', [ + encodedName, + accounts[0].address, + 0, + 0n, + zeroAddress, + '0x', + ]) + .toBeRevertedWithString('Unauthorised') + }) + }) + + describe('other', () => { + const label = 'to-upgrade' + const parentLabel = 'wrapped2' + const name = `${label}.${parentLabel}.eth` + const parentLabelHash = labelhash(parentLabel) + const parentHash = namehash(`${parentLabel}.eth`) + const nameHash = namehash(name) + const encodedName = dnsEncodeName(name) + + async function fixtureWithSubWrapped() { + const initial = await loadFixture(fixture) + const { ensRegistry, baseRegistrar, nameWrapper, accounts } = initial + + await ensRegistry.write.setApprovalForAll([nameWrapper.address, true]) + await baseRegistrar.write.setApprovalForAll([nameWrapper.address, true]) + await baseRegistrar.write.register([ + toTokenId(parentLabelHash), + accounts[0].address, + 1n * DAY, + ]) + await nameWrapper.write.wrapETH2LD([ + parentLabel, + accounts[0].address, + FUSES.CANNOT_UNWRAP, + zeroAddress, + ]) + await nameWrapper.write.setSubnodeOwner([ + parentHash, + 'to-upgrade', + accounts[0].address, + 0, + 0n, + ]) + + await expect( + nameWrapper.read.ownerOf([toTokenId(nameHash)]), + ).resolves.toEqualAddress(accounts[0].address) + + return initial + } + + it('allows unwrapping from an approved NameWrapper', async () => { + const { ensRegistry, nameWrapper, testUnwrap, accounts } = + await loadFixture(fixtureWithSubWrapped) + + await testUnwrap.write.setWrapperApproval([nameWrapper.address, true]) + + await nameWrapper.write.upgrade([encodedName, '0x']) + + await expect( + ensRegistry.read.owner([nameHash]), + ).resolves.toEqualAddress(accounts[0].address) + }) + + it('does not allow unwrapping from an unapproved NameWrapper', async () => { + const { nameWrapper } = await loadFixture(fixtureWithSubWrapped) + + await expect(nameWrapper) + .write('upgrade', [encodedName, '0x']) + .toBeRevertedWithString('Unauthorised') + }) + + it('does not allow unwrapping from an unapproved sender', async () => { + const { nameWrapper, testUnwrap, accounts } = await loadFixture( + fixtureWithSubWrapped, + ) + + await testUnwrap.write.setWrapperApproval([nameWrapper.address, true]) + + await expect(testUnwrap) + .write('wrapFromUpgrade', [ + encodedName, + accounts[0].address, + 0, + 0n, + zeroAddress, + '0x', + ]) + .toBeRevertedWithString('Unauthorised') + }) + }) + }) +})