diff --git a/docs/integration-guide.md b/docs/integration-guide.md index 423de15..6d64649 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -386,14 +386,22 @@ const archiveProof = await buildArchiveProof(node, blockHash); #### 2. Sign ```typescript -// Private notes +// Private notes only const sig = await wallet.signMigrationModeB( - signer, recipient, oldVersion, newVersion, newApp, [fullProof], + signer, recipient, oldVersion, newVersion, newApp, + { notes }, ); -// Public state (owned) -const sig = await wallet.signPublicStateMigrationModeB( - signer, recipient, oldVersion, newVersion, newApp, data, abiType, +// Public state only (owned) +const sig = await wallet.signMigrationModeB( + signer, recipient, oldVersion, newVersion, newApp, + { publicData: [{ data, abiType }] }, +); + +// Mixed (public state + private notes in one signature) +const sig = await wallet.signMigrationModeB( + signer, recipient, oldVersion, newVersion, newApp, + { publicData: [{ data, abiType }], notes }, ); ``` diff --git a/docs/spec/mode-b-spec.md b/docs/spec/mode-b-spec.md index f30fe6d..33052ca 100644 --- a/docs/spec/mode-b-spec.md +++ b/docs/spec/mode-b-spec.md @@ -143,7 +143,7 @@ Only the true owner of the nullifier hiding key can migrate their notes. Both private notes and owned public state use a single domain separator (`DOM_SEP__CLAIM_B`). The builder accumulates all data into a single running hash (note hashes and packed public state fields), then signs once: ``` -final_hash = poseidon2_hash([...note_hashes, ...packed_public_state_fields]) +final_hash = poseidon2_hash([...packed_public_state_hashes, ...note_hashes]) msg = poseidon2_hash([DOM_SEP__CLAIM_B, old_rollup, current_rollup, final_hash, recipient, new_app]) ``` @@ -230,8 +230,8 @@ The `MigrationArchiveRegistry` on the new rollup stores this address (set at dep A migration webapp will orchestrate the end-to-end Mode B flow. The wallet's role is to expose key management, signing, and key registration primitives. Mode B extends the Mode A wallet requirements (see [Mode A -- Wallet Integration](mode-a-spec.md#wallet-integration)) with the following additional responsibilities: - **Key registration.** Before the snapshot height H, the wallet must support calling `keyRegistry.register(mpk)` on the old rollup's `MigrationKeyRegistry`. This is a one-time, write-once operation. If a user misses this window, Mode B migration is permanently unavailable for that account. Wallets should prompt registration early and confirm inclusion in a block before H. -- **Nullifier hiding key access.** Mode B requires the wallet to expose the nullifier hiding key (NHK) for address verification. The `MigrationAccount` interface provides `getNhk()` for this purpose. Note: the NHK currently leaves the wallet in raw form. A future improvement could mask the NHK (e.g. `nhk + mask`) so it never leaves the wallet unprotected. -- **Public state signing.** In addition to `signMigrationModeB()` (private notes), the wallet must support `signPublicStateMigrationModeB()` for owned public state migration, which produces a hash of encoded migration data. +- **Nullifier hiding key access.** Mode B requires the wallet to expose the nullifier hiding key (NHK) for the non-nullification check. The `MigrationAccount` interface provides `getNhk()` for this purpose. Note: the NHK currently leaves the wallet in raw form. A future improvement could mask the NHK (e.g. `nhk + mask`) so it never leaves the wallet unprotected. +- **Signing.** `signMigrationModeB()` produces a single signature covering private notes, public state data, or both. The hash input order matches the Noir builder: packed public data fields first, then note hashes. For key derivation, Browser vs Node environments, and key persistence, see [General Specification -- Wallet Integration](migration-spec.md#wallet-integration-shared). diff --git a/e2e-tests/migration-mode-b.test.ts b/e2e-tests/migration-mode-b.test.ts index 309cba7..548a5d5 100644 --- a/e2e-tests/migration-mode-b.test.ts +++ b/e2e-tests/migration-mode-b.test.ts @@ -223,9 +223,9 @@ async function main() { oldMigrationSigner, blockHeader.global_variables.version, new Fr(env.newRollupVersion), - [balanceNote], newUserManager.address, newApp.address, + { notes: [balanceNote] }, ); console.log(` Migration args prepared.\n`); @@ -295,9 +295,9 @@ async function main() { oldMigrationSigner, blockHeader.global_variables.version, new Fr(env.newRollupVersion), - [nullifiedNote], newUserManager.address, newApp.address, + { notes: [nullifiedNote] }, ); await expectRevert( diff --git a/e2e-tests/migration-public-mode-b.test.ts b/e2e-tests/migration-public-mode-b.test.ts index dd98b98..a70ce79 100644 --- a/e2e-tests/migration-public-mode-b.test.ts +++ b/e2e-tests/migration-public-mode-b.test.ts @@ -297,16 +297,14 @@ async function main() { const oldMigrationSigner = await oldUserWallet.getMigrationSignerFromAddress( OWNED_STRUCT_MAP_OWNER, ); - const ownedStructMapSignature = - await newUserWallet.signPublicStateMigrationModeB( - oldMigrationSigner, - newUserManager.address, - new Fr(env.oldRollupVersion), - new Fr(env.newRollupVersion), - newApp, - OWNED_STRUCT_MAP, - someStructAbiType, - ); + const ownedStructMapSignature = await newUserWallet.signMigrationModeB( + oldMigrationSigner, + newUserManager.address, + new Fr(env.oldRollupVersion), + new Fr(env.newRollupVersion), + newApp, + { publicData: [{ data: OWNED_STRUCT_MAP, abiType: someStructAbiType }] }, + ); await newAppUser.methods .migrate_to_public_owned_struct_map_mode_b( ownedStructMapProof, @@ -332,16 +330,18 @@ async function main() { const oldMigrationSigner2 = await oldUserWallet.getMigrationSignerFromAddress( OWNED_STRUCT_NESTED_MAP_OWNER, ); - const ownedStructNestedMapSignature = - await newUserWallet.signPublicStateMigrationModeB( - oldMigrationSigner2, - newUser2Manager.address, - new Fr(env.oldRollupVersion), - new Fr(env.newRollupVersion), - newApp, - OWNED_STRUCT_NESTED_MAP, - someStructAbiType, - ); + const ownedStructNestedMapSignature = await newUserWallet.signMigrationModeB( + oldMigrationSigner2, + newUser2Manager.address, + new Fr(env.oldRollupVersion), + new Fr(env.newRollupVersion), + newApp, + { + publicData: [ + { data: OWNED_STRUCT_NESTED_MAP, abiType: someStructAbiType }, + ], + }, + ); await newAppUser.methods .migrate_to_public_owned_struct_nested_map_mode_b( ownedStructNestedMapProof, diff --git a/e2e-tests/nft-migration-mode-b.test.ts b/e2e-tests/nft-migration-mode-b.test.ts index 400fee5..d2c62e2 100644 --- a/e2e-tests/nft-migration-mode-b.test.ts +++ b/e2e-tests/nft-migration-mode-b.test.ts @@ -243,9 +243,9 @@ async function main() { oldMigrationSigner, blockHeader.global_variables.version, new Fr(env.newRollupVersion), - [activeNote], newUserManager.address, newApp.address, + { notes: [activeNote] }, ); console.log(" Migration args prepared.\n"); @@ -323,9 +323,9 @@ async function main() { oldMigrationSigner, blockHeader.global_variables.version, new Fr(env.newRollupVersion), - [nullifiedNote], newUserManager.address, newApp.address, + { notes: [nullifiedNote] }, ); const nullifiedTokenId = nullifiedNoteProof.note_proof_data.data.token_id; diff --git a/e2e-tests/nft-migration-public-mode-b.test.ts b/e2e-tests/nft-migration-public-mode-b.test.ts index b676a70..6411538 100644 --- a/e2e-tests/nft-migration-public-mode-b.test.ts +++ b/e2e-tests/nft-migration-public-mode-b.test.ts @@ -190,14 +190,13 @@ async function main() { oldUserManager.address, ); // For NFT, the signed data is the owner AztecAddress (not a balance amount) - const signature = await newUserWallet.signPublicStateMigrationModeB( + const signature = await newUserWallet.signMigrationModeB( oldMigrationSigner, newUserManager.address, new Fr(env.oldRollupVersion), new Fr(env.newRollupVersion), newApp.address, - oldUserManager.address, - ownerAbiType, + { publicData: [{ data: oldUserManager.address, abiType: ownerAbiType }] }, ); // ============================================================ diff --git a/e2e-tests/token-migration-mode-a.test.ts b/e2e-tests/token-migration-mode-a.test.ts index 2ae9d2f..e149929 100644 --- a/e2e-tests/token-migration-mode-a.test.ts +++ b/e2e-tests/token-migration-mode-a.test.ts @@ -1,7 +1,7 @@ import { TokenMigrationAppV1Contract } from "./artifacts/TokenMigrationAppV1.js"; import { TokenMigrationAppV2Contract } from "./artifacts/TokenMigrationAppV2.js"; import { Fr } from "@aztec/foundation/curves/bn254"; -import { signMigrationModeA } from "../ts/aztec-state-migration/index.js"; +import { signMigrationModeA } from "aztec-state-migration/mode-a"; import { deploy } from "./deploy.js"; import { deployTokenAppPair, @@ -132,12 +132,11 @@ async function main() { // Step 5: Bridge archive root // ============================================================ console.log("Step 5. Bridging archive root..."); - const { l1Result, provenBlockNumber, blockHeader } = await bridgeBlock( + const { provenBlockNumber, blockHeader } = await bridgeBlock( env, newArchiveRegistry, ); - console.log(` Proven block: ${l1Result.provenBlockNumber}`); - console.log(` Archive root: ${l1Result.provenArchiveRoot}\n`); + console.log(` Proven block: ${provenBlockNumber}`); // ============================================================ // Step 6: Prepare migration args @@ -155,9 +154,9 @@ async function main() { ); } - const [migrationNoteProof] = await oldUserWallet.buildMigrationNoteProofs( + const migrationNoteProof = await oldUserWallet.buildMigrationNoteProof( provenBlockNumber, - lockNotesAndData, + lockNotesAndData[0], ); const oldMigrationSigner = await oldUserWallet.getMigrationSignerFromAddress( @@ -290,11 +289,10 @@ async function main() { console.log("Step 11. Bridging archive root for public lock note..."); const { - l1Result: l1ResultPublic, provenBlockNumber: publicProvenBlockNumber, blockHeader: publicBlockHeader, } = await bridgeBlock(env, newArchiveRegistry); - console.log(` Proven block: ${l1ResultPublic.provenBlockNumber}\n`); + console.log(` Proven block: ${publicProvenBlockNumber}\n`); // ============================================================ // Step 12: Get public lock note, filter, build proof @@ -325,11 +323,10 @@ async function main() { ); } - const [publicMigrationNoteProof] = - await oldUserWallet.buildMigrationNoteProofs( - publicProvenBlockNumber, - filteredNotes, - ); + const publicMigrationNoteProof = await oldUserWallet.buildMigrationNoteProof( + publicProvenBlockNumber, + filteredNotes[0], + ); const publicSignature = await signMigrationModeA( oldMigrationSigner, diff --git a/e2e-tests/token-migration-mode-b.test.ts b/e2e-tests/token-migration-mode-b.test.ts index 1abbabd..e49f815 100644 --- a/e2e-tests/token-migration-mode-b.test.ts +++ b/e2e-tests/token-migration-mode-b.test.ts @@ -1,5 +1,5 @@ import { Fr } from "@aztec/foundation/curves/bn254"; -import { signMigrationModeB } from "../ts/aztec-state-migration/index.js"; +import { signMigrationModeB } from "aztec-state-migration/mode-b"; import { deploy } from "./deploy.js"; import { deployTokenAppPair, @@ -217,11 +217,11 @@ async function main() { ` Active notes: ${balanceNotesActive.length}, Nullified notes: ${balanceNotesNullified.length}`, ); - const balanceNotes = balanceNotesActive.slice(0, 1); + const balanceNote = balanceNotesActive[0]; - const fullProofs = await oldUserWallet.buildFullNoteProofs( + const fullProof = await oldUserWallet.buildFullNoteProof( provenBlockNumber, - balanceNotes, + balanceNote, (note) => UintNote.fromNote(note), ); @@ -238,9 +238,9 @@ async function main() { oldMigrationSigner, blockHeader.global_variables.version, new Fr(env.newRollupVersion), - balanceNotes, newUserManager.address, newApp.address, + { notes: [balanceNote] }, ); console.log(" Migration args prepared.\n"); @@ -250,8 +250,7 @@ async function main() { // ============================================================ console.log("Step 9. Calling migrate_mode_b on NEW rollup..."); - const noteProof = fullProofs[0]; - const migrateAmount = noteProof.note_proof_data.data.value; + const migrateAmount = fullProof.note_proof_data.data.value; console.log(` Migrating amount: ${migrateAmount}`); const newBalanceBefore = await newAppUser.methods @@ -263,7 +262,7 @@ async function main() { .migrate_mode_b( migrateAmount, signature, - noteProof, + fullProof, blockHeader, oldUserManager.address, publicKeys, @@ -297,7 +296,7 @@ async function main() { .migrate_mode_b( migrateAmount, signature, - noteProof, + fullProof, blockHeader, oldUserManager.address, publicKeys, @@ -320,9 +319,9 @@ async function main() { const nullifiedNote = balanceNotesNullified[0]; - const [nullifiedNoteProof] = await oldUserWallet.buildFullNoteProofs( + const nullifiedNoteProof = await oldUserWallet.buildFullNoteProof( provenBlockNumber, - [nullifiedNote], + nullifiedNote, (note) => UintNote.fromNote(note), ); @@ -330,9 +329,9 @@ async function main() { oldMigrationSigner, blockHeader.global_variables.version, new Fr(env.newRollupVersion), - [nullifiedNote], newUserManager.address, newApp.address, + { notes: [nullifiedNote] }, ); const nullifiedAmount = nullifiedNoteProof.note_proof_data.data.value; diff --git a/e2e-tests/token-migration-public-mode-b.test.ts b/e2e-tests/token-migration-public-mode-b.test.ts index 3473943..4ca19d3 100644 --- a/e2e-tests/token-migration-public-mode-b.test.ts +++ b/e2e-tests/token-migration-public-mode-b.test.ts @@ -184,14 +184,13 @@ async function main() { const oldMigrationSigner = await oldUserWallet.getMigrationSignerFromAddress( oldUserManager.address, ); - const signature = await newUserWallet.signPublicStateMigrationModeB( + const signature = await newUserWallet.signMigrationModeB( oldMigrationSigner, newUserManager.address, new Fr(env.oldRollupVersion), new Fr(env.newRollupVersion), newApp.address, - MINT_AMOUNT, - balanceAbiType, + { publicData: [{ data: MINT_AMOUNT, abiType: balanceAbiType }] }, ); // ============================================================ diff --git a/ts/aztec-state-migration/mode-b/index.ts b/ts/aztec-state-migration/mode-b/index.ts index 1742c6d..bd8fc0f 100644 --- a/ts/aztec-state-migration/mode-b/index.ts +++ b/ts/aztec-state-migration/mode-b/index.ts @@ -11,7 +11,4 @@ export { buildPublicMapDataProof, } from "./proofs.js"; -export { - signMigrationModeB, - signPublicStateMigrationModeB, -} from "./signature.js"; +export { signMigrationModeB } from "./signature.js"; diff --git a/ts/aztec-state-migration/mode-b/signature.ts b/ts/aztec-state-migration/mode-b/signature.ts index 42c6fcf..b34099f 100644 --- a/ts/aztec-state-migration/mode-b/signature.ts +++ b/ts/aztec-state-migration/mode-b/signature.ts @@ -11,71 +11,57 @@ import { } from "@aztec/stdlib/abi"; /** - * Produce a Schnorr signature over a Mode B (emergency snapshot) private note claim message. + * Produce a Schnorr signature over a Mode B (emergency snapshot) claim message. * - * The signed payload is `poseidon2_hash([DOM_SEP__CLAIM_B, oldVersion, newVersion, notesHash, recipient, newApp])`. + * Supports private notes, public state data, or both in a single signature -- + * matching the Noir builder which feeds packed public data fields and note hashes + * into the same `Poseidon2Hasher`. * - * @param signer - Signing callback (typically {@link MigrationAccount.migrationKeySigner}). - * @param oldRollupVersion - Version field from the old rollup's block header. - * @param newRollupVersion - Target rollup version the tokens are migrating to. - * @param notes - The private notes on the old rollup whose existence is being proven. - * @param recipient - Address that will call the migration tx on the new rollup (`msg_sender()`). - * @param newAppAddress - Address of the app contract on the new rollup. - * @returns The Schnorr signature as a {@link MigrationSignature}. - */ -export async function signMigrationModeB( - signer: (msg: Buffer) => Promise, - oldRollupVersion: Fr, - newRollupVersion: Fr, - notes: NoteDao[], - recipient: AztecAddress, - newAppAddress: AztecAddress, -): Promise { - const notesHash = await poseidon2Hash(notes.map((n) => n.noteHash)); - const msg = await poseidon2Hash([ - DOM_SEP__CLAIM_B, - oldRollupVersion, - newRollupVersion, - notesHash, - recipient, - newAppAddress, - ]); - return signer(msg.toBuffer()); -} - -/** - * Produce a Schnorr signature over a Mode B (public state) claim message. + * Hash input order (matches the Noir builder): packed public data fields first, then note hashes. * - * The data is packed to `Fr[]` via {@link encodeValue} (matching Noir's `Packable::pack()` field ordering), - * then hashed with `poseidon2_hash` to produce `dataHash`. - * - * The signed payload is `poseidon2_hash([DOM_SEP__CLAIM_B, oldVersion, newVersion, dataHash, recipient, newApp])`. + * The signed payload is `poseidon2_hash([DOM_SEP__CLAIM_B, oldVersion, newVersion, finalHash, recipient, newApp])`. * * @param signer - Signing callback (typically {@link MigrationAccount.migrationKeySigner}). * @param oldRollupVersion - Version field from the old rollup's block header. * @param newRollupVersion - Target rollup version the tokens are migrating to. - * @param data - The public state data to sign. Must match the struct shape defined by `abiType`. - * @param abiType - ABI type describing `data`'s structure, extracted from the contract artifact. * @param recipient - Address that will call the migration tx on the new rollup (`msg_sender()`). * @param newAppAddress - Address of the app contract on the new rollup. + * @param options - The data to sign: `notes` (private notes), `publicData` (public state entries), or both. * @returns The Schnorr signature as a {@link MigrationSignature}. */ -export async function signPublicStateMigrationModeB( +export async function signMigrationModeB( signer: (msg: Buffer) => Promise, oldRollupVersion: Fr, newRollupVersion: Fr, - data: any, - abiType: AbiType, recipient: AztecAddress, newAppAddress: AztecAddress, + options: { + publicData?: { data: any; abiType: AbiType }[]; + notes?: NoteDao[]; + }, ): Promise { - const packedData = encodeValue(data, abiType); - const dataHash = await poseidon2Hash(packedData); + const hashInputs: Fr[] = []; + + // Public data fields first (matches Noir builder order) + if (options.publicData) { + for (const { data, abiType } of options.publicData) { + hashInputs.push(...encodeValue(data, abiType)); + } + } + + // Then note hashes + if (options.notes) { + for (const note of options.notes) { + hashInputs.push(note.noteHash); + } + } + + const finalHash = await poseidon2Hash(hashInputs); const msg = await poseidon2Hash([ DOM_SEP__CLAIM_B, oldRollupVersion, newRollupVersion, - dataHash, + finalHash, recipient, newAppAddress, ]); diff --git a/ts/aztec-state-migration/wallet/migration-base-wallet.ts b/ts/aztec-state-migration/wallet/migration-base-wallet.ts index 95fbb75..e4cb92b 100644 --- a/ts/aztec-state-migration/wallet/migration-base-wallet.ts +++ b/ts/aztec-state-migration/wallet/migration-base-wallet.ts @@ -20,10 +20,7 @@ import { buildNullifierProof } from "../mode-b/proofs.js"; import { Point } from "@aztec/foundation/schemas"; import { AztecAddress } from "@aztec/stdlib/aztec-address"; import { MigrationAccount } from "./migration-account.js"; -import { - signMigrationModeB as signModeB, - signPublicStateMigrationModeB as signPubStateModeB, -} from "../mode-b/signature.js"; +import { signMigrationModeB as signModeB } from "../mode-b/signature.js"; import { signMigrationModeA as signModeA } from "../mode-a/signature.js"; import { PublicKeys } from "@aztec/stdlib/keys"; import { AbiType, decodeFromAbi, EventSelector } from "@aztec/stdlib/abi"; @@ -113,13 +110,16 @@ export abstract class MigrationBaseWallet extends BaseWallet { /** * Produce a Mode B (emergency snapshot) claim signature via the wallet. * + * Supports private notes, public state data, or both in a single signature. + * Hash input order matches the Noir builder: packed public data fields first, then note hashes. + * * @param signer - The migration account that holds the signing key. * @param recipient - Address on the new rollup that will receive the balance. * @param oldRollupVersion - Version of the old rollup. * @param newRollupVersion - Version of the new rollup. * @param newAppAddress - App contract address on the new rollup. - * @param notes - The balance notes whose values are being claimed. - * @returns The raw Schnorr signature buffer. + * @param options - The data to sign: `notes` (private notes), `publicData` (public state entries), or both. + * @returns The Schnorr signature as a {@link MigrationSignature}. */ async signMigrationModeB( signer: (msg: Uint8Array) => Promise, @@ -127,35 +127,18 @@ export abstract class MigrationBaseWallet extends BaseWallet { oldRollupVersion: Fr, newRollupVersion: Fr, newAppAddress: AztecAddress, - notes: NoteDao[], + options: { + publicData?: { data: any; abiType: AbiType }[]; + notes?: NoteDao[]; + }, ): Promise { return signModeB( signer, oldRollupVersion, newRollupVersion, - notes, - recipient, - newAppAddress, - ); - } - - async signPublicStateMigrationModeB( - signer: (msg: Uint8Array) => Promise, - recipient: AztecAddress, - oldRollupVersion: Fr, - newRollupVersion: Fr, - newAppAddress: AztecAddress, - data: any, - dataAbiType: AbiType, - ): Promise { - return signPubStateModeB( - signer, - oldRollupVersion, - newRollupVersion, - data, - dataAbiType, recipient, newAppAddress, + options, ); }