diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fc665d9f0c..f3f46e3f37 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -81,7 +81,7 @@ jobs: with: node-version: "22" - name: Install Foundry - uses: foundry-rs/foundry-toolchain@8789b3e21e6c11b2697f5eb56eddae542f746c10 # v1.7.0 + uses: foundry-rs/foundry-toolchain@908c540300062bd5a7e473851cdb4282204cee09 # v1.9.1 - run: cd ethereum && make test-push0 && make test ethereum-upgrade: @@ -94,7 +94,7 @@ jobs: with: node-version: "22" - name: Install Foundry - uses: foundry-rs/foundry-toolchain@8789b3e21e6c11b2697f5eb56eddae542f746c10 # v1.7.0 + uses: foundry-rs/foundry-toolchain@908c540300062bd5a7e473851cdb4282204cee09 # v1.9.1 - run: cd clients/js && make install - run: cd ethereum && make test-upgrade diff --git a/clients/js/README.md b/clients/js/README.md index 2e5bb1b7cf..bbbeaa8968 100644 --- a/clients/js/README.md +++ b/clients/js/README.md @@ -153,6 +153,9 @@ worm generate [command] Commands: worm generate registration Generate registration VAA worm generate upgrade Generate contract upgrade VAA + worm generate set-pauser-addresses Generate a token bridge + SetPauserAddresses VAA (whitepaper + 0003) worm generate attestation Generate a token attestation VAA worm generate recover-chain-id Generate a recover chain ID VAA worm generate Sets the default delivery provider diff --git a/clients/js/src/algorand.ts b/clients/js/src/algorand.ts index 4140351ce1..b5d5ac6bda 100644 --- a/clients/js/src/algorand.ts +++ b/clients/js/src/algorand.ts @@ -100,6 +100,8 @@ export async function execute_algorand( case "AttestMeta": console.log("Creating wrapped token"); break; + case "SetPauserAddresses": + throw new Error("SetPauserAddresses not supported on algorand"); case "TransferWithPayload": throw Error("Can't complete payload 3 transfer from CLI"); default: diff --git a/clients/js/src/aptos.ts b/clients/js/src/aptos.ts index fc52e5e991..685d8849b5 100644 --- a/clients/js/src/aptos.ts +++ b/clients/js/src/aptos.ts @@ -220,6 +220,8 @@ export async function execute_aptos( ); break; } + case "SetPauserAddresses": + throw new Error("SetPauserAddresses not supported on aptos"); case "TransferWithPayload": throw Error("Can't complete payload 3 transfer from CLI"); default: diff --git a/clients/js/src/chains/sei/submit.ts b/clients/js/src/chains/sei/submit.ts index 634f9caa08..385c23fbe1 100644 --- a/clients/js/src/chains/sei/submit.ts +++ b/clients/js/src/chains/sei/submit.ts @@ -116,6 +116,8 @@ export const submit = async ( case "AttestMeta": console.log("Creating wrapped token"); break; + case "SetPauserAddresses": + throw new Error("SetPauserAddresses not supported on sei"); case "TransferWithPayload": throw Error("Can't complete payload 3 transfer from CLI"); default: diff --git a/clients/js/src/chains/sui/submit.ts b/clients/js/src/chains/sui/submit.ts index 67c79e5f37..2aaae7a9f0 100644 --- a/clients/js/src/chains/sui/submit.ts +++ b/clients/js/src/chains/sui/submit.ts @@ -7,6 +7,7 @@ import { import { Payload, impossible } from "../../vaa"; import { assertSuccess, + buildTokenBridgePackage, executeTransactionBlock, getOriginalPackageId, getPackageId, @@ -14,10 +15,14 @@ import { getPublishedPackageId, getSigner, getUpgradeCapObjectId, + migrateTokenBridge, normalizeSuiAddress, registerChain, setMaxGasBudgetDevnet, + setPauserAddresses, SUI_CLOCK_OBJECT_ID, + upgradeTokenBridge, + waitForNewPackage, } from "./utils"; import { SuiGrpcClient } from "@mysten/sui/grpc"; import { @@ -204,8 +209,64 @@ export const submit = async ( break; } - case "ContractUpgrade": - throw new Error("ContractUpgrade not supported on Sui"); + case "ContractUpgrade": { + console.log("Upgrading contract"); + // On Sui the VAA authorizes a build digest, and the upgrade + // transaction itself carries the compiled bytecode — build the + // package locally and refuse to submit if it doesn't reproduce the + // authorized digest (the chain would reject it anyway). + const { modules, dependencies, digest } = + buildTokenBridgePackage(network); + const authorized = payload.address.replace(/^0x/, ""); + if (digest.toString("hex") !== authorized) { + throw new Error( + `local build digest ${digest.toString( + "hex" + )} does not match the digest authorized by the VAA (${authorized}); ` + + "rebuild from the proposal's commit with the proposal's sui CLI version" + ); + } + const oldPackage = await getPackageId( + client, + tokenBridgeStateObjectId + ); + const tx = await upgradeTokenBridge( + client, + network, + vaa, + coreBridgeStateObjectId, + tokenBridgeStateObjectId, + modules, + dependencies + ); + setMaxGasBudgetDevnet(network, tx); + const res = await executeTransactionBlock(signer, tx); + console.log(JSON.stringify(res)); + assertSuccess(res, "Upgrade failed."); + + // The upgrade only publishes the new package; it becomes active + // once migrate flips the version on the state object. + const newPackage = await waitForNewPackage( + client, + tokenBridgeStateObjectId, + oldPackage + ); + console.log(`New package: ${newPackage}`); + + console.log("Migrating"); + const migrateTx = await migrateTokenBridge( + client, + network, + vaa, + coreBridgeStateObjectId, + tokenBridgeStateObjectId + ); + setMaxGasBudgetDevnet(network, migrateTx); + const migrateRes = await executeTransactionBlock(signer, migrateTx); + console.log(JSON.stringify(migrateRes)); + assertSuccess(migrateRes, "Migrate failed."); + break; + } case "RecoverChainId": throw new Error("RecoverChainId not supported on Sui"); case "RegisterChain": { @@ -222,6 +283,20 @@ export const submit = async ( console.log(JSON.stringify(res)); break; } + case "SetPauserAddresses": { + console.log("Setting pauser addresses"); + const tx = await setPauserAddresses( + client, + network, + vaa, + coreBridgeStateObjectId, + tokenBridgeStateObjectId + ); + setMaxGasBudgetDevnet(network, tx); + const res = await executeTransactionBlock(signer, tx); + console.log(JSON.stringify(res)); + break; + } case "Transfer": throw new Error("Transfer not supported on Sui"); case "TransferWithPayload": diff --git a/clients/js/src/chains/sui/utils.ts b/clients/js/src/chains/sui/utils.ts index c233d13545..36ee86702c 100644 --- a/clients/js/src/chains/sui/utils.ts +++ b/clients/js/src/chains/sui/utils.ts @@ -3,6 +3,9 @@ import type { SuiClientTypes } from "@mysten/sui/client"; import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519"; import { Transaction } from "@mysten/sui/transactions"; import { fromBase64 } from "@mysten/sui/utils"; +import { execSync } from "child_process"; +import { existsSync } from "fs"; +import { resolve } from "path"; import { NETWORKS } from "../../consts"; import { Payload, VAA, parse, serialiseVAA } from "../../vaa"; import { Network } from "@wormhole-foundation/sdk"; @@ -534,6 +537,279 @@ export const registerChain = async ( return tx; }; +/** + * Build the token bridge Move package and return its bytecode plus the build + * digest. On Sui the governance VAA authorizes a *digest*, not an address — + * the upgrade transaction itself carries the compiled bytecode and the chain + * rejects it unless it hashes to the authorized digest, so the executor must + * reproduce the exact build the guardians signed off on (same source, same + * `sui` CLI version). + * + * The package directory defaults to `sui/token_bridge` in the wormhole repo + * checkout the CLI was built from; override with SUI_PACKAGE_DIR. + */ +export const buildTokenBridgePackage = ( + network: Network +): { modules: string[]; dependencies: string[]; digest: Buffer } => { + if (network === "Devnet") { + throw new Error( + "Building for Devnet is not supported (no devnet environment in Published.toml)" + ); + } + const env = network === "Mainnet" ? "mainnet" : "testnet"; + + // Locate the package by walking up from this file to the wormhole repo + // root, wherever that is relative to the executing bundle (the bundled CLI + // runs from clients/js/build, tests run from src/chains/sui). + let packagePath = process.env.SUI_PACKAGE_DIR; + if (!packagePath) { + for (let dir = __dirname; ; ) { + const candidate = resolve(dir, "sui/token_bridge"); + if (existsSync(`${candidate}/Move.toml`)) { + packagePath = candidate; + break; + } + const parent = resolve(dir, ".."); + if (parent === dir) break; // filesystem root reached + dir = parent; + } + } + if (!packagePath || !existsSync(`${packagePath}/Move.toml`)) { + throw new Error( + `token bridge package not found above ${__dirname}; set SUI_PACKAGE_DIR` + ); + } + + const buildOutput: { + modules: string[]; + dependencies: string[]; + digest: number[]; + } = JSON.parse( + execSync( + `sui move build --dump-bytecode-as-base64 -e ${env} -p ${packagePath} 2> /dev/null`, + { encoding: "utf-8", maxBuffer: 64 * 1024 * 1024 } + ) + ); + return { + modules: buildOutput.modules, // base64 strings, accepted by tx.upgrade + dependencies: buildOutput.dependencies.map(normalizeSuiAddress), + digest: Buffer.from(buildOutput.digest), + }; +}; + +/** + * Build the token bridge upgrade PTB: parse_and_verify -> authorize_governance + * -> verify_vaa -> authorize_upgrade -> Upgrade -> commit_upgrade, all against + * the CURRENT package + */ +export const upgradeTokenBridge = async ( + client: SuiGrpcClient, + network: Network, + vaa: Buffer, + coreBridgeStateObjectId: string, + tokenBridgeStateObjectId: string, + modules: string[], + dependencies: string[] +): Promise => { + if (network === "Devnet") { + // Modify the VAA to only have 1 guardian signature (see registerChain). + const parsedVaa = parse(vaa); + parsedVaa.signatures = [parsedVaa.signatures[0]]; + vaa = Buffer.from(serialiseVAA(parsedVaa as VAA), "hex"); + } + + const coreBridgePackageId = await getPackageId( + client, + coreBridgeStateObjectId + ); + const tokenBridgePackageId = await getPackageId( + client, + tokenBridgeStateObjectId + ); + + // Leaving the budget for the simulation, as it's an heavy operation + const tx = new Transaction(); + + const [verifiedVaa] = tx.moveCall({ + target: `${coreBridgePackageId}::vaa::parse_and_verify`, + arguments: [ + tx.object(coreBridgeStateObjectId), + tx.pure("vector", [...vaa]), + tx.object(SUI_CLOCK_OBJECT_ID), + ], + }); + + const [decreeTicket] = tx.moveCall({ + target: `${tokenBridgePackageId}::upgrade_contract::authorize_governance`, + arguments: [tx.object(tokenBridgeStateObjectId)], + }); + + const [decreeReceipt] = tx.moveCall({ + target: `${coreBridgePackageId}::governance_message::verify_vaa`, + arguments: [tx.object(coreBridgeStateObjectId), verifiedVaa, decreeTicket], + typeArguments: [ + `${tokenBridgePackageId}::upgrade_contract::GovernanceWitness`, + ], + }); + + const [upgradeTicket] = tx.moveCall({ + target: `${tokenBridgePackageId}::upgrade_contract::authorize_upgrade`, + arguments: [tx.object(tokenBridgeStateObjectId), decreeReceipt], + }); + + const [upgradeReceipt] = tx.upgrade({ + modules, + dependencies, + package: tokenBridgePackageId, + ticket: upgradeTicket, + }); + + tx.moveCall({ + target: `${tokenBridgePackageId}::upgrade_contract::commit_upgrade`, + arguments: [tx.object(tokenBridgeStateObjectId), upgradeReceipt], + }); + + return tx; +}; + +/** + * Build the migrate PTB against the NEW package + * Until migrate succeeds the previous version stays active + */ +export const migrateTokenBridge = async ( + client: SuiGrpcClient, + network: Network, + vaa: Buffer, + coreBridgeStateObjectId: string, + tokenBridgeStateObjectId: string +): Promise => { + if (network === "Devnet") { + const parsedVaa = parse(vaa); + parsedVaa.signatures = [parsedVaa.signatures[0]]; + vaa = Buffer.from(serialiseVAA(parsedVaa as VAA), "hex"); + } + + const coreBridgePackageId = await getPackageId( + client, + coreBridgeStateObjectId + ); + const newTokenBridgePackageId = await getPackageId( + client, + tokenBridgeStateObjectId + ); + + const tx = new Transaction(); + + const [verifiedVaa] = tx.moveCall({ + target: `${coreBridgePackageId}::vaa::parse_and_verify`, + arguments: [ + tx.object(coreBridgeStateObjectId), + tx.pure("vector", [...vaa]), + tx.object(SUI_CLOCK_OBJECT_ID), + ], + }); + + const [decreeTicket] = tx.moveCall({ + target: `${newTokenBridgePackageId}::upgrade_contract::authorize_governance`, + arguments: [tx.object(tokenBridgeStateObjectId)], + }); + + const [decreeReceipt] = tx.moveCall({ + target: `${coreBridgePackageId}::governance_message::verify_vaa`, + arguments: [tx.object(coreBridgeStateObjectId), verifiedVaa, decreeTicket], + typeArguments: [ + `${newTokenBridgePackageId}::upgrade_contract::GovernanceWitness`, + ], + }); + + tx.moveCall({ + target: `${newTokenBridgePackageId}::migrate::migrate`, + arguments: [tx.object(tokenBridgeStateObjectId), decreeReceipt], + }); + + return tx; +}; + +/** + * Poll the state object until its upgrade_cap points at a package other than old one + */ +export const waitForNewPackage = async ( + client: SuiGrpcClient, + stateObjectId: string, + oldPackage: string +): Promise => { + for (let i = 0; i < 30; i++) { + await new Promise((r) => setTimeout(r, 2000)); + const pkg = await getPackageId(client, stateObjectId); + if (pkg !== oldPackage) return pkg; + } + throw new Error( + "state object still reports the old package after 60s — check the upgrade tx" + ); +}; + +export const setPauserAddresses = async ( + client: SuiGrpcClient, + network: Network, + vaa: Buffer, + coreBridgeStateObjectId: string, + tokenBridgeStateObjectId: string, + transaction?: Transaction +): Promise => { + if (network === "Devnet") { + const parsedVaa = parse(vaa); + parsedVaa.signatures = [parsedVaa.signatures[0]]; + vaa = Buffer.from(serialiseVAA(parsedVaa as VAA), "hex"); + } + + const coreBridgePackageId = await getPackageId( + client, + coreBridgeStateObjectId + ); + const tokenBridgePackageId = await getPackageId( + client, + tokenBridgeStateObjectId + ); + + let tx = transaction; + if (!tx) { + tx = new Transaction(); + tx.setGasBudget(5000000); + } + + const [verifiedVaa] = tx.moveCall({ + target: `${coreBridgePackageId}::vaa::parse_and_verify`, + arguments: [ + tx.object(coreBridgeStateObjectId), + tx.pure("vector", [...vaa]), + tx.object(SUI_CLOCK_OBJECT_ID), + ], + }); + + // Get decree ticket + const [decreeTicket] = tx.moveCall({ + target: `${tokenBridgePackageId}::set_pauser_addresses::authorize_governance`, + arguments: [tx.object(tokenBridgeStateObjectId)], + }); + + // Get decree receipt + const [decreeReceipt] = tx.moveCall({ + target: `${coreBridgePackageId}::governance_message::verify_vaa`, + arguments: [tx.object(coreBridgeStateObjectId), verifiedVaa, decreeTicket], + typeArguments: [ + `${tokenBridgePackageId}::set_pauser_addresses::GovernanceWitness`, + ], + }); + + // Set the pauser/freezer/unpauser addresses + tx.moveCall({ + target: `${tokenBridgePackageId}::set_pauser_addresses::set_pauser_addresses`, + arguments: [tx.object(tokenBridgeStateObjectId), decreeReceipt], + }); + + return tx; +}; + /** * Currently, (Sui SDK version 0.32.2 and Sui 1.0.0 testnet), there is a * mismatch in the max gas budget that causes an error when executing a diff --git a/clients/js/src/chains/terra2/terra2.ts b/clients/js/src/chains/terra2/terra2.ts index 52a15733b9..40ac10bd5a 100644 --- a/clients/js/src/chains/terra2/terra2.ts +++ b/clients/js/src/chains/terra2/terra2.ts @@ -105,6 +105,8 @@ export async function execute_terra2( case "AttestMeta": console.log("Creating wrapped token"); break; + case "SetPauserAddresses": + throw new Error("SetPauserAddresses not supported on terra2"); case "TransferWithPayload": throw Error("Can't complete payload 3 transfer from CLI"); default: diff --git a/clients/js/src/cli.test.ts b/clients/js/src/cli.test.ts index 83af233b02..f2d2426d77 100644 --- a/clients/js/src/cli.test.ts +++ b/clients/js/src/cli.test.ts @@ -341,6 +341,45 @@ describe("Generate Tests", () => { expect(outputObject.digest).toMatch(/^0x[0-9a-f]{64}$/); } + it("worm generate set-pauser-addresses", (done) => { + exec( + "node build/main.js generate set-pauser-addresses --chain ethereum --pauser 0x8F26A0025dcCc6Cfc07A7d38756280a10E295ad7 --unpauser 0x8F26A0025dcCc6Cfc07A7d38756280a10E295ad7 --guardian-secret cfb12303a19cde580bb4dd771639b0d26bc68353645571a8cff516ab2ee113a0", + (error: any, stdout: string, stderr: any) => { + if (error) { + return done(new Error(`Execution error during generation: ${error}`)); + } + + const vaa = stdout.trim(); + expect(vaa).not.toBeNull(); + + exec( + `node build/main.js parse ${vaa}`, + (error: any, stdout: string, stderr: any) => { + if (error) { + return done(new Error(`Execution error during parse: ${error}`)); + } + try { + const outputObject = JSON.parse(stdout); + expectValidVaaEnvelope(outputObject); + expect(outputObject.payload).toMatchObject({ + module: "TokenBridge", + type: "SetPauserAddresses", + chain: 2, + // 20-byte native size on EVM, freezer left unassigned + pauser: "0x8f26a0025dccc6cfc07a7d38756280a10e295ad7", + freezer: "", + unpauser: "0x8f26a0025dccc6cfc07a7d38756280a10e295ad7", + }); + done(); + } catch (assertionError: any) { + done(assertionError); + } + } + ); + } + ); + }); + it("worm generate registration", (done) => { exec( "node build/main.js generate registration --module NFTBridge --chain bsc --contract-address 0x706abc4E45D419950511e474C7B9Ed348A4a716c --guardian-secret cfb12303a19cde580bb4dd771639b0d26bc68353645571a8cff516ab2ee113a0", diff --git a/clients/js/src/cmds/generate.ts b/clients/js/src/cmds/generate.ts index eb6abe5103..130ba7516f 100644 --- a/clients/js/src/cmds/generate.ts +++ b/clients/js/src/cmds/generate.ts @@ -18,6 +18,7 @@ import { serialiseVAA, sign, TokenBridgeAttestMeta, + TokenBridgeSetPauserAddresses, VAA, WormholeRelayerSetDefaultDeliveryProvider, } from "../vaa"; @@ -175,6 +176,55 @@ export const builder = function (y: typeof yargs) { console.log(serialiseVAA(vaa)); } ) + .command( + "set-pauser-addresses", + "Generate a token bridge SetPauserAddresses VAA (whitepaper 0003)", + (yargs) => + yargs + .option("chain", { + alias: "c", + describe: + "Chain to set the pauser addresses on. To see a list of supported chains, run `worm chains`", + type: "string", + demandOption: true, + } as const) + .option("pauser", { + describe: + "Pauser address, in the chain's native size (omit to leave the role unassigned)", + type: "string", + demandOption: false, + }) + .option("freezer", { + describe: + "Freezer address, in the chain's native size (omit to leave the role unassigned)", + type: "string", + demandOption: false, + }) + .option("unpauser", { + describe: + "Unpauser address, in the chain's native size (omit to leave the role unassigned)", + type: "string", + demandOption: false, + }), + (argv) => { + const chain = chainToCliChain(argv.chain); + const payload: TokenBridgeSetPauserAddresses = { + module: "TokenBridge", + type: "SetPauserAddresses", + chain: cliChainToChainId(chain), + pauser: parsePauserAddress(chain, argv.pauser), + freezer: parsePauserAddress(chain, argv.freezer), + unpauser: parsePauserAddress(chain, argv.unpauser), + }; + const vaa = makeVAA( + GOVERNANCE_CHAIN, + GOVERNANCE_EMITTER, + argv["guardian-secret"].split(","), + payload + ); + console.log(serialiseVAA(vaa)); + } + ) .command( "attestation", "Generate a token attestation VAA", @@ -368,3 +418,27 @@ function parseCodeAddress(chain: CliChain, address: string): string { return parseAddress(chain, address); } } + +// Native address sizes for the SetPauserAddresses roles +const PAUSER_ADDRESS_SIZE: Partial> = { + Evm: 20, + Solana: 32, + Sui: 32, +}; + +function parsePauserAddress(chain: CliChain, address?: string): string { + if (!address) { + return ""; + } + const platform = cliChainToPlatform(chain); + const size = PAUSER_ADDRESS_SIZE[platform]; + if (size === undefined) { + throw Error(`SetPauserAddresses is not supported on platform ${platform}`); + } + const padded = parseAddress(chain, address).slice(2); + const excess = padded.slice(0, (32 - size) * 2); + if (!/^0*$/.test(excess)) { + throw Error(`address ${address} does not fit in ${size} bytes`); + } + return "0x" + padded.slice((32 - size) * 2); +} diff --git a/clients/js/src/evm.ts b/clients/js/src/evm.ts index bd4803392e..255003db4f 100644 --- a/clients/js/src/evm.ts +++ b/clients/js/src/evm.ts @@ -429,6 +429,20 @@ export async function execute_evm( console.log("Registering chain"); console.log("Hash: " + (await tb.registerChain(vaa, overrides)).hash); break; + case "SetPauserAddresses": { + console.log("Setting pauser addresses"); + const pauserGovernance = new ethers.Contract( + contract_address, + ["function submitSetPauserAddresses(bytes memory encodedVM)"], + signer + ); + console.log( + "Hash: " + + (await pauserGovernance.submitSetPauserAddresses(vaa, overrides)) + .hash + ); + break; + } case "Transfer": console.log("Completing transfer"); console.log( diff --git a/clients/js/src/injective.ts b/clients/js/src/injective.ts index 0b58b89194..6a8dc99a42 100644 --- a/clients/js/src/injective.ts +++ b/clients/js/src/injective.ts @@ -136,6 +136,8 @@ export async function execute_injective( case "AttestMeta": console.log("Creating wrapped token"); break; + case "SetPauserAddresses": + throw new Error("SetPauserAddresses not supported on injective"); case "TransferWithPayload": throw Error("Can't complete payload 3 transfer from CLI"); default: diff --git a/clients/js/src/near.ts b/clients/js/src/near.ts index 6f799944e5..855045b407 100644 --- a/clients/js/src/near.ts +++ b/clients/js/src/near.ts @@ -120,6 +120,8 @@ export const execute_near = async ( case "AttestMeta": console.log("Creating wrapped token"); break; + case "SetPauserAddresses": + throw new Error("SetPauserAddresses not supported on near"); case "TransferWithPayload": throw Error("Can't complete payload 3 transfer from CLI"); default: diff --git a/clients/js/src/solana.ts b/clients/js/src/solana.ts index 69e2b69e20..04988a4dca 100644 --- a/clients/js/src/solana.ts +++ b/clients/js/src/solana.ts @@ -10,13 +10,23 @@ import { createRegisterChainInstruction as createTokenBridgeRegisterChainInstruction, createUpgradeContractInstruction as createTokenBridgeUpgradeContractInstruction, deriveEndpointKey, + deriveTokenBridgeConfigKey, getEndpointRegistration, } from "@certusone/wormhole-sdk/lib/esm/solana/tokenBridge"; import { createTransferFeesInstruction, createUpgradeGuardianSetInstruction, createUpgradeContractInstruction as createWormholeUpgradeContractInstruction, + deriveClaimKey, + derivePostedVaaKey, } from "@certusone/wormhole-sdk/lib/esm/solana/wormhole"; +import { TokenBridgeInstruction } from "@certusone/wormhole-sdk/lib/esm/solana/tokenBridge/coder/instruction"; +import { parseVaa } from "@certusone/wormhole-sdk/lib/esm/vaa"; + +// SetPauserAddresses is the instruction added right after the last one the +// published SDK coder knows about, matching the program's instruction enum +const SET_PAUSER_ADDRESSES_INSTRUCTION = + TokenBridgeInstruction.TransferNativeWithPayload + 1; import * as web3s from "@solana/web3.js"; import base58 from "bs58"; import { NETWORKS } from "./consts"; @@ -185,6 +195,57 @@ export async function execute_solana( vaa ); break; + case "SetPauserAddresses": { + console.log("Setting pauser addresses"); + const parsed = parseVaa(vaa); + ix = new web3s.TransactionInstruction({ + programId: tokenBridgeId, + keys: [ + { pubkey: from.publicKey, isSigner: true, isWritable: true }, + { + pubkey: deriveTokenBridgeConfigKey(tokenBridgeId), + isSigner: false, + isWritable: true, + }, + { + pubkey: derivePostedVaaKey(bridgeId, parsed.hash), + isSigner: false, + isWritable: false, + }, + { + pubkey: deriveClaimKey( + tokenBridgeId, + parsed.emitterAddress, + parsed.emitterChain, + parsed.sequence + ), + isSigner: false, + isWritable: true, + }, + { + pubkey: PublicKey.findProgramAddressSync( + [Buffer.from("__event_authority")], + tokenBridgeId + )[0], + isSigner: false, + isWritable: false, + }, + { pubkey: tokenBridgeId, isSigner: false, isWritable: false }, + { + pubkey: web3s.SYSVAR_RENT_PUBKEY, + isSigner: false, + isWritable: false, + }, + { + pubkey: web3s.SystemProgram.programId, + isSigner: false, + isWritable: false, + }, + ], + data: Buffer.from([SET_PAUSER_ADDRESSES_INSTRUCTION]), + }); + break; + } case "Transfer": console.log("Completing transfer"); if (payload.tokenChain === chainToChainId(chain)) { diff --git a/clients/js/src/vaa.test.ts b/clients/js/src/vaa.test.ts new file mode 100644 index 0000000000..08c2fbd071 --- /dev/null +++ b/clients/js/src/vaa.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from "@jest/globals"; +import { parse, serialiseVAA, Payload, VAA } from "./vaa"; + +describe("TokenBridgeSetPauserAddresses", () => { + // Real testnet governance VAAs (TokenBridge action 4) submitted during the + // WTT pauser rollout, one per runtime. Signatures are from the testnet + // guardian; the payloads were accepted on-chain. + const REAL_VAAS: { [name: string]: { hex: string; chain: number } } = { + solana: { + chain: 1, + hex: "01000000000100179bed5737f8629a824731f9b760cf887c059f21bf572999401e0ca605df27650e6e676b1892b7204105d1965757b8b119cf9941b39722312ab8092294acfbdc01000000010000000100010000000000000000000000000000000000000000000000000000000000000004000000006a749e2400000000000000000000000000000000000000000000546f6b656e4272696467650400012083718b7ec89617b7040685e01bdcca03214022980daae91340e0c3f840c005ef2083718b7ec89617b7040685e01bdcca03214022980daae91340e0c3f840c005ef2083718b7ec89617b7040685e01bdcca03214022980daae91340e0c3f840c005ef", + }, + sepolia: { + chain: 10002, + hex: "010000000001002427b3e53e0e06e48ecaac44ecd76313e382cc7d3b6edda4e0986b35630da9210783874a5b2f9de31f967e6558e15f787bb3db37fa20a21f7856cf4856a40c2901000000010000000100010000000000000000000000000000000000000000000000000000000000000004000000006a74ab7600000000000000000000000000000000000000000000546f6b656e427269646765042712148f26a0025dccc6cfc07a7d38756280a10e295ad7148f26a0025dccc6cfc07a7d38756280a10e295ad7148f26a0025dccc6cfc07a7d38756280a10e295ad7", + }, + sui: { + chain: 21, + hex: "0100000000010006a64d534f591b1b21fcfd2ee55f699c97d70450cc62f38c9590d8aaa8f05de2566101c532ea993c0f93a8cb9a2e359ddb34a77fb0258f65762c3fd387ad5133006a75bf9d0000000000010000000000000000000000000000000000000000000000000000000000000004000000006a75bf9d00000000000000000000000000000000000000000000546f6b656e427269646765040015200c15ca93dbe1f92189ce4ce5caa5e718bdcc0e4080dd43bc255d7f30ebed64f0200c15ca93dbe1f92189ce4ce5caa5e718bdcc0e4080dd43bc255d7f30ebed64f0200c15ca93dbe1f92189ce4ce5caa5e718bdcc0e4080dd43bc255d7f30ebed64f0", + }, + }; + + it.each(Object.entries(REAL_VAAS))( + "parses the real %s testnet VAA", + (_name, { hex, chain }) => { + const vaa = parse(Buffer.from(hex, "hex")); + const payload = vaa.payload; + if (payload.type !== "SetPauserAddresses") { + throw new Error(`parsed as ${payload.type}`); + } + expect(payload.module).toBe("TokenBridge"); + expect(payload.chain).toBe(chain); + // All three testnet deployments set the same address for every role. + expect(payload.pauser).toBe(payload.freezer); + expect(payload.freezer).toBe(payload.unpauser); + // Native address size: 20 bytes on EVM, 32 elsewhere. + const expectedLen = chain === 10002 ? 2 + 40 : 2 + 64; + expect(payload.pauser.length).toBe(expectedLen); + } + ); + + it.each(Object.entries(REAL_VAAS))( + "round-trips the real %s testnet VAA byte-identically", + (_name, { hex }) => { + const vaa = parse(Buffer.from(hex, "hex")); + expect(vaa.payload.type).toBe("SetPauserAddresses"); + expect(serialiseVAA(vaa as VAA)).toBe(hex); + } + ); + + it("round-trips unassigned (zero-length) roles", () => { + const payload = { + module: "TokenBridge", + type: "SetPauserAddresses", + chain: 2, + pauser: "0x8f26a0025dccc6cfc07a7d38756280a10e295ad7", + freezer: "", + unpauser: "", + } as const; + const vaa: VAA = { + version: 1, + guardianSetIndex: 0, + signatures: [], + timestamp: 1, + nonce: 1, + emitterChain: 1, + emitterAddress: + "0x0000000000000000000000000000000000000000000000000000000000000004", + sequence: BigInt(1), + consistencyLevel: 0, + payload, + }; + const hex = serialiseVAA(vaa); + const reparsed = parse(Buffer.from(hex, "hex")); + expect(reparsed.payload).toMatchObject({ + type: "SetPauserAddresses", + chain: 2, + pauser: "0x8f26a0025dccc6cfc07a7d38756280a10e295ad7", + freezer: "", + unpauser: "", + }); + expect(serialiseVAA(reparsed as VAA)).toBe(hex); + }); + + it("rejects trailing bytes (degrades to Other)", () => { + const withTrailingByte = REAL_VAAS.sui.hex + "00"; + const vaa = parse(Buffer.from(withTrailingByte, "hex")); + expect(vaa.payload.type).toBe("Other"); + }); + + it("does not claim Core action 4 (TransferFees)", () => { + // Same action number under a different module must not be shadowed. + const payload = Buffer.concat([ + Buffer.from("Core".padStart(32, "\0"), "ascii"), + Buffer.from([4]), + Buffer.from([0, 2]), + Buffer.alloc(64), // amount || recipient + ]); + const envelope = Buffer.concat([ + Buffer.from("010000000000", "hex"), // version, gsIndex, 0 sigs + Buffer.from( + "000000010000000100010000000000000000000000000000000000000000000000000000000000000004000000000000000100", + "hex" + ), + payload, + ]); + const vaa = parse(envelope); + expect(vaa.payload.type).toBe("TransferFees"); + }); +}); + +describe("TokenBridgeSetPauserAddresses adversarial round-trips", () => { + const HEADER = + "010000000000" + // version, gsIndex, 0 sigs + "000000010000000100010000000000000000000000000000000000000000000000000000000000000004000000000000000100"; + + const MODULE_HEX = Buffer.from("TokenBridge") + .toString("hex") + .padStart(64, "0"); + + function vaaWithPayload(payloadHex: string): Buffer { + return Buffer.from(HEADER + payloadHex, "hex"); + } + + it("preserves a 32-zero-byte role (does NOT collapse to zero-length)", () => { + // The Solana canonical serializer emits an unassigned role as 32 zero + // bytes rather than zero-length; both are legal and must round-trip + // byte-identically (digest stability). + const zeros64 = "00".repeat(32); + const payloadHex = + MODULE_HEX + + "04" + + "0001" + + "20" + + zeros64 + + "20" + + zeros64 + + "20" + + zeros64; + const buf = vaaWithPayload(payloadHex); + const vaa = parse(buf); + expect(vaa.payload.type).toBe("SetPauserAddresses"); + const p: any = vaa.payload; + expect(p.pauser).toBe("0x" + zeros64); + expect(serialiseVAA(vaa as VAA)).toBe(buf.toString("hex")); + }); + + it("preserves an on-chain-invalid length (e.g. 5 bytes) byte-identically", () => { + const payloadHex = + MODULE_HEX + "04" + "0002" + "05" + "aabbccddee" + "00" + "00"; + const buf = vaaWithPayload(payloadHex); + const vaa = parse(buf); + expect(vaa.payload.type).toBe("SetPauserAddresses"); + expect(serialiseVAA(vaa as VAA)).toBe(buf.toString("hex")); + }); + + it("degrades to Other when a role length overruns the buffer", () => { + const payloadHex = MODULE_HEX + "04" + "0002" + "20" + "00".repeat(10); + const vaa = parse(vaaWithPayload(payloadHex)); + expect(vaa.payload.type).toBe("Other"); + }); + + it("degrades to Other when fewer than 3 roles are present", () => { + const payloadHex = MODULE_HEX + "04" + "0002" + "00" + "00"; // only 2 roles + const vaa = parse(vaaWithPayload(payloadHex)); + expect(vaa.payload.type).toBe("Other"); + }); + + it("round-trips chain 0 and 65535", () => { + for (const chainHex of ["0000", "ffff"]) { + const payloadHex = MODULE_HEX + "04" + chainHex + "00" + "00" + "00"; + const buf = vaaWithPayload(payloadHex); + const vaa = parse(buf); + expect(vaa.payload.type).toBe("SetPauserAddresses"); + expect(serialiseVAA(vaa as VAA)).toBe(buf.toString("hex")); + } + }); + + it("does not let SetPauserAddresses shadow TokenBridge RegisterChain (action 1)", () => { + // module || action=1 || chain || emitterChain || emitterAddress + const payloadHex = MODULE_HEX + "01" + "0000" + "0001" + "11".repeat(32); + const vaa = parse(vaaWithPayload(payloadHex)); + expect(vaa.payload.type).toBe("RegisterChain"); + }); + + it("NFTBridge module with action 4 is NOT claimed as SetPauserAddresses", () => { + const nftModuleHex = Buffer.from("NFTBridge") + .toString("hex") + .padStart(64, "0"); + const payloadHex = nftModuleHex + "04" + "0002" + "00" + "00" + "00"; + const vaa = parse(vaaWithPayload(payloadHex)); + expect(vaa.payload.type).toBe("Other"); + }); +}); diff --git a/clients/js/src/vaa.ts b/clients/js/src/vaa.ts index e8edfb2431..5637cc237f 100644 --- a/clients/js/src/vaa.ts +++ b/clients/js/src/vaa.ts @@ -76,7 +76,8 @@ export type Payload = | PortalContractRecoverChainId<"TokenBridge"> | PortalContractRecoverChainId<"NFTBridge"> | WormholeRelayerSetDefaultDeliveryProvider - | CoreContractTransferFees; + | CoreContractTransferFees + | TokenBridgeSetPauserAddresses; export type ContractUpgrade = | CoreContractUpgrade @@ -107,7 +108,8 @@ export function parse(buffer: Buffer): VAA { .or(portalContractRecoverChainId("TokenBridge")) .or(portalContractRecoverChainId("NFTBridge")) .or(wormholeRelayerSetDefaultDeliveryProvider()) - .or(coreContractTransferFeesParser()); + .or(coreContractTransferFeesParser()) + .or(tokenBridgeSetPauserAddressesParser()); let payload: Payload | Other | null = parser.parse(vaa.payload); if (payload === null) { payload = { @@ -268,6 +270,9 @@ function vaaBody(vaa: VAA) { case "AttestMeta": payload_str = serialiseTokenBridgeAttestMeta(payload); break; + case "SetPauserAddresses": + payload_str = serialiseTokenBridgeSetPauserAddresses(payload); + break; default: impossible(payload); break; @@ -659,6 +664,79 @@ function serialiseCoreContractTransferFees( return [...head, ...tail].join(""); } +export interface TokenBridgeSetPauserAddresses { + module: "TokenBridge"; + type: "SetPauserAddresses"; + chain: number; + pauser: string; + freezer: string; + unpauser: string; +} + +const TOKEN_BRIDGE_MODULE_HEX = Buffer.from("TokenBridge") + .toString("hex") + .padStart(64, "0"); + +function tokenBridgeSetPauserAddressesParser(): P { + const p = new P(new Parser()); + p.parse = (buffer: Buffer): TokenBridgeSetPauserAddresses | null => { + // module(32) || action(1) || chain(2) || 3 x (len(1) || address(len)). + if (buffer.length < 35) return null; + if (buffer.slice(0, 32).toString("hex") !== TOKEN_BRIDGE_MODULE_HEX) + return null; + if (buffer.readUInt8(32) !== 4) return null; + const chain = buffer.readUInt16BE(33); + let offset = 35; + const roles: string[] = []; + for (let i = 0; i < 3; i++) { + if (offset >= buffer.length) return null; + const len = buffer.readUInt8(offset); + offset += 1; + if (offset + len > buffer.length) return null; + roles.push( + len === 0 + ? "" + : "0x" + buffer.slice(offset, offset + len).toString("hex") + ); + offset += len; + } + // No trailing bytes allowed (mirrors the on-chain parsers). + if (offset !== buffer.length) return null; + return { + module: "TokenBridge", + type: "SetPauserAddresses", + chain, + pauser: roles[0], + freezer: roles[1], + unpauser: roles[2], + }; + }; + return p; +} + +function serialiseTokenBridgeSetPauserAddresses( + payload: TokenBridgeSetPauserAddresses +): string { + // The roles are variable-length (native address size, or zero-length when + // unassigned), so they are length-prefixed manually — `encode` only + // supports fixed widths. + const role = (addr: string): string => { + if (!addr || addr === "0x") { + return encode("uint8", 0); + } + const bytes = hex(addr); + return encode("uint8", bytes.length) + bytes.toString("hex"); + }; + return [ + encode("bytes32", encodeString(payload.module)), + encode("uint8", 4), + encode("uint16", payload.chain), + role(payload.pauser), + role(payload.freezer), + role(payload.unpauser), + ].join(""); +} + export interface PortalContractRecoverChainId< Module extends "NFTBridge" | "TokenBridge" | "WormholeRelayer" > {