diff --git a/plans/names-codes/e2e/LocalChain.test.ts b/plans/names-codes/e2e/LocalChain.test.ts new file mode 100644 index 0000000000..5e1aa1279f --- /dev/null +++ b/plans/names-codes/e2e/LocalChain.test.ts @@ -0,0 +1,108 @@ +// Checks a deployLocal.ts deployment on a running anvil as the store service will use it: +// the service key signs raw transactions over HTTP, and the deploy's real commitment ages apply. +import { randomBytes } from 'node:crypto' +import { readFileSync } from 'node:fs' +import hre from 'hardhat' +import { BaseError, ContractFunctionRevertedError, createWalletClient, decodeErrorResult, defineChain, encodeFunctionData, getAddress, http, labelhash, namehash, toHex, zeroHash } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { describe, expect, it } from 'vitest' + +import { eip712Types, YEAR } from './fixtures/namesV2.js' + +const deployment = JSON.parse(readFileSync(process.env.LOCAL_CHAIN_OUT || 'local-chain.json', 'utf8')) + +// the PR's deploy scripts' settings, which deployLocal.ts copies +const DEPLOY_MIN_COMMITMENT_AGE = 60n +const DEPLOY_MAX_COMMITMENT_AGE = 86400n +const DEPLOY_MAX_LABEL_LENGTH = 63n + +const connection = await hre.network.connect('localhost') +const publicClient = await connection.viem.getPublicClient() +const chain = defineChain({ + id: deployment.chainId, + name: 'local', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: [deployment.rpcUrl] } }, +}) +const service = createWalletClient({ account: privateKeyToAccount(deployment.serviceKey), chain, transport: http(deployment.rpcUrl) }) +const asService = { client: { public: publicClient, wallet: service } } +// a contract's simulate does not take the wallet's account +const fromService = { account: service.account.address } +const controller = await connection.viem.getContractAt('SimplexController', deployment.controller, asService) +const baseRegistrar = await connection.viem.getContractAt('BaseRegistrarImplementation', deployment.baseRegistrar, asService) +const sponsor = await connection.viem.getContractAt('SimplexSponsor', deployment.sponsor, asService) +const resolver = await connection.viem.getContractAt('SimplexResolver', deployment.resolver, asService) +// the sponsor passes the controller's and base registrar's reverts through, so decode with their errors too +const revertAbi = [...sponsor.abi, ...controller.abi, ...baseRegistrar.abi].filter((item) => item.type === 'error') + +async function revertName(call: Promise): Promise { + try { + await call + } catch (e) { + const reverted = e instanceof BaseError ? e.walk((x) => x instanceof ContractFunctionRevertedError) : null + if (reverted instanceof ContractFunctionRevertedError && reverted.raw) + return decodeErrorResult({ abi: revertAbi, data: reverted.raw }).errorName + throw e + } + throw new Error('the call did not revert') +} +const mined = async (hash: `0x${string}`) => + expect((await publicClient.waitForTransactionReceipt({ hash })).status).toBe('success') + +describe('local deployment of PR #35 for the store service', () => { + it('is wired as the service config says', async () => { + expect(await publicClient.getChainId()).toBe(deployment.chainId) + expect(getAddress(await controller.read.sponsor())).toBe(deployment.sponsor) + expect(getAddress(await controller.read.defaultResolver())).toBe(deployment.resolver) + expect(getAddress(await controller.read.beneficiary())).toBe(deployment.guardian) + expect(getAddress(await sponsor.read.controller())).toBe(deployment.controller) + expect(await controller.read.minCommitmentAge()).toBe(DEPLOY_MIN_COMMITMENT_AGE) + expect(await controller.read.maxCommitmentAge()).toBe(DEPLOY_MAX_COMMITMENT_AGE) + expect(await baseRegistrar.read.maxLabelLength()).toBe(DEPLOY_MAX_LABEL_LENGTH) + expect(await baseRegistrar.read.controllers([deployment.controller])).toBe(true) + expect(service.account.address).toBe(deployment.registrar) + expect(await sponsor.read.registrarAllowance([deployment.registrar])).toBeGreaterThan(0n) + }) + + it('the service commits, waits minCommitmentAge, and registers with the owner intent', async () => { + const owner = privateKeyToAccount(toHex(randomBytes(32))) + const label = 'local' + randomBytes(4).toString('hex') + const node = namehash(`${label}.${deployment.tld}`) + const contact = 'https://smp8.simplex.im/a#Y8m3qT0vLw2' + const data = [encodeFunctionData({ abi: resolver.abi, functionName: 'setText', args: [node, 'simplex.contact', contact] })] + const reg = { label, owner: owner.address, duration: 2n * YEAR, secret: toHex(randomBytes(32)), resolver: deployment.resolver, data, reverseRecord: 0, referrer: zeroHash } as const + const tooLong = { ...reg, label: 'a'.repeat(deployment.maxLabelLength + 1), data: [] as `0x${string}`[] } + + const commitment = await controller.read.makeCommitment([reg]) + await mined(await controller.write.commit([commitment])) + await mined(await controller.write.commit([await controller.read.makeCommitment([tooLong])])) + + // revealBefore of the first commitment; the second, mined in the same second or later, lapses no earlier + const deadline = (await controller.read.commitments([commitment])) + BigInt(deployment.maxCommitmentAge) + const sign = (r: typeof reg | typeof tooLong, nonce: bigint) => + owner.signTypedData({ + domain: { name: 'SimplexSponsor', version: '1', chainId: deployment.chainId, verifyingContract: deployment.sponsor }, + types: eip712Types, primaryType: 'RegisterName', + message: { label: r.label, owner: r.owner, resolver: r.resolver, data: r.data, nonce, deadline }, + }) + const nonce = await sponsor.read.nonces([owner.address]) + const signature = await sign(reg, nonce) + expect(await revertName(sponsor.simulate.registerWithCredit([reg, nonce, deadline, signature], fromService))).toBe('CommitmentTooNew') + + await connection.networkHelpers.time.increase(deployment.minCommitmentAge + 1) + const allowanceBefore = await sponsor.read.registrarAllowance([deployment.registrar]) + const price = await controller.read.rentPriceUSD([label, reg.duration]) + await sponsor.simulate.registerWithCredit([reg, nonce, deadline, signature], fromService) + await mined(await sponsor.write.registerWithCredit([reg, nonce, deadline, signature])) + + expect(getAddress(await baseRegistrar.read.ownerOf([BigInt(labelhash(label))]))).toBe(owner.address) + expect(await resolver.read.text([node, 'simplex.contact'])).toBe(contact) + expect(await sponsor.read.registrarAllowance([deployment.registrar])).toBe(allowanceBefore - price.base - price.premium) + expect(await sponsor.read.nonces([owner.address])).toBe(nonce + 1n) + + const longNonce = await sponsor.read.nonces([owner.address]) + expect( + await revertName(sponsor.simulate.registerWithCredit([tooLong, longNonce, deadline, await sign(tooLong, longNonce)], fromService)), + ).toBe('LabelTooLong') + }) +}) diff --git a/plans/names-codes/e2e/README.md b/plans/names-codes/e2e/README.md new file mode 100644 index 0000000000..be839d08fd --- /dev/null +++ b/plans/names-codes/e2e/README.md @@ -0,0 +1,39 @@ +# End-to-end check against the contracts + +`StoreE2E.test.ts` runs the store's flow on a local hardhat chain against ens-contracts PR #35 (`simplex-network/ens-contracts`): +- the owner code round trip, its BIP-39 phrase, seed and account-0 wallet, and the commitment secret from the seed; +- the commitment with a record, `commit`, the owner's `RegisterName` intent (with its nonce, and `revealBefore` as its deadline), and `registerWithCredit` from the registrar while public sales are closed, spending exactly the list price from the allowance; +- a second order for the name reverting `NameNotAvailable`, and, with an expired intent, `SignatureExpired` instead while the chain's owner is someone else and `available(label)` is false, the case the service must still call taken; +- another browser with only the owner code and the registration view (`sealed`, `commitment`, `years`, resolver, sponsor, chain id, TLD, `intentNonce`, `revealBefore`): opening the sealed label and record values (AES-256-GCM under the seed's sealing key; a wrong code fails), checking that a longest order seals to exactly the service's 11095-character bound, rebuilding the same commitment, and registering with an intent signed from the view's fields; +- two record edits with consecutive nonces, each relayed after the previous is mined: `setTextWithSig`, then `clearRecordsWithSig`; +- redeeming by `approveWithSig` and `claimWithSig`, the token and registry node moving to the app's wallet; +- a commitment's limits to the second: at its `revealBefore` it reverts `CommitmentTooOld` and cannot be committed again while one a second younger registers, and it commits again a second later; an expired intent reverting `SignatureExpired`, and `ReverseRecordNotSupported`, `ResolverRequiredWhenDataSupplied` and `DurationTooShort`. + +It uses the PR's own deployment fixture (`test/simplex/fixtures/namesV2.ts`), so it runs from inside a checkout of the PR: + +``` +git clone https://github.com/simplex-network/ens-contracts.git && cd ens-contracts +git fetch origin pull/35/head:pr35 && git checkout pr35 +npx pnpm@9 install --frozen-lockfile --ignore-scripts +cp /plans/names-codes/e2e/*.ts test/simplex/ +npx hardhat compile +NODE_OPTIONS="--disable-warning=ExperimentalWarning" npx vitest run test/simplex/StoreE2E.test.ts +``` + +Last run: PR #35 at `0f882c0`, 6 tests passed. + +## A local chain for the service + +`deployLocal.ts` deploys the same stack to a running node, wired as the fixture wires it but with the PR deploy scripts' settings: `minCommitmentAge` 60 s, `maxCommitmentAge` 24 h, `maxLabelLength` 63, and the fixture's TLD `simplex`. The PR's scripts default to `testing` with the NFT gate on; `SIMPLEX_TLD` changes only the TLD here, and the gate stays off. The guardian (account 1) gives the registrar (account 2) an allowance. The script writes `local-chain.json` (addresses, the service key, the label cap and the commitment ages) and prints the service's `[names]` and `[chain]` config, whose `service_key` is account 2's well-known development key. + +The node is anvil from Foundry (`foundryup`, or the `foundry_stable_linux_amd64.tar.gz` release), not `hardhat node`: Hardhat 3.1.4, which the PR pins, returned every revert we sent (`InvalidSignature` from `registerWithCredit`, and a plain call) as `-32603 Internal error` without its data, since its `_handleError` wraps any error that is not a `ProviderError`, so a client cannot tell `NameNotAvailable` from any other failure. Hardhat 3.19.0 fixes this: its `node/json-rpc/handler.js` `_handleError` extracts the revert data before wrapping the error, and answers a code 3 revert with the raw data, as geth and anvil do (checked in the published 3.19.0 package). Anvil's default accounts and chain id 31337 match the PR's `localhost` network. + +``` +anvil --chain-id 31337 --state local-chain.anvil # keeps the deployment across restarts +npx hardhat run test/simplex/deployLocal.ts --network localhost +NODE_OPTIONS="--disable-warning=ExperimentalWarning" npx vitest run test/simplex/LocalChain.test.ts +``` + +`LocalChain.test.ts` checks the deployment as the service will use it. The service key signs raw transactions over HTTP. The test checks the wiring the config names, then commits a name with a record and one with a 64-character label. Before 60 s the register reverts `CommitmentTooNew`; after the wait it registers, spending exactly the list price. The long label reverts `LabelTooLong`. Each test can be rerun on the same deployment: the registered label and every secret are random, so each run makes new commitments, and the long label never registers. A run spends $20 of the $1000 allowance, so 49 runs succeed. The sponsor spends the allowance before it calls the controller, so the 50th, which empties it, then sees `InsufficientAllowance` instead of `LabelTooLong`, and the 51st fails the wiring check; deploy again then. The sponsor passes the controller's and base registrar's reverts through unchanged, so the test, like the service, decodes revert data against all three contracts' errors. + +Last run: anvil 1.5.1, 2 tests passed. diff --git a/plans/names-codes/e2e/StoreE2E.test.ts b/plans/names-codes/e2e/StoreE2E.test.ts new file mode 100644 index 0000000000..190bbca0f4 --- /dev/null +++ b/plans/names-codes/e2e/StoreE2E.test.ts @@ -0,0 +1,416 @@ +// The simplex.domains store flow against PR #35, in process: owner code and wallet, commit, the owner's +// intent, registerWithCredit by the registrar, record edits, restore from the code, and redeeming. +import { createCipheriv, createDecipheriv, createHash, createHmac, pbkdf2Sync, randomBytes } from 'node:crypto' +import hre from 'hardhat' +import { + encodeFunctionData, + getAddress, + labelhash, + namehash, + toHex, + zeroHash, +} from 'viem' +import { english, mnemonicToAccount, privateKeyToAccount } from 'viem/accounts' +import { describe, expect, it } from 'vitest' + +import { + AMPLE_ALLOWANCE, + deployNamesV2, + eip712Types, + MIN_REGISTRATION, + YEAR, +} from './fixtures/namesV2.js' + +const connection = await hre.network.connect() +const [ownerClient, guardianClient, registrarClient] = + await connection.viem.getWalletClients() +const owner = ownerClient.account +const guardian = guardianClient.account +const registrar = registrarClient.account +const publicClient = await connection.viem.getPublicClient() + +// the owner code, as Alain's formatNameCode / parseNameCode (Badges/Code.hs) +const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ' +const sha256 = (b: Uint8Array) => createHash('sha256').update(b).digest() +// the owner code carries the first 22 bits of the entropy's SHA-256 +const CHECKSUM_BITS = 22n +const ENTROPY_BYTES = 16 +const OWNER_CODE_CHARS = 30 +const BASE = 32n +const checksumOf = (entropy: Uint8Array) => BigInt('0x' + sha256(entropy).toString('hex')) >> (256n - CHECKSUM_BITS) + +function formatOwnerCode(entropy: Uint8Array): string { + const payload = BigInt('0x' + Buffer.from(entropy).toString('hex')) + let n = payload * 2n ** CHECKSUM_BITS + checksumOf(entropy) + const digits: string[] = [] + for (let i = 0; i < OWNER_CODE_CHARS; i++) { + digits.unshift(ALPHABET[Number(n % BASE)]) + n /= BASE + } + return 'SN-' + digits.join('').match(/.{5}/g)!.join('-') +} + +function parseOwnerCode(text: string): Uint8Array { + const folded = text.toUpperCase().replace(/[^0-9A-Z]/g, '').replace(/[IL]/g, '1').replace(/O/g, '0') + if (!folded.startsWith('SN')) throw new Error('not SN') + const body = folded.slice(2) + if (body.length !== OWNER_CODE_CHARS) throw new Error('length') + let n = 0n + for (const c of body) { + const v = ALPHABET.indexOf(c) + if (v < 0) throw new Error('char') + n = n * BASE + BigInt(v) + } + const payload = n >> CHECKSUM_BITS + const entropy = Buffer.from(payload.toString(16).padStart(ENTROPY_BYTES * 2, '0'), 'hex') + if (n % 2n ** CHECKSUM_BITS !== checksumOf(entropy)) throw new Error('checksum') + return entropy +} + +// BIP-39: 128 bits of entropy and 4 bits of its SHA-256, as 12 words +function entropyToMnemonic(entropy: Uint8Array): string { + const bits = + [...entropy].map((b) => b.toString(2).padStart(8, '0')).join('') + + sha256(entropy)[0].toString(2).padStart(8, '0').slice(0, 4) + return bits.match(/.{11}/g)!.map((b) => english[parseInt(b, 2)]).join(' ') +} + +function walletFromCode(code: string) { + const mnemonic = entropyToMnemonic(parseOwnerCode(code)) + // viem's default path is m/44'/60'/0'/0/0, account 0 + const account = mnemonicToAccount(mnemonic) + // the BIP-39 seed, PBKDF2-SHA512 of the phrase with salt "mnemonic", as the page computes it with WebCrypto + const seed = pbkdf2Sync(mnemonic, 'mnemonic', 2048, 64, 'sha512') + return { account, seed } +} + +// the commitment secret, derived from the code so the code alone finishes an order +const commitmentSecret = (seed: Uint8Array, label: string) => + toHex(createHmac('sha256', Buffer.from(seed)).update('simplex.domains commitment ' + label).digest()) + +// the order's label and record values, sealed under a key from the code so another browser can rebuild the commitment +type Sealed = { label: string; contact: string; channel: string } +const sealingKey = (seed: Uint8Array) => createHmac('sha256', Buffer.from(seed)).update('simplex.domains sealed').digest() +// snrc-resolve.py splits a record's links on it +const LINK_SEPARATOR = ';' +const RECORD_MAX_BYTES = 4096 +const LABEL_MAX = 63 +// base64url of the 12-byte IV, the JSON of a longest label and two longest record values, and the 16-byte tag +const SEALED_MAX = 11095 +const GCM_IV = 12 +const GCM_TAG = 16 + +function seal(seed: Uint8Array, order: Sealed): string { + const iv = randomBytes(GCM_IV) + const cipher = createCipheriv('aes-256-gcm', sealingKey(seed), iv) + const body = Buffer.concat([cipher.update(JSON.stringify(order)), cipher.final(), cipher.getAuthTag()]) + return Buffer.concat([iv, body]).toString('base64url') +} + +function unseal(seed: Uint8Array, sealed: string): Sealed { + const raw = Buffer.from(sealed, 'base64url') + const decipher = createDecipheriv('aes-256-gcm', sealingKey(seed), raw.subarray(0, GCM_IV)) + decipher.setAuthTag(raw.subarray(raw.length - GCM_TAG)) + return JSON.parse(Buffer.concat([decipher.update(raw.subarray(GCM_IV, raw.length - GCM_TAG)), decipher.final()]).toString()) +} + +describe('simplex.domains store, end to end on PR #35', () => { + it('owner code round-trips, and the abandon…about vector gives #7475 account 0', () => { + const entropy = randomBytes(ENTROPY_BYTES) + const code = formatOwnerCode(entropy) + expect(code).toMatch(/^SN-([0-9A-Z]{5}-){5}[0-9A-Z]{5}$/) + expect(Buffer.from(parseOwnerCode(code)).equals(entropy)).toBe(true) + // the mockups' code and its stated entropy + expect(formatOwnerCode(Buffer.from('6f2d1c9a4b7e3f80d15a2c6e9b04f7a3', 'hex'))).toBe( + 'SN-DWPHS-6JBFR-ZR1MA-T5HQ9-P17QM-F5GM5', + ) + // and the address the mockups show for it, 0x014f…9b73 + expect(walletFromCode('SN-DWPHS-6JBFR-ZR1MA-T5HQ9-P17QM-F5GM5').account.address).toBe( + '0x014f9c09CcEeDEF1B713110d04D3a07d5B5D9b73', + ) + expect(mnemonicToAccount(entropyToMnemonic(Buffer.alloc(ENTROPY_BYTES))).address).toBe( + '0x9858EfFD232B4033E47d90003D41EC34EcaEda94', + ) + }) + + it('registers to the code wallet with records, then redeems to the app wallet', async () => { + const f = await deployNamesV2(connection, { owner: owner.address, beneficiary: guardian.address }) + await f.sponsor.write.setRegistrarAllowance([registrar.address, AMPLE_ALLOWANCE], { account: guardian }) + + // page: owner code and wallet + const code = formatOwnerCode(randomBytes(ENTROPY_BYTES)) + const wallet = walletFromCode(code) + const label = 'dynamis' + + // page: search sends only the hash; the chain agrees it is available + expect(await f.controller.read.available([label])).toBe(true) + + // page: the contact link as a setText call on the default resolver (a channel would follow it) + const nameNode = namehash(`${label}.simplex`) + const contact = 'https://smp8.simplex.im/a#Y8m3qT0vLw2' + const data = [ + encodeFunctionData({ abi: f.resolver.abi, functionName: 'setText', args: [nameNode, 'simplex.contact', contact] }), + ] + const resolver = await f.controller.read.defaultResolver() + const reg = { + label, + owner: wallet.account.address, + duration: 2n * YEAR, + secret: commitmentSecret(wallet.seed, label), + resolver, + data, + reverseRecord: 0, + referrer: zeroHash, + } as const + expect(reg.duration).toBe(MIN_REGISTRATION) + + // page: commitment; service: commit from the registrar key + const commitment = await f.controller.read.makeCommitment([reg]) + await f.controller.write.commit([commitment], { account: registrar }) + + // wait minCommitmentAge (1 s in the fixture) + await connection.networkHelpers.time.increase(2) + + // page at the reveal: the owner's RegisterName intent, signed with the code wallet's key + const chainId = await publicClient.getChainId() + const nonce = await f.sponsor.read.nonces([wallet.account.address]) + // revealBefore: the commitment lapses then anyway + const deadline = (await f.controller.read.commitments([commitment])) + (await f.controller.read.maxCommitmentAge()) + const signature = await wallet.account.signTypedData({ + domain: { name: 'SimplexSponsor', version: '1', chainId, verifyingContract: f.sponsor.address }, + types: eip712Types, + primaryType: 'RegisterName', + message: { label, owner: wallet.account.address, resolver, data, nonce, deadline }, + }) + + // service: registerWithCredit, dry-run first, from the registrar key; no ETH value + const allowanceBefore = await f.sponsor.read.registrarAllowance([registrar.address]) + const listPrice = await f.controller.read.rentPriceUSD([label, reg.duration]) + // the sponsored path works while public sales are closed + expect(await f.controller.read.publicSalesOpen()).toBe(false) + await f.sponsor.simulate.registerWithCredit([reg, nonce, deadline, signature], { account: registrar.address }) + await f.sponsor.write.registerWithCredit([reg, nonce, deadline, signature], { account: registrar }) + + // the name belongs to the code wallet, with its record, and the allowance paid for it + const tokenId = BigInt(labelhash(label)) + expect(getAddress(await f.baseRegistrar.read.ownerOf([tokenId]))).toBe(wallet.account.address) + expect(getAddress(await f.ens.read.owner([nameNode]))).toBe(wallet.account.address) + expect(await f.resolver.read.text([nameNode, 'simplex.contact'])).toBe(contact) + expect(await f.sponsor.read.registrarAllowance([registrar.address])).toBe(allowanceBefore - listPrice.base - listPrice.premium) + expect(await f.controller.read.available([label])).toBe(false) + + // a second order for the same name: the service's dry run reverts NameNotAvailable + const other = privateKeyToAccount(toHex(randomBytes(32))) + const reg2 = { ...reg, owner: other.address, secret: toHex(randomBytes(32)), data: [] as `0x${string}`[] } + await f.controller.write.commit([await f.controller.read.makeCommitment([reg2])], { account: registrar }) + await connection.networkHelpers.time.increase(2) + const sig2 = await other.signTypedData({ + domain: { name: 'SimplexSponsor', version: '1', chainId, verifyingContract: f.sponsor.address }, + types: eip712Types, + primaryType: 'RegisterName', + message: { label, owner: other.address, resolver, data: [], nonce: 0n, deadline }, + }) + await expect( + f.sponsor.simulate.registerWithCredit([reg2, 0n, deadline, sig2], { account: registrar.address }), + ).rejects.toThrow(/NameNotAvailable/) + // with an expired intent the sponsor reverts before the controller sees the name, so the chain's owner and available() tell it is taken + const past = (await publicClient.getBlock()).timestamp - 1n + const expiredSig = await other.signTypedData({ + domain: { name: 'SimplexSponsor', version: '1', chainId, verifyingContract: f.sponsor.address }, + types: eip712Types, + primaryType: 'RegisterName', + message: { label, owner: other.address, resolver, data: [], nonce: 0n, deadline: past }, + }) + await expect( + f.sponsor.simulate.registerWithCredit([reg2, 0n, past, expiredSig], { account: registrar.address }), + ).rejects.toThrow(/SignatureExpired/) + expect(getAddress(await f.baseRegistrar.read.ownerOf([BigInt(labelhash(label))]))).not.toBe(other.address) + expect(await f.controller.read.available([label])).toBe(false) + + // a later link edit: the code wallet signs SetText, the service relays setTextWithSig, paying gas + const channel = 'https://smp9.simplex.im/c#Rk27xN4pDe9' + const textNonce = await f.resolver.read.nonces([wallet.account.address]) + const textSig = await wallet.account.signTypedData({ + domain: { name: 'SimplexResolver', version: '1', chainId, verifyingContract: f.resolver.address }, + types: eip712Types, primaryType: 'SetText', + message: { node: nameNode, key: 'simplex.channel', value: channel, nonce: textNonce, deadline }, + }) + await f.resolver.write.setTextWithSig([nameNode, 'simplex.channel', channel, textNonce, deadline, textSig], { account: registrar }) + expect(await f.resolver.read.text([nameNode, 'simplex.channel'])).toBe(channel) + // the next edit is signed once the first is mined, with the nonce the chain now has + const clearNonce = await f.resolver.read.nonces([wallet.account.address]) + expect(clearNonce).toBe(textNonce + 1n) + const clearSig = await wallet.account.signTypedData({ + domain: { name: 'SimplexResolver', version: '1', chainId, verifyingContract: f.resolver.address }, + types: eip712Types, primaryType: 'ClearRecords', + message: { node: nameNode, nonce: clearNonce, deadline }, + }) + await f.resolver.write.clearRecordsWithSig([nameNode, clearNonce, deadline, clearSig], { account: registrar }) + expect(await f.resolver.read.text([nameNode, 'simplex.contact'])).toBe('') + expect(await f.resolver.read.text([nameNode, 'simplex.channel'])).toBe('') + + // app: redeem. The code wallet offers the name to the app wallet, the app wallet claims it; + // the service relays both, paying gas + const app = privateKeyToAccount(toHex(randomBytes(32))) + const names = { name: 'SimplexNames', version: '1', chainId, verifyingContract: f.baseRegistrar.address } as const + const approveNonce = await f.baseRegistrar.read.nonces([wallet.account.address]) + const approveSig = await wallet.account.signTypedData({ + domain: names, types: eip712Types, primaryType: 'ApproveName', + message: { from: wallet.account.address, to: app.address, tokenId, ephemeralPubKey: '0x', viewTag: '0x00', nonce: approveNonce, deadline }, + }) + await f.baseRegistrar.write.approveWithSig( + [wallet.account.address, app.address, tokenId, approveNonce, deadline, approveSig, '0x', '0x00'], + { account: registrar }, + ) + const offer = await f.baseRegistrar.read.offers([tokenId]) + const claimNonce = await f.baseRegistrar.read.nonces([app.address]) + const claimSig = await app.signTypedData({ + domain: names, types: eip712Types, primaryType: 'ClaimName', + message: { from: wallet.account.address, to: app.address, tokenId, offer, nonce: claimNonce, deadline }, + }) + await f.baseRegistrar.write.claimWithSig( + [wallet.account.address, app.address, tokenId, offer, claimNonce, deadline, claimSig], + { account: registrar }, + ) + expect(getAddress(await f.baseRegistrar.read.ownerOf([tokenId]))).toBe(app.address) + expect(getAddress(await f.ens.read.owner([nameNode]))).toBe(app.address) + }) + + it('another browser with only the owner code opens the sealed order, rebuilds the commitment and registers', async () => { + const f = await deployNamesV2(connection, { owner: owner.address, beneficiary: guardian.address }) + await f.sponsor.write.setRegistrarAllowance([registrar.address, AMPLE_ALLOWANCE], { account: guardian }) + const recordsFor = (label: string, tld: string, order: Sealed) => { + const node = namehash(`${label}.${tld}`) + const text = (key: string, value: string) => + encodeFunctionData({ abi: f.resolver.abi, functionName: 'setText', args: [node, key, value] }) + const records: [string, string][] = [['simplex.contact', order.contact], ['simplex.channel', order.channel]] + return records.filter(([, value]) => value !== '').map(([key, value]) => text(key, value)) + } + const registrationFor = (seed: Uint8Array, owner: `0x${string}`, order: Sealed, resolver: `0x${string}`, tld: string, years: bigint) => ({ + label: order.label, owner, duration: years * YEAR, secret: commitmentSecret(seed, order.label), + resolver, data: recordsFor(order.label, tld, order), reverseRecord: 0, referrer: zeroHash, + } as const) + + // browser A: checkout builds the commitment from the search answer and seals the label and links + const search = { resolver: await f.controller.read.defaultResolver(), tld: 'simplex' } + const years = 2n + const code = formatOwnerCode(randomBytes(ENTROPY_BYTES)) + const a = walletFromCode(code) + const order: Sealed = { label: 'lighthouse', contact: ['https://smp8.simplex.im/a#Y8m3qT0vLw2', 'https://smp5.simplex.im/a#Qp4nV7cZ1sE'].join(LINK_SEPARATOR), channel: 'https://smp9.simplex.im/c#Rk27xN4pDe9' } + const commitment = await f.controller.read.makeCommitment([registrationFor(a.seed, a.account.address, order, search.resolver, search.tld, years)]) + const sealed = seal(a.seed, order) + + // service: commits, then serves the registration view from its row and the chain + await f.controller.write.commit([commitment], { account: registrar }) + const view = { + owner: a.account.address, + commitment, + sealed, + years, + resolver: search.resolver, + sponsor: f.sponsor.address, + chainId: await publicClient.getChainId(), + tld: search.tld, + intentNonce: await f.sponsor.read.nonces([a.account.address]), + revealBefore: (await f.controller.read.commitments([commitment])) + (await f.controller.read.maxCommitmentAge()), + } + await connection.networkHelpers.time.increase(2) + + // a wrong code cannot open it + expect(() => unseal(walletFromCode(formatOwnerCode(randomBytes(ENTROPY_BYTES))).seed, view.sealed)).toThrow() + + // browser B: the typed code and the registration view only + const b = walletFromCode(code.toLowerCase().replace(/-/g, ' ')) + expect(b.account.address).toBe(view.owner) + const opened = unseal(b.seed, view.sealed) + const reg = registrationFor(b.seed, b.account.address, opened, view.resolver, view.tld, view.years) + expect(await f.controller.read.makeCommitment([reg])).toBe(view.commitment) + + const signature = await b.account.signTypedData({ + domain: { name: 'SimplexSponsor', version: '1', chainId: view.chainId, verifyingContract: view.sponsor }, + types: eip712Types, primaryType: 'RegisterName', + message: { label: reg.label, owner: reg.owner, resolver: view.resolver, data: reg.data, nonce: view.intentNonce, deadline: view.revealBefore }, + }) + await f.sponsor.write.registerWithCredit([reg, view.intentNonce, view.revealBefore, signature], { account: registrar }) + const node = namehash('lighthouse.simplex') + expect(getAddress(await f.ens.read.owner([node]))).toBe(view.owner) + expect(await f.resolver.read.text([node, 'simplex.contact'])).toBe(order.contact) + expect(await f.resolver.read.text([node, 'simplex.channel'])).toBe(order.channel) + + // the service's bound fits the longest order exactly + const longest: Sealed = { label: 'a'.repeat(LABEL_MAX), contact: 'c'.repeat(RECORD_MAX_BYTES), channel: 'h'.repeat(RECORD_MAX_BYTES) } + expect(seal(a.seed, longest).length).toBe(SEALED_MAX) + }) + + it('a commitment registers until a second before revealBefore, and commits again only a second after it', async () => { + const f = await deployNamesV2(connection, { owner: owner.address, beneficiary: guardian.address }) + await f.sponsor.write.setRegistrarAllowance([registrar.address, AMPLE_ALLOWANCE], { account: guardian }) + const resolver = await f.controller.read.defaultResolver() + const chainId = await publicClient.getChainId() + const maxAge = await f.controller.read.maxCommitmentAge() + const order = (label: string) => { + const w = privateKeyToAccount(toHex(randomBytes(32))) + const reg = { label, owner: w.address, duration: 2n * YEAR, secret: toHex(randomBytes(32)), resolver, data: [] as `0x${string}`[], reverseRecord: 0, referrer: zeroHash } as const + return { w, reg } + } + const intent = (o: ReturnType, deadline: bigint) => + o.w.signTypedData({ + domain: { name: 'SimplexSponsor', version: '1', chainId, verifyingContract: f.sponsor.address }, + types: eip712Types, primaryType: 'RegisterName', + message: { label: o.reg.label, owner: o.w.address, resolver, data: [], nonce: 0n, deadline }, + }) + + // `older` is committed a second before `younger`, so younger's last second is older's revealBefore + const older = order('lapsing') + const younger = order('justintime') + const olderCommitment = await f.controller.read.makeCommitment([older.reg]) + await f.controller.write.commit([olderCommitment], { account: registrar }) + const revealBefore = (await f.controller.read.commitments([olderCommitment])) + maxAge + await connection.networkHelpers.time.setNextBlockTimestamp(revealBefore - maxAge + 1n) + await f.controller.write.commit([await f.controller.read.makeCommitment([younger.reg])], { account: registrar }) + await expect(f.controller.simulate.commit([olderCommitment], { account: registrar.address })).rejects.toThrow(/UnexpiredCommitmentExists/) + + // at revealBefore the older commitment can neither register nor be committed again; the younger registers + await connection.networkHelpers.time.setNextBlockTimestamp(revealBefore) + await expect( + f.sponsor.write.registerWithCredit([older.reg, 0n, revealBefore, await intent(older, revealBefore)], { account: registrar }), + ).rejects.toThrow(/CommitmentTooOld/) + await expect(f.controller.write.commit([olderCommitment], { account: registrar })).rejects.toThrow(/UnexpiredCommitmentExists/) + await f.sponsor.write.registerWithCredit([younger.reg, 0n, revealBefore + 1n, await intent(younger, revealBefore + 1n)], { account: registrar }) + expect((await publicClient.getBlock()).timestamp).toBe(revealBefore) + expect(getAddress(await f.baseRegistrar.read.ownerOf([BigInt(labelhash(younger.reg.label))]))).toBe(younger.w.address) + + await connection.networkHelpers.time.setNextBlockTimestamp(revealBefore + 1n) + await f.controller.write.commit([olderCommitment], { account: registrar }) + expect(await f.controller.read.commitments([olderCommitment])).toBe(revealBefore + 1n) + }) + + it('an intent past its deadline reverts SignatureExpired', async () => { + const f = await deployNamesV2(connection, { owner: owner.address, beneficiary: guardian.address }) + await f.sponsor.write.setRegistrarAllowance([registrar.address, AMPLE_ALLOWANCE], { account: guardian }) + const w = privateKeyToAccount(toHex(randomBytes(32))) + const resolver = await f.controller.read.defaultResolver() + const reg = { label: 'nebula1', owner: w.address, duration: 2n * YEAR, secret: toHex(randomBytes(32)), resolver, data: [] as `0x${string}`[], reverseRecord: 0, referrer: zeroHash } as const + await f.controller.write.commit([await f.controller.read.makeCommitment([reg])], { account: registrar }) + const now = (await publicClient.getBlock()).timestamp + const deadline = now + 10n + const sig = await w.signTypedData({ + domain: { name: 'SimplexSponsor', version: '1', chainId: await publicClient.getChainId(), verifyingContract: f.sponsor.address }, + types: eip712Types, primaryType: 'RegisterName', + message: { label: reg.label, owner: w.address, resolver, data: [], nonce: 0n, deadline }, + }) + await connection.networkHelpers.time.increase(60) + await expect(f.sponsor.simulate.registerWithCredit([reg, 0n, deadline, sig], { account: registrar.address })).rejects.toThrow(/SignatureExpired/) + }) + + it('refuses a reverse record, records without a resolver, and a duration under two years', async () => { + const f = await deployNamesV2(connection, { owner: owner.address, beneficiary: guardian.address }) + const w = privateKeyToAccount(toHex(randomBytes(32))) + const resolver = await f.controller.read.defaultResolver() + const base = { label: 'orbital', owner: w.address, duration: 2n * YEAR, secret: toHex(randomBytes(32)), resolver, data: [] as `0x${string}`[], reverseRecord: 0, referrer: zeroHash } as const + const setText = encodeFunctionData({ abi: f.resolver.abi, functionName: 'setText', args: [namehash('orbital.simplex'), 'simplex.contact', 'x'] }) + await expect(f.controller.read.makeCommitment([{ ...base, reverseRecord: 1 }])).rejects.toThrow(/ReverseRecordNotSupported/) + await expect(f.controller.read.makeCommitment([{ ...base, resolver: '0x0000000000000000000000000000000000000000', data: [setText] }])).rejects.toThrow(/ResolverRequiredWhenDataSupplied/) + await expect(f.controller.read.makeCommitment([{ ...base, duration: 2n * YEAR - 1n }])).rejects.toThrow(/DurationTooShort/) + }) +}) diff --git a/plans/names-codes/e2e/deployLocal.ts b/plans/names-codes/e2e/deployLocal.ts new file mode 100644 index 0000000000..23dfedcf6b --- /dev/null +++ b/plans/names-codes/e2e/deployLocal.ts @@ -0,0 +1,106 @@ +// Deploys PR #35's .simplex stack to a running anvil for the store service to use, +// wired as fixtures/namesV2.ts wires it, with the commitment ages and label cap of deploy/simplex/00 and 02. +import { writeFileSync } from 'node:fs' +import hre from 'hardhat' +import { encodeFunctionData, getAddress, isAddressEqual, labelhash, namehash, zeroAddress, zeroHash } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' + +import { AMPLE_ALLOWANCE, PRICE_BASE, PRICE_EXCEPTIONS } from './fixtures/namesV2.js' + +const TLD = process.env.SIMPLEX_TLD || 'simplex' +const MIN_COMMITMENT_AGE = 60n +const MAX_COMMITMENT_AGE = 86400n +const MIN_CHAR_LENGTH = 6 +// the DNS label limit in bytes +const MAX_LABEL_LENGTH = 63n +// Chainlink's 8 decimals; 1e8 makes 1 attoUSD cost 1 wei, as in the fixture +const FEED_DECIMALS = 8 +const FEED_PRICE = 100000000n +// account 2 of anvil's well-known development mnemonic, so the service can sign with it locally +const REGISTRAR_KEY = '0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a' +const OUT = process.env.LOCAL_CHAIN_OUT || 'local-chain.json' + +const connection = await hre.network.connect() +const viem = connection.viem +const [ownerClient, guardianClient, registrarClient] = await viem.getWalletClients() +const owner = ownerClient.account +const guardian = guardianClient.account +const registrar = registrarClient.account +if (!isAddressEqual(registrar.address, privateKeyToAccount(REGISTRAR_KEY).address)) + throw new Error(`account 2 is ${registrar.address}, not anvil's default`) +const publicClient = await viem.getPublicClient() +const tldNode = namehash(TLD) + +const ens = await viem.deployContract('ENSRegistry', []) +const baseRegistrar = await viem.deployContract('BaseRegistrarImplementation', [ens.address, tldNode]) +await ens.write.setSubnodeOwner([zeroHash, labelhash(TLD), baseRegistrar.address]) +await baseRegistrar.write.setMaxLabelLength([MAX_LABEL_LENGTH]) + +const feed = await viem.deployContract('DummyOracle', [FEED_PRICE]) +const priceOracle = await viem.deployContract('SimplexPriceOracle', [feed.address, FEED_DECIMALS, PRICE_BASE, PRICE_EXCEPTIONS]) + +const implementation = await viem.deployContract('SimplexController', []) +const initData = encodeFunctionData({ + abi: implementation.abi, + functionName: 'initialize', + args: [ + baseRegistrar.address, + priceOracle.address, + MIN_COMMITMENT_AGE, + MAX_COMMITMENT_AGE, + ens.address, + { tldNode, tldSuffix: `.${TLD}`, minCharLength: MIN_CHAR_LENGTH, smpxNft: zeroAddress, nftGateEnabled: false }, + owner.address, + ], +}) +const proxy = await viem.deployContract('SimplexControllerProxy', [implementation.address, initData]) +const controller = await viem.getContractAt('SimplexController', proxy.address) + +const subnameRegistrar = await viem.deployContract('SubnameRegistrar', [ens.address, baseRegistrar.address]) +const resolver = await viem.deployContract('SimplexResolver', [ens.address, subnameRegistrar.address, controller.address, zeroAddress]) +await subnameRegistrar.write.setResolver([resolver.address]) + +await baseRegistrar.write.addController([controller.address]) +await baseRegistrar.write.setSubnameHook([subnameRegistrar.address]) +await controller.write.setDefaultResolver([resolver.address], { account: owner }) +await controller.write.setBeneficiary([guardian.address], { account: owner }) + +const sponsor = await viem.deployContract('SimplexSponsor', [controller.address]) +await controller.write.setSponsor([sponsor.address], { account: owner }) +await sponsor.write.setRegistrarAllowance([registrar.address, AMPLE_ALLOWANCE], { account: guardian }) + +const chainId = await publicClient.getChainId() +const deployment = { + rpcUrl: 'http://127.0.0.1:8545', + chainId, + tld: TLD, + controller: getAddress(controller.address), + sponsor: getAddress(sponsor.address), + baseRegistrar: getAddress(baseRegistrar.address), + resolver: getAddress(resolver.address), + ens: getAddress(ens.address), + owner: getAddress(owner.address), + guardian: getAddress(guardian.address), + registrar: getAddress(registrar.address), + serviceKey: REGISTRAR_KEY, + maxLabelLength: Number(await baseRegistrar.read.maxLabelLength()), + minCommitmentAge: Number(await controller.read.minCommitmentAge()), + maxCommitmentAge: Number(await controller.read.maxCommitmentAge()), +} +writeFileSync(OUT, JSON.stringify(deployment, null, 2) + '\n') + +console.log(`wrote ${OUT}\n +[names] +tld = ${TLD} +; no resolver runs here: set resolver_url to simplexmq's scripts/resolver started against this chain + +[chain] +rpc_url = ${deployment.rpcUrl} +chain_id = ${chainId} +controller = ${deployment.controller} +sponsor = ${deployment.sponsor} +base_registrar = ${deployment.baseRegistrar} +resolver = ${deployment.resolver} +service_key = ${REGISTRAR_KEY} +; 1 for the local chain only; 3 in production +confirmations = 1`)