diff --git a/e2e-tests/package.json b/e2e-tests/package.json index b9ccbd1..f1ebcb3 100644 --- a/e2e-tests/package.json +++ b/e2e-tests/package.json @@ -15,10 +15,12 @@ "build": "tsc", "typecheck": "tsc --noEmit", "test": "node --experimental-specifier-resolution=node --loader ts-node/esm test-attestor.ts", + "generate-attester": "bun run src/generate-attester.ts", "deploy-example": "bun run src/deploy-example.ts", "deploy-migration": "bun run src/deploy-migration.ts", "claim-migration": "bun run src/claim-migration.ts", - "migrate-nft": "bun run src/migrate-nft/index.ts" + "migrate-nft:testnet": "bun run src/migrate-nft/testnet/index.ts", + "migrate-nft:sandbox": "bun run src/migrate-nft/sandbox/index.ts" }, "dependencies": { "@aztec/accounts": "5.0.0-rc.1", diff --git a/e2e-tests/src/generate-attester.ts b/e2e-tests/src/generate-attester.ts new file mode 100644 index 0000000..689de5a --- /dev/null +++ b/e2e-tests/src/generate-attester.ts @@ -0,0 +1,29 @@ +import { Fr } from "@aztec/aztec.js/fields"; +import { Attester } from "./index.js"; + +async function main() { + const secret = process.env.ATTESTER_SECRET + ? Fr.fromHexString(process.env.ATTESTER_SECRET) + : Fr.random(); + const reused = Boolean(process.env.ATTESTER_SECRET); + + const attester = await Attester.create(secret); + + console.log("=== Continuum attester key pair ===\n"); + console.log(reused ? "(re-derived from the ATTESTER_SECRET you passed)\n" : "(freshly generated)\n"); + + console.log(`ATTESTER_SECRET=${secret.toString()}`); + console.log(`ATTESTER_PUBKEY_X=${attester.publicKey.x.toString()}`); + console.log(`ATTESTER_PUBKEY_Y=${attester.publicKey.y.toString()}`); + + console.log("\nWhere these go:"); + console.log(" • ATTESTER_SECRET → PRIVATE. Set in continuum/.env; the API derives everything from it."); + console.log(" Restart the API after changing it. This is all the migrate-nft flow needs."); + console.log(" • ATTESTER_PUBKEY_X/Y → PUBLIC, derived from the secret. The migrate-nft scripts fetch"); + console.log(" these live from GET /attester."); +} + +main().catch((err) => { + console.error("\nKey generation failed:", err); + process.exit(1); +}); diff --git a/e2e-tests/src/index.ts b/e2e-tests/src/index.ts index 8eb9549..1d8f7b1 100644 --- a/e2e-tests/src/index.ts +++ b/e2e-tests/src/index.ts @@ -1,21 +1,3 @@ -/** - * Minimal Schnorr attestation library for Aztec. - * - * This library provides a generic way to: - * 1. Sign any array of Field elements with Schnorr - * 2. Verify the signature in an Aztec smart contract - * - * Usage: - * // Off-chain (TypeScript) - * const attester = await Attester.create(secretKey); - * const fields = [new Fr(1), new Fr(2), someAddress.toField()]; - * const { hash, signature } = await attester.attest(fields); - * - * // On-chain (Noir) - * use attestation_lib::assert_valid_attestation; - * assert_valid_attestation(pubkey, signature, [field1, field2, field3]); - */ - import { Fr } from "@aztec/aztec.js/fields"; import { Schnorr } from "@aztec/foundation/crypto/schnorr"; import { GrumpkinScalar } from "@aztec/foundation/curves/grumpkin"; diff --git a/e2e-tests/src/migrate-nft/README.md b/e2e-tests/src/migrate-nft/README.md index c60a774..bc1269b 100644 --- a/e2e-tests/src/migrate-nft/README.md +++ b/e2e-tests/src/migrate-nft/README.md @@ -44,6 +44,10 @@ You need **MongoDB + the indexer + the API** running first (see `continuum/Makefile` / `docker-compose.yml`), and the indexer must be watching the **same network** you point the script at. +There's one runnable folder per network — `sandbox/` and `testnet/` — each with +its own `bun run` script. Pick the one matching the network your indexer is on; +no env juggling required to switch. + ### Sandbox (fast — recommended for iterating) No L1 key, no waiting. Start a local sandbox, then: @@ -57,7 +61,7 @@ aztec start --local-network # → http://localhost:8080 # CONTINUUM_AZTEC_NODE_URL_SANDBOX=http://host.docker.internal:8080 (it runs in Docker) cd continuum/e2e-tests -AZTEC_NODE_URL=http://localhost:8080 CONTINUUM_NETWORK=sandbox bun run migrate-nft +bun run migrate-nft:sandbox ``` On sandbox the script reuses the two pre-funded genesis accounts, so it takes ~1 minute. @@ -73,21 +77,23 @@ a full run takes several minutes. # CONTINUUM_AZTEC_NODE_URL_TESTNET=https://v5.testnet.rpc.aztec-labs.com cd continuum/e2e-tests -L1_PRIVATE_KEY=0x bun run migrate-nft +L1_PRIVATE_KEY=0x bun run migrate-nft:testnet ``` -(Defaults are already testnet, so you only add the L1 key. Reuse -`OLD_SECRET/OLD_SALT` + `NEW_SECRET/NEW_SALT` across runs to skip re-bridging.) +(You only add the L1 key. Reuse `OLD_SECRET/OLD_SALT` + `NEW_SECRET/NEW_SALT` +across runs to skip re-bridging.) --- ## Environment variables +The network itself is no longer an env var — it's the folder you run. These env +vars only fine-tune a run: + | Var | Default | What it's for | |---|---|---| -| `AZTEC_NODE_URL` | `https://v5.testnet.rpc.aztec-labs.com` | Which Aztec node to talk to | -| `CONTINUUM_API_URL` | `http://localhost:3004` | Continuum API base URL | -| `CONTINUUM_NETWORK` | `testnet` | Network label for the indexer/registry | +| `AZTEC_NODE_URL` | sandbox: `http://localhost:8080`; testnet: `https://v5.testnet.rpc.aztec-labs.com` | Override which Aztec node to talk to | +| `CONTINUUM_API_URL` | `http://localhost:3000` | Continuum API base URL | | `L1_PRIVATE_KEY` | — | Sepolia-funded key (testnet only, for fee-juice bridging) | | `FEE_JUICE_AMOUNT` | `10^22` | Fee juice bridged per account (testnet). Raise it if a run hits "Not enough balance" | | `OLD_SECRET` / `OLD_SALT` | random | Reuse a funded Alice-OLD account (testnet) | @@ -97,16 +103,38 @@ L1_PRIVATE_KEY=0x bun run migrate-nft ## The files -Each file does one job; `index.ts` ties them together and is the place to read first. +There are **two standalone scripts** — `sandbox/index.ts` and `testnet/index.ts`. +Each one contains the whole OLD → NEW → CLAIM → VERIFY flow top-to-bottom, so you +can read or tweak either in isolation without touching the other. They share only +the small reusable **helper functions** in `shared/` (the NFT contract wrappers, +the API client, the verify assertions, constants). Nothing orchestrates them from +above — each `main()` is the script. + +``` +migrate-nft/ + shared/ # reusable helper functions + constants (no orchestration) + config.ts # shared constants (tokens, timeout, artifact path) + MigrationAccounts type + continuum-api.ts # typed Continuum API client + nft.ts # NFT artifact loading + contract-call wrappers + verify.ts # post-claim assertions + sandbox/ + accounts.ts # sandbox-only: the pre-funded genesis-account strategy + index.ts # the full SANDBOX script (hardcoded, reads no env) + testnet/ + accounts.ts # testnet-only: bridged Schnorr accounts + the testnet env vars + index.ts # the full TESTNET script (env-overridable) +``` | File | What's in it | |---|---| -| `index.ts` | The orchestrator — the OLD → NEW → CLAIM → VERIFY story above | -| `config.ts` | Env vars, constants (tokens, timeouts), artifact path | -| `continuum-api.ts` | Typed client for the Continuum API (`/attester`, `/migration/new-secret`, `/contracts/upload`, `/collections/register`, `/request_data`) | -| `accounts.ts` | Sets up the two wallets — genesis accounts on sandbox, bridged accounts on testnet | -| `nft.ts` | Loads the NFT artifact and wraps the contract calls (deploy, mint, register, claim, reads) | -| `verify.ts` | Checks the right tokens were attested and the claim landed correctly | +| `sandbox/index.ts` | **The sandbox script** — the whole flow inline; hardcoded, reads no env | +| `testnet/index.ts` | **The testnet script** — the whole flow inline; env-overridable URLs, proving on | +| `sandbox/accounts.ts` | Sandbox-only account strategy (the two pre-funded genesis accounts) | +| `testnet/accounts.ts` | Testnet-only account strategy (bridged Schnorr accounts; its own `feeJuiceBalance` read; reads `FEE_JUICE_AMOUNT`, `OLD_*`/`NEW_*`) | +| `shared/config.ts` | Constants both scripts import (tokens, send timeout, artifact path) + the `MigrationAccounts` type | +| `shared/continuum-api.ts` | Typed client for the Continuum API (`/attester`, `/migration/new-secret`, `/contracts/upload`, `/collections/register`, `/request_data`) | +| `shared/nft.ts` | Loads the NFT artifact and wraps the contract calls (deploy, mint, register, claim, reads) | +| `shared/verify.ts` | Checks the right tokens were attested and the claim landed correctly | --- @@ -119,7 +147,7 @@ Each file does one job; `index.ts` ties them together and is the place to read f the top-level docs. - **`Not enough balance for fee payer to pay for transaction` (testnet).** The bridged fee juice ran out — testnet gas prices vary. Bridge more with - `FEE_JUICE_AMOUNT=50000000000000000000000 bun run migrate-nft`, or reuse already-funded + `FEE_JUICE_AMOUNT=50000000000000000000000 bun run migrate-nft:testnet`, or reuse already-funded accounts via `OLD_SECRET/OLD_SALT` + `NEW_SECRET/NEW_SALT` to avoid re-bridging. - **`Cannot satisfy constraint … signature[32 + i]`.** The deployed contract's signature scheme doesn't match the attester. Recompile `contracts/nft_contract` diff --git a/e2e-tests/src/migrate-nft/accounts.ts b/e2e-tests/src/migrate-nft/accounts.ts deleted file mode 100644 index 0afdfa3..0000000 --- a/e2e-tests/src/migrate-nft/accounts.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Account setup for the two actors (Alice's OLD wallet and her NEW wallet). - * - * - Sandbox: reuse the pre-funded genesis test accounts (no bridging, no proving). - * - Testnet: reconstruct fresh Schnorr accounts and bridge fee juice from L1. - */ - -import { Fr } from "@aztec/aztec.js/fields"; -import { AztecAddress } from "@aztec/aztec.js/addresses"; -import { createAztecNodeClient } from "@aztec/aztec.js/node"; -import { FeeJuicePaymentMethodWithClaim } from "@aztec/aztec.js/fee"; -import { NO_FROM } from "@aztec/aztec.js/account"; -import { ProtocolContractAddress } from "@aztec/aztec.js/protocol"; -import { EmbeddedWallet } from "@aztec/wallets/embedded"; -import { deriveStorageSlotInMap } from "@aztec/stdlib/hash"; -import { getInitialTestAccountsData, INITIAL_TEST_SIGNING_KEYS } from "@aztec/accounts/testing"; - -import { bridgeL1FeeJuice } from "../bridge-fee-juice.js"; -import { FEE_JUICE_AMOUNT, isSandbox, logger } from "./config.js"; - -type Node = ReturnType; - -export type MigrationAccounts = { - /** Owner + minter on the old collection. */ - oldAddr: AztecAddress; - /** Claimer on the new collection. */ - newAddr: AztecAddress; -}; - -async function feeJuiceBalance(node: Node, address: AztecAddress): Promise { - const slot = await deriveStorageSlotInMap(new Fr(1), address); - return ( - await node.getPublicStorageAt("latest", ProtocolContractAddress.FeeJuice, slot) - ).toBigInt(); -} - -const envFr = (name: string): Fr => - process.env[name] ? Fr.fromString(process.env[name]!) : Fr.random(); - -/** Reuse a pre-funded sandbox genesis account (already deployed + funded). */ -async function useSandboxAccount( - wallet: EmbeddedWallet, - node: Node, - label: string, - index: number, -): Promise { - console.log(`\n[${label}] using pre-funded sandbox test account...`); - - const data = (await getInitialTestAccountsData())[index]; - if (!data) { - throw new Error( - `Sandbox has no initial test account #${index}. ` + - "Start it with `aztec start --local-network`.", - ); - } - - // The data's `signingKey` field is the encryption key; the address is derived - // from INITIAL_TEST_SIGNING_KEYS, so pass that to reconstruct the funded account. - const account = await wallet.createSchnorrInitializerlessAccount( - data.secret, - data.salt, - INITIAL_TEST_SIGNING_KEYS[index], - ); - const address = account.address; - - const balance = await feeJuiceBalance(node, address); - if (balance === 0n) { - throw new Error( - `Sandbox test account ${address.toString()} has no fee juice. ` + - "Start the sandbox with `aztec start --local-network` so the initial accounts are funded.", - ); - } - console.log(` ✓ ${address.toString()} (${balance} fee juice)`); - return address; -} - -/** Reconstruct a Schnorr account on testnet, bridging fee juice + deploying if unfunded. */ -async function useTestnetAccount( - wallet: EmbeddedWallet, - node: Node, - label: string, - secret: Fr, - salt: Fr, -): Promise { - console.log(`\n[${label}] setting up account (testnet)...`); - const account = await wallet.createSchnorrAccount(secret, salt); - const address = account.address; - console.log(` address: ${address.toString()}`); - - if ((await feeJuiceBalance(node, address)) > 0n) { - console.log(" ✓ already deployed"); - return address; - } - - console.log(" bridging fee juice from L1 Sepolia (can take a few minutes)..."); - const claim = await bridgeL1FeeJuice(node, address, FEE_JUICE_AMOUNT, logger); - await ( - await account.getDeployMethod() - ).send({ - from: NO_FROM, - fee: { paymentMethod: new FeeJuicePaymentMethodWithClaim(address, claim) }, - }); - console.log(" ✓ account deployed"); - return address; -} - -/** Resolve the OLD + NEW accounts for the current network. */ -export async function resolveAccounts( - wallet: EmbeddedWallet, - node: Node, -): Promise { - if (isSandbox) { - return { - oldAddr: await useSandboxAccount(wallet, node, "ALICE-OLD", 0), - newAddr: await useSandboxAccount(wallet, node, "ALICE-NEW", 1), - }; - } - return { - oldAddr: await useTestnetAccount(wallet, node, "ALICE-OLD", envFr("OLD_SECRET"), envFr("OLD_SALT")), - newAddr: await useTestnetAccount(wallet, node, "ALICE-NEW", envFr("NEW_SECRET"), envFr("NEW_SALT")), - }; -} diff --git a/e2e-tests/src/migrate-nft/config.ts b/e2e-tests/src/migrate-nft/config.ts deleted file mode 100644 index fa81063..0000000 --- a/e2e-tests/src/migrate-nft/config.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { dirname, join } from "path"; -import { fileURLToPath } from "url"; -import { createLogger } from "@aztec/aztec.js/log"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -export const TESTNET_URL = "https://v5.testnet.rpc.aztec-labs.com"; - -export const FEE_JUICE_AMOUNT = process.env.FEE_JUICE_AMOUNT - ? BigInt(process.env.FEE_JUICE_AMOUNT) - : 10n ** 22n; - -export const SEND_TIMEOUT = 1_800_000; // 30 min — testnet proving can be slow - -export const NODE_URL = process.env.AZTEC_NODE_URL ?? TESTNET_URL; -export const API_URL = (process.env.CONTINUUM_API_URL ?? "http://localhost:3000").replace(/\/$/, ""); -export const NETWORK = process.env.CONTINUUM_NETWORK ?? "testnet"; - -export const isSandbox = NODE_URL.includes("localhost"); -export const isRemote = !isSandbox; - -// Tokens minted on the old collection. ALICE_TOKENS migrate; OTHER_TOKEN belongs -// to a third party and must be excluded by /request_data. -export const ALICE_TOKENS = [101n, 102n]; -export const OTHER_TOKEN = 999n; - -// Compiled NFT artifact. The codegen NFT.ts wrapper is stale; the target JSON -// carries the migration constructor + events. -export const ARTIFACT_PATH = join( - __dirname, - "../../../contracts/nft_contract/target/nft_contract-NFT.json", -); - -export const logger = createLogger("aztec:migrate-nft"); diff --git a/e2e-tests/src/migrate-nft/sandbox/accounts.ts b/e2e-tests/src/migrate-nft/sandbox/accounts.ts new file mode 100644 index 0000000..b074052 --- /dev/null +++ b/e2e-tests/src/migrate-nft/sandbox/accounts.ts @@ -0,0 +1,39 @@ + +import { AztecAddress } from "@aztec/aztec.js/addresses"; +import { EmbeddedWallet } from "@aztec/wallets/embedded"; +import { getInitialTestAccountsData, INITIAL_TEST_SIGNING_KEYS } from "@aztec/accounts/testing"; + +import type { MigrationAccounts } from "../shared/config.js"; + +async function useSandboxAccount( + wallet: EmbeddedWallet, + label: string, + index: number, +): Promise { + console.log(`\n[${label}] using pre-funded sandbox test account...`); + + const data = (await getInitialTestAccountsData())[index]; + if (!data) { + throw new Error( + `Sandbox has no initial test account #${index}. ` + + "Start it with `aztec start --local-network`.", + ); + } + + const account = await wallet.createSchnorrInitializerlessAccount( + data.secret, + data.salt, + INITIAL_TEST_SIGNING_KEYS[index], + ); + console.log(` ✓ ${account.address.toString()}`); + return account.address; +} + +export async function resolveSandboxAccounts( + wallet: EmbeddedWallet, +): Promise { + return { + oldAddr: await useSandboxAccount(wallet, "ALICE-OLD", 0), + newAddr: await useSandboxAccount(wallet, "ALICE-NEW", 1), + }; +} diff --git a/e2e-tests/src/migrate-nft/sandbox/index.ts b/e2e-tests/src/migrate-nft/sandbox/index.ts new file mode 100644 index 0000000..6156407 --- /dev/null +++ b/e2e-tests/src/migrate-nft/sandbox/index.ts @@ -0,0 +1,155 @@ +import { Fr } from "@aztec/aztec.js/fields"; +import { AztecAddress } from "@aztec/aztec.js/addresses"; +import { createAztecNodeClient } from "@aztec/aztec.js/node"; +import { EmbeddedWallet } from "@aztec/wallets/embedded"; + +import { ALICE_TOKENS, OTHER_TOKEN } from "../shared/config.js"; +import { ContinuumApi } from "../shared/continuum-api.js"; +import { deployCollection, loadNftArtifact, migrateAndClaim, mintPublic, registerMigration } from "../shared/nft.js"; +import { assertExpectedTokens, verifyClaims } from "../shared/verify.js"; +import { resolveSandboxAccounts } from "./accounts.js"; + +const NETWORK = "sandbox"; +const NODE_URL = "http://localhost:8080"; +const API_URL = "http://localhost:3000"; + +const section = (title: string) => console.log(`\n${title}`); +const step = (msg: string) => console.log(` ${msg}`); + +async function main() { + const api = new ContinuumApi(API_URL); + const { artifact, raw } = loadNftArtifact(); + + const node = createAztecNodeClient(NODE_URL); + await node.getNodeInfo(); + + const wallet = await EmbeddedWallet.create(node, { + ephemeral: true, + pxeConfig: { proverEnabled: false }, + }); + + const { oldAddr, newAddr } = await resolveSandboxAccounts(wallet); + + const startBlock = await node.getBlockNumber(); + + section("[OLD] deploying old NFT collection (migration disabled)..."); + const oldNft = await deployCollection(wallet, artifact, { + name: "Continuum Old", + symbol: "COLD", + minter: oldAddr, + attester: { x: Fr.ZERO, y: Fr.ZERO }, + from: oldAddr, + }); + step(`✓ old collection: ${oldNft.address.toString()}`); + + section("[OLD] registering NFT artifact with the indexer..."); + const artifactId = `nft-${NETWORK}`; + + const upload = await api.uploadArtifact({ + artifactId, + name: "NFT", + abi: raw, + eventTypes: ["Transfer", "MigrationRegistered"], + startBlock: { [NETWORK]: startBlock }, + migration: { + type: "nft", + ownership_model: "latest_transfer_event", + addresses: [], + events: { + transfer: { name: "Transfer", token_id: "token_id", from: "from", to: "to" }, + registration: { + source: "contract_event", + name: "MigrationRegistered", + owner: "owner", + commitment: "migration_commitment", + }, + }, + claim: { + domain: "0x4e46544d", + attestation_fields: ["domain", "new_collection_address", "new_wallet_address", "token_id"], + }, + }, + }); + + step( + upload === "registered" + ? `✓ artifact '${artifactId}' registered (start_block.${NETWORK}=${startBlock})` + : `✓ artifact '${artifactId}' already registered (reusing existing sync state)`, + ); + + section("[OLD] minting public NFTs..."); + + for (const tokenId of ALICE_TOKENS) { + await mintPublic(oldNft, oldAddr, tokenId, oldAddr); + step(`✓ minted #${tokenId} → Alice-OLD`); + } + await mintPublic(oldNft, await AztecAddress.random(), OTHER_TOKEN, oldAddr); + step(`✓ minted #${OTHER_TOKEN} → someone else (must be excluded)`); + + section("[OLD] fetching a fresh migration secret..."); + + const { secret, commitment } = await api.newMigrationSecret(); + step(`secret: ${secret.slice(0, 18)}… (saved by the user)`); + step(`commitment: ${commitment.slice(0, 18)}…`); + + section("[OLD] Alice-OLD calls register_migration(commitment)..."); + await registerMigration(oldNft, commitment, oldAddr); + step("✓ MigrationRegistered emitted (owner = Alice-OLD, authenticated)"); + + section("[NEW] fetching attester public key..."); + const attester = await api.getAttester(); + step(`pubkey.x: ${attester.x.slice(0, 18)}…`); + + section("[NEW] deploying new NFT collection (migration enabled)..."); + const newNft = await deployCollection(wallet, artifact, { + name: "Continuum New", + symbol: "CNEW", + minter: oldAddr, // irrelevant for migration + attester: { x: Fr.fromString(attester.x), y: Fr.fromString(attester.y) }, + from: oldAddr, + }); + step(`✓ new collection: ${newNft.address.toString()}`); + + section("[NEW] registering old → new collection mapping..."); + await api.registerCollection({ + oldAddress: oldNft.address.toString(), + newAddress: newNft.address.toString(), + network: NETWORK, + name: "Continuum E2E", + artifactId, + }); + step("✓ mapping registered"); + + section("[CLAIM] polling /request_data until the indexer catches up..."); + const tokens = await api.pollRequestData( + { + collectionAddress: newNft.address.toString(), + migrationSecret: secret, + newWalletAddress: newAddr.toString(), + }, + { + expected: ALICE_TOKENS.length, + onWait: (attempt, got) => step(`attempt ${attempt}: ${got}/${ALICE_TOKENS.length} tokens indexed…`), + }, + ); + step(`✓ got ${tokens.length} signed token(s)`); + assertExpectedTokens(tokens); + + section("[CLAIM] Alice-NEW migrate_and_claim() for each token..."); + for (const token of tokens) { + await migrateAndClaim(newNft, token.token_id, token.signature_bytes, newAddr); + step(`✓ claimed #${BigInt(token.token_id)}`); + } + + await verifyClaims(newNft, newAddr, tokens); + + console.log("\n=== E2E migration complete ✅ (sandbox) ==="); + console.log(` Old collection: ${oldNft.address.toString()}`); + console.log(` New collection: ${newNft.address.toString()}`); + console.log(` Migrated tokens: [${ALICE_TOKENS.join(", ")}] Alice-OLD → Alice-NEW`); +} + +main().catch((err) => { + console.error("\nE2E migration failed:", err); + process.exit(1); +}); diff --git a/e2e-tests/src/migrate-nft/shared/config.ts b/e2e-tests/src/migrate-nft/shared/config.ts new file mode 100644 index 0000000..8494ff9 --- /dev/null +++ b/e2e-tests/src/migrate-nft/shared/config.ts @@ -0,0 +1,20 @@ +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; +import type { AztecAddress } from "@aztec/aztec.js/addresses"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export type MigrationAccounts = { + oldAddr: AztecAddress; + newAddr: AztecAddress; +}; + +export const SEND_TIMEOUT = 1_800_000; // 30 min — testnet proving can be slow + +export const ALICE_TOKENS = [101n, 102n]; +export const OTHER_TOKEN = 999n; + +export const ARTIFACT_PATH = join( + __dirname, + "../../../../contracts/nft_contract/target/nft_contract-NFT.json", +); diff --git a/e2e-tests/src/migrate-nft/continuum-api.ts b/e2e-tests/src/migrate-nft/shared/continuum-api.ts similarity index 82% rename from e2e-tests/src/migrate-nft/continuum-api.ts rename to e2e-tests/src/migrate-nft/shared/continuum-api.ts index d6a0a69..f4f741b 100644 --- a/e2e-tests/src/migrate-nft/continuum-api.ts +++ b/e2e-tests/src/migrate-nft/shared/continuum-api.ts @@ -2,8 +2,6 @@ * Typed client for the Continuum HTTP API used by the migration flow. */ -import { API_URL } from "./config.js"; - const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); export type TokenAttestation = { token_id: string; signature_bytes: number[] }; @@ -17,7 +15,7 @@ export type MigrationData = { }; export class ContinuumApi { - constructor(private readonly baseUrl: string = API_URL) {} + constructor(private readonly baseUrl: string) {} private async get(path: string): Promise { const res = await fetch(`${this.baseUrl}${path}`); @@ -33,17 +31,14 @@ export class ContinuumApi { }); } - /** GET /attester → the Grumpkin pubkey the new collection embeds. */ getAttester(): Promise<{ x: string; y: string }> { return this.get("/attester"); } - /** GET /migration/new-secret → a fresh { secret, commitment } pair. */ newMigrationSecret(): Promise<{ secret: string; commitment: string }> { return this.get("/migration/new-secret"); } - /** POST /contracts/upload — register the NFT artifact for indexing (idempotent). */ async uploadArtifact(input: { artifactId: string; name: string; @@ -68,7 +63,6 @@ export class ContinuumApi { throw new Error(`/contracts/upload → ${res.status} ${await res.text()}`); } - /** POST /collections/register — map an old collection address → new one. */ async registerCollection(input: { oldAddress: string; newAddress: string; @@ -87,11 +81,6 @@ export class ContinuumApi { if (!res.ok) throw new Error(`/collections/register → ${res.status} ${await res.text()}`); } - /** - * POST /request_data — attested ownership for a migration secret. - * Returns null on 404 (registration/transfers not indexed yet); throws on any - * other non-2xx so genuine errors surface instead of being mistaken for "not ready". - */ async requestData(input: { collectionAddress: string; migrationSecret: string; @@ -107,10 +96,6 @@ export class ContinuumApi { return res.json(); } - /** - * Poll /request_data until at least `expected` tokens are attested (the indexer - * needs a few cycles to ingest the registration + transfers), or time out. - */ async pollRequestData( input: { collectionAddress: string; migrationSecret: string; newWalletAddress: string }, opts: { diff --git a/e2e-tests/src/migrate-nft/nft.ts b/e2e-tests/src/migrate-nft/shared/nft.ts similarity index 86% rename from e2e-tests/src/migrate-nft/nft.ts rename to e2e-tests/src/migrate-nft/shared/nft.ts index fd9b6bb..5cb32d2 100644 --- a/e2e-tests/src/migrate-nft/nft.ts +++ b/e2e-tests/src/migrate-nft/shared/nft.ts @@ -13,9 +13,7 @@ import { ARTIFACT_PATH, SEND_TIMEOUT } from "./config.js"; const sendOpts = (from: AztecAddress) => ({ from, wait: { timeout: SEND_TIMEOUT } }); export type NftArtifact = { - /** Parsed artifact for aztec.js (deploy / Contract.at). */ artifact: ContractArtifact; - /** Raw JSON — what POST /contracts/upload expects as `abi`. */ raw: NoirCompiledContract; }; @@ -24,7 +22,6 @@ export function loadNftArtifact(): NftArtifact { return { artifact: loadContractArtifact(raw), raw }; } -/** Deploy an NFT collection. Migration is enabled iff a non-zero attester pubkey is given. */ export async function deployCollection( wallet: EmbeddedWallet, artifact: ContractArtifact, @@ -65,15 +62,12 @@ export function migrateAndClaim( .send(sendOpts(from)); } -/** Token ids held as private notes by `owner` (zeros filtered out). */ export async function getPrivateNftIds(nft: Contract, owner: AztecAddress): Promise { const { result } = await nft.methods.get_private_nfts(owner, 0).simulate({ from: owner }); return (result[0] as Array) .map((v) => BigInt(v.toString())) .filter((v) => v !== 0n); } - -/** Public owner of a token (zero for privately-owned / migrated tokens). */ export async function publicOwnerOf( nft: Contract, tokenId: bigint, diff --git a/e2e-tests/src/migrate-nft/verify.ts b/e2e-tests/src/migrate-nft/shared/verify.ts similarity index 87% rename from e2e-tests/src/migrate-nft/verify.ts rename to e2e-tests/src/migrate-nft/shared/verify.ts index 46ef0db..882bafe 100644 --- a/e2e-tests/src/migrate-nft/verify.ts +++ b/e2e-tests/src/migrate-nft/shared/verify.ts @@ -1,7 +1,3 @@ -/** - * Assertions on the attested set and the post-claim on-chain state. - */ - import { AztecAddress } from "@aztec/aztec.js/addresses"; import { Contract } from "@aztec/aztec.js/contracts"; @@ -9,7 +5,6 @@ import { ALICE_TOKENS, OTHER_TOKEN } from "./config.js"; import type { TokenAttestation } from "./continuum-api.js"; import { getPrivateNftIds, migrateAndClaim, publicOwnerOf } from "./nft.js"; -/** The attested set must be exactly Alice's tokens — the third party's is excluded. */ export function assertExpectedTokens(tokens: TokenAttestation[]): void { const ids = tokens.map((t) => BigInt(t.token_id)); if (ids.includes(OTHER_TOKEN)) { @@ -22,7 +17,6 @@ export function assertExpectedTokens(tokens: TokenAttestation[]): void { } } -/** After claiming: tokens are private notes with zero public owner, and re-claiming reverts. */ export async function verifyClaims( nft: Contract, owner: AztecAddress, diff --git a/e2e-tests/src/migrate-nft/testnet/accounts.ts b/e2e-tests/src/migrate-nft/testnet/accounts.ts new file mode 100644 index 0000000..72923d9 --- /dev/null +++ b/e2e-tests/src/migrate-nft/testnet/accounts.ts @@ -0,0 +1,70 @@ +import { Fr } from "@aztec/aztec.js/fields"; +import { AztecAddress } from "@aztec/aztec.js/addresses"; +import { createLogger } from "@aztec/aztec.js/log"; +import { FeeJuicePaymentMethodWithClaim } from "@aztec/aztec.js/fee"; +import { NO_FROM } from "@aztec/aztec.js/account"; +import { ProtocolContractAddress } from "@aztec/aztec.js/protocol"; +import { deriveStorageSlotInMap } from "@aztec/stdlib/hash"; +import type { createAztecNodeClient } from "@aztec/aztec.js/node"; +import { EmbeddedWallet } from "@aztec/wallets/embedded"; + +import { bridgeL1FeeJuice } from "../../bridge-fee-juice.js"; +import type { MigrationAccounts } from "../shared/config.js"; + +type Node = ReturnType; + +const logger = createLogger("aztec:migrate-nft"); + +const FEE_JUICE_AMOUNT = process.env.FEE_JUICE_AMOUNT + ? BigInt(process.env.FEE_JUICE_AMOUNT) + : 10n ** 22n; + +const envFr = (name: string): Fr => + process.env[name] ? Fr.fromString(process.env[name]!) : Fr.random(); + +async function feeJuiceBalance(node: Node, address: AztecAddress): Promise { + const slot = await deriveStorageSlotInMap(new Fr(1), address); + return ( + await node.getPublicStorageAt("latest", ProtocolContractAddress.FeeJuice, slot) + ).toBigInt(); +} + +async function useTestnetAccount( + wallet: EmbeddedWallet, + node: Node, + label: string, + secret: Fr, + salt: Fr, +): Promise { + console.log(`\n[${label}] setting up account (testnet)...`); + const account = await wallet.createSchnorrAccount(secret, salt); + const address = account.address; + console.log(` address: ${address.toString()}`); + + if ((await feeJuiceBalance(node, address)) > 0n) { + console.log(" ✓ already deployed"); + return address; + } + + console.log(" bridging fee juice from L1 Sepolia (can take a few minutes)..."); + const claim = await bridgeL1FeeJuice(node, address, FEE_JUICE_AMOUNT, logger); + await ( + await account.getDeployMethod() + ).send({ + from: NO_FROM, + fee: { paymentMethod: new FeeJuicePaymentMethodWithClaim(address, claim) }, + }); + console.log(" ✓ account deployed"); + return address; +} + +/** Fresh (or reused via OLD_* / NEW_* env) bridged Schnorr accounts. */ +export async function resolveTestnetAccounts( + wallet: EmbeddedWallet, + node: Node, +): Promise { + return { + oldAddr: await useTestnetAccount(wallet, node, "ALICE-OLD", envFr("OLD_SECRET"), envFr("OLD_SALT")), + newAddr: await useTestnetAccount(wallet, node, "ALICE-NEW", envFr("NEW_SECRET"), envFr("NEW_SALT")), + }; +} diff --git a/e2e-tests/src/migrate-nft/index.ts b/e2e-tests/src/migrate-nft/testnet/index.ts similarity index 55% rename from e2e-tests/src/migrate-nft/index.ts rename to e2e-tests/src/migrate-nft/testnet/index.ts index d47f1fc..610962a 100644 --- a/e2e-tests/src/migrate-nft/index.ts +++ b/e2e-tests/src/migrate-nft/testnet/index.ts @@ -1,39 +1,5 @@ /** - * End-to-end NFT public-state migration — Continuum. - * - * Drives the FULL flow against the real Continuum HTTP stack (Mongo + indexer + - * API) plus an Aztec node, for PUBLICLY-owned NFTs: - * - * OLD ROLLUP - * 1. Deploy NFT "old collection" (migration disabled). - * 2. Register the NFT artifact with the indexer (POST /contracts/upload). - * 3. mint_to_public() a few tokens to Alice-OLD (+ one to someone else). - * 4. GET /migration/new-secret → { secret, commitment }. - * 5. Alice-OLD calls register_migration(commitment) — owner = msg_sender, - * unforgeable. This is the "real caller" check, enforced on-chain. - * - * NEW ROLLUP - * 6. GET /attester → attester pubkey (x, y). - * 7. Deploy NFT "new collection" (migration enabled with that pubkey). - * 8. POST /collections/register (old → new address mapping). - * - * CLAIM - * 9. Poll POST /request_data { migration_secret } until the indexer has - * ingested the registration + transfers; receive per-token signatures. - * 10. Alice-NEW calls migrate_and_claim(token_id, signature) for each token. - * 11. Verify: tokens land as private notes for Alice-NEW, public owner is zero, - * and a second claim is rejected (double-claim guard). - * - * Prerequisites: - * - MongoDB + indexer + API running, indexer on the same network as AZTEC_NODE_URL. - * - API reachable at CONTINUUM_API_URL (default http://localhost:3004). - * - On testnet: a Sepolia-funded L1_PRIVATE_KEY for fee-juice bridging. - * - * Usage: - * bun run migrate-nft # testnet (default) - * AZTEC_NODE_URL=http://localhost:8080 CONTINUUM_NETWORK=sandbox bun run migrate-nft - * - * See ./config.ts for all env vars. + * L1_PRIVATE_KEY=0x bun run migrate-nft:testnet */ import { Fr } from "@aztec/aztec.js/fields"; @@ -41,36 +7,31 @@ import { AztecAddress } from "@aztec/aztec.js/addresses"; import { createAztecNodeClient } from "@aztec/aztec.js/node"; import { EmbeddedWallet } from "@aztec/wallets/embedded"; -import { ALICE_TOKENS, API_URL, NETWORK, NODE_URL, OTHER_TOKEN, isRemote } from "./config.js"; -import { ContinuumApi } from "./continuum-api.js"; -import { resolveAccounts } from "./accounts.js"; -import { deployCollection, loadNftArtifact, migrateAndClaim, mintPublic, registerMigration } from "./nft.js"; -import { assertExpectedTokens, verifyClaims } from "./verify.js"; +import { ALICE_TOKENS, OTHER_TOKEN } from "../shared/config.js"; +import { ContinuumApi } from "../shared/continuum-api.js"; +import { deployCollection, loadNftArtifact, migrateAndClaim, mintPublic, registerMigration } from "../shared/nft.js"; +import { assertExpectedTokens, verifyClaims } from "../shared/verify.js"; +import { resolveTestnetAccounts } from "./accounts.js"; + +const NETWORK = "testnet"; +const NODE_URL = process.env.AZTEC_NODE_URL ?? "https://v5.testnet.rpc.aztec-labs.com"; +const API_URL = (process.env.CONTINUUM_API_URL ?? "http://localhost:3000").replace(/\/$/, ""); const section = (title: string) => console.log(`\n${title}`); const step = (msg: string) => console.log(` ${msg}`); async function main() { - console.log("=== Continuum — NFT public-state migration (E2E) ==="); - console.log(`Node: ${NODE_URL}`); - console.log(`API: ${API_URL}`); - console.log(`Network: ${NETWORK}`); - - const api = new ContinuumApi(); + const api = new ContinuumApi(API_URL); const { artifact, raw } = loadNftArtifact(); const node = createAztecNodeClient(NODE_URL); await node.getNodeInfo(); const wallet = await EmbeddedWallet.create(node, { ephemeral: true, - pxeConfig: { proverEnabled: isRemote }, + pxeConfig: { proverEnabled: true }, }); - const { oldAddr, newAddr } = await resolveAccounts(wallet, node); - - // ════════════════════════════ OLD ROLLUP ═════════════════════════════════ - - // Record the block before deploying so the indexer can start from here. + const { oldAddr, newAddr } = await resolveTestnetAccounts(wallet, node); const startBlock = await node.getBlockNumber(); section("[OLD] deploying old NFT collection (migration disabled)..."); @@ -84,10 +45,6 @@ async function main() { step(`✓ old collection: ${oldNft.address.toString()}`); section("[OLD] registering NFT artifact with the indexer..."); - // The API now requires a migration manifest so /request_data can resolve - // ownership and registration events. Passing the manifest explicitly here - // matches the NFT defaults (Transfer/MigrationRegistered) and avoids the - // legacy "migration is null" path that blows up in migrationData.js. const artifactId = `nft-${NETWORK}`; const upload = await api.uploadArtifact({ artifactId, @@ -100,12 +57,7 @@ async function main() { ownership_model: "latest_transfer_event", addresses: [], events: { - transfer: { - name: "Transfer", - token_id: "token_id", - from: "from", - to: "to", - }, + transfer: { name: "Transfer", token_id: "token_id", from: "from", to: "to" }, registration: { source: "contract_event", name: "MigrationRegistered", @@ -142,8 +94,6 @@ async function main() { await registerMigration(oldNft, commitment, oldAddr); step("✓ MigrationRegistered emitted (owner = Alice-OLD, authenticated)"); - // ════════════════════════════ NEW ROLLUP ═════════════════════════════════ - section("[NEW] fetching attester public key..."); const attester = await api.getAttester(); step(`pubkey.x: ${attester.x.slice(0, 18)}…`); @@ -168,8 +118,6 @@ async function main() { }); step("✓ mapping registered"); - // ══════════════════════════════ CLAIM ════════════════════════════════════ - section("[CLAIM] polling /request_data until the indexer catches up..."); const tokens = await api.pollRequestData( { @@ -191,11 +139,9 @@ async function main() { step(`✓ claimed #${BigInt(token.token_id)}`); } - // ═════════════════════════════ VERIFY ════════════════════════════════════ - await verifyClaims(newNft, newAddr, tokens); - console.log("\n=== E2E migration complete ✅ ==="); + console.log("\n=== E2E migration complete ✅ (testnet) ==="); console.log(` Old collection: ${oldNft.address.toString()}`); console.log(` New collection: ${newNft.address.toString()}`); console.log(` Migrated tokens: [${ALICE_TOKENS.join(", ")}] Alice-OLD → Alice-NEW`);