plans: names e2e and local chain on PR #35

This commit is contained in:
shum committed 2026-10-14 08:58:15 +00:00
1 parent 9f420a2396
commit d3832e9bcc
4 files changed
+669

No files matched your search

+108
View File
@@ -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<unknown>): Promise<string> {
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')
})
})
+39
View File
@@ -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 <simplex-chat>/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.
+416
View File
@@ -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<typeof order>, 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/)
})
})
+106
View File
@@ -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`)