diff --git a/package.json b/package.json index 96f56b46..2183b675 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/dnsregistrar/TestRecordParser.ts", + "test": "NODE_OPTIONS=\"--experimental-loader ts-node/esm/transpile-only\" TS_NODE_PREFER_TS_EXTS=true hardhat test ./test/wrapper/TestNameWrapper.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", diff --git a/test/wrapper/Constraints.behaviour.ts b/test/wrapper/Constraints.behaviour.ts new file mode 100644 index 00000000..3bcc8818 --- /dev/null +++ b/test/wrapper/Constraints.behaviour.ts @@ -0,0 +1,1526 @@ +import { loadFixture } from '@nomicfoundation/hardhat-toolbox-viem/network-helpers.js' +import { expect } from 'chai' +import { getAddress, labelhash, namehash, zeroAddress } from 'viem' +import { DAY, FUSES } from '../fixtures/constants.js' +import { dnsEncodeName } from '../fixtures/dnsEncodeName.js' +import { toTokenId } from '../fixtures/utils.js' +import { deployNameWrapperFixture } from './fixtures/deploy.js' + +// States +// Expiry > block.timestamp CU burned PCC burned Parent burned parent's CU +// CU = CANNOT_UNWRAP +// PCC = PARENT_CANNOT_CONTROL + +// Each describe represents a specific state +// 0000 = Default Wrapped (DW) +// 1000 = Not expired (NE) +// 0100 = CU burned (CU) +// 0010 = PCC burned (PCC) +// 0001 = Parent burned parent's CU (PCU) +// Each can be combined to represent multiple states + +const { + CANNOT_UNWRAP, + CANNOT_SET_RESOLVER, + PARENT_CANNOT_CONTROL, + CAN_DO_EVERYTHING, + IS_DOT_ETH, +} = FUSES + +const GRACE_PERIOD = 90n * DAY +const MAX_EXPIRY = 2n ** 64n - 1n + +const parentLabel = 'test1' +const parentLabelHash = labelhash(parentLabel) +const parentLabelId = toTokenId(parentLabelHash) +const parentNode = namehash('test1.eth') +const parentNodeId = toTokenId(parentNode) +const childNode = namehash('sub.test1.eth') +const childNodeId = toTokenId(childNode) +const childLabel = 'sub' +const childLabelHash = labelhash(childLabel) + +async function baseFixture() { + const initial = await loadFixture(deployNameWrapperFixture) + + await initial.baseRegistrar.write.setApprovalForAll([ + initial.nameWrapper.address, + true, + ]) + + return initial +} + +// Reusable state setup +const setupState = ({ + parentFuses, + childFuses, + childExpiry, +}: { + parentFuses: number + childFuses: number + childExpiry: bigint +}) => + async function setupStateFixture() { + const initial = await loadFixture(baseFixture) + const { baseRegistrar, nameWrapper, accounts } = initial + + await baseRegistrar.write.register([ + parentLabelId, + accounts[0].address, + DAY, + ]) + await nameWrapper.write.wrapETH2LD([ + parentLabel, + accounts[0].address, + parentFuses, + zeroAddress, + ]) + + await nameWrapper.write.setSubnodeOwner([ + parentNode, + childLabel, + accounts[1].address, + childFuses, + childExpiry, // Expired ?? + ]) + + return initial + } + +// Reusable state setup +const setupStateUnexpired = ({ + parentFuses, + childFuses, +}: { + parentFuses: number + childFuses: number +}) => + async function setupStateUnexpiredFixture() { + const initial = await loadFixture(baseFixture) + const { baseRegistrar, nameWrapper, accounts } = initial + + await baseRegistrar.write.register([ + parentLabelId, + accounts[0].address, + DAY * 2n, + ]) + const parentExpiry = await baseRegistrar.read.nameExpires([parentLabelId]) + await nameWrapper.write.wrapETH2LD([ + parentLabel, + accounts[0].address, + parentFuses, + zeroAddress, + ]) + + await nameWrapper.write.setSubnodeOwner([ + parentNode, + childLabel, + accounts[1].address, + childFuses, + parentExpiry - DAY, // Expires a day before parent + ]) + + return initial + } + +// Expired, nothing burnt. +const setupState0000DW = setupState({ + parentFuses: CAN_DO_EVERYTHING, + childFuses: CAN_DO_EVERYTHING, + childExpiry: 0n, +}) +const setupState0001PCU = setupState({ + parentFuses: CANNOT_UNWRAP, + childFuses: CAN_DO_EVERYTHING, + childExpiry: 0n, +}) +const setupState1000NE = setupStateUnexpired({ + childFuses: CAN_DO_EVERYTHING, + parentFuses: CAN_DO_EVERYTHING, +}) +const setupState1001NE_PCU = setupStateUnexpired({ + childFuses: CAN_DO_EVERYTHING, + parentFuses: CANNOT_UNWRAP, +}) +const setupState1011NE_PCC_PCU = setupStateUnexpired({ + childFuses: PARENT_CANNOT_CONTROL, + parentFuses: CANNOT_UNWRAP, +}) +const setupState1111NE_CU_PCC_PCU = setupStateUnexpired({ + childFuses: PARENT_CANNOT_CONTROL | CANNOT_UNWRAP, + parentFuses: CANNOT_UNWRAP, +}) + +type BaseTestParameters = { + fixture: () => ReturnType +} + +// Reusable tests +const parentCanExtend = ({ + fixture, + isNotExpired, +}: BaseTestParameters & { + isNotExpired?: boolean +}) => { + if (isNotExpired) { + it('Child should have an expiry < parent', async () => { + const { nameWrapper, baseRegistrar, publicClient } = await loadFixture( + fixture, + ) + + const [, , childExpiry] = await nameWrapper.read.getData([childNodeId]) + const parentExpiry = await baseRegistrar.read.nameExpires([parentLabelId]) + expect(childExpiry).toBeLessThan(parentExpiry) + + const timestamp = await publicClient.getBlock().then((b) => b.timestamp) + expect(childExpiry).toBeGreaterThan(timestamp) + }) + } else { + it('Child should have a 0 expiry before extending', async () => { + const { nameWrapper } = await loadFixture(fixture) + + const [, , expiryBefore] = await nameWrapper.read.getData([childNodeId]) + expect(expiryBefore).toEqual(0n) + }) + } + + it('Parent can extend expiry with setChildFuses()', async () => { + const { nameWrapper, baseRegistrar, accounts } = await loadFixture(fixture) + + const parentExpiry = await baseRegistrar.read.nameExpires([parentLabelId]) + + await nameWrapper.write.setChildFuses([ + parentNode, + childLabelHash, + CAN_DO_EVERYTHING, + MAX_EXPIRY, + ]) + + const [, , expiry] = await nameWrapper.read.getData([childNodeId]) + + expect(expiry).toEqual(parentExpiry + GRACE_PERIOD) + }) + + it('Parent can extend expiry with setSubnodeOwner()', async () => { + const { nameWrapper, baseRegistrar, accounts } = await loadFixture(fixture) + + const parentExpiry = await baseRegistrar.read.nameExpires([parentLabelId]) + + await nameWrapper.write.setSubnodeOwner([ + parentNode, + childLabel, + accounts[1].address, + CAN_DO_EVERYTHING, + MAX_EXPIRY, + ]) + + const [, , expiry] = await nameWrapper.read.getData([childNodeId]) + + expect(expiry).toEqual(parentExpiry + GRACE_PERIOD) + }) + + it('Parent can extend expiry with setSubnodeRecord()', async () => { + const { nameWrapper, baseRegistrar, accounts } = await loadFixture(fixture) + + const parentExpiry = await baseRegistrar.read.nameExpires([parentLabelId]) + + await nameWrapper.write.setSubnodeRecord([ + parentNode, + childLabel, + accounts[1].address, + zeroAddress, + 0n, + CAN_DO_EVERYTHING, + MAX_EXPIRY, + ]) + + const [, , expiry] = await nameWrapper.read.getData([childNodeId]) + + expect(expiry).toEqual(parentExpiry + GRACE_PERIOD) + }) +} + +const parentCannotBurnFusesOrPCC = ({ fixture }: BaseTestParameters) => { + it('Parent cannot burn fuses with setChildFuses()', async () => { + const { nameWrapper } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setChildFuses', [ + parentNode, + childLabelHash, + CANNOT_UNWRAP | PARENT_CANNOT_CONTROL, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) + + it('Parent cannot burn fuses with setSubnodeOwner()', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setSubnodeOwner', [ + parentNode, + childLabel, + accounts[1].address, + CANNOT_UNWRAP | PARENT_CANNOT_CONTROL, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) + + it('Parent cannot burn fuses with setSubnodeRecord()', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setSubnodeRecord', [ + parentNode, + childLabel, + accounts[1].address, + zeroAddress, + 0n, + CANNOT_UNWRAP | PARENT_CANNOT_CONTROL, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) +} + +const parentCanReplaceOwner = ({ fixture }: BaseTestParameters) => { + it('Parent can replace owner with setSubnodeOwner()', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect( + nameWrapper.read.ownerOf([childNodeId]), + ).resolves.toEqualAddress(accounts[1].address) + + await nameWrapper.write.setSubnodeOwner([ + parentNode, + childLabel, + accounts[0].address, + CAN_DO_EVERYTHING, + 0n, + ]) + + await expect( + nameWrapper.read.ownerOf([childNodeId]), + ).resolves.toEqualAddress(accounts[0].address) + }) + + it('Parent can replace owner with setSubnodeRecord()', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect( + nameWrapper.read.ownerOf([childNodeId]), + ).resolves.toEqualAddress(accounts[1].address) + + await nameWrapper.write.setSubnodeRecord([ + parentNode, + childLabel, + accounts[0].address, + zeroAddress, + 0n, + CAN_DO_EVERYTHING, + 0n, + ]) + + await expect( + nameWrapper.read.ownerOf([childNodeId]), + ).resolves.toEqualAddress(accounts[0].address) + }) +} + +const parentCanUnwrapChild = ({ fixture }: BaseTestParameters) => { + it('Parent can unwrap owner with setSubnodeRecord() and then unwrap', async () => { + const { ensRegistry, nameWrapper, accounts } = await loadFixture(fixture) + + //check previous owners + await expect( + nameWrapper.read.ownerOf([childNodeId]), + ).resolves.toEqualAddress(accounts[1].address) + await expect(ensRegistry.read.owner([childNode])).resolves.toEqualAddress( + nameWrapper.address, + ) + + await nameWrapper.write.setSubnodeRecord([ + parentNode, + childLabel, + accounts[0].address, + zeroAddress, + 0n, + CAN_DO_EVERYTHING, + 0n, + ]) + + await nameWrapper.write.unwrap([ + parentNode, + childLabelHash, + accounts[0].address, + ]) + + await expect( + nameWrapper.read.ownerOf([childNodeId]), + ).resolves.toEqualAddress(zeroAddress) + await expect(ensRegistry.read.owner([childNode])).resolves.toEqualAddress( + accounts[0].address, + ) + }) +} + +const parentCannotBurnParentControlledFuses = ({ + fixture, +}: BaseTestParameters) => { + it('Parent cannot burn parent-controlled fuses', async () => { + const { nameWrapper } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setChildFuses', [parentNode, childLabelHash, 1 << 18, 0n]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) +} + +const ownerIsOwnerWhenExpired = ({ fixture }: BaseTestParameters) => { + it('Owner is still owner when expired', async () => { + const { nameWrapper, accounts, publicClient } = await loadFixture(fixture) + + const timestamp = await publicClient.getBlock().then((b) => b.timestamp) + const [, , expiry] = await nameWrapper.read.getData([childNodeId]) + + expect(expiry).toBeLessThan(timestamp) + + await expect( + nameWrapper.read.ownerOf([childNodeId]), + ).resolves.toEqualAddress(accounts[1].address) + }) +} + +const ownerCannotBurnFuses = ({ fixture }: BaseTestParameters) => { + it('Owner cannot burn CU because PCC is not burned', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setFuses', [childNode, CANNOT_UNWRAP], { account: accounts[1] }) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) + + it('Owner cannot burn other fuses because CU and PCC are not burned', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setFuses', [childNode, CANNOT_SET_RESOLVER], { + account: accounts[1], + }) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) +} + +const ownerCanUnwrap = ({ fixture }: BaseTestParameters) => { + it('Owner can unwrap', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await nameWrapper.write.unwrap( + [parentNode, childLabelHash, accounts[1].address], + { account: accounts[1] }, + ) + + await expect( + nameWrapper.read.ownerOf([childNodeId]), + ).resolves.toEqualAddress(zeroAddress) + }) +} + +const parentCanBurnParentControlledFusesWithExpiry = ({ + fixture, +}: BaseTestParameters) => { + it('Parent cannot burn parent-controlled fuses as they reset to 0', async () => { + const { nameWrapper } = await loadFixture(fixture) + + await nameWrapper.write.setChildFuses([ + parentNode, + childLabelHash, + 1 << 18, + 0n, + ]) + + // expired names get normalised to 0 + const [, fuses] = await nameWrapper.read.getData([childNodeId]) + expect(fuses).toEqual(0) + }) + + it('Parent can burn parent-controlled fuses, if expiry is extended', async () => { + const { nameWrapper } = await loadFixture(fixture) + + await nameWrapper.write.setChildFuses([ + parentNode, + childLabelHash, + 1 << 18, + MAX_EXPIRY, + ]) + + const [, fuses] = await nameWrapper.read.getData([childNodeId]) + expect(fuses).toEqual(1 << 18) + }) +} + +const parentCanBurnFusesOrPCC = ({ fixture }: BaseTestParameters) => { + it('Parent can burn fuses with setChildFuses()', async () => { + const { nameWrapper } = await loadFixture(fixture) + + await nameWrapper.write.setChildFuses([ + parentNode, + childLabelHash, + CANNOT_UNWRAP | PARENT_CANNOT_CONTROL | CANNOT_SET_RESOLVER, + 0n, + ]) + + const [, fuses] = await nameWrapper.read.getData([childNodeId]) + expect(fuses).toEqual( + CANNOT_UNWRAP | PARENT_CANNOT_CONTROL | CANNOT_SET_RESOLVER, + ) + }) + + it('Parent can burn fuses with setSubnodeOwner()', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await nameWrapper.write.setSubnodeOwner([ + parentNode, + childLabel, + accounts[1].address, + CANNOT_UNWRAP | PARENT_CANNOT_CONTROL | CANNOT_SET_RESOLVER, + 0n, + ]) + + const [, fuses] = await nameWrapper.read.getData([childNodeId]) + expect(fuses).toEqual( + CANNOT_UNWRAP | PARENT_CANNOT_CONTROL | CANNOT_SET_RESOLVER, + ) + }) + + it('Parent can burn fuses with setSubnodeRecord()', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await nameWrapper.write.setSubnodeRecord([ + parentNode, + childLabel, + accounts[1].address, + zeroAddress, + 0n, + CANNOT_UNWRAP | PARENT_CANNOT_CONTROL | CANNOT_SET_RESOLVER, + 0n, + ]) + + const [, fuses] = await nameWrapper.read.getData([childNodeId]) + expect(fuses).toEqual( + CANNOT_UNWRAP | PARENT_CANNOT_CONTROL | CANNOT_SET_RESOLVER, + ) + }) + + it('Parent cannot burn fuses if PCC is not burnt too', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setSubnodeRecord', [ + parentNode, + childLabel, + accounts[1].address, + zeroAddress, + 0n, + CANNOT_UNWRAP | CANNOT_SET_RESOLVER, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + + await expect(nameWrapper) + .write('setChildFuses', [ + parentNode, + childLabelHash, + CANNOT_UNWRAP | CANNOT_SET_RESOLVER, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + + await expect(nameWrapper) + .write('setSubnodeOwner', [ + parentNode, + childLabel, + accounts[1].address, + CANNOT_UNWRAP | CANNOT_SET_RESOLVER, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) +} + +const parentCanBurnParentControlledFuses = ({ + fixture, +}: BaseTestParameters) => { + it('Parent can burn parent-controlled fuses', async () => { + const { nameWrapper } = await loadFixture(fixture) + + await nameWrapper.write.setChildFuses([ + parentNode, + childLabelHash, + 1 << 18, + 0n, + ]) + + const [, fuses] = await nameWrapper.read.getData([childNodeId]) + expect(fuses).toEqual(1 << 18) + }) +} + +const testStateTransition1000to1010 = ({ fixture }: BaseTestParameters) => { + it('1000 => 1010 - Parent cannot burn PCC with setChildFuses()', async () => { + const { nameWrapper } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setChildFuses', [ + parentNode, + childLabelHash, + PARENT_CANNOT_CONTROL, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) + + it('1000 => 1010 - Parent cannot burn PCC with setSubnodeOwner()', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setSubnodeOwner', [ + parentNode, + childLabel, + accounts[1].address, + PARENT_CANNOT_CONTROL, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) + + it('1000 => 1010 - Parent cannot burn PCC with setSubnodeRecord()', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setSubnodeRecord', [ + parentNode, + childLabel, + accounts[1].address, + zeroAddress, + 0n, + PARENT_CANNOT_CONTROL, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) +} + +const parentCanExtendWithSetChildFuses = ({ fixture }: BaseTestParameters) => { + it('Child should have a { + const { nameWrapper, baseRegistrar, publicClient } = await loadFixture( + fixture, + ) + + const [, , childExpiry] = await nameWrapper.read.getData([childNodeId]) + const parentExpiry = await baseRegistrar.read.nameExpires([parentLabelId]) + expect(childExpiry).toBeLessThan(parentExpiry) + + const timestamp = await publicClient.getBlock().then((b) => b.timestamp) + expect(childExpiry).toBeGreaterThan(timestamp) + }) + + it('Parent can extend expiry with setChildFuses()', async () => { + const { nameWrapper, baseRegistrar } = await loadFixture(fixture) + + const parentExpiry = await baseRegistrar.read.nameExpires([parentLabelId]) + + await nameWrapper.write.setChildFuses([ + parentNode, + childLabelHash, + CAN_DO_EVERYTHING, + MAX_EXPIRY, + ]) + + const [, , expiry] = await nameWrapper.read.getData([childNodeId]) + + expect(expiry).toEqual(parentExpiry + GRACE_PERIOD) + }) + + it('Parent cannot extend expiry with setSubnodeOwner()', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setSubnodeOwner', [ + parentNode, + childLabel, + accounts[1].address, + CAN_DO_EVERYTHING, + MAX_EXPIRY, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) + + it('Parent cannot extend expiry with setSubnodeRecord()', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setSubnodeRecord', [ + parentNode, + childLabel, + accounts[1].address, + zeroAddress, + 0n, + CAN_DO_EVERYTHING, + MAX_EXPIRY, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) +} + +const parentCannotBurnFusesWhenPCCisBurned = ({ + fixture, +}: BaseTestParameters) => { + it('Parent cannot burn fuses with setChildFuses()', async () => { + const { nameWrapper } = await loadFixture(fixture) + + const [, fusesBefore] = await nameWrapper.read.getData([childNodeId]) + expect(fusesBefore).toEqual(PARENT_CANNOT_CONTROL) + + await expect(nameWrapper) + .write('setChildFuses', [ + parentNode, + childLabelHash, + CANNOT_UNWRAP | PARENT_CANNOT_CONTROL | CANNOT_SET_RESOLVER, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + + await expect(nameWrapper) + .write('setChildFuses', [ + parentNode, + childLabelHash, + CANNOT_UNWRAP | PARENT_CANNOT_CONTROL, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + + // parent can burn PCC again, but has no effect since it's already burnt + await nameWrapper.write.setChildFuses([ + parentNode, + childLabelHash, + PARENT_CANNOT_CONTROL, + 0n, + ]) + + const [, fuses] = await nameWrapper.read.getData([childNodeId]) + expect(fuses).toEqual(PARENT_CANNOT_CONTROL) + }) + + it('Parent cannot burn fuses with setSubnodeOwner()', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setSubnodeOwner', [ + parentNode, + childLabel, + accounts[1].address, + CANNOT_SET_RESOLVER, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + + await expect(nameWrapper) + .write('setSubnodeOwner', [ + parentNode, + childLabel, + accounts[1].address, + CANNOT_UNWRAP | CANNOT_SET_RESOLVER, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + + await expect(nameWrapper) + .write('setSubnodeOwner', [ + parentNode, + childLabel, + accounts[1].address, + PARENT_CANNOT_CONTROL | CANNOT_UNWRAP | CANNOT_SET_RESOLVER, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) + + it('Parent cannot burn fuses with setSubnodeRecord()', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setSubnodeRecord', [ + parentNode, + childLabel, + accounts[1].address, + zeroAddress, + 0n, + CANNOT_UNWRAP | PARENT_CANNOT_CONTROL | CANNOT_SET_RESOLVER, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + + await expect(nameWrapper) + .write('setSubnodeRecord', [ + parentNode, + childLabel, + accounts[1].address, + zeroAddress, + 0n, + CANNOT_UNWRAP | PARENT_CANNOT_CONTROL, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + + await expect(nameWrapper) + .write('setSubnodeRecord', [ + parentNode, + childLabel, + accounts[1].address, + zeroAddress, + 0n, + PARENT_CANNOT_CONTROL, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) +} + +const parentCannotReplaceOwner = ({ fixture }: BaseTestParameters) => { + it('Parent cannot replace owner with setSubnodeOwner()', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect( + nameWrapper.read.ownerOf([childNodeId]), + ).resolves.toEqualAddress(accounts[1].address) + + await expect(nameWrapper) + .write('setSubnodeOwner', [ + parentNode, + childLabel, + accounts[0].address, + CAN_DO_EVERYTHING, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + + await expect( + nameWrapper.read.ownerOf([childNodeId]), + ).resolves.toEqualAddress(accounts[1].address) + }) + + it('Parent cannot replace owner with setSubnodeRecord()', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect( + nameWrapper.read.ownerOf([childNodeId]), + ).resolves.toEqualAddress(accounts[1].address) + + await expect(nameWrapper) + .write('setSubnodeRecord', [ + parentNode, + childLabel, + accounts[0].address, + zeroAddress, + 0n, + CAN_DO_EVERYTHING, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + + await expect( + nameWrapper.read.ownerOf([childNodeId]), + ).resolves.toEqualAddress(accounts[1].address) + }) +} + +const parentCannotUnwrapChild = ({ fixture }: BaseTestParameters) => { + it('Parent cannot unwrap itself', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('unwrapETH2LD', [ + parentLabelHash, + accounts[0].address, + accounts[0].address, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(parentNode) + }) + + it('Parent cannot unwrap child', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('unwrap', [parentNode, childLabelHash, accounts[0].address]) + .toBeRevertedWithCustomError('Unauthorised') + .withArgs(childNode, getAddress(accounts[0].address)) + }) + + it('Parent cannot call ens.setSubnodeOwner() to forcefully unwrap', async () => { + const { ensRegistry, accounts } = await loadFixture(fixture) + + await expect(ensRegistry) + .write('setSubnodeOwner', [parentNode, childNode, accounts[0].address]) + .toBeRevertedWithoutReason() + }) +} + +const ownerResetsToZeroWhenExpired = ({ + fixture, + expectedFuses, +}: BaseTestParameters & { expectedFuses: number }) => { + it('Owner resets to 0 after expiry', async () => { + const { nameWrapper, accounts, publicClient, testClient } = + await loadFixture(fixture) + + const [ownerBefore, fusesBefore, expiryBefore] = + await nameWrapper.read.getData([childNodeId]) + const timestampBefore = await publicClient + .getBlock() + .then((b) => b.timestamp) + // not expired + expect(ownerBefore).toEqualAddress(accounts[1].address) + expect(fusesBefore).toEqual(expectedFuses) + expect(expiryBefore).toBeGreaterThan(timestampBefore) + + // force expiry + await testClient.increaseTime({ seconds: Number(2n * DAY) }) + await testClient.mine({ blocks: 1 }) + + const [ownerAfter, fusesAfter, expiryAfter] = + await nameWrapper.read.getData([childNodeId]) + const timestampAfter = await publicClient + .getBlock() + .then((b) => b.timestamp) + // owner and fuses are reset when expired + expect(ownerAfter).toEqualAddress(zeroAddress) + expect(fusesAfter).toEqual(0) + expect(expiryAfter).toBeLessThan(timestampAfter) + }) +} + +export const shouldRespectConstraints = () => { + describe("0000 - Wrapped expired without CU/PCC burned, Parent's CU not burned", () => { + const fixture = setupState0000DW + + it('correct test setup', async () => { + const { nameWrapper } = await loadFixture(fixture) + + const [, parentFuses] = await nameWrapper.read.getData([parentNodeId]) + expect(parentFuses).toEqual(PARENT_CANNOT_CONTROL | IS_DOT_ETH) + + const [, childFuses, childExpiry] = await nameWrapper.read.getData([ + childNodeId, + ]) + expect(childFuses).toEqual(CAN_DO_EVERYTHING) + expect(childExpiry).toEqual(0n) + }) + + parentCanExtend({ fixture }) + + parentCannotBurnFusesOrPCC({ fixture }) + + it('Parent cannot burn fuses with setChildFuses() even when extending expiry', async () => { + const { nameWrapper } = await loadFixture(fixture) + + const [, fusesBefore] = await nameWrapper.read.getData([childNodeId]) + expect(fusesBefore).toEqual(0) + + await expect(nameWrapper) + .write('setChildFuses', [ + parentNode, + childLabelHash, + CANNOT_UNWRAP | PARENT_CANNOT_CONTROL | CANNOT_SET_RESOLVER, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) + + parentCanReplaceOwner({ fixture }) + + parentCanUnwrapChild({ fixture }) + + parentCannotBurnParentControlledFuses({ fixture }) + + ownerIsOwnerWhenExpired({ fixture }) + + ownerCannotBurnFuses({ fixture }) + + ownerCanUnwrap({ fixture }) + }) + + describe("0001 - PCU - Wrapped expired without CU/PCC burned, Parent's CU is burned", () => { + const fixture = setupState0001PCU + + parentCanExtend({ fixture }) + + it('Parent cannot burn fuses with setChildFuses()', async () => { + const { nameWrapper } = await loadFixture(fixture) + + const [, fusesBefore] = await nameWrapper.read.getData([childNodeId]) + expect(fusesBefore).toEqual(0) + + await nameWrapper.write.setChildFuses([ + parentNode, + childLabelHash, + CANNOT_UNWRAP | PARENT_CANNOT_CONTROL | CANNOT_SET_RESOLVER, + 0n, + ]) + + // expired names get normalised to 0 + const [, fuses] = await nameWrapper.read.getData([childNodeId]) + expect(fuses).toEqual(0) + }) + + it('Parent can burn fuses with setChildFuses() if expiry is also extended', async () => { + const { nameWrapper } = await loadFixture(fixture) + + const [, fusesBefore] = await nameWrapper.read.getData([childNodeId]) + expect(fusesBefore).toEqual(0) + + await nameWrapper.write.setChildFuses([ + parentNode, + childLabelHash, + CANNOT_UNWRAP | PARENT_CANNOT_CONTROL, + MAX_EXPIRY, + ]) + + const [, fuses] = await nameWrapper.read.getData([childNodeId]) + expect(fuses).toEqual(CANNOT_UNWRAP | PARENT_CANNOT_CONTROL) + }) + + parentCanReplaceOwner({ fixture }) + + parentCanUnwrapChild({ fixture }) + + parentCanBurnParentControlledFusesWithExpiry({ fixture }) + + it('Parent cannot unwrap itself', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('unwrapETH2LD', [ + parentLabelHash, + accounts[0].address, + accounts[0].address, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(parentNode) + }) + + ownerCannotBurnFuses({ fixture }) + + ownerCanUnwrap({ fixture }) + + ownerIsOwnerWhenExpired({ fixture }) + }) + + describe("0010 - PCC - Impossible state - WrappedPCC burned without Parent's CU", () => { + // starts with the same setup as 0000 to test that this state is impossible + const fixture = setupState0000DW + + it('0000 => 0010 - Parent cannot burn PCC with setChildFuses()', async () => { + const { nameWrapper } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setChildFuses', [ + parentNode, + childLabelHash, + PARENT_CANNOT_CONTROL, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) + }) + + describe("0011 - PCC_PCU - Impossible state - Wrapped expired, PCC burned and Parent's CU burned", () => { + const fixture = setupState0001PCU + + it('0001 => 0010 - PCU => PCC - Parent cannot burn PCC with setChildFuses()', async () => { + const { nameWrapper } = await loadFixture(fixture) + + await nameWrapper.write.setChildFuses([ + parentNode, + childLabelHash, + PARENT_CANNOT_CONTROL, + 0n, + ]) + + const [, fuses] = await nameWrapper.read.getData([childNodeId]) + + // fuses are normalised + expect(fuses).toEqual(0) + }) + }) + + describe("0100 - CU - Impossible state - Wrapped expired, CU burned, PCC unburned and Parent's CU unburned", () => { + const fixture = setupState0000DW + + it('0000 => 0100 - DW => CU Parent - cannot burn CANNOT_UNWRAP with setChildFuses()', async () => { + const { nameWrapper } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setChildFuses', [parentNode, childLabelHash, CANNOT_UNWRAP, 0n]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) + + it('0000 => 0100 - DW => CU - Owner cannot burn CANNOT_UNWRAP with setFuses()', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setFuses', [childNode, CANNOT_UNWRAP], { account: accounts[1] }) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) + }) + + describe("0101 - Impossible state - Wrapped expired, CU burned, PCC unburned and Parent's CU burned", () => { + const fixture = setupState0001PCU + + it('0001 => 0101 - PCU => CU_PCU - Parent cannot burn CANNOT_UNWRAP with setChildFuses()', async () => { + const { nameWrapper } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setChildFuses', [parentNode, childLabelHash, CANNOT_UNWRAP, 0n]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) + + it('0001 => 0101 - PCU => CU_PCU - Owner cannot burn CANNOT_UNWRAP with setFuses()', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setFuses', [childNode, CANNOT_UNWRAP], { account: accounts[1] }) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) + }) + + describe("0110 - CU_PCC - Impossible state - Wrapped expired, CU burned, PCC burned and Parent's CU unburned", () => { + const fixture = setupState0000DW + + it('0000 => 0010 - DW => PCC - Parent cannot burn PARENT_CANNOT_CONTROL with setChildFuses()', async () => { + const { nameWrapper } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setChildFuses', [ + parentNode, + childLabelHash, + PARENT_CANNOT_CONTROL, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) + }) + + describe("0111 - CU_PCC_PCU - Impossible state - Wrapped expired, CU burned, PCC burned and Parent's CU burned", () => { + const fixture = setupState0001PCU + + it('0001 => 0111 - PCU => CU_PCC_PCU - Parent cannot burn PARENT_CANNOT_CONTROL | CANNOT_UNWRAP with setChildFuses()', async () => { + const { nameWrapper } = await loadFixture(fixture) + + await nameWrapper.write.setChildFuses([ + parentNode, + childLabelHash, + PARENT_CANNOT_CONTROL | CANNOT_UNWRAP, + 0n, + ]) + + const [, fuses] = await nameWrapper.read.getData([childNodeId]) + expect(fuses).toEqual(0) + }) + }) + + describe("1000 - NE - Wrapped, but not expired, CU, PCC, and Parent's CU unburned", () => { + const fixture = setupState1000NE + + parentCanExtend({ fixture, isNotExpired: true }) + parentCannotBurnFusesOrPCC({ fixture }) + parentCanReplaceOwner({ fixture }) + parentCanUnwrapChild({ fixture }) + parentCannotBurnParentControlledFuses({ fixture }) + ownerCannotBurnFuses({ fixture }) + ownerCanUnwrap({ fixture }) + // TODO: re-add if necessary + // ownerIsOwnerWhenExpired({ fixture }) + }) + + describe("1001 - NE_PCU - Wrapped unexpired, CU and PCC unburned, and Parent's CU burned", () => { + const fixture = setupState1001NE_PCU + + parentCanExtend({ fixture, isNotExpired: true }) + parentCanBurnFusesOrPCC({ fixture }) + parentCanReplaceOwner({ fixture }) + parentCanUnwrapChild({ fixture }) + parentCanBurnParentControlledFuses({ fixture }) + ownerCannotBurnFuses({ fixture }) + ownerCanUnwrap({ fixture }) + // TODO: re-add if necessary + // ownerIsOwnerWhenExpired({ fixture }) + }) + + describe("1010 - NE_PCC - Impossible state - Wrapped unexpired, CU unburned, PCC burned and Parent's CU burned", () => { + const fixture = setupState1000NE + + testStateTransition1000to1010({ fixture }) + }) + + describe("1011 - NE_PCC_PCU Wrapped unexpired, CU, PCC and Parent's CU burned", () => { + const fixture = setupState1011NE_PCC_PCU + + parentCanExtendWithSetChildFuses({ fixture }) + parentCannotBurnFusesWhenPCCisBurned({ fixture }) + + it('Parent cannot unburn fuses with setChildFuses()', async () => { + const { nameWrapper } = await loadFixture(fixture) + + await nameWrapper.write.setChildFuses([parentNode, childLabelHash, 0, 0n]) + + const [, fuses] = await nameWrapper.read.getData([childNodeId]) + expect(fuses).toEqual(PARENT_CANNOT_CONTROL) + }) + + it('Parent cannot unburn fuses with setSubnodeOwner()', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setSubnodeOwner', [ + parentNode, + childLabel, + accounts[1].address, + 0, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + + const [, fuses] = await nameWrapper.read.getData([childNodeId]) + expect(fuses).toEqual(PARENT_CANNOT_CONTROL) + }) + + it('Parent cannot unburn fuses with setSubnodeRecord()', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setSubnodeRecord', [ + parentNode, + childLabel, + accounts[1].address, + zeroAddress, + 0n, + 0, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + + const [, fuses] = await nameWrapper.read.getData([childNodeId]) + expect(fuses).toEqual(PARENT_CANNOT_CONTROL) + }) + + parentCannotReplaceOwner({ fixture }) + parentCannotUnwrapChild({ fixture }) + parentCannotBurnParentControlledFuses({ fixture }) + + it('Owner can burn CU', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await nameWrapper.write.setFuses([childNode, CANNOT_UNWRAP], { + account: accounts[1], + }) + + const [, fuses] = await nameWrapper.read.getData([childNodeId]) + expect(fuses).toEqual(CANNOT_UNWRAP | PARENT_CANNOT_CONTROL) + }) + + it('Owner cannot burn fuses because CU is unburned', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setFuses', [childNode, CANNOT_SET_RESOLVER], { + account: accounts[1], + }) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) + + it('Owner cannot unwrap and wrap to unburn PCC', async () => { + const { ensRegistry, nameWrapper, accounts } = await loadFixture(fixture) + + const [, fusesBefore] = await nameWrapper.read.getData([childNodeId]) + expect(fusesBefore).toEqual(PARENT_CANNOT_CONTROL) + + await nameWrapper.write.unwrap( + [parentNode, childLabelHash, accounts[1].address], + { account: accounts[1] }, + ) + await ensRegistry.write.setApprovalForAll([nameWrapper.address, true], { + account: accounts[1], + }) + await nameWrapper.write.wrap( + [ + dnsEncodeName(`${childLabel}.${parentLabel}.eth`), + accounts[1].address, + zeroAddress, + ], + { account: accounts[1] }, + ) + + const [, fusesAfter] = await nameWrapper.read.getData([childNodeId]) + expect(fusesAfter).toEqual(PARENT_CANNOT_CONTROL) + }) + + ownerCanUnwrap({ fixture }) + ownerResetsToZeroWhenExpired({ + fixture, + expectedFuses: PARENT_CANNOT_CONTROL, + }) + }) + + describe("1100 - NE_CU - Impossible State - Wrapped unexpired, CU burned, and PCC and Parent's CU unburned ", () => { + const fixture = setupState1000NE + + it('1000 => 1100 - NE => NE_CU - Parent cannot burn CU with setChildFuses()', async () => { + const { nameWrapper } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setChildFuses', [parentNode, childLabelHash, CANNOT_UNWRAP, 0n]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) + + it('1000 => 1100 - NE => NE_CU - Parent cannot burn CU with setSubnodeOwner()', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setSubnodeOwner', [ + parentNode, + childLabel, + accounts[1].address, + CANNOT_UNWRAP, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) + + it('1000 => 1100 - NE => NE_CU - Parent cannot burn CU with setSubnodeRecord()', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setSubnodeRecord', [ + parentNode, + childLabel, + accounts[1].address, + zeroAddress, + 0n, + CANNOT_UNWRAP, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) + + it('1000 => 1100 - NE => NE_CU - Owner cannot burn CU with setFuses()', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setFuses', [childNode, CANNOT_UNWRAP], { account: accounts[1] }) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) + }) + + describe("1101 - NE_CU_PCU - Impossible State - Wrapped unexpired, CU burned, PCC unburned, and Parent's CU burned ", () => { + const fixture = setupState1001NE_PCU + + it('1001 => 1101 - NE_PCU => NE_CU_PCU - Parent cannot burn CU with setChildFuses()', async () => { + const { nameWrapper } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setChildFuses', [parentNode, childLabelHash, CANNOT_UNWRAP, 0n]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) + + it('1001 => 1101 - NE_PCU => NE_CU_PCU - Parent cannot burn CU with setSubnodeOwner()', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setSubnodeOwner', [ + parentNode, + childLabel, + accounts[1].address, + CANNOT_UNWRAP, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) + + it('1001 => 1101 - NE_PCU => NE_CU_PCU - Parent cannot burn CU with setSubnodeRecord()', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setSubnodeRecord', [ + parentNode, + childLabel, + accounts[1].address, + zeroAddress, + 0n, + CANNOT_UNWRAP, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) + + it('1001 => 1101 - NE_PCU => NE_CU_PCU - Owner cannot burn CU with setFuses()', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setFuses', [childNode, CANNOT_UNWRAP], { account: accounts[1] }) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) + }) + + // TODO: this is a duplicate of 1010 - NE_PCC?? + describe.skip("1110 - NE_CU_PCC - Impossible state - Wrapped unexpired, CU and PCC burned, and Parent's CU unburned ", () => { + // testStateTransition1000to1010({ }) + }) + + describe("1111 - NE_CU_PCC_PCU - Wrapped unexpired, CU, PCC and Parent's CU burned ", () => { + const fixture = setupState1111NE_CU_PCC_PCU + + parentCanExtendWithSetChildFuses({ fixture }) + + it('Parent cannot unburn fuses with setChildFuses()', async () => { + const { nameWrapper } = await loadFixture(fixture) + + await nameWrapper.write.setChildFuses([parentNode, childLabelHash, 0, 0n]) + + const [, fuses] = await nameWrapper.read.getData([childNodeId]) + expect(fuses).toEqual(PARENT_CANNOT_CONTROL | CANNOT_UNWRAP) + }) + + it('Parent cannot unburn fuses with setSubnodeOwner()', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setSubnodeOwner', [ + parentNode, + childLabel, + accounts[1].address, + 0, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + + const [, fuses] = await nameWrapper.read.getData([childNodeId]) + expect(fuses).toEqual(PARENT_CANNOT_CONTROL | CANNOT_UNWRAP) + }) + + it('Parent cannot unburn fuses with setSubnodeRecord()', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('setSubnodeRecord', [ + parentNode, + childLabel, + accounts[1].address, + zeroAddress, + 0n, + 0, + 0n, + ]) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + + const [, fuses] = await nameWrapper.read.getData([childNodeId]) + expect(fuses).toEqual(PARENT_CANNOT_CONTROL | CANNOT_UNWRAP) + }) + + parentCannotReplaceOwner({ fixture }) + parentCannotUnwrapChild({ fixture }) + parentCannotBurnParentControlledFuses({ fixture }) + + it('Owner can burn fuses', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + const [, fusesBefore] = await nameWrapper.read.getData([childNodeId]) + expect(fusesBefore).toEqual(PARENT_CANNOT_CONTROL | CANNOT_UNWRAP) + + await nameWrapper.write.setFuses([childNode, CANNOT_SET_RESOLVER], { + account: accounts[1], + }) + + const [, fusesAfter] = await nameWrapper.read.getData([childNodeId]) + expect(fusesAfter).toEqual( + PARENT_CANNOT_CONTROL | CANNOT_UNWRAP | CANNOT_SET_RESOLVER, + ) + }) + + it('Owner cannot unburn fuses', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + const [, fusesBefore] = await nameWrapper.read.getData([childNodeId]) + expect(fusesBefore).toEqual(PARENT_CANNOT_CONTROL | CANNOT_UNWRAP) + + await nameWrapper.write.setFuses([childNode, 0], { account: accounts[1] }) + + const [, fusesAfter] = await nameWrapper.read.getData([childNodeId]) + expect(fusesAfter).toEqual(PARENT_CANNOT_CONTROL | CANNOT_UNWRAP) + }) + + it('Owner cannot unwrap', async () => { + const { nameWrapper, accounts } = await loadFixture(fixture) + + await expect(nameWrapper) + .write('unwrap', [parentNode, childLabelHash, accounts[1].address], { + account: accounts[1], + }) + .toBeRevertedWithCustomError('OperationProhibited') + .withArgs(childNode) + }) + + ownerResetsToZeroWhenExpired({ + fixture, + expectedFuses: PARENT_CANNOT_CONTROL | CANNOT_UNWRAP, + }) + }) +} diff --git a/test/wrapper/ERC1155.behaviour.ts b/test/wrapper/ERC1155.behaviour.ts new file mode 100644 index 00000000..77597584 --- /dev/null +++ b/test/wrapper/ERC1155.behaviour.ts @@ -0,0 +1,1076 @@ +import { loadFixture } from '@nomicfoundation/hardhat-toolbox-viem/network-helpers.js' +import { GetContractReturnType } from '@nomicfoundation/hardhat-viem/types.js' +import { expect } from 'chai' +import hre from 'hardhat' +import type { ArtifactsMap } from 'hardhat/types/artifacts.js' +import { + zeroAddress, + type Abi, + type Account, + type Address, + type Hash, + type Hex, +} from 'viem' +import { shouldSupportInterfaces } from './SupportsInterface.behaviour.js' + +const RECEIVER_SINGLE_MAGIC_VALUE = '0xf23a6e61' +const RECEIVER_BATCH_MAGIC_VALUE = '0xbc197c81' + +type ERC1155Abi = ArtifactsMap['IERC1155']['abi'] +type ERC1155Contract = GetContractReturnType + +const getNamedAccounts = ([ + minter, + firstTokenHolder, + secondTokenHolder, + multiTokenHolder, + recipient, + proxy, +]: Account[]) => ({ + minter, + firstTokenHolder, + secondTokenHolder, + multiTokenHolder, + recipient, + proxy, +}) + +export const shouldBehaveLikeErc1155 = < + TContract extends { + abi: Abi + address: Address + read: ERC1155Contract['read'] + write: ERC1155Contract['write'] + }, + TContracts extends { contract: TContract; accounts: Account[] }, +>({ + contracts: contracts_, + targetTokenIds: [firstTokenId, secondTokenId, unknownTokenId], + mint: mint_, +}: { + contracts: () => Promise + targetTokenIds: [bigint, bigint, bigint] | readonly [bigint, bigint, bigint] + mint: ( + contracts: NoInfer, + addresses: [firstTokenHolder: Address, secondTokenHolder: Address], + ) => Promise +}) => { + const contracts = async () => { + const contractsObject = await contracts_() + return { + ...getNamedAccounts(contractsObject.accounts), + ...(contractsObject as Omit), + mint: ( + addresses: [firstTokenHolder: Address, secondTokenHolder: Address], + ) => mint_(contractsObject, addresses), + contract: contractsObject.contract as unknown as ERC1155Contract, + } + } + type ContractsObject = ReturnType & + Omit & { + mint: ( + addresses: [firstTokenHolder: Address, secondTokenHolder: Address], + ) => Promise + contract: ERC1155Contract + } + + describe('like an ERC1155', () => { + describe('balanceOf', () => { + it('reverts when queried about the zero address', async () => { + const { contract } = await contracts() + await expect(contract) + .read('balanceOf', [zeroAddress, firstTokenId]) + .toBeRevertedWithString('ERC1155: balance query for the zero address') + }) + + context("when accounts don't own tokens", () => { + it('returns zero for given addresses', async () => { + const { contract, firstTokenHolder, secondTokenHolder } = + await contracts() + + await expect( + contract.read.balanceOf([firstTokenHolder.address, firstTokenId]), + ).resolves.toEqual(0n) + await expect( + contract.read.balanceOf([secondTokenHolder.address, secondTokenId]), + ).resolves.toEqual(0n) + await expect( + contract.read.balanceOf([firstTokenHolder.address, unknownTokenId]), + ).resolves.toEqual(0n) + }) + }) + + context('when accounts own some tokens', () => { + it('returns the amount of tokens owned by the given addresses', async () => { + const { contract, mint, firstTokenHolder, secondTokenHolder } = + await contracts() + + await mint([firstTokenHolder.address, secondTokenHolder.address]) + + await expect( + contract.read.balanceOf([firstTokenHolder.address, firstTokenId]), + ).resolves.toEqual(1n) + await expect( + contract.read.balanceOf([secondTokenHolder.address, secondTokenId]), + ).resolves.toEqual(1n) + await expect( + contract.read.balanceOf([firstTokenHolder.address, unknownTokenId]), + ).resolves.toEqual(0n) + }) + }) + }) + + describe('balanceOfBatch', () => { + it("reverts when input arrays don't match up", async () => { + const { contract, firstTokenHolder, secondTokenHolder } = + await contracts() + + await expect(contract) + .read('balanceOfBatch', [ + [ + firstTokenHolder.address, + secondTokenHolder.address, + firstTokenHolder.address, + secondTokenHolder.address, + ], + [firstTokenId, secondTokenId, unknownTokenId], + ]) + .toBeRevertedWithString('ERC1155: accounts and ids length mismatch') + + await expect(contract) + .read('balanceOfBatch', [ + [firstTokenHolder.address, secondTokenHolder.address], + [firstTokenId, secondTokenId, unknownTokenId], + ]) + .toBeRevertedWithString('ERC1155: accounts and ids length mismatch') + }) + + it('reverts when one of the addresses is the zero address', async () => { + const { contract, firstTokenHolder, secondTokenHolder } = + await contracts() + + await expect(contract) + .read('balanceOfBatch', [ + [firstTokenHolder.address, secondTokenHolder.address, zeroAddress], + [firstTokenId, secondTokenId, unknownTokenId], + ]) + .toBeRevertedWithString('ERC1155: balance query for the zero address') + }) + + context("when accounts don't own tokens", () => { + it('returns zeros for each account', async () => { + const { contract, firstTokenHolder, secondTokenHolder } = + await contracts() + + await expect( + contract.read.balanceOfBatch([ + [ + firstTokenHolder.address, + secondTokenHolder.address, + firstTokenHolder.address, + ], + [firstTokenId, secondTokenId, unknownTokenId], + ]), + ).resolves.toMatchObject([0n, 0n, 0n]) + }) + }) + + context('when accounts own some tokens', () => { + it('returns amounts owned by each account in order passed', async () => { + const { contract, mint, firstTokenHolder, secondTokenHolder } = + await contracts() + + await mint([firstTokenHolder.address, secondTokenHolder.address]) + + await expect( + contract.read.balanceOfBatch([ + [ + secondTokenHolder.address, + firstTokenHolder.address, + firstTokenHolder.address, + ], + [secondTokenId, firstTokenId, unknownTokenId], + ]), + ).resolves.toMatchObject([1n, 1n, 0n]) + }) + + it('returns multiple times the balance of the same address when asked', async () => { + const { contract, mint, firstTokenHolder, secondTokenHolder } = + await contracts() + + await mint([firstTokenHolder.address, secondTokenHolder.address]) + + await expect( + contract.read.balanceOfBatch([ + [ + firstTokenHolder.address, + secondTokenHolder.address, + firstTokenHolder.address, + ], + [firstTokenId, secondTokenId, firstTokenId], + ]), + ).resolves.toMatchObject([1n, 1n, 1n]) + }) + }) + }) + + describe('setApprovalForAll', () => { + it('sets approval status which can be queried via isApprovedForAll', async () => { + const { contract, multiTokenHolder, proxy } = await contracts() + + await contract.write.setApprovalForAll([proxy.address, true], { + account: multiTokenHolder, + }) + + await expect( + contract.read.isApprovedForAll([ + multiTokenHolder.address, + proxy.address, + ]), + ).resolves.toBe(true) + }) + + it('emits an ApprovalForAll log', async () => { + const { contract, multiTokenHolder, proxy } = await contracts() + + await expect(contract) + .write('setApprovalForAll', [proxy.address, true], { + account: multiTokenHolder, + }) + .toEmitEvent('ApprovalForAll') + .withArgs(multiTokenHolder.address, proxy.address, true) + }) + + it('can unset approval for an operator', async () => { + const { contract, multiTokenHolder, proxy } = await contracts() + + await contract.write.setApprovalForAll([proxy.address, true], { + account: multiTokenHolder, + }) + await contract.write.setApprovalForAll([proxy.address, false], { + account: multiTokenHolder, + }) + + await expect( + contract.read.isApprovedForAll([ + multiTokenHolder.address, + proxy.address, + ]), + ).resolves.toBe(false) + }) + + it('reverts if attempting to approve self as an operator', async () => { + const { contract, multiTokenHolder } = await contracts() + + await expect(contract) + .write('setApprovalForAll', [multiTokenHolder.address, true], { + account: multiTokenHolder, + }) + .toBeRevertedWithString('ERC1155: setting approval status for self') + }) + }) + + async function mintedToMultiFixture() { + const initial = await contracts() + await initial.mint([ + initial.multiTokenHolder.address, + initial.multiTokenHolder.address, + ]) + return initial + } + + describe('safeTransferFrom', () => { + it('reverts when transferring more than balance', async () => { + const { contract, multiTokenHolder, recipient } = await loadFixture( + mintedToMultiFixture, + ) + + await expect(contract) + .write( + 'safeTransferFrom', + [ + multiTokenHolder.address, + recipient.address, + firstTokenId, + 2n, + '0x', + ], + { account: multiTokenHolder }, + ) + .toBeRevertedWithString('ERC1155: insufficient balance for transfer') + }) + + it('reverts when transferring to zero address', async () => { + const { contract, multiTokenHolder } = await loadFixture( + mintedToMultiFixture, + ) + + await expect(contract) + .write( + 'safeTransferFrom', + [multiTokenHolder.address, zeroAddress, firstTokenId, 1n, '0x'], + { account: multiTokenHolder }, + ) + .toBeRevertedWithString('ERC1155: transfer to the zero address') + }) + + const transferWasSuccessful = ( + fixture: () => Promise< + ContractsObject & { + operator: Account + from: Account + to: { address: Address } + id: bigint + value: bigint + tx: Hash + } + >, + ) => { + it('debits transferred balance from sender', async () => { + const { contract, from, id } = await loadFixture(fixture) + + await expect( + contract.read.balanceOf([from.address, id]), + ).resolves.toEqual(0n) + }) + + it('credits transferred balance to receiver', async () => { + const { contract, to, id, value } = await loadFixture(fixture) + + await expect( + contract.read.balanceOf([to.address, id]), + ).resolves.toEqual(value) + }) + + it('emits a TransferSingle log', async () => { + const { contract, operator, from, to, tx, id, value } = + await loadFixture(fixture) + + await expect(contract) + .transaction(tx) + .toEmitEvent('TransferSingle') + .withArgs(operator.address, from.address, to.address, id, value) + }) + } + + context('when called by the multiTokenHolder', () => { + async function fixture() { + const contractsObject = await loadFixture(mintedToMultiFixture) + const { contract, multiTokenHolder, recipient } = contractsObject + const operator = multiTokenHolder + const from = multiTokenHolder + const to = recipient + const id = firstTokenId + const value = 1n + + const tx = await contract.write.safeTransferFrom( + [from.address, to.address, id, value, '0x'], + { account: operator }, + ) + + return { ...contractsObject, operator, from, to, id, value, tx } + } + + transferWasSuccessful(fixture) + + it('preserves existing balances which are not transferred by multiTokenHolder', async () => { + const { contract, multiTokenHolder, recipient } = await loadFixture( + fixture, + ) + + await expect( + contract.read.balanceOf([multiTokenHolder.address, secondTokenId]), + ).resolves.toEqual(1n) + await expect( + contract.read.balanceOf([recipient.address, secondTokenId]), + ).resolves.toEqual(0n) + }) + }) + + context( + 'when called by an operator on behalf of the multiTokenHolder', + () => { + context('when operator is not approved by multiTokenHolder', () => { + it('reverts', async () => { + const { contract, multiTokenHolder, recipient, proxy } = + await loadFixture(mintedToMultiFixture) + + await expect(contract) + .write( + 'safeTransferFrom', + [ + multiTokenHolder.address, + recipient.address, + firstTokenId, + 1n, + '0x', + ], + { account: proxy }, + ) + .toBeRevertedWithString( + 'ERC1155: caller is not owner nor approved', + ) + }) + }) + + context('when operator is approved by multiTokenHolder', () => { + async function fixture() { + const contractsObject = await loadFixture(mintedToMultiFixture) + const { contract, multiTokenHolder, proxy, recipient } = + contractsObject + const operator = proxy + const from = multiTokenHolder + const to = recipient + const id = firstTokenId + const value = 1n + + await contract.write.setApprovalForAll([operator.address, true], { + account: from, + }) + + const tx = await contract.write.safeTransferFrom( + [from.address, to.address, id, value, '0x'], + { account: operator }, + ) + + return { ...contractsObject, operator, from, to, id, value, tx } + } + + transferWasSuccessful(fixture) + + it("preserves operator's balances not involved in the transfer", async () => { + const { contract, proxy } = await loadFixture(fixture) + await expect( + contract.read.balanceOf([proxy.address, firstTokenId]), + ).resolves.toEqual(0n) + await expect( + contract.read.balanceOf([proxy.address, secondTokenId]), + ).resolves.toEqual(0n) + }) + }) + }, + ) + + context('when sending to a valid receiver', () => { + const createValidReceiverFixture = (data: Hex) => + async function contractsWithReceiver() { + const contractsObject = await loadFixture(mintedToMultiFixture) + const receiver = await hre.viem.deployContract( + 'ERC1155ReceiverMock', + [ + RECEIVER_SINGLE_MAGIC_VALUE, + false, + RECEIVER_BATCH_MAGIC_VALUE, + false, + ], + ) + + const { contract, multiTokenHolder } = contractsObject + const operator = multiTokenHolder + const from = multiTokenHolder + const to = receiver + const id = firstTokenId + const value = 1n + + const tx = await contract.write.safeTransferFrom( + [from.address, to.address, id, value, data], + { account: operator }, + ) + + return { + ...contractsObject, + receiver, + operator, + from, + to, + id, + value, + tx, + } + } + + context('without data', () => { + const fixture = createValidReceiverFixture('0x') + + transferWasSuccessful(fixture) + + it('calls onERC1155Received', async () => { + const { contract, receiver, multiTokenHolder, tx } = + await loadFixture(fixture) + + await expect(contract) + .transaction(tx) + .toEmitEventFrom(receiver, 'Received') + .withArgs( + multiTokenHolder.address, + multiTokenHolder.address, + firstTokenId, + 1n, + '0x', + ) + }) + }) + + context('with data', () => { + const data = '0xf00dd00d' + const fixture = createValidReceiverFixture(data) + + transferWasSuccessful(fixture) + + it('calls onERC1155Received', async () => { + const { contract, receiver, multiTokenHolder, tx } = + await loadFixture(fixture) + + await expect(contract) + .transaction(tx) + .toEmitEventFrom(receiver, 'Received') + .withArgs( + multiTokenHolder.address, + multiTokenHolder.address, + firstTokenId, + 1n, + data, + ) + }) + }) + }) + + context('to a receiver contract returning unexpected value', () => { + it('reverts', async () => { + const { contract, multiTokenHolder } = await loadFixture( + mintedToMultiFixture, + ) + + const receiver = await hre.viem.deployContract( + 'ERC1155ReceiverMock', + ['0x00c0ffee', false, RECEIVER_BATCH_MAGIC_VALUE, false], + ) + + await expect(contract) + .write( + 'safeTransferFrom', + [ + multiTokenHolder.address, + receiver.address, + firstTokenId, + 1n, + '0x', + ], + { account: multiTokenHolder }, + ) + .toBeRevertedWithString('ERC1155: ERC1155Receiver rejected tokens') + }) + }) + + context('to a receiver that reverts', () => { + it('reverts', async () => { + const { contract, multiTokenHolder } = await loadFixture( + mintedToMultiFixture, + ) + + const receiver = await hre.viem.deployContract( + 'ERC1155ReceiverMock', + [ + RECEIVER_SINGLE_MAGIC_VALUE, + true, + RECEIVER_BATCH_MAGIC_VALUE, + false, + ], + ) + + await expect(contract) + .write( + 'safeTransferFrom', + [ + multiTokenHolder.address, + receiver.address, + firstTokenId, + 1n, + '0x', + ], + { account: multiTokenHolder }, + ) + .toBeRevertedWithString('ERC1155ReceiverMock: reverting on receive') + }) + }) + + context( + 'to a contract that does not implement the required function', + () => { + it('reverts', async () => { + const { contract, multiTokenHolder } = await loadFixture( + mintedToMultiFixture, + ) + + const receiver = contract + + await expect(contract) + .write( + 'safeTransferFrom', + [ + multiTokenHolder.address, + receiver.address, + firstTokenId, + 1n, + '0x', + ], + { account: multiTokenHolder }, + ) + .toBeRevertedWithString( + 'ERC1155: transfer to non ERC1155Receiver implementer', + ) + }) + }, + ) + }) + + describe('safeBatchTransferFrom', () => { + it('reverts when transferring amount more than any of balances', async () => { + const { contract, multiTokenHolder, recipient } = await loadFixture( + mintedToMultiFixture, + ) + + await expect(contract) + .write( + 'safeBatchTransferFrom', + [ + multiTokenHolder.address, + recipient.address, + [firstTokenId, secondTokenId], + [1n, 2n], + '0x', + ], + { account: multiTokenHolder }, + ) + .toBeRevertedWithString('ERC1155: insufficient balance for transfer') + }) + + it("reverts when ids array length doesn't match amounts array length", async () => { + const { contract, multiTokenHolder, recipient } = await loadFixture( + mintedToMultiFixture, + ) + + await expect(contract) + .write( + 'safeBatchTransferFrom', + [ + multiTokenHolder.address, + recipient.address, + [firstTokenId], + [1n, 1n], + '0x', + ], + { account: multiTokenHolder }, + ) + .toBeRevertedWithString('ERC1155: ids and amounts length mismatch') + + await expect(contract) + .write( + 'safeBatchTransferFrom', + [ + multiTokenHolder.address, + recipient.address, + [firstTokenId, secondTokenId], + [1n], + '0x', + ], + { account: multiTokenHolder }, + ) + .toBeRevertedWithString('ERC1155: ids and amounts length mismatch') + }) + + it('reverts when transferring to zero address', async () => { + const { contract, multiTokenHolder } = await loadFixture( + mintedToMultiFixture, + ) + + await expect(contract) + .write( + 'safeBatchTransferFrom', + [ + multiTokenHolder.address, + zeroAddress, + [firstTokenId, secondTokenId], + [1n, 1n], + '0x', + ], + { account: multiTokenHolder }, + ) + .toBeRevertedWithString('ERC1155: transfer to the zero address') + }) + + const batchTransferWasSuccessful = ( + fixture: () => Promise< + ContractsObject & { + operator: Account + from: Account + to: { address: Address } + ids: bigint[] + values: bigint[] + tx: Hash + } + >, + ) => { + it('debits transferred balance from sender', async () => { + const { contract, from, ids } = await loadFixture(fixture) + + await expect( + contract.read.balanceOfBatch([ + new Array(ids.length).fill(from.address), + ids, + ]), + ).resolves.toEqual(new Array(ids.length).fill(0n)) + }) + + it('credits transferred balance to receiver', async () => { + const { contract, to, ids, values } = await loadFixture(fixture) + + await expect( + contract.read.balanceOfBatch([ + new Array(ids.length).fill(to.address), + ids, + ]), + ).resolves.toEqual(values) + }) + + it('emits a TransferSingle log', async () => { + const { contract, operator, from, to, tx, ids, values } = + await loadFixture(fixture) + + await expect(contract) + .transaction(tx) + .toEmitEvent('TransferBatch') + .withArgs(operator.address, from.address, to.address, ids, values) + }) + } + + context('when called by the multiTokenHolder', () => { + async function fixture() { + const contractsObject = await loadFixture(mintedToMultiFixture) + const { contract, multiTokenHolder, recipient } = contractsObject + const operator = multiTokenHolder + const from = multiTokenHolder + const to = recipient + const ids = [firstTokenId, secondTokenId] + const values = [1n, 1n] + + const tx = await contract.write.safeBatchTransferFrom( + [from.address, to.address, ids, values, '0x'], + { account: operator }, + ) + + return { ...contractsObject, operator, from, to, ids, values, tx } + } + + batchTransferWasSuccessful(fixture) + }) + + context( + 'when called by an operator on behalf of the multiTokenHolder', + () => { + context('when operator is not approved by multiTokenHolder', () => { + it('reverts', async () => { + const { contract, multiTokenHolder, recipient, proxy } = + await loadFixture(mintedToMultiFixture) + + await expect(contract) + .write( + 'safeBatchTransferFrom', + [ + multiTokenHolder.address, + recipient.address, + [firstTokenId, secondTokenId], + [1n, 1n], + '0x', + ], + { account: proxy }, + ) + .toBeRevertedWithString( + 'ERC1155: transfer caller is not owner nor approved', + ) + }) + }) + + context('when operator is approved by multiTokenHolder', () => { + async function fixture() { + const contractsObject = await loadFixture(mintedToMultiFixture) + const { contract, multiTokenHolder, proxy, recipient } = + contractsObject + const operator = proxy + const from = multiTokenHolder + const to = recipient + const ids = [firstTokenId, secondTokenId] + const values = [1n, 1n] + + await contract.write.setApprovalForAll([operator.address, true], { + account: from, + }) + + const tx = await contract.write.safeBatchTransferFrom( + [from.address, to.address, ids, values, '0x'], + { account: operator }, + ) + + return { ...contractsObject, operator, from, to, ids, values, tx } + } + + batchTransferWasSuccessful(fixture) + + it("preserves operator's balances not involved in the transfer", async () => { + const { contract, proxy } = await loadFixture(fixture) + await expect( + contract.read.balanceOf([proxy.address, firstTokenId]), + ).resolves.toEqual(0n) + await expect( + contract.read.balanceOf([proxy.address, secondTokenId]), + ).resolves.toEqual(0n) + }) + }) + }, + ) + + context('when sending to a valid receiver', () => { + const createValidReceiverFixture = (data: Hex) => + async function contractsWithReceiver() { + const contractsObject = await loadFixture(mintedToMultiFixture) + const receiver = await hre.viem.deployContract( + 'ERC1155ReceiverMock', + [ + RECEIVER_SINGLE_MAGIC_VALUE, + false, + RECEIVER_BATCH_MAGIC_VALUE, + false, + ], + ) + + const { contract, multiTokenHolder } = contractsObject + const operator = multiTokenHolder + const from = multiTokenHolder + const to = receiver + const ids = [firstTokenId, secondTokenId] + const values = [1n, 1n] + + const tx = await contract.write.safeBatchTransferFrom( + [from.address, to.address, ids, values, data], + { account: operator }, + ) + + return { + ...contractsObject, + receiver, + operator, + from, + to, + ids, + values, + tx, + } + } + + context('without data', () => { + const fixture = createValidReceiverFixture('0x') + + batchTransferWasSuccessful(fixture) + + it('calls onERC1155BatchReceived', async () => { + const { contract, receiver, multiTokenHolder, tx } = + await loadFixture(fixture) + + await expect(contract) + .transaction(tx) + .toEmitEventFrom(receiver, 'BatchReceived') + .withArgs( + multiTokenHolder.address, + multiTokenHolder.address, + [firstTokenId, secondTokenId], + [1n, 1n], + '0x', + ) + }) + }) + + context('with data', () => { + const data = '0xf00dd00d' + const fixture = createValidReceiverFixture(data) + + batchTransferWasSuccessful(fixture) + + it('calls onERC1155BatchReceived', async () => { + const { contract, receiver, multiTokenHolder, tx } = + await loadFixture(fixture) + + await expect(contract) + .transaction(tx) + .toEmitEventFrom(receiver, 'BatchReceived') + .withArgs( + multiTokenHolder.address, + multiTokenHolder.address, + [firstTokenId, secondTokenId], + [1n, 1n], + data, + ) + }) + }) + }) + + context('to a receiver contract returning unexpected value', () => { + it('reverts', async () => { + const { contract, multiTokenHolder } = await loadFixture( + mintedToMultiFixture, + ) + + const receiver = await hre.viem.deployContract( + 'ERC1155ReceiverMock', + [ + RECEIVER_SINGLE_MAGIC_VALUE, + false, + RECEIVER_SINGLE_MAGIC_VALUE, + false, + ], + ) + + await expect(contract) + .write( + 'safeBatchTransferFrom', + [ + multiTokenHolder.address, + receiver.address, + [firstTokenId, secondTokenId], + [1n, 1n], + '0x', + ], + { account: multiTokenHolder }, + ) + .toBeRevertedWithString('ERC1155: ERC1155Receiver rejected tokens') + }) + }) + + context('to a receiver contract that reverts', () => { + it('reverts', async () => { + const { contract, multiTokenHolder } = await loadFixture( + mintedToMultiFixture, + ) + + const receiver = await hre.viem.deployContract( + 'ERC1155ReceiverMock', + [ + RECEIVER_SINGLE_MAGIC_VALUE, + false, + RECEIVER_BATCH_MAGIC_VALUE, + true, + ], + ) + + await expect(contract) + .write( + 'safeBatchTransferFrom', + [ + multiTokenHolder.address, + receiver.address, + [firstTokenId, secondTokenId], + [1n, 1n], + '0x', + ], + { account: multiTokenHolder }, + ) + .toBeRevertedWithString( + 'ERC1155ReceiverMock: reverting on batch receive', + ) + }) + }) + + context( + 'to a receiver contract that reverts only on single transfers', + () => { + async function fixture() { + const contractsObject = await loadFixture(mintedToMultiFixture) + const receiver = await hre.viem.deployContract( + 'ERC1155ReceiverMock', + [ + RECEIVER_SINGLE_MAGIC_VALUE, + true, + RECEIVER_BATCH_MAGIC_VALUE, + false, + ], + ) + + const { contract, multiTokenHolder } = contractsObject + const operator = multiTokenHolder + const from = multiTokenHolder + const to = receiver + const ids = [firstTokenId, secondTokenId] + const values = [1n, 1n] + + const tx = await contract.write.safeBatchTransferFrom( + [from.address, to.address, ids, values, '0x'], + { account: operator }, + ) + + return { + ...contractsObject, + receiver, + operator, + from, + to, + ids, + values, + tx, + } + } + + batchTransferWasSuccessful(fixture) + + it('calls onERC1155BatchReceived', async () => { + const { contract, receiver, multiTokenHolder, tx } = + await loadFixture(fixture) + + await expect(contract) + .transaction(tx) + .toEmitEventFrom(receiver, 'BatchReceived') + .withArgs( + multiTokenHolder.address, + multiTokenHolder.address, + [firstTokenId, secondTokenId], + [1n, 1n], + '0x', + ) + }) + }, + ) + + context( + 'to a contract that does not implement the required function', + () => { + it('reverts', async () => { + const { contract, multiTokenHolder } = await loadFixture( + mintedToMultiFixture, + ) + + const receiver = contract + + await expect(contract) + .write( + 'safeBatchTransferFrom', + [ + multiTokenHolder.address, + receiver.address, + [firstTokenId, secondTokenId], + [1n, 1n], + '0x', + ], + { account: multiTokenHolder }, + ) + .toBeRevertedWithString( + 'ERC1155: transfer to non ERC1155Receiver implementer', + ) + }) + }, + ) + }) + + shouldSupportInterfaces({ + contract: () => contracts().then(({ contract }) => contract), + interfaces: ['IERC165', 'IERC1155'], + }) + }) +} diff --git a/test/wrapper/SupportsInterface.behaviour.ts b/test/wrapper/SupportsInterface.behaviour.ts new file mode 100644 index 00000000..ed0621e0 --- /dev/null +++ b/test/wrapper/SupportsInterface.behaviour.ts @@ -0,0 +1,247 @@ +// Based on https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.1.0/test/token/ERC1155/ERC1155.behaviour.js +// Copyright (c) 2016-2020 zOS Global Limited + +import { GetContractReturnType } from '@nomicfoundation/hardhat-viem/types.js' +import { expect } from 'chai' +import hre from 'hardhat' +import type { ArtifactsMap, CompilerInput } from 'hardhat/types/artifacts.js' +import { + encodeFunctionData, + getAbiItem, + toFunctionSelector, + toFunctionSignature, + type Abi, + type AbiFunction, + type Address, + type Hex, +} from 'viem' +import { createInterfaceId } from '../fixtures/createInterfaceId.js' + +type SupportsInterfaceAbi = { + inputs: [ + { + internalType: 'bytes4' + name: 'interfaceId' + type: 'bytes4' + }, + ] + name: 'supportsInterface' + outputs: [ + { + internalType: 'bool' + name: '' + type: 'bool' + }, + ] + stateMutability: 'view' + type: 'function' +} + +type SupportsInterfaceContract = GetContractReturnType<[SupportsInterfaceAbi]> + +/** + * @description Matches a function signature string to an exact ABI function. + * + * - Required to ensure that the ABI function is an **exact** match for the string, avoiding any potential mismatches. + * + * @param {Object} params + * @param {Abi} params.artifactAbi - The ABI of the interface artifact + * @param {string} params.fnString - The function signature string to match + * @returns + */ +const matchStringFunctionToAbi = ({ + artifactAbi, + fnString, +}: { + artifactAbi: Abi + fnString: string +}) => { + // Extract the function name from the function signature string + const name = fnString.match(/(?<=function ).*?(?=\()/)![0] + + // Find all functions with the same name + let matchingFunctions = artifactAbi.filter( + (abi): abi is AbiFunction => abi.type === 'function' && abi.name === name, + ) + // If there is only one function with the same name, return it + if (matchingFunctions.length === 1) return matchingFunctions[0] + + // Extract the input types as strings from the function signature string + const inputStrings = fnString + .match(/(?<=\().*?(?=\))/)![0] + .split(',') + .map((x) => x.trim()) + + // Filter out functions with a different number of inputs + matchingFunctions = matchingFunctions.filter( + (abi) => abi.inputs.length === inputStrings.length, + ) + // If there is only one function with the same number of inputs, return it + if (matchingFunctions.length === 1) return matchingFunctions[0] + + // Parse the input strings into input type/name + const parsedInputs = inputStrings.map((x) => { + const [type, name] = x.split(' ') + return { type, name } + }) + + // Filter out functions with different input types + matchingFunctions = matchingFunctions.filter((abi) => { + for (let i = 0; i < abi.inputs.length; i++) { + const current = parsedInputs[i] + const reference = abi.inputs[i] + // Standard match for most cases (e.g. 'uint256' === 'uint256') + if (reference.type === current.type) continue + if ('internalType' in reference && reference.internalType) { + // Internal types that are equal + if (reference.internalType === current.type) continue + // Internal types that are effectively equal (e.g. 'contract INameWrapperUpgrade' === 'INameWrapperUpgrade') + // Multiple internal type aliases can't exist in the same contract, so this is safe + const internalTypeName = reference.internalType.split(' ')[1] + if (internalTypeName === current.type) continue + } + // Not matching + return false + } + // 0 length input - matched by default since the filter for input length already passed + return true + }) + // If there is only one function with the same inputs, return it + if (matchingFunctions.length === 1) return matchingFunctions[0] + + throw new Error(`Could not find matching function for ${fnString}`) +} + +/** + * @description Gets the interface ABI that would be used in Solidity + * + * - This function is required since `type(INameWrapper).interfaceId` in Solidity uses **only the function signatures explicitly defined in the interface**. The value for it however can't be derived from any Solidity output?!?! + * + * @param interfaceName - The name of the interface to get the ABI for + * @returns The explicitly defined ABI for the interface + */ +const getSolidityReferenceInterfaceAbi = async ( + interfaceName: keyof ArtifactsMap, +) => { + const artifact = await hre.artifacts.readArtifact(interfaceName) + const fullyQualifiedNames = await hre.artifacts.getAllFullyQualifiedNames() + + const fullyQualifiedInterfaceName = fullyQualifiedNames.find((n) => + n.endsWith(interfaceName), + ) + + if (!fullyQualifiedInterfaceName) + throw new Error("Couldn't find fully qualified interface name") + + const buildInfo = await hre.artifacts.getBuildInfo( + fullyQualifiedInterfaceName, + ) + + if (!buildInfo) throw new Error("Couldn't find build info for interface") + + const path = fullyQualifiedInterfaceName.split(':')[0] + const buildMetadata = JSON.parse( + (buildInfo.output.contracts[path][interfaceName] as any).metadata, + ) as CompilerInput + const { content } = buildMetadata.sources[path] + + return ( + content + // Remove comments - single and multi-line + .replace(/\/\*[\s\S]*?\*\/|\/\/.*$/gm, '') + // Match only the interface block + nested curly braces + .match(`interface ${interfaceName} .*?{(?:\{??[^{]*?})+`)![0] + // Remove the interface keyword and the interface name + .replace(/.*{/s, '') + // Remove the closing curly brace + .replace(/}$/s, '') + // Match array of all function signatures + .match(/function .*?;/gs)! + // Remove newlines and trailing semicolons + .map((fn) => + fn + .split('\n') + .map((l) => l.trim()) + .join('') + .replace(/;$/, ''), + ) + // Match the function signature string to the exact ABI function + .map((fnString) => + matchStringFunctionToAbi({ + artifactAbi: artifact.abi as Abi, + fnString, + }), + ) + ) +} + +export const shouldSupportInterfaces = < + TContract extends { + abi: Abi + address: Address + read: { + supportsInterface: SupportsInterfaceContract['read']['supportsInterface'] + } + }, +>({ + contract, + interfaces, +}: { + contract: () => TContract | Promise + interfaces: (keyof ArtifactsMap)[] +}) => { + let deployedContract: TContract + + before(async () => { + deployedContract = await contract() + }) + + describe('Contract interface', function () { + for (const interfaceName of interfaces) { + describe(interfaceName, function () { + let interfaceAbi: AbiFunction[] + let interfaceId: Hex + + before(async () => { + interfaceAbi = await getSolidityReferenceInterfaceAbi(interfaceName) + interfaceId = createInterfaceId(interfaceAbi as Abi) + + for (const fn of interfaceAbi) { + const sig = toFunctionSignature(fn) + const selector = toFunctionSelector(fn) + this.addTest( + it(`implements ${sig}`, () => { + expect( + getAbiItem({ abi: deployedContract.abi, name: selector }), + ).not.toBeUndefined() + }), + ) + } + }) + + describe("ERC165's supportsInterface(bytes4)", () => { + it('uses less than 30k gas [skip-on-coverage]', async () => { + const publicClient = await hre.viem.getPublicClient() + + await expect( + publicClient.estimateGas({ + to: deployedContract.address, + data: encodeFunctionData({ + abi: deployedContract.abi, + functionName: 'supportsInterface', + args: [interfaceId], + }), + }), + ).resolves.toBeLessThan(30000n) + }) + + it('claims support', async () => { + await expect( + deployedContract.read.supportsInterface([interfaceId]), + ).resolves.toBe(true) + }) + }) + }) + } + }) +} diff --git a/test/wrapper/TestNameWrapper.ts b/test/wrapper/TestNameWrapper.ts new file mode 100644 index 00000000..72a91411 --- /dev/null +++ b/test/wrapper/TestNameWrapper.ts @@ -0,0 +1,62 @@ +import { loadFixture } from '@nomicfoundation/hardhat-toolbox-viem/network-helpers.js' +import { zeroAddress } from 'viem' +import { DAY, FUSES } from '../fixtures/constants.js' +import { toLabelId, toNameId } from '../fixtures/utils.js' +import { shouldRespectConstraints } from './Constraints.behaviour.js' +import { shouldBehaveLikeErc1155 } from './ERC1155.behaviour.js' +import { shouldSupportInterfaces } from './SupportsInterface.behaviour.js' +import { deployNameWrapperFixture } from './fixtures/deploy.js' + +describe('NameWrapper', () => { + shouldSupportInterfaces({ + contract: () => + loadFixture(deployNameWrapperFixture).then( + ({ nameWrapper }) => nameWrapper, + ), + interfaces: ['INameWrapper', 'IERC721Receiver'], + }) + + shouldBehaveLikeErc1155({ + contracts: () => + loadFixture(deployNameWrapperFixture).then((contracts) => ({ + contract: contracts.nameWrapper, + ...contracts, + })), + targetTokenIds: [ + toNameId('test1.eth'), + toNameId('test2.eth'), + toNameId('doesnotexist.eth'), + ], + mint: async ( + { nameWrapper, baseRegistrar, accounts }, + [firstTokenHolder, secondTokenHolder], + ) => { + await baseRegistrar.write.setApprovalForAll([nameWrapper.address, true]) + await baseRegistrar.write.register([ + toLabelId('test1'), + accounts[0].address, + 1n * DAY, + ]) + await nameWrapper.write.wrapETH2LD([ + 'test1', + firstTokenHolder, + FUSES.CAN_DO_EVERYTHING, + zeroAddress, + ]) + + await baseRegistrar.write.register([ + toLabelId('test2'), + accounts[0].address, + 1n * DAY, + ]) + await nameWrapper.write.wrapETH2LD([ + 'test2', + secondTokenHolder, + FUSES.CAN_DO_EVERYTHING, + zeroAddress, + ]) + }, + }) + + shouldRespectConstraints() +}) diff --git a/test/wrapper/fixtures/deploy.ts b/test/wrapper/fixtures/deploy.ts new file mode 100644 index 00000000..013218ed --- /dev/null +++ b/test/wrapper/fixtures/deploy.ts @@ -0,0 +1,86 @@ +import hre from 'hardhat' +import { labelhash, namehash, zeroAddress, zeroHash } from 'viem' + +export async function deployNameWrapperFixture() { + const accounts = await hre.viem + .getWalletClients() + .then((clients) => clients.map((c) => c.account)) + const publicClient = await hre.viem.getPublicClient() + const testClient = await hre.viem.getTestClient() + 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 metadataService = await hre.viem.deployContract( + 'StaticMetadataService', + ['https://ens.domains'], + ) + + // setup reverse registrar + 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, + zeroAddress, + zeroAddress, + reverseRegistrar.address, + ]) + + await reverseRegistrar.write.setDefaultResolver([publicResolver.address]) + + const nameWrapper = await hre.viem.deployContract('NameWrapper', [ + ensRegistry.address, + baseRegistrar.address, + metadataService.address, + ]) + + const nameWrapperUpgraded = await hre.viem.deployContract( + 'UpgradedNameWrapperMock', + [ensRegistry.address, baseRegistrar.address], + ) + + // setup .eth + await ensRegistry.write.setSubnodeOwner([ + zeroHash, + labelhash('eth'), + baseRegistrar.address, + ]) + + // setup .xyz + await ensRegistry.write.setSubnodeOwner([ + zeroHash, + labelhash('xyz'), + accounts[0].address, + ]) + + return { + ensRegistry, + baseRegistrar, + metadataService, + reverseRegistrar, + publicResolver, + nameWrapper, + nameWrapperUpgraded, + accounts, + publicClient, + testClient, + } +}