diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index af3b506b5..5707c2ee7 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -71,7 +71,10 @@ jobs: NTT_EVM_CACHE_DIR: ${{ github.workspace }}/evm SEPOLIA_RPC_URL: ${{ secrets.SEPOLIA_RPC_URL }} # zizmor: ignore[secrets-outside-env] BASE_SEPOLIA_RPC_URL: ${{ secrets.BASE_SEPOLIA_RPC_URL }} # zizmor: ignore[secrets-outside-env] - run: bun run --cwd cli test:e2e + # `test:e2e:evm` instead of `test:e2e` — the latter also runs the + # Solana e2e suite, which needs `solana-test-validator` (not + # installed on this runner) and is exercised by `test-cli-solana-e2e`. + run: bun run --cwd cli test:e2e:evm # Build Solana contracts (v1.0.0 and v2.0.0 in parallel) # Artifacts are cached based on version tag + build script hash @@ -133,6 +136,110 @@ jobs: path: /tmp/solana-artifacts/v${{ matrix.version }} retention-days: 1 + # CLI bun-test e2e against a freshly-spun `solana-test-validator`. Builds + # the v4 .so from source (no v4 tag yet) and runs `bun test + # cli/e2e/e2e-solana.test.ts`, which drives `ntt init` / + # `ntt add-chain --instance-of` / `ntt upgrade` end-to-end and asserts on + # deployment.json + on-chain state. Mirrors solana.yml's `anchor-test` + # setup (same bun, solana, anchor, sdk steps) so we get the same .so the + # SDK tests use. + test-cli-solana-e2e: + name: CLI Solana E2E (bun) + runs-on: ubuntu-latest + env: + node-version: "24" + solana-cli-version: "1.18.26" + anchor-version: "0.29.0" + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: "1.3.4" + + - uses: ./.github/actions/setup-anchor + with: + anchor-version: ${{ env.anchor-version }} + solana-cli-version: ${{ env.solana-cli-version }} + node-version: ${{ env.node-version }} + + # Install root + workspace deps first. The CLI imports several + # `@wormhole-foundation/sdk-*-ntt` packages that resolve via bun's + # workspace symlinks under root `node_modules`; without this step the + # CLI can't even start (`Cannot find module @wormhole-foundation/ + # sdk-evm-ntt`). Mirrors the pattern in `test-cli-unit`. + - name: Install workspace deps + run: bun ci + shell: bash + + - name: Cache solana node_modules + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: ./solana/node_modules/ + key: node-modules-${{ runner.os }}-build-${{ env.node-version }} + + - name: Install solana node_modules + working-directory: ./solana + run: make node_modules + shell: bash + + - name: Create keypair + run: solana-keygen new --no-bip39-passphrase + shell: bash + + - name: Make Anchor.toml compatible with runner + working-directory: ./solana + run: sed -i 's:/user/:/runner/:' Anchor.toml + shell: bash + + - name: Install Cargo toolchain + uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1 # zizmor: ignore[archived-uses] + with: + toolchain: stable + profile: minimal + components: rustc + + - name: Cache Cargo dependencies + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: "solana" + + - name: Build Solana program (anchor build + patch IDL) + working-directory: ./solana + run: make anchor-build + shell: bash + + # `make sdk` only builds `bun run build:solana`, but the CLI also + # imports `@wormhole-foundation/sdk-evm-ntt` and `sdk-sui-ntt` + # workspace packages. Without their `dist/` populated, bun + # resolves the symlink to a package whose main/module fields + # point at non-existent files and reports `Cannot find module`. + # `bun run build` builds every workspace package. + - name: Build all SDK workspace packages + run: bun run build + shell: bash + + - name: Run CLI bun e2e + env: + # `tail -f /tmp/ntt-e2e-validator.log` from another step would be + # nice, but bun:test prints one [ntt] progress line per CLI + # invocation in this mode — enough breadcrumbs to localize a + # CI-only failure without drowning the log in SDK noise. Switch + # to NTT_E2E_VERBOSE=1 if a bug actually demands the full dump. + NTT_E2E_DEBUG: "1" + run: bun test cli/e2e/e2e-solana.test.ts + shell: bash + + - name: Upload validator log on failure + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ntt-e2e-validator-log + path: /tmp/ntt-e2e-validator.log + if-no-files-found: ignore + # Run Solana tests using pre-built artifacts test-solana: needs: build-solana diff --git a/cli/e2e/e2e-solana.test.ts b/cli/e2e/e2e-solana.test.ts new file mode 100644 index 000000000..30bf9bc1e --- /dev/null +++ b/cli/e2e/e2e-solana.test.ts @@ -0,0 +1,586 @@ +/** + * E2E test: NTT deployment on a local Solana test validator. + * + * Spawns a `solana-test-validator` with the Wormhole core bridge + post-message + * shim loaded as genesis programs, plus the local v4 NTT program at its + * `declare_id`, then drives `ntt` end-to-end via `Bun.spawn`. Asserts on + * deployment.json contents and (where it makes sense) on-chain state. + * + * The pre-loaded NTT program lets us exercise the multi-tenant + * `add-chain --instance-of ` path without needing the program-id + * keypair (which we don't have for the mainnet `nttiK1Sep…` address): the + * validator hosts the binary at its declared id, and the CLI just creates + * Instance accounts under it. + * + * Prerequisites: + * - `solana-test-validator`, `solana-keygen`, `spl-token` on PATH. + * - `solana/target/deploy/example_native_token_transfers.so` built with + * `declare_id!` matching `solana/Anchor.toml`'s + * `[programs.localnet] example_native_token_transfers` entry. Run + * `anchor build -- --features mainnet` in `solana/` first if missing. + * - macOS: `COPYFILE_DISABLE=1` in env (set automatically below) so the + * validator's genesis-archive unpacker doesn't choke on `._foo` files. + * + * Run: + * bun test cli/e2e/e2e-solana.test.ts + * + * Per-test and beforeAll timeouts are set in-file (the default 5s isn't + * enough — validator boot ≈ 10s, full add-chain ≈ 70s), so no `--timeout` + * flag is needed. + * + * Logging knobs: + * NTT_E2E_DEBUG=1 one progress line per `ntt` invocation + * (command + exit code). Useful for "where is + * the test stuck?" without dumping SDK noise. + * NTT_E2E_VERBOSE=1 full stdout+stderr of every `ntt` invocation + * (includes the swallowed-errors noise from + * `addSolanaInstance` against a local validator). + * + * On failure, the full stdout+stderr of the failing `ntt` invocation is + * dumped through the thrown error regardless of these flags. + * + * The spawned `solana-test-validator`'s output is appended to + * /tmp/ntt-e2e-validator.log unconditionally; `tail -f` it in another + * shell to watch slot advancement / RPC activity in real time. + */ + +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import * as anchor from "@coral-xyz/anchor"; +import * as spl from "@solana/spl-token"; +import { Connection, Keypair, PublicKey } from "@solana/web3.js"; +import fs from "fs"; +import path from "path"; + +// --------------------------------------------------------------------------- +// Constants & paths +// --------------------------------------------------------------------------- + +const CLI = path.resolve(import.meta.dir, "../src/index.ts"); +const SOLANA_DIR = path.resolve(import.meta.dir, "../../solana"); + +// Solana test-validator ports — pick something the rest of the test suite +// doesn't use so we can run alongside `anchor test`. +const RPC_PORT = 8910; +const FAUCET_PORT = 8911; +const RPC_URL = `http://127.0.0.1:${RPC_PORT}`; + +// Wormhole core bridge fixtures (mainnet snapshots committed under +// solana/tests). Mirrors the [[test.genesis]] / [[test.validator.account]] +// blocks in solana/Anchor.toml. +const CORE_BRIDGE = "worm2ZoG2kUd4vFXhvjh93UUH596ayRfgQ2MgjNMTth"; +const POST_MSG_SHIM = "EtZMZM22ViKMo4r5y4Anovs3wKQ2owUmDpjygnMMcdEX"; +const VERIFY_VAA_SHIM = "EFaNWErqAtVWufdNb7yofSHHfWFos843DFpu4JBw24at"; +const CORE_BRIDGE_CONFIG = "2yVjuQwpsvdsrywzsJJVs9Ueh4zayyo5DYJbBNc3DDpn"; +const CORE_BRIDGE_FEE_COLLECTOR = + "9bFNrXNb2WTx8fMHXCheaZqkLZ3YCCaiqTftHxeintHy"; +const GUARDIAN_SET_0 = "DS7qfSAgYsonPpKoAjcGhX9VFjXdGkiHjEDkTidf8H2P"; + +// --------------------------------------------------------------------------- +// Test state +// --------------------------------------------------------------------------- + +let validator: ReturnType | null = null; +let validatorDir: string; +let testDir: string; +let connection: Connection; + +let nttProgramId: string; // the declare_id from Anchor.toml +let payer: Keypair; // pre-funded payer for all CLI calls +let payerPath: string; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Read the example_native_token_transfers program id from Anchor.toml. */ +function readDeclaredProgramId(): string { + const anchorToml = fs.readFileSync( + path.join(SOLANA_DIR, "Anchor.toml"), + "utf8" + ); + const m = anchorToml.match(/example_native_token_transfers\s*=\s*"([^"]+)"/); + if (!m) { + throw new Error( + "Could not find example_native_token_transfers in solana/Anchor.toml" + ); + } + return m[1]!; +} + +/** Wait until `getVersion` succeeds, then return. */ +async function waitForRpc(timeoutMs = 30_000): Promise { + const conn = new Connection(RPC_URL, "confirmed"); + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + await conn.getVersion(); + return; + } catch { + // not ready + } + await Bun.sleep(500); + } + throw new Error( + `solana-test-validator did not become ready in ${timeoutMs}ms` + ); +} + +/** + * Drip SOL onto an account via the validator's faucet. `requestAirdrop` is + * rate-limited per call (capped well below the LAMPORTS_PER_SOL we'd want + * for a deploy), and the in-RPC `confirmTransaction` wait can race a fresh + * validator's commitment progression. Loop a few smaller airdrops with a + * generous total timeout so first-tx flakiness doesn't fail the suite. + */ +async function fundPayer( + conn: Connection, + pubkey: PublicKey, + totalSol: number +): Promise { + // Shell out to `solana airdrop` rather than `requestAirdrop` directly: + // the RPC method is silently rate-limited per-IP after one 10-SOL + // request, while the CLI drips happily. + // `--commitment confirmed` (vs the CLI default `finalized`) cuts the + // per-airdrop wait from tens of seconds to ~1 slot. solana-test-validator + // is single-node so confirmed and finalized give the same guarantees in + // practice — no second voter to disagree — but the wait is much shorter. + const proc = Bun.spawn( + [ + "solana", + "airdrop", + String(totalSol), + pubkey.toBase58(), + "--url", + RPC_URL, + "--commitment", + "confirmed", + ], + { stdout: "ignore", stderr: "pipe" } + ); + const stderr = await new Response(proc.stderr).text(); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(`solana airdrop failed (exit ${exitCode}): ${stderr}`); + } + const balance = await conn.getBalance(pubkey, "confirmed"); + if (balance < totalSol * anchor.web3.LAMPORTS_PER_SOL) { + throw new Error( + `airdrop reported success but balance is only ${ + balance / anchor.web3.LAMPORTS_PER_SOL + } / ${totalSol} SOL` + ); + } +} + +/** + * Run the local NTT CLI from a working directory, capture output. + * + * Output is silent by default — passing tests print just the jest result + * line. On failure (or when `expectExit` mismatches) we always dump the + * full stdout+stderr through the thrown error so the diagnosis is + * recoverable from the test runner's report. + * + * NTT_E2E_DEBUG=1 one-line progress per `ntt` invocation: command + + * exit code. Useful for "did the test get past + * step X?" without drowning in CLI noise. Most of + * the noise — `addSolanaInstance` swallows two + * expected-on-local-validator errors (duplicate + * `Initialize` send from confirmation race; + * wormhole core bridge `AlreadyInitialized` from + * mainnet snapshot fixtures) — is suppressed. + * NTT_E2E_VERBOSE=1 dump full stdout+stderr of every invocation, + * pass or fail. Match this when you actually want + * to see what the SDK is doing. + */ +async function ntt( + args: string[], + opts?: { cwd?: string; timeout?: number; expectExit?: number } +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const debug = !!process.env.NTT_E2E_DEBUG; + const verbose = !!process.env.NTT_E2E_VERBOSE; + if (debug || verbose) { + console.log(`[ntt] $ ntt ${args.join(" ")}`); + } + const proc = Bun.spawn(["bun", "run", CLI, ...args], { + cwd: opts?.cwd ?? testDir, + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, CI: "true" }, + }); + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const exitCode = await proc.exited; + if (verbose) { + if (stdout) console.log(`[ntt stdout]\n${stdout}`); + if (stderr) console.log(`[ntt stderr]\n${stderr}`); + } + if (debug || verbose) { + console.log(`[ntt] exit=${exitCode}`); + } + if (opts?.expectExit !== undefined && exitCode !== opts.expectExit) { + throw new Error( + `ntt ${args.join(" ")} exited ${exitCode}, expected ${opts.expectExit}\n` + + `stdout:\n${stdout}\nstderr:\n${stderr}` + ); + } + return { stdout, stderr, exitCode }; +} + +/** Read deployment.json from the current testDir. */ +function readDeployment(): any { + return JSON.parse( + fs.readFileSync(path.join(testDir, "deployment.json"), "utf8") + ); +} + +/** + * Wrapper around the `spl-token` CLI to create a Token-2022 mint. We use + * the CLI rather than `spl.createMint` because the JS client's + * confirmation strategy is fragile on a fresh test-validator (frequent + * BlockheightExceeded on multi-ix txs); the CLI retries internally. + */ +async function splCreateMint(): Promise { + const proc = Bun.spawn( + [ + "spl-token", + "create-token", + "--program-id", + spl.TOKEN_2022_PROGRAM_ID.toBase58(), + "--decimals", + "9", + "--url", + RPC_URL, + "--fee-payer", + payerPath, + "--mint-authority", + payerPath, + "--output", + "json", + ], + { stdout: "pipe", stderr: "pipe" } + ); + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(`spl-token create-token failed: ${stderr}`); + } + const parsed = JSON.parse(stdout) as { commandOutput: { address: string } }; + return new PublicKey(parsed.commandOutput.address); +} + +/** Hand mint authority over to a new pubkey via the `spl-token` CLI. */ +async function splSetMintAuthority( + mint: PublicKey, + newAuthority: PublicKey +): Promise { + const proc = Bun.spawn( + [ + "spl-token", + "authorize", + mint.toBase58(), + "mint", + newAuthority.toBase58(), + "--url", + RPC_URL, + "--fee-payer", + payerPath, + "--owner", + payerPath, + ], + { stdout: "ignore", stderr: "pipe" } + ); + const stderr = await new Response(proc.stderr).text(); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(`spl-token authorize failed: ${stderr}`); + } +} + +/** Write `overrides.json` so `ntt` talks to our local validator. */ +function writeOverrides(): void { + fs.writeFileSync( + path.join(testDir, "overrides.json"), + JSON.stringify({ chains: { Solana: { rpc: RPC_URL } } }, null, 2) + ); +} + +function writeKeypair(pathname: string, keypair: Keypair): void { + fs.writeFileSync(pathname, JSON.stringify(Array.from(keypair.secretKey))); +} + +async function deriveInstanceTokenAuthority( + instance: PublicKey +): Promise { + const taResult = await ntt( + [ + "solana", + "token-authority", + nttProgramId, + "--instance", + instance.toBase58(), + ], + { expectExit: 0 } + ); + return new PublicKey(taResult.stdout.trim().split("\n").pop()!); +} + +async function addSolanaInstanceCli( + mint: PublicKey, + instanceKeypairPath: string +): Promise { + await ntt( + [ + "add-chain", + "Solana", + "--ver", + "4.0.0", + "--mode", + "burning", + "--token", + mint.toBase58(), + "--payer", + payerPath, + "--instance-of", + nttProgramId, + "--instance-key", + instanceKeypairPath, + "--yes", + ], + { expectExit: 0, timeout: 120_000 } + ); +} + +// --------------------------------------------------------------------------- +// Suite +// --------------------------------------------------------------------------- + +describe("CLI e2e (Solana, multi-tenant)", () => { + beforeAll(async () => { + nttProgramId = readDeclaredProgramId(); + + const nttSo = path.join( + SOLANA_DIR, + "target/deploy/example_native_token_transfers.so" + ); + if (!fs.existsSync(nttSo)) { + throw new Error( + `${nttSo} not found. Run \`anchor build -- --features mainnet\` in ${SOLANA_DIR}.` + ); + } + + // 1. Spin up an isolated validator with the wormhole core bridge + shims + // loaded as genesis programs and the v4 NTT .so loaded at its + // declared id. Mirrors the [[test.genesis]] / [[test.validator.account]] + // setup in solana/Anchor.toml so `initializeOrUpdateLUT`'s wormhole + // CPI can actually find the bridge. + validatorDir = fs.mkdtempSync("/tmp/ntt-e2e-validator-"); + // Tee validator stdout/stderr to /tmp/ntt-e2e-validator.log so a hanging + // run is debuggable: `tail -f /tmp/ntt-e2e-validator.log` shows + // initialization progress, RPC traffic, slot advancement, etc. We open + // it append-mode so successive runs accumulate. + const validatorLogPath = "/tmp/ntt-e2e-validator.log"; + const validatorLogFd = fs.openSync(validatorLogPath, "a"); + fs.writeSync( + validatorLogFd, + `\n=== ${new Date().toISOString()} starting validator ===\n` + ); + if (process.env.NTT_E2E_DEBUG) { + console.log(`[validator] log -> ${validatorLogPath}`); + } + validator = Bun.spawn( + [ + "solana-test-validator", + "--reset", + "--quiet", + "--rpc-port", + String(RPC_PORT), + "--faucet-port", + String(FAUCET_PORT), + // Default 64 ticks/slot @ 160 ticks/s ≈ 400ms slots — total + // confirmation latency adds up across the deploy+initialize+LUT + // pipeline. 16 ticks/slot ≈ 100ms slots (4× faster) keeps + // blockhash validity (~150 slots) at ~15s, comfortably above any + // single-tx confirmation. Single-node validator already finalizes + // immediately, so we don't need extra slot duration for safety. + "--ticks-per-slot", + "16", + "--ledger", + path.join(validatorDir, "ledger"), + "--bpf-program", + nttProgramId, + nttSo, + "--bpf-program", + CORE_BRIDGE, + path.join(SOLANA_DIR, "tests/fixtures/mainnet_core_bridge.so"), + "--bpf-program", + POST_MSG_SHIM, + path.join( + SOLANA_DIR, + "tests/fixtures/mainnet_wormhole_post_message_shim.so" + ), + "--bpf-program", + VERIFY_VAA_SHIM, + path.join( + SOLANA_DIR, + "tests/fixtures/mainnet_wormhole_verify_vaa_shim.so" + ), + "--account", + CORE_BRIDGE_CONFIG, + path.join(SOLANA_DIR, "tests/accounts/mainnet/core_bridge_config.json"), + "--account", + CORE_BRIDGE_FEE_COLLECTOR, + path.join( + SOLANA_DIR, + "tests/accounts/mainnet/core_bridge_fee_collector.json" + ), + "--account", + GUARDIAN_SET_0, + path.join(SOLANA_DIR, "tests/accounts/mainnet/guardian_set_0.json"), + ], + { + env: { ...process.env, COPYFILE_DISABLE: "1" }, + stdout: validatorLogFd, + stderr: validatorLogFd, + } + ); + await waitForRpc(); + connection = new Connection(RPC_URL, "confirmed"); + + // 2. Pre-fund a payer keypair we'll thread through every `ntt` call. + // Solana program rent for a ~700KB BPF program is ~5 SOL; 25 SOL is + // well above what any single test needs. + payer = Keypair.generate(); + await fundPayer(connection, payer.publicKey, 25); + + // 3. Pre-fund the SDK's hardcoded "version probe" sender pubkeys. The + // SDK's `SolanaNtt.getVersion` invokes the on-chain `version` + // instruction via `simulateTransaction` (no signing) but the + // simulation still requires the fee-payer account to exist with + // enough lamports to cover the (simulated) fee. On mainnet/devnet + // these are real funded accounts; on a fresh local validator they + // don't exist yet, the simulate fails, and the SDK silently falls + // back to "3.0.0" — which then trips our v4-instance / v3-no- + // instance constraint in the SolanaNtt constructor. + for (const probe of [ + "Hk3SdYTJFpawrvRz4qRztuEt2SqoCG7BGj2yJfDJSFbJ", // mainnet/devnet + "98evdAiWr7ey9MAQzoQQMwFQkTsSR6KkWQuFqKrgwNwb", // localhost + "6sbzC1eH4FTujJXWj51eQe25cYvr4xfXbJ1vAj7j2k5J", // CI devnet + ]) { + await fundPayer(connection, new PublicKey(probe), 1); + } + + // 4. Make a clean working dir per suite run; CLI commands will write + // deployment.json + overrides.json here. + testDir = fs.mkdtempSync("/tmp/ntt-e2e-solana-"); + payerPath = path.join(testDir, "payer.json"); + fs.writeFileSync(payerPath, JSON.stringify(Array.from(payer.secretKey))); + }, 120_000); // validator boot + airdrops + version-probe funding + + afterAll(() => { + validator?.kill(); + if (testDir && fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + if (validatorDir && fs.existsSync(validatorDir)) { + fs.rmSync(validatorDir, { recursive: true, force: true }); + } + }); + + test("`ntt init Mainnet` creates deployment.json", async () => { + await ntt(["init", "Mainnet"], { expectExit: 0 }); + const dep = readDeployment(); + expect(dep.network).toBe("Mainnet"); + expect(dep.chains).toEqual({}); + }); + + // Per-test timeout: deploy + initialize + LUT + register + broadcast + // round-trips against the local validator land in ~70s on a warm machine. + // Hardcode 180s here so callers don't need `bun test --timeout` magic. + test("`add-chain --instance-of` creates a multi-tenant instance and persists `instance` in deployment.json", async () => { + writeOverrides(); + + // Generate the per-instance keypair up front so we can derive the + // matching `token_authority` PDA before creating the mint. + const instanceKeypair = Keypair.generate(); + const instanceKeypairPath = path.join(testDir, "instance-a.json"); + writeKeypair(instanceKeypairPath, instanceKeypair); + + // Compute the per-instance token_authority PDA via the CLI itself + // (proving the new `--instance` flag works) and capture the address. + const tokenAuthority = await deriveInstanceTokenAuthority( + instanceKeypair.publicKey + ); + + // Create the mint with the per-instance token_authority as mint + // authority from the start. v4 initialize's mint-authority constraint + // expects exactly this. We shell out to `spl-token create-token` + // instead of `spl.createMint`: the JS client's confirmation strategy + // races the local validator (fresh validator + multi-instruction tx + // → frequent BlockheightExceeded), while the CLI tool retries + // internally and is reliable here. Mirrors `cli/test/solana.sh`. + const mint = await splCreateMint(); + await splSetMintAuthority(mint, tokenAuthority); + + // Run add-chain. `--instance-of` skips the deploy step (the program is + // already loaded by the validator) and just creates the Instance. + await addSolanaInstanceCli(mint, instanceKeypairPath); + + const dep = readDeployment(); + expect(dep.chains.Solana).toBeDefined(); + expect(dep.chains.Solana.manager).toBe(nttProgramId); + expect(dep.chains.Solana.instance).toBe( + instanceKeypair.publicKey.toBase58() + ); + expect(dep.chains.Solana.version.startsWith("4.")).toBe(true); + expect(dep.chains.Solana.mode).toBe("burning"); + expect(dep.chains.Solana.token).toBe(mint.toBase58()); + }, 180_000); + + test("`ntt upgrade Solana --ver 4.0.0` from v3 is blocked by canUpgrade()", async () => { + // canUpgrade() reads `currentVersion` straight off chainConfig.version + // from deployment.json (before any chain interaction), so we can stage + // a synthetic v3 deployment in an isolated dir and assert the block + // fires without needing a real v3 program on chain. + const upgradeTestDir = fs.mkdtempSync("/tmp/ntt-e2e-upgrade-"); + try { + // Plausible v3 deployment.json shape. Manager value is arbitrary — + // canUpgrade() doesn't dereference it. + const dep = { + network: "Mainnet", + chains: { + Solana: { + version: "2.0.0", + mode: "burning", + paused: false, + owner: payer.publicKey.toBase58(), + manager: "ntTSft4TuqNLFPehBZKokku3kAVTDAFQxEyot5jQi3S", + token: "So11111111111111111111111111111111111111112", + transceivers: { + threshold: 1, + wormhole: { + address: "9SLh85VZ47ihpVUfsmsX9oN4hLcyajzh4Hq8X9MwzBoz", + }, + }, + limits: { outbound: "0.000000000", inbound: {} }, + }, + }, + }; + fs.writeFileSync( + path.join(upgradeTestDir, "deployment.json"), + JSON.stringify(dep, null, 2) + ); + + const { stderr, exitCode } = await ntt( + ["upgrade", "Solana", "--ver", "4.0.0", "--payer", payerPath, "--yes"], + { cwd: upgradeTestDir } + ); + expect(exitCode).not.toBe(0); + expect(stderr).toMatch( + /cannot upgrade Solana from .* to 4\.0\.0 in place/ + ); + } finally { + fs.rmSync(upgradeTestDir, { recursive: true, force: true }); + } + }); +}); diff --git a/cli/package.json b/cli/package.json index cdaae2e43..35e0ce6fd 100644 --- a/cli/package.json +++ b/cli/package.json @@ -7,7 +7,9 @@ "typecheck": "tsc --noEmit", "prettier": "prettier --write ./src", "test": "bun test src/ __tests__/", - "test:e2e": "bun test e2e/ --timeout 600000" + "test:e2e:evm": "bun test e2e/e2e-anvil.test.ts --timeout 600000", + "test:e2e:solana": "bun test e2e/e2e-solana.test.ts", + "test:e2e": "bun run test:e2e:evm && bun run test:e2e:solana" }, "devDependencies": { "@types/bun": "latest", diff --git a/cli/src/__tests__/cli-help.test.ts b/cli/src/__tests__/cli-help.test.ts index 495c40cdf..ea88fa56e 100644 --- a/cli/src/__tests__/cli-help.test.ts +++ b/cli/src/__tests__/cli-help.test.ts @@ -52,6 +52,7 @@ const SOLANA_SUBCOMMANDS = [ "ata", "create-spl-multisig", "build", + "deploy-program", ]; const CONFIG_SUBCOMMANDS = ["set-chain", "unset-chain", "get-chain"]; @@ -150,6 +151,8 @@ describe("Command-specific options", () => { expect(stdout).toContain("--token"); expect(stdout).toContain("--latest"); expect(stdout).toContain("--skip-verify"); + expect(stdout).toContain("--deploy-program"); + expect(stdout).toContain("--instance-of"); }); it("push shows --yes option", async () => { diff --git a/cli/src/__tests__/upgradeBarriers.test.ts b/cli/src/__tests__/upgradeBarriers.test.ts new file mode 100644 index 000000000..0f5173360 --- /dev/null +++ b/cli/src/__tests__/upgradeBarriers.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "bun:test"; +import { canUpgrade } from "../upgradeBarriers"; + +describe("canUpgrade", () => { + it("blocks a Solana v3 -> v4 upgrade across the barrier", () => { + const check = canUpgrade("Solana", "3.0.0", "4.0.0"); + expect(check.ok).toBe(false); + if (!check.ok) expect(check.reason).toContain("v4"); + }); + + it("blocks a Solana v4 -> v3 downgrade across the barrier", () => { + // Regression: the barrier must hold in both directions. A downgrade would + // run v3 bytecode against the v4 account layout and strand the deployment. + const check = canUpgrade("Solana", "4.0.0", "3.0.0"); + expect(check.ok).toBe(false); + }); + + it("permits a Solana upgrade that stays below the barrier", () => { + expect(canUpgrade("Solana", "2.0.0", "3.0.0").ok).toBe(true); + }); + + it("permits a Solana upgrade that stays at or above the barrier", () => { + expect(canUpgrade("Solana", "4.0.0", "4.1.0").ok).toBe(true); + }); + + it("permits a local (null target) version change", () => { + expect(canUpgrade("Solana", "3.0.0", null).ok).toBe(true); + }); + + it("does not block a chain that has no registered barrier", () => { + // Only Solana has a v4 barrier registered; EVM crossing majors is allowed. + expect(canUpgrade("Ethereum", "3.0.0", "4.0.0").ok).toBe(true); + }); +}); diff --git a/cli/src/commands/add-chain.ts b/cli/src/commands/add-chain.ts index 18939a547..baa97e4d6 100644 --- a/cli/src/commands/add-chain.ts +++ b/cli/src/commands/add-chain.ts @@ -72,6 +72,22 @@ export function createAddChainCommand( type: "number" as const, default: 50000, }) + .option("instance-of", { + describe: + "(SVM v4 only) Skip program deploy and create a new instance under the existing program at this address. Mutually exclusive with --deploy-program/--binary/--program-key.", + type: "string" as const, + }) + .option("deploy-program", { + describe: + "(SVM) Deploy a fresh program AND create the first instance under it. One of --deploy-program or --instance-of is required for Solana. For v4+ the recommended flow is to first run `ntt solana deploy-program` and then `add-chain --instance-of `.", + type: "boolean" as const, + default: false, + }) + .option("instance-key", { + describe: + "(SVM v4 only) Path to the keypair file to use as the Instance account. Defaults to a freshly-generated keypair (written to `-instance.json` in the deployment dir).", + type: "string" as const, + }) .option("sui-gas-budget", { describe: "Gas budget for Sui deployment", type: "number" as const, @@ -290,6 +306,106 @@ export function createAddChainCommand( ); const ch = wh.getChain(chain); + // v4 multi-host path: --instance-of creates a fresh Instance + // under the existing program rather than deploying a new one. + const instanceOf = argv["instance-of"] as string | undefined; + const deployProgramFlag = Boolean(argv["deploy-program"]); + + if (platform === "Solana") { + if (!instanceOf && !deployProgramFlag) { + console.error( + colors.red( + "Solana add-chain now requires one of:\n" + + " --deploy-program deploy a fresh program AND create the first instance under it.\n" + + " --instance-of create an instance under an existing program (v4+).\n" + + "\n" + + "For v4+ the recommended flow is two steps:\n" + + " 1. ntt solana deploy-program --payer [--ver | --latest]\n" + + " 2. ntt add-chain --instance-of --token --mode --payer " + ) + ); + process.exit(1); + } + if (instanceOf && deployProgramFlag) { + console.error( + colors.red( + "--instance-of and --deploy-program are mutually exclusive" + ) + ); + process.exit(1); + } + } else if (deployProgramFlag) { + console.error( + colors.red("--deploy-program is only supported on Solana") + ); + process.exit(1); + } + + if (instanceOf !== undefined) { + if (platform !== "Solana") { + console.error( + colors.red("--instance-of is only supported on Solana (v4)") + ); + process.exit(1); + } + if (argv["binary"] || argv["program-key"]) { + console.error( + colors.red( + "--instance-of is mutually exclusive with --binary / --program-key" + ) + ); + process.exit(1); + } + const resolvedVersion = version ?? "4.0.0"; + const resolvedMajor = parseInt( + resolvedVersion.split(".")[0] ?? "0", + 10 + ); + if (resolvedMajor < 4) { + console.error( + colors.red( + `--instance-of requires Solana NTT v4 or later (got ${resolvedVersion})` + ) + ); + process.exit(1); + } + if (!payerPath) { + console.error(colors.red("--payer is required for Solana")); + process.exit(1); + } + + const { addSolanaInstance } = await import("../solana/deploy"); + const { manager: deployedManager, instance } = await addSolanaInstance( + resolvedVersion, + mode, + ch as any, + token, + payerPath, + instanceOf, + argv["instance-key"] + ); + + const [config, _ctx, _ntt, decimals] = await pullChainConfig( + network, + deployedManager, + overrides, + instance.toBase58() + ); + + console.log("token decimals:", colors.yellow(decimals)); + deployments.chains[chain] = config; + await configureInboundLimitsForNewChain( + deployments, + chain, + Boolean(argv["yes"]) + ); + fs.writeFileSync(path, JSON.stringify(deployments, null, 2)); + console.log( + `Added ${chain} to ${path} (instance ${instance.toBase58()} under program ${instanceOf})` + ); + return; + } + // TODO: make manager configurable const deployedManager = await deploy( version, @@ -310,13 +426,26 @@ export function createAddChainCommand( argv["sui-treasury-cap"], argv["gas-estimate-multiplier"], cclConfig, - overrides + overrides, + argv["instance-key"] ); + // Multi-tenant Solana deploys return the per-instance Config pubkey + // alongside the manager program ID. Thread it into pullChainConfig so + // the SDK constructs against the multi-tenant layout (it throws + // otherwise) and persist it on the resulting config. + const solanaInstance = + platform === "Solana" && + "instance" in deployedManager && + typeof deployedManager.instance === "string" + ? deployedManager.instance + : undefined; + const [config, _ctx, _ntt, decimals] = await pullChainConfig( network, deployedManager, overrides, + solanaInstance, { waitForTransceiver: true } ); diff --git a/cli/src/commands/manual.ts b/cli/src/commands/manual.ts index 7831b4c8c..6142477de 100644 --- a/cli/src/commands/manual.ts +++ b/cli/src/commands/manual.ts @@ -111,7 +111,11 @@ export function createManualCommand( const [config, ctx, ntt] = await pullChainConfig( network, sourceManager, - overrides + overrides, + // v4 (multi-tenant) Solana: the SDK needs the per-instance + // Config pubkey to derive instance-scoped state, and throws + // without it. Undefined/no-op for v3 and non-Solana. + sourceConfig.instance ); console.log( @@ -330,7 +334,10 @@ export function createManualCommand( const [, ctx, ntt] = await pullChainConfig( network, sourceManager, - overrides + overrides, + // v4 (multi-tenant) Solana: instance-scoped state derivation + // requires the Config pubkey. Undefined/no-op otherwise. + sourceConfig.instance ); console.log( @@ -514,7 +521,11 @@ export function createManualCommand( const [, ctx, ntt] = await pullChainConfig( network, managerAddress, - overrides + overrides, + // v4 (multi-tenant) Solana: emergency redeem must build + // instance-scoped state, which needs the Config pubkey. + // Undefined/no-op for v3 and non-Solana. + chainConfig.instance ); console.log( diff --git a/cli/src/commands/set-mint-authority.ts b/cli/src/commands/set-mint-authority.ts index e1ce1da3f..a8e08b40d 100644 --- a/cli/src/commands/set-mint-authority.ts +++ b/cli/src/commands/set-mint-authority.ts @@ -55,6 +55,15 @@ export function createSetMintAuthorityCommand( describe: "Token address", type: "string", }) + .option("instance", { + describe: + "(Multi-tenant / v4) The Instance pubkey under the program. " + + "v4 token_authority PDAs are scoped by instance, so this " + + "flag is required when the undeployed program is " + + "multi-tenant. For a deployed program the instance is read " + + "from the deployment file.", + type: "string", + }) .option("path", options.deploymentPath) .option("yes", options.yes) .option("payer", { ...options.payer, demandOption: true }) @@ -108,10 +117,22 @@ export function createSetMintAuthorityCommand( process.exit(1); } + const instance = argv["instance"] + ? new PublicKey(argv["instance"]) + : undefined; + if (instance && !manager) { + console.error( + "--instance is only valid together with --token and --manager; " + + "for a deployed program the instance is read from the deployment file" + ); + process.exit(1); + } + let solanaNtt: SolanaNtt | undefined; let tokenMint: PublicKey; let managerKey: PublicKey; let major: number; + let tokenAuthority: PublicKey; // program deployed so fetch token and manager addresses from deployment if (!token && !manager) { @@ -125,12 +146,17 @@ export function createSetMintAuthorityCommand( const [, , ntt] = await pullChainConfig( network, { chain, address: toUniversal(chain, chainConfig.manager) }, - overrides + overrides, + chainConfig.instance ); solanaNtt = ntt as SolanaNtt; tokenMint = (await solanaNtt.getConfig()).mint; managerKey = new PublicKey(chainConfig.manager); major = Number(solanaNtt.version.split(".")[0]); + // `solanaNtt.pdas` is scoped by `chainConfig.instance` for v4 + // (multi-tenant) managers, so this derives the instance-scoped token + // authority; for v3 it's the legacy derivation. + tokenAuthority = solanaNtt.pdas.tokenAuthority(); } // default values as undeployed program else { @@ -138,6 +164,7 @@ export function createSetMintAuthorityCommand( tokenMint = new PublicKey(token!); managerKey = new PublicKey(manager!); major = -1; + tokenAuthority = NTT.pdas(managerKey, instance).tokenAuthority(); } const wh = new Wormhole( @@ -148,8 +175,6 @@ export function createSetMintAuthorityCommand( const ch = wh.getChain(chain); const connection: Connection = await ch.getRpc(); - const tokenAuthority = NTT.pdas(managerKey).tokenAuthority(); - // verify current mint authority is not token authority const mintInfo = await connection.getAccountInfo(tokenMint); if (!mintInfo) { @@ -291,7 +316,8 @@ export function createSetMintAuthorityCommand( { currentAuthority: payerKeypair.publicKey, multisigTokenAuthority, - } + }, + solanaNtt.pdas ), ], recentBlockhash: latestBlockHash.blockhash, diff --git a/cli/src/commands/shared.ts b/cli/src/commands/shared.ts index adf7e3de5..bfd1b9d5e 100644 --- a/cli/src/commands/shared.ts +++ b/cli/src/commands/shared.ts @@ -134,6 +134,13 @@ export const CCL_CONTRACT_ADDRESSES: Partial< // These are local-only configurations that don't have on-chain representations export const EXCLUDED_DIFF_PATHS = ["managerVariant"]; +// Extended ChainAddress type for Solana v4 deployments that include the +// per-instance Config pubkey alongside the manager program ID. +export type SolanaDeploymentResult = ChainAddress & { + /** v4-only: the keypair-created Instance pubkey under the shared program. */ + instance?: string; +}; + // Extended ChainAddress type for Sui deployments that includes additional metadata export type SuiDeploymentResult = ChainAddress & { adminCaps?: { diff --git a/cli/src/commands/solana.ts b/cli/src/commands/solana.ts index dcb8299b9..41dabf1e2 100644 --- a/cli/src/commands/solana.ts +++ b/cli/src/commands/solana.ts @@ -37,6 +37,7 @@ import { askForConfirmation, buildSvm, createWorkTree, + uploadSolanaProgram, } from "../index"; export function createSolanaCommand( @@ -69,14 +70,29 @@ export function createSolanaCommand( "token-authority ", "print the token authority address for a given program ID", (yargs: any) => - yargs.positional("programId", { - describe: "Program ID", - type: "string", - demandOption: true, - }), + yargs + .positional("programId", { + describe: "Program ID", + type: "string", + demandOption: true, + }) + .option("instance", { + describe: + "(Multi-tenant / v4) The Instance pubkey under the program. " + + "v4 token_authority PDAs are scoped by instance, so this " + + "flag is required when the program is multi-tenant.", + type: "string", + demandOption: false, + }), (argv: any) => { const programId = new PublicKey(argv["programId"]); - const tokenAuthority = NTT.pdas(programId).tokenAuthority(); + const instance = argv["instance"] + ? new PublicKey(argv["instance"]) + : undefined; + const tokenAuthority = NTT.pdas( + programId, + instance + ).tokenAuthority(); console.log(tokenAuthority.toBase58()); } ) @@ -136,6 +152,15 @@ export function createSolanaCommand( describe: "Token address", type: "string", }) + .option("instance", { + describe: + "(Multi-tenant / v4) The Instance pubkey under the program. " + + "v4 token_authority PDAs are scoped by instance, so this " + + "flag is required when the undeployed program is " + + "multi-tenant. For a deployed program the instance is read " + + "from the deployment file.", + type: "string", + }) .option("path", options.deploymentPath) .option("yes", options.yes) .option("payer", { ...options.payer, demandOption: true }) @@ -176,6 +201,17 @@ export function createSolanaCommand( process.exit(1); } + const instance = argv["instance"] + ? new PublicKey(argv["instance"]) + : undefined; + if (instance && !manager) { + console.error( + "--instance is only valid together with --token and --manager; " + + "for a deployed program the instance is read from the deployment file" + ); + process.exit(1); + } + const wh = new Wormhole( network, [solana.Platform, evm.Platform], @@ -185,7 +221,7 @@ export function createSolanaCommand( const connection: Connection = await ch.getRpc(); let solanaNtt: SolanaNtt | undefined; - let managerKey: PublicKey; + let tokenAuthority: PublicKey; let major: number; let tokenProgram: PublicKey; @@ -201,12 +237,16 @@ export function createSolanaCommand( const [, , ntt] = await pullChainConfig( network, { chain, address: toUniversal(chain, chainConfig.manager) }, - overrides + overrides, + chainConfig.instance ); solanaNtt = ntt as SolanaNtt; - managerKey = new PublicKey(chainConfig.manager); major = Number(solanaNtt.version.split(".")[0]); tokenProgram = (await solanaNtt.getConfig()).tokenProgram; + // `solanaNtt.pdas` is scoped by `chainConfig.instance` for v4 + // (multi-tenant) managers, so this derives the instance-scoped + // token authority; for v3 it's the legacy derivation. + tokenAuthority = solanaNtt.pdas.tokenAuthority(); } // default values as undeployed program else { @@ -222,13 +262,14 @@ export function createSolanaCommand( spl.unpackMint(tokenMint, mintInfo, mintInfo.owner); solanaNtt = undefined; - managerKey = new PublicKey(manager!); major = -1; tokenProgram = mintInfo.owner; + tokenAuthority = NTT.pdas( + new PublicKey(manager!), + instance + ).tokenAuthority(); } - const tokenAuthority = NTT.pdas(managerKey).tokenAuthority(); - // check if SPL-Multisig is supported for manager version // undeployed -- assume version compatible via warning if (major === -1) { @@ -384,6 +425,114 @@ export function createSolanaCommand( console.log(`Keypair: ${buildResult.programKeypairPath}`); } ) + .command( + "deploy-program ", + "build (or reuse) and upload the SVM program binary, without initializing an instance", + (yargs: any) => + yargs + .positional("chain", options.chain) + .option("network", options.network) + .option("payer", { ...options.payer, demandOption: true }) + .option("program-key", { + describe: "Path to program key json", + type: "string", + }) + .option("binary", { + describe: + "Path to pre-built program binary (.so file); skips build if provided", + type: "string", + }) + .option("solana-priority-fee", { + describe: "Priority fee for SVM deployment (in microlamports)", + type: "number", + default: 50000, + }) + .option("ver", options.version) + .option("latest", options.latest) + .option("local", options.local) + .example( + "$0 svm deploy-program Solana --network Mainnet --latest --payer ./payer.json", + "Build and deploy the latest program; no instance is created" + ) + .example( + "$0 svm deploy-program Solana --network Mainnet --ver 4.0.0 --payer ./payer.json --program-key ./prog-keypair.json", + "Deploy a specific version with a chosen program keypair (run `ntt add-chain Solana --instance-of ` next)" + ), + async (argv: any) => { + const chain: Chain = argv["chain"]; + const network = argv["network"] as Network; + + const platform = chainToPlatform(chain); + if (platform !== "Solana") { + console.error( + `deploy-program is only supported for Solana chains. Got platform: ${platform}` + ); + process.exit(1); + } + + validateChain(network, chain); + + const payerPath = validatePayerOption( + argv["payer"], + chain, + (m) => new Error(m), + (m) => console.warn(colors.yellow(m)) + ); + if (!payerPath) { + console.error("Payer not found. Specify with --payer"); + process.exit(1); + } + + const version = resolveVersion( + argv["latest"], + argv["ver"], + argv["local"], + platform + ); + + const worktree = version ? createWorkTree(platform, version) : "."; + + const wh = new Wormhole( + network, + [solana.Platform, evm.Platform, sui.Platform], + overrides + ); + const ch = wh.getChain(chain); + const wormhole = ch.config.contracts.coreBridge; + if (!wormhole) { + console.error("Core bridge not found"); + process.exit(1); + } + + console.log(`Building SVM program for ${chain} on ${network}...`); + const { binary, programId, programKeypairPath } = await buildSvm( + worktree, + network, + chain, + wormhole, + version, + argv["program-key"], + argv["binary"], + overrides + ); + + console.log( + `Deploying program ${programId} to ${chain} ${network}...` + ); + await uploadSolanaProgram({ + binary, + programKeypairPath, + payerPath, + rpc: ch.config.rpc, + priorityFee: argv["solana-priority-fee"], + }); + + console.log(`Deployed program: ${colors.green(programId)}`); + console.log( + `Next: \`ntt add-chain ${chain} --instance-of ${programId} --token --mode --payer ${payerPath}\`` + ); + } + ) .demandCommand(); }, handler: (_argv: any) => {}, diff --git a/cli/src/commands/transfer-ownership.ts b/cli/src/commands/transfer-ownership.ts index 6db2b6c3c..dffe760c1 100644 --- a/cli/src/commands/transfer-ownership.ts +++ b/cli/src/commands/transfer-ownership.ts @@ -83,7 +83,8 @@ export function createTransferOwnershipCommand( const [, , ntt] = await pullChainConfig( network, { chain, address: toUniversal(chain, chainConfig.manager) }, - overrides + overrides, + chainConfig.instance ); // Get current owner diff --git a/cli/src/commands/upgrade.ts b/cli/src/commands/upgrade.ts index 60a954ff2..5010d1753 100644 --- a/cli/src/commands/upgrade.ts +++ b/cli/src/commands/upgrade.ts @@ -15,6 +15,7 @@ import sui from "@wormhole-foundation/sdk/platforms/sui"; import { colors } from "../colors.js"; import { loadConfig, type Config } from "../deployments"; import type { SignerType } from "../signers/getSigner"; +import { canUpgrade } from "../upgradeBarriers"; import { validatePayerOption } from "../validation"; import { options } from "./shared"; @@ -119,6 +120,17 @@ export function createUpgradeCommand( process.exit(0); } + const upgradeCheck = canUpgrade(chain, currentVersion, toVersion); + if (!upgradeCheck.ok) { + console.error( + colors.red( + `error: cannot upgrade ${chain} from ${currentVersion} to ${toVersion} in place.` + ) + ); + console.error(upgradeCheck.reason); + process.exit(1); + } + console.log( `Upgrading ${chain} from version ${currentVersion} to ${ toVersion || "local version" @@ -139,7 +151,8 @@ export function createUpgradeCommand( const [_, ctx, ntt] = await pullChainConfig( network, { chain, address: toUniversal(chain, chainConfig.manager) }, - overrides + overrides, + chainConfig.instance ); // Determine manager variant: use flag if provided, otherwise use config value, default to "standard" @@ -163,7 +176,11 @@ export function createUpgradeCommand( // reinit the ntt object to get the new version // TODO: is there an easier way to do this? - const { ntt: upgraded } = await nttFromManager(ch, chainConfig.manager); + const { ntt: upgraded } = await nttFromManager( + ch, + chainConfig.manager, + chainConfig.instance + ); chainConfig.version = getVersion(chain, upgraded); fs.writeFileSync(path, JSON.stringify(deployments, null, 2)); diff --git a/cli/src/config-mgmt.ts b/cli/src/config-mgmt.ts index 189474d8a..b81f00dbe 100644 --- a/cli/src/config-mgmt.ts +++ b/cli/src/config-mgmt.ts @@ -40,6 +40,7 @@ import { } from "./query"; import { askForConfirmation } from "./prompts.js"; import { upgrade } from "./deploy"; +import { canUpgrade } from "./upgradeBarriers"; import { runTaskPoolWithSequential } from "./utils/concurrency"; export async function pushDeployment( @@ -81,8 +82,20 @@ export async function pushDeployment( let managerUpgrade: { from: string; to: string } | undefined; for (const k of Object.keys(diff)) { if (k === "version") { - // TODO: check against existing version, and make sure no major version changes - managerUpgrade = { from: diff[k]!.pull!, to: diff[k]!.push! }; + const from = diff[k]!.pull!; + const to = diff[k]!.push!; + // Enforce the same breaking-change barrier as the dedicated `upgrade` + // command: a config push must not drive a version change across an + // incompatible on-chain layout (e.g. Solana v3<->v4 in place, which + // would strand custody). See canUpgrade / UPGRADE_BARRIERS. + const check = canUpgrade(deployment.manager.chain, from, to); + if (!check.ok) { + console.error( + `Refusing to change ${deployment.manager.chain} version from ${from} to ${to} via push:\n${check.reason}` + ); + process.exit(1); + } + managerUpgrade = { from, to }; } else if (k === "owner") { const address: AccountAddress = toUniversal( deployment.manager.chain, @@ -101,12 +114,17 @@ export async function pushDeployment( const ix = dangerouslyTransferOwnershipInOneStep ? await NTT.createTransferOwnershipOneStepUncheckedInstruction( solanaNtt.program, - { owner, newOwner } + { owner, newOwner }, + solanaNtt.pdas ) - : await NTT.createTransferOwnershipInstruction(solanaNtt.program, { - owner, - newOwner, - }); + : await NTT.createTransferOwnershipInstruction( + solanaNtt.program, + { + owner, + newOwner, + }, + solanaNtt.pdas + ); const tx = new solanaWeb3.Transaction(); tx.add(ix); @@ -272,7 +290,8 @@ export async function pullDeployments( const [remote, ctx, ntt, decimals] = await pullChainConfig( network, { chain, address: toUniversal(chain, managerAddress) }, - overrides + overrides, + deployment.instance ); const local = deployments.chains[chain]; @@ -356,6 +375,10 @@ export async function pullChainConfig( network: N, manager: ChainAddress, overrides?: WormholeConfigOverrides, + // Solana-only: per-instance Config pubkey for multi-tenant deployments + // (>= v4). Required to construct the SDK against a multi-tenant manager. + // Pass `undefined` for legacy single-tenant managers and non-Solana chains. + solanaInstance?: string, opts?: { waitForTransceiver?: boolean } ): Promise< [ChainConfig, ChainContext, Ntt, number] @@ -373,7 +396,7 @@ export async function pullChainConfig( ntt, addresses, }: { ntt: Ntt; addresses: Partial } = - await nttFromManager(ch, nativeManagerAddress, opts); + await nttFromManager(ch, nativeManagerAddress, solanaInstance, opts); const mode = await ntt.getMode(); const outboundLimit = await ntt.getOutboundLimit(); @@ -399,6 +422,7 @@ export async function pullChainConfig( paused, owner: owner.toString(), manager: nativeManagerAddress, + ...(solanaInstance && { instance: solanaInstance }), token: addresses.token!, transceivers: { threshold, diff --git a/cli/src/deploy.ts b/cli/src/deploy.ts index 1f6d39461..8e735786a 100644 --- a/cli/src/deploy.ts +++ b/cli/src/deploy.ts @@ -16,7 +16,11 @@ import type { SuiChains } from "@wormhole-foundation/sdk-sui"; import type { SuiNtt } from "@wormhole-foundation/sdk-sui-ntt"; import type { SignerType } from "./signers/getSigner"; -import type { CclConfig, SuiDeploymentResult } from "./commands/shared"; +import type { + CclConfig, + SolanaDeploymentResult, + SuiDeploymentResult, +} from "./commands/shared"; import { createWorkTree, warnLocalDeployment } from "./tag"; import { deployEvm, upgradeEvm } from "./evm/deploy"; import { deploySvm, upgradeSolana } from "./solana/deploy"; @@ -100,8 +104,11 @@ export async function deploy( suiTreasuryCap?: string, gasEstimateMultiplier?: number, cclConfig?: CclConfig | null, - overrides?: WormholeConfigOverrides -): Promise | SuiDeploymentResult> { + overrides?: WormholeConfigOverrides, + solanaInstanceKeyPath?: string +): Promise< + ChainAddress | SolanaDeploymentResult | SuiDeploymentResult +> { if (version === null) { await warnLocalDeployment(yes); } @@ -126,7 +133,7 @@ export async function deploy( process.exit(1); } const solanaCtx = ch as ChainContext; - return (await deploySvm( + const result = await deploySvm( worktree, version, mode, @@ -137,8 +144,16 @@ export async function deploy( solanaProgramKeyPath, solanaBinaryPath, solanaPriorityFee, - overrides - )) as ChainAddress; + overrides, + solanaInstanceKeyPath + ); + // Lift to the unified deploy() return shape: a `ChainAddress` plus, + // for v4 Solana, the new instance pubkey. + return { + chain: result.chain, + address: result.address, + ...(result.instance && { instance: result.instance.toBase58() }), + } as ChainAddress | SolanaDeploymentResult; } case "Sui": { const suiCtx = ch as ChainContext; // TODO: Use proper SuiChains type diff --git a/cli/src/deployments.ts b/cli/src/deployments.ts index b62d4157f..d2f0ebe01 100644 --- a/cli/src/deployments.ts +++ b/cli/src/deployments.ts @@ -9,6 +9,13 @@ export type ChainConfig = { owner: string; pauser?: string; manager: string; + /** + * v4-only (Solana): the keypair-created Instance pubkey under the shared + * `manager` program. Required for `version >= "4.0.0"`; absent for v3 + * singleton deployments. PDA derivation, the on-the-wire NTT manager + * identity, and SDK construction all key off this pubkey. + */ + instance?: string; token: string; transceivers: { threshold: number; diff --git a/cli/src/index.ts b/cli/src/index.ts index bf6b878d1..33493549e 100755 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -68,7 +68,14 @@ yargs(hideBin(process.argv)) .help() .strict() .demandCommand() - .parse(); + // Use `parseAsync` so we can `await` async command handlers and then exit + // explicitly. Without this, the Solana SDK's `Connection` leaves a websocket + // subscription open after a successful command (`add-chain`, `upgrade`, …) + // and the process keeps the event loop alive until something times out. + // `parseAsync()` resolves once handlers finish; on error yargs still + // prints + exits 1 via its default fail handler before this resolves. + .parseAsync() + .then(() => process.exit(0)); // ── Re-exports for backward compatibility ──────────────────────────── // Commands import from "../index" — keep these re-exports so nothing breaks. @@ -111,7 +118,7 @@ export { parseCclFlag, confirmCustomFinality } from "./commands/shared"; export { deploy, upgrade } from "./deploy"; // Re-exports from solana/deploy.ts -export { buildSvm } from "./solana/deploy"; +export { buildSvm, uploadSolanaProgram } from "./solana/deploy"; // Re-exports from config-mgmt.ts export { diff --git a/cli/src/query.ts b/cli/src/query.ts index de420e526..bc330cc45 100644 --- a/cli/src/query.ts +++ b/cli/src/query.ts @@ -89,8 +89,12 @@ export async function getPdas( } const solanaNtt = ntt as SolanaNtt; const config = solanaNtt.pdas.configAccount(); + // Scope by the v4 instance pubkey so this derives the instance's emitter + // (`[b"emitter", config.key()]`); `instance` is undefined for v3, which + // falls back to the legacy unscoped derivation. const emitter = NTT.transceiverPdas( - solanaNtt.program.programId + solanaNtt.program.programId, + solanaNtt.instance ).emitterAccount(); const outboxRateLimit = solanaNtt.pdas.outboxRateLimitAccount(); const tokenAuthority = solanaNtt.pdas.tokenAuthority(); @@ -131,12 +135,18 @@ export function getVersion( export async function nttFromManager( ch: ChainContext, nativeManagerAddress: string, + // Solana-only: per-instance Config pubkey for multi-tenant (v4+) + // deployments. Required when reading a multi-tenant manager — the SDK + // throws on construction without it. No-op for legacy single-tenant + // managers and non-Solana chains. + instance?: string, opts?: { waitForTransceiver?: boolean } ): Promise<{ ntt: Ntt; addresses: Partial }> { const onlyManager = await ch.getProtocol("Ntt", { ntt: { manager: nativeManagerAddress, transceiver: {}, + ...(instance && { instance }), }, }); @@ -163,8 +173,9 @@ export async function nttFromManager( diff = await onlyManager.verifyAddresses(); } - const addresses: Partial = { + const addresses: Partial & { instance?: string } = { manager: nativeManagerAddress, + ...(instance && { instance }), ...diff, }; diff --git a/cli/src/solana/deploy.ts b/cli/src/solana/deploy.ts index 5b9b12301..b3c590650 100644 --- a/cli/src/solana/deploy.ts +++ b/cli/src/solana/deploy.ts @@ -39,6 +39,7 @@ import { * For legacy builds on non-Solana chains, patches the binary after building. * @param pwd - Project root directory * @param network - Network to build for +/** * @param chain - Target chain (used to determine if patching is needed) * @param wormhole - Wormhole core bridge address * @param overrides - Wormhole SDK config overrides @@ -269,6 +270,69 @@ export async function buildSvm( }; } +/** + * Upload a built program binary via `solana program deploy`. + * + * Manages a `buffer.json` keypair in the current directory so an interrupted + * deploy can be resumed: the helper creates `buffer.json` if it's missing, + * prompts the user when an existing one is found (so they can decide whether + * to resume or delete it), and removes it only after a successful upload. + * + * Calls `process.exit` on non-zero exit from `solana program deploy` so + * callers don't have to thread the failure back themselves. + */ +export async function uploadSolanaProgram(args: { + binary: string; + programKeypairPath: string; + payerPath: string; + rpc: string; + priorityFee?: number; +}): Promise { + if (!fs.existsSync(`buffer.json`)) { + execSync(`solana-keygen new -o buffer.json --no-bip39-passphrase`); + } else { + console.info("buffer.json already exists."); + await askForConfirmation( + "Do you want continue an exiting deployment? If not, delete the buffer.json file and run the command again." + ); + } + + const deployCommand = [ + "solana", + "program", + "deploy", + "--program-id", + args.programKeypairPath, + "--buffer", + `buffer.json`, + args.binary, + "--keypair", + args.payerPath, + "-u", + args.rpc, + "--commitment", + "finalized", + ]; + + if (args.priorityFee !== undefined) { + deployCommand.push( + "--with-compute-unit-price", + args.priorityFee.toString() + ); + } + + const deployProc = Bun.spawn(deployCommand); + const out = await new Response(deployProc.stdout).text(); + await deployProc.exited; + + if (deployProc.exitCode !== 0) { + process.exit(deployProc.exitCode ?? 1); + } + + fs.unlinkSync("buffer.json"); + console.log(out); +} + export async function deploySvm( pwd: string, version: string | null, @@ -280,14 +344,68 @@ export async function deploySvm( managerKeyPath?: string, binaryPath?: string, priorityFee?: number, - overrides?: WormholeConfigOverrides -): Promise> { + overrides?: WormholeConfigOverrides, + instanceKeyPath?: string, + // Upgrade path only: the existing deployment's Instance pubkey. When set, the + // program is redeployed against this instance instead of a freshly generated + // one (which would derive a different token authority and break the + // burning-mode mint-authority check). Only the pubkey is needed here because + // an upgrade does not run `initialize`, so nothing has to co-sign as it. + existingInstance?: PublicKey +): Promise<{ + chain: C; + address: ChainAddress["address"]; + instance?: PublicKey; +}> { const wormhole = ch.config.contracts.coreBridge; if (!wormhole) { console.error("Core bridge not found"); process.exit(1); } + // Multi-tenant Solana NTT (≥ v4) keys every per-instance PDA by the + // Instance account pubkey and requires that keypair to co-sign + // `initialize` so Anchor's `init` can allocate the Config account at it. + // Generate (or load) it before SDK construction so the ntt object derives + // instance-scoped PDAs throughout. + const major = version ? parseInt(version.split(".")[0] ?? "0", 10) : 0; + const multiTenant = major >= 4; + // The keypair is only needed to co-sign `initialize` (fresh deploy). The + // pubkey is what every PDA derivation below actually consumes, so track it + // separately: on upgrade we have only the pubkey of the existing instance. + let instanceKeypair: Keypair | undefined; + let instancePubkey: PublicKey | undefined; + if (multiTenant) { + if (existingInstance) { + // Upgrade: reuse the existing instance so PDAs (and the burning-mode + // mint-authority check) resolve against the real deployment. + instancePubkey = existingInstance; + } else if (instanceKeyPath) { + instanceKeypair = Keypair.fromSecretKey( + new Uint8Array(JSON.parse(fs.readFileSync(instanceKeyPath).toString())) + ); + instancePubkey = instanceKeypair.publicKey; + } else if (initialize) { + instanceKeypair = Keypair.generate(); + instancePubkey = instanceKeypair.publicKey; + const generatedPath = `${ch.chain}-instance.json`; + fs.writeFileSync( + generatedPath, + JSON.stringify(Array.from(instanceKeypair.secretKey)) + ); + console.log( + `Generated instance keypair at ${generatedPath} (pubkey: ${instanceKeypair.publicKey.toBase58()})` + ); + } else { + // Redeploy without initializing and without a known instance would + // silently derive the wrong (fresh) authorities. Fail instead. + throw new Error( + "Multi-tenant Solana redeploy requires the existing instance pubkey " + + "(pass `existingInstance`); refusing to generate a new one." + ); + } + } + // Build the Solana program (or use provided binary) const buildResult = await buildSvm( pwd, @@ -310,7 +428,13 @@ export async function deploySvm( // time by checking it here and failing early (not to mention better // diagnostics). - const emitter = NTT.transceiverPdas(providedProgramId) + // Singleton (legacy) deployments use the emitter PDA derived from the + // program ID alone; multi-tenant deployments scope it by the instance + // pubkey. EVM/Sui peers register against this address. + const emitter = NTT.transceiverPdas( + providedProgramId, + multiTenant ? instancePubkey! : undefined + ) .emitterAccount() .toBase58(); const payerKeypair = Keypair.fromSecretKey( @@ -336,7 +460,19 @@ export async function deploySvm( dummy.network, dummy.chain, dummy.connection, - dummy.contracts, + { + ...dummy.contracts, + ntt: { + ...dummy.contracts.ntt!, + // Thread the instance pubkey for multi-tenant deployments so the + // SDK derives the per-instance Config / token_authority / etc. + // The constructor refuses a multi-tenant version without `instance` + // (and a singleton version with it). + ...(multiTenant && { + instance: instancePubkey!.toBase58(), + }), + }, + }, version ?? undefined ); @@ -387,55 +523,13 @@ export async function deploySvm( } // Deploy the binary (patching was already done during build for legacy builds on non-Solana chains) - const skipDeploy = false; - - if (!skipDeploy) { - // if buffer.json doesn't exist, create it - if (!fs.existsSync(`buffer.json`)) { - execSync(`solana-keygen new -o buffer.json --no-bip39-passphrase`); - } else { - console.info("buffer.json already exists."); - await askForConfirmation( - "Do you want continue an exiting deployment? If not, delete the buffer.json file and run the command again." - ); - } - - const deployCommand = [ - "solana", - "program", - "deploy", - "--program-id", - programKeypairPath, - "--buffer", - `buffer.json`, - binary, - "--keypair", - payer, - "-u", - ch.config.rpc, - "--commitment", - "finalized", - ]; - - if (priorityFee !== undefined) { - deployCommand.push("--with-compute-unit-price", priorityFee.toString()); - } - - const deployProc = Bun.spawn(deployCommand); - - const out = await new Response(deployProc.stdout).text(); - - await deployProc.exited; - - if (deployProc.exitCode !== 0) { - process.exit(deployProc.exitCode ?? 1); - } - - // success. remove buffer.json - fs.unlinkSync("buffer.json"); - - console.log(out); - } + await uploadSolanaProgram({ + binary, + programKeypairPath, + payerPath: payer, + rpc: ch.config.rpc, + priorityFee, + }); if (initialize) { // wait 3 seconds @@ -447,6 +541,10 @@ export async function deploySvm( mint: new PublicKey(token), mode, outboundLimit: 100000000n, + // Multi-tenant only: pass the instance keypair so it can co-sign + // the initialize tx — Anchor's `init` allocates the Config account + // at that pubkey. + ...(multiTenant && { instance: instanceKeypair! }), ...(mode === "burning" && !mint.mintAuthority!.equals(tokenAuthority) && { multisigTokenAuthority: mint.mintAuthority!, @@ -474,7 +572,179 @@ export async function deploySvm( } } - return { chain: ch.chain, address: toUniversal(ch.chain, providedProgramId) }; + return { + chain: ch.chain, + address: toUniversal(ch.chain, providedProgramId), + ...(multiTenant && { instance: instancePubkey! }), + }; +} + +/** + * v4 only: create a new Instance under an existing Solana NTT program. + * + * Skips the program-deploy step entirely. Generates (or loads) an Instance + * keypair, constructs a `SolanaNtt` bound to the existing `programId` and the + * fresh instance pubkey, and submits the v4 `initialize` transaction signed by + * both the payer and the instance keypair. + * + * Returns the program id (as `manager` in the deployment.json sense) plus the + * instance pubkey; the caller is responsible for writing both into + * `ChainConfig`. + */ +export async function addSolanaInstance< + N extends Network, + C extends SolanaChains, +>( + version: string, + mode: Ntt.Mode, + ch: ChainContext, + token: string, + payer: string, + programId: string, + instanceKeyPath?: string, + outboundLimit: bigint = 100_000_000n +): Promise<{ chain: C; manager: ChainAddress; instance: PublicKey }> { + // Load or generate the instance keypair. The keypair's pubkey is the new + // Instance account address; in v4 it's also the on-the-wire NTT manager + // identity for this deployment. + let instanceKeypair: Keypair; + if (instanceKeyPath) { + instanceKeypair = Keypair.fromSecretKey( + new Uint8Array(JSON.parse(fs.readFileSync(instanceKeyPath).toString())) + ); + } else { + instanceKeypair = Keypair.generate(); + const generatedPath = `${ch.chain}-instance.json`; + fs.writeFileSync( + generatedPath, + JSON.stringify(Array.from(instanceKeypair.secretKey)) + ); + console.log( + `Generated instance keypair at ${generatedPath} (pubkey: ${instanceKeypair.publicKey.toBase58()})` + ); + } + + const payerKeypair = Keypair.fromSecretKey( + new Uint8Array(JSON.parse(fs.readFileSync(payer).toString())) + ); + + // The Wormhole transceiver address in v4 is the instance-scoped emitter PDA. + const emitter = NTT.transceiverPdas( + new PublicKey(programId), + instanceKeypair.publicKey + ) + .emitterAccount() + .toBase58(); + + const connection: Connection = await ch.getRpc(); + const ntt: SolanaNtt = new SolanaNtt( + ch.network, + ch.chain, + connection, + { + ...ch.config.contracts, + ntt: { + manager: programId, + token, + instance: instanceKeypair.publicKey.toBase58(), + transceiver: { wormhole: emitter }, + }, + }, + version + ); + + // Sanity check the mint's mint_authority matches the per-instance token_authority + // for burning mode (mirrors the program-side constraint in v4 initialize). + const tokenMint = new PublicKey(token); + const mintInfo = await connection.getAccountInfo(tokenMint); + if (mintInfo === null) { + console.error(`Mint ${token} not found`); + process.exit(1); + } + const tokenProgram = mintInfo.owner; + // Read mint state at the connection's default commitment (typically + // "confirmed"). Using "finalized" here races mint creation in fast-spin + // local-validator setups — by the time we land on this line the mint can + // be confirmed but not yet finalized, and `getMint` then 404s. + const mint = await spl.getMint( + connection, + tokenMint, + undefined, + tokenProgram + ); + const tokenAuthority = ntt.pdas.tokenAuthority(); + if ( + mode === "burning" && + !mint.mintAuthority?.equals(tokenAuthority) && + !(await checkSvmValidSplMultisig( + connection, + mint.mintAuthority!, + tokenProgram, + tokenAuthority + )) + ) { + console.error( + `In burning mode, the mint authority must be either the token_authority PDA or a 1-of-N SPL Multisig containing it.\n` + + ` mint authority: ${mint.mintAuthority?.toBase58()}\n` + + ` expected token_authority (per-instance): ${tokenAuthority.toBase58()}` + ); + process.exit(1); + } + + const initTxs = ntt.initialize( + toUniversal(ch.chain, payerKeypair.publicKey.toBase58()), + { + mint: tokenMint, + mode, + outboundLimit, + instance: instanceKeypair, + ...(mode === "burning" && + !mint.mintAuthority!.equals(tokenAuthority) && { + multisigTokenAuthority: mint.mintAuthority!, + }), + } + ); + + const signer = await getSigner( + ch, + "privateKey", + encoding.b58.encode(payerKeypair.secretKey) + ); + + // The initialize generator yields the initialize ix first, then + // initializeOrUpdateLUT. The latter CPIs into the wormhole core bridge, + // which can be unavailable in dev environments (e.g. a bare + // solana-test-validator without the bridge loaded). Log and continue — the + // LUT can be (re)built later via `ntt push`. A genuine `initialize` failure + // still surfaces, because the transceiver registration below then fails and + // is propagated. Log the full error, not just `e.logs` (undefined for + // non-SendTransactionError, which would otherwise be silent). + try { + await signSendWait(ch, initTxs, signer.signer); + } catch (e: any) { + console.error("Instance initialize/LUT step failed:", e.logs ?? e); + } + + // Register the Wormhole transceiver under the new instance. Unlike the LUT, + // this is not optional: an instance with no registered transceiver cannot + // send or receive. Fail loudly rather than persist a broken instance to the + // deployment file (the caller only records the return value on success). + try { + await registerSolanaTransceiver(ntt as any, ch, signer); + } catch (e: any) { + console.error("Transceiver registration failed:", e.logs ?? e); + throw new Error( + `Failed to register the Wormhole transceiver for the new ${ch.chain} ` + + `instance ${instanceKeypair.publicKey.toBase58()}; it cannot transfer ` + + `without one. Not recording it in the deployment.` + ); + } + + return { + chain: ch.chain, + manager: { chain: ch.chain, address: toUniversal(ch.chain, programId) }, + instance: instanceKeypair.publicKey, + }; } export async function upgradeSolana( @@ -502,7 +772,12 @@ export async function upgradeSolana( programKeyPath, binaryPath, undefined, - overrides + overrides, + undefined, + // Redeploy against the existing instance so PDAs and the burning-mode + // mint-authority check resolve against the live deployment rather than a + // freshly generated (and wrong) instance. + ntt.instance ); // TODO: call initializeOrUpdateLUT. currently it's done in the following 'ntt push' step. } diff --git a/cli/src/upgradeBarriers.ts b/cli/src/upgradeBarriers.ts new file mode 100644 index 000000000..debddd999 --- /dev/null +++ b/cli/src/upgradeBarriers.ts @@ -0,0 +1,69 @@ +import type { Chain } from "@wormhole-foundation/sdk"; + +/** + * A registered breaking-change barrier between two major versions of the NTT + * deployment for a given chain. An in-place version change is blocked iff it + * crosses the barrier in *either* direction — i.e. exactly one of the source + * and target majors is at or above the barrier. This covers both an upgrade + * past the barrier and a downgrade back across it, since the on-chain layout + * is incompatible in both directions. + * + * Add an entry here whenever a major version introduces an on-chain layout or + * wire-format change that an existing deployment cannot be migrated into in + * place. The `reason` string is surfaced verbatim when blocking the attempt. + */ +export type UpgradeBarrier = { + chain: Chain; + breakingMajor: number; + reason: string; +}; + +export const UPGRADE_BARRIERS: UpgradeBarrier[] = [ + { + chain: "Solana", + breakingMajor: 4, + reason: + "Solana NTT v4 changes the on-chain account layout (PDAs are scoped by " + + "instance ID, the Instance account is keypair-created instead of a PDA, " + + "and the on-the-wire NTT manager identity is the Instance pubkey rather " + + "than the program ID). v3 and v4 cannot be migrated into each other in " + + "place, in either direction. Deploy fresh v4 with " + + "`ntt add-chain Solana --version 4.0.0` instead.", + }, +]; + +export type UpgradeCheck = { ok: true } | { ok: false; reason: string }; + +function parseMajor(version: string): number { + return parseInt(version.split(".")[0] ?? "0", 10); +} + +/** + * Returns whether the proposed `fromVersion → toVersion` version change on + * `chain` is permitted in place. A registered barrier blocks the change when it + * crosses the barrier in either direction (upgrade past it or downgrade back + * across it). + * + * Local-version (`toVersion === null`) changes are always permitted; the + * caller is asserting they know what they're doing. + */ +export function canUpgrade( + chain: Chain, + fromVersion: string, + toVersion: string | null +): UpgradeCheck { + if (toVersion === null) return { ok: true }; + const fromMajor = parseMajor(fromVersion); + const toMajor = parseMajor(toVersion); + for (const barrier of UPGRADE_BARRIERS) { + if (barrier.chain !== chain) continue; + const fromBelow = fromMajor < barrier.breakingMajor; + const toBelow = toMajor < barrier.breakingMajor; + // Blocked when the change straddles the barrier — one side below it and + // the other at or above it — regardless of direction. + if (fromBelow !== toBelow) { + return { ok: false, reason: barrier.reason }; + } + } + return { ok: true }; +} diff --git a/cli/src/validation.ts b/cli/src/validation.ts index 28379bd48..fde785be6 100644 --- a/cli/src/validation.ts +++ b/cli/src/validation.ts @@ -4,6 +4,7 @@ import { assertChain, chainToPlatform, chains, + toUniversal, type Chain, type ChainAddress, type ChainContext, @@ -178,6 +179,27 @@ export function printMissingConfigReport( return true; } +/** + * The address that peers on other chains must register as this deployment's + * NTT manager. For a Solana v4 (multi-tenant) deployment this is the + * per-instance Config pubkey — the on-wire `recipient_ntt_manager` — which the + * SDK exposes via `peerManagerAddress()`. Everywhere else it is the + * deployment's `manager`. Registering the raw program ID for a v4 Solana + * deployment would create a peer whose transfers the instance rejects as + * unredeemable. + */ +function onWireManager(d: Deployment): ChainAddress { + if (chainToPlatform(d.manager.chain) === "Solana") { + const solanaNtt = d.ntt as SolanaNtt; + const identity = solanaNtt.peerManagerAddress(); + return { + chain: d.manager.chain, + address: toUniversal(d.manager.chain, identity.toBase58()), + } as ChainAddress; + } + return d.manager; +} + /** Collect missing implicit config across deployments (peers, Solana LUT/transceiver). */ export async function collectMissingConfigs( deps: Partial<{ [C in Chain]: Deployment }>, @@ -270,20 +292,23 @@ export async function collectMissingConfigs( 5, 5000 ); + // The on-wire manager identity the peer must point at: for a v4 Solana + // `to` this is the instance pubkey, not the program ID (see onWireManager). + const toManager = onWireManager(to); if (peer === null) { const configLimit = from.config.local?.limits?.inbound?.[ toChain ]?.replace(/\./g, ""); missingCounts[fromChain] = (missingCounts[fromChain] ?? 0) + 1; missing.managerPeers.push({ - address: to.manager, + address: toManager, tokenDecimals: to.decimals, inboundLimit: BigInt(configLimit ?? 0), }); } else { if ( !Buffer.from(peer.address.address.address.toString()).equals( - Buffer.from(to.manager.address.address.toString()) + Buffer.from(toManager.address.address.toString()) ) ) { console.error(`Peer address mismatch for ${fromChain} -> ${toChain}`); diff --git a/cli/test/solana.sh b/cli/test/solana.sh index bef5d5885..67433011e 100755 --- a/cli/test/solana.sh +++ b/cli/test/solana.sh @@ -12,10 +12,22 @@ set -euo pipefail -# Share cargo target directory across builds to speed up compilation -# This allows v1.0.0 and v2.0.0 builds to reuse compiled dependencies -export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-/tmp/solana-test-target}" -mkdir -p "$CARGO_TARGET_DIR" +# Resolve the script's own directory before any `cd`s. Used by the v4 section +# below to locate the local source tree from which to build the v4 binary. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Intentionally NOT exporting CARGO_TARGET_DIR here. The CLI manages a +# separate worktree per program version under `.deployments/Solana-/`, +# each with its own source tree; a single global CARGO_TARGET_DIR collapses +# their `target/` dirs and lets v1's `.so` / program-id keypair clobber v2's +# (and vice versa). Each worktree builds into its own `target/`. If you want +# cross-version dep caching for faster local iteration, use `sccache` — +# that's the right tool for "share rustc cache without sharing outputs." + +# macOS adds AppleDouble metadata files (`._foo`) inside tar archives, which +# `solana-test-validator` rejects when unpacking the genesis archive. Disable +# that behavior so the validator can boot on darwin hosts. No-op on linux. +export COPYFILE_DISABLE=1 # Default values PORT=6000 @@ -98,6 +110,11 @@ if [ "$USE_TMP_DIR" = true ]; then cd test-ntt || exit fi +# Source-tree files the v4 multi-tenant section temporarily patches (so the +# .so we deploy has a `declare_id!` matching a keypair we actually have). +# Tracked here so cleanup restores them even if the test exits early. +V4_PATCHED_FILES=() + # Function to clean up resources cleanup() { echo "Cleaning up..." @@ -111,6 +128,13 @@ cleanup() { else rm -f "$OVERRIDES_FILE" fi + # Restore any source files the v4 section patched. `${var+x}` shields the + # unset case under `set -u` for hosts that haven't reached the v4 block. + if [ "${V4_PATCHED_FILES+x}" ] && [ "${#V4_PATCHED_FILES[@]}" -gt 0 ]; then + for f in "${V4_PATCHED_FILES[@]}"; do + [ -f "$f.cli-test-bak" ] && mv "$f.cli-test-bak" "$f" + done + fi solana config set --keypair "$old_default_keypair" > /dev/null } @@ -172,8 +196,12 @@ keypair=$(solana-keygen grind --starts-with w:1 --ignore-case | grep 'Wrote keyp keypair=$(realpath "$keypair") solana config set --keypair "$keypair" -# Airdrop SOL -solana airdrop 50 -u "$NETWORK" --keypair "$keypair" +# Airdrop SOL. `ntt add-chain` ultimately invokes `solana program deploy +# --commitment finalized`, which checks the payer's balance at the finalized +# slot. Solana's airdrop is confirmed but not finalized, so without +# `--commitment finalized` here the deploy can race the finalization and fail +# with a bogus "insufficient funds" error against a freshly-airdropped account. +solana airdrop 50 -u "$NETWORK" --keypair "$keypair" --commitment finalized # This steps is a bit voodoo -- we airdrop to this special address, which is # needed for querying the program version. For more info, grep for these pubkeys in the ntt repo. solana airdrop 1 Hk3SdYTJFpawrvRz4qRztuEt2SqoCG7BGj2yJfDJSFbJ -u "$NETWORK" --keypair "$keypair" > /dev/null @@ -219,9 +247,9 @@ if [ -n "${SOLANA_ARTIFACTS_DIR:-}" ]; then fi if [ -n "$V1_BINARY" ]; then - ntt add-chain Solana --ver 1.0.0 --mode burning --token "$token" --payer "$keypair" --program-key "$ntt_keypair" --binary "$V1_BINARY" + ntt add-chain Solana --ver 1.0.0 --mode burning --token "$token" --payer "$keypair" --program-key "$ntt_keypair" --binary "$V1_BINARY" --deploy-program else - ntt add-chain Solana --ver 1.0.0 --mode burning --token "$token" --payer "$keypair" --program-key "$ntt_keypair" + ntt add-chain Solana --ver 1.0.0 --mode burning --token "$token" --payer "$keypair" --program-key "$ntt_keypair" --deploy-program fi echo "Getting status" @@ -240,6 +268,139 @@ ntt push --payer "$keypair" --yes cat "$DEPLOYMENT_FILE" +########################################################################### +# v4 multi-tenant section +# +# Exercises the multi-tenant deployment shape: a single deployed Solana +# program that hosts many independent NTT instances (one Config keypair per +# instance, all PDAs scoped by `config.key()`). Tests: +# +# 1. `ntt upgrade Solana --ver 4.0.0` from the just-deployed v2 program is +# blocked by `canUpgrade()` with the expected migration error. +# 2. After deploying a fresh v4 program raw (`solana program deploy`), +# `ntt add-chain Solana --instance-of ` creates the first +# instance under it and persists `instance` in deployment.json. +# 3. A second `--instance-of` against the same program creates an +# independent second instance, demonstrating multi-tenant isolation +# under one program ID. +########################################################################### +echo +echo "==============================" +echo " v4 multi-tenant section" +echo "==============================" + +# (1) v3 → v4 in-place upgrade is rejected by canUpgrade(). +echo +echo "Asserting v3 -> v4 in-place upgrade is blocked..." +upgrade_v4_log=$(ntt upgrade Solana --ver 4.0.0 --payer "$keypair" --program-key "$ntt_keypair" --yes 2>&1 || true) +if echo "$upgrade_v4_log" | grep -q "cannot upgrade Solana from .* to 4\.0\.0 in place"; then + echo "OK: v3 -> v4 upgrade was blocked" +else + echo "FAIL: expected v3 -> v4 upgrade to be blocked. Got:" + echo "$upgrade_v4_log" + exit 1 +fi + +# (2) Fresh v4 program + first instance via `--instance-of`. +# +# We don't have a v4.0.0+solana git tag yet (still in development on this +# branch), so we can't go through the CLI's tag-based fresh-deploy path. +# Locate (or build) a v4 binary from the local source tree, deploy it raw, +# then run `ntt add-chain --instance-of` — which doesn't depend on a +# worktree, so the lack of tag is fine here. +V4_SOURCE_ROOT="$(realpath "$SCRIPT_DIR/../..")" +V4_SOLANA_DIR="$V4_SOURCE_ROOT/solana" +# Look for the v4 binary in the canonical anchor location under the source +# tree. We do NOT honor `CARGO_TARGET_DIR` here: this script also exports it +# for sharing v1/v2 build caches, but those builds produce binaries with a +# *different* `declare_id!` — picking one of those here would give us a v4 +# program ID whose runtime check then fires `DeclaredProgramIdMismatch`. +V4_BINARY="$V4_SOLANA_DIR/target/deploy/example_native_token_transfers.so" +V4_PROGRAM_KEYPAIR="$V4_SOLANA_DIR/target/deploy/example_native_token_transfers-keypair.json" +if [ ! -f "$V4_BINARY" ] || [ ! -f "$V4_PROGRAM_KEYPAIR" ]; then + echo + echo "Skipping v4 multi-tenant deploy test: binary not found at $V4_BINARY." + echo "Run \`anchor build -- --features mainnet\` in $V4_SOLANA_DIR to populate" + echo "the target/deploy dir, then re-run this script." + exit 0 +fi +# A `declare_id!` mismatch between the .so and the keypair would surface as +# AnchorError 4100 (DeclaredProgramIdMismatch) at the very first instruction. +# When mismatched, patch Anchor.toml + lib.rs to declare the keypair we +# have, rebuild, and restore the source on exit. (We can't deploy at the +# `nttiK1Sep…` mainnet program ID locally because we don't have its +# keypair; this lets the test exercise the actual deploy path under a +# fresh, locally-keypair'd id.) +V4_DECLARED_ID="$(grep 'example_native_token_transfers = "' "$V4_SOLANA_DIR/Anchor.toml" | sed 's/.*"\(.*\)"/\1/')" +V4_KEYPAIR_PUBKEY="$(solana-keygen pubkey "$V4_PROGRAM_KEYPAIR")" +if [ "$V4_DECLARED_ID" != "$V4_KEYPAIR_PUBKEY" ]; then + echo + echo "Patching $V4_SOLANA_DIR/Anchor.toml + lib.rs to declare the local" + echo "keypair ($V4_KEYPAIR_PUBKEY) instead of the mainnet id" + echo "($V4_DECLARED_ID), then rebuilding. Source will be restored on exit." + V4_ANCHOR_TOML="$V4_SOLANA_DIR/Anchor.toml" + V4_LIB_RS="$V4_SOLANA_DIR/programs/example-native-token-transfers/src/lib.rs" + cp "$V4_ANCHOR_TOML" "$V4_ANCHOR_TOML.cli-test-bak" + cp "$V4_LIB_RS" "$V4_LIB_RS.cli-test-bak" + V4_PATCHED_FILES+=("$V4_ANCHOR_TOML" "$V4_LIB_RS") + sed -i.tmp "s/$V4_DECLARED_ID/$V4_KEYPAIR_PUBKEY/g" "$V4_ANCHOR_TOML" + sed -i.tmp "s/$V4_DECLARED_ID/$V4_KEYPAIR_PUBKEY/g" "$V4_LIB_RS" + rm -f "$V4_ANCHOR_TOML.tmp" "$V4_LIB_RS.tmp" + # The v3 worktree's `add-chain` ran `agave-install init` for its own + # pinned solana version (e.g. 1.18.10), which leaves the active install + # on the wrong version for the v4 build. Re-init to the version v4 + # Anchor.toml pins so anchor's toolchain switcher resolves cleanly. + V4_PINNED_SOLANA="$(grep '^solana_version' "$V4_ANCHOR_TOML" | sed 's/.*"\(.*\)"/\1/')" + if [ -n "$V4_PINNED_SOLANA" ] && [ "$(solana --version | awk '{print $2}')" != "$V4_PINNED_SOLANA" ]; then + agave-install init "$V4_PINNED_SOLANA" > /dev/null + fi + # `anchor build` reads `target/deploy/-keypair.json` as the source + # of truth for the program id and patches lib.rs / Anchor.toml back to + # that pubkey before compiling — so the rebuilt `.so`'s declared id + # will match `$V4_PROGRAM_KEYPAIR` (which lives at exactly this path) + # without any extra copy step. + (cd "$V4_SOLANA_DIR" && COPYFILE_DISABLE=1 anchor build -- --features mainnet) > /dev/null +fi +echo +echo "Using v4 binary at $V4_BINARY" +v4_program_id=$(solana-keygen pubkey "$V4_PROGRAM_KEYPAIR") +echo "v4 program ID: $v4_program_id" + +# Top up the payer so we can afford another rent-exempt program account. +solana airdrop 50 -u "$NETWORK" --keypair "$keypair" --commitment finalized > /dev/null + +echo "Deploying v4 program binary..." +solana program deploy --program-id "$V4_PROGRAM_KEYPAIR" "$V4_BINARY" \ + --keypair "$keypair" -u "$NETWORK" --commitment finalized > /dev/null + +# Reset deployment.json — the v3 chain config no longer applies. +rm -f "$DEPLOYMENT_FILE" +ntt init Mainnet + +# Create a fresh mint and pre-set its authority to the per-instance +# token_authority PDA. v4 derives token_authority from `(programId, instance)`, +# so we have to commit to the instance pubkey before initialize. +v4_token_a=$(spl-token create-token --program-id TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb -u "$NETWORK" | grep "Address:" | awk '{print $2}') +v4_instance_a_keypair="$KEYS_DIR/v4-instance-a.json" +solana-keygen new --no-bip39-passphrase --silent -o "$v4_instance_a_keypair" --force > /dev/null +v4_instance_a=$(solana-keygen pubkey "$v4_instance_a_keypair") +v4_token_authority_a=$(ntt solana token-authority "$v4_program_id" --instance "$v4_instance_a") +echo "Instance A: $v4_instance_a (token_authority $v4_token_authority_a, mint $v4_token_a)" +spl-token authorize "$v4_token_a" mint "$v4_token_authority_a" -u "$NETWORK" > /dev/null + +ntt add-chain Solana --ver 4.0.0 --mode burning --token "$v4_token_a" \ + --payer "$keypair" --instance-of "$v4_program_id" \ + --instance-key "$v4_instance_a_keypair" --yes + +# Sanity-check: deployment.json must record the instance pubkey alongside +# the manager program ID so subsequent commands derive instance-scoped PDAs. +if ! grep -q "\"instance\": \"$v4_instance_a\"" "$DEPLOYMENT_FILE"; then + echo "FAIL: deployment.json missing instance pubkey for instance A" + cat "$DEPLOYMENT_FILE" + exit 1 +fi +echo "OK: deployment.json records instance A under program $v4_program_id" + if [ "$KEEP_ALIVE" = true ]; then # wait for C-c to kill the validator # print information about the running validator diff --git a/sdk/__tests__/utils.ts b/sdk/__tests__/utils.ts index 72df8b714..3435ce6b0 100644 --- a/sdk/__tests__/utils.ts +++ b/sdk/__tests__/utils.ts @@ -125,9 +125,17 @@ export async function link(chainInfos: Ctx[], accountantPrivateKey: string) { const hubChain = hub.context.chain; // handle Solana emitter account case separately + const hubContracts = hub.contracts! as Ntt.Contracts & { + instance?: string; + }; const whTransceiver = chainToPlatform(hubChain) === "Solana" - ? NTT.transceiverPdas(hub.contracts!.transceiver["wormhole"]!) + ? NTT.transceiverPdas( + hub.contracts!.transceiver["wormhole"]!, + hubContracts.instance + ? new PublicKey(hubContracts.instance) + : undefined + ) .emitterAccount() .toString() : hub.contracts!.transceiver["wormhole"]!; @@ -514,14 +522,20 @@ async function deploySolana(ctx: Ctx): Promise { ? "NTTTransceiver22222222222222222222222222222" : "NTTTransceiver11111111111111111111111111111"; + // v4: each deployment is keyed by a per-instance Config keypair. The pubkey + // is the on-the-wire NTT manager identity for this deployment and the seed + // scope for every per-instance PDA. + const instanceKeypair = web3.Keypair.generate(); + ctx.contracts = { token: mint.toBase58(), manager: managerProgramId, + instance: instanceKeypair.publicKey.toBase58(), transceiver: { wormhole: transceiverProgramId, }, svmShims: {}, - }; + } as Ntt.Contracts; const manager = (await getNtt(ctx)) as SolanaNtt; @@ -547,6 +561,7 @@ async function deploySolana(ctx: Ctx): Promise { mint, outboundLimit: 1000000000n, mode: ctx.mode, + instance: instanceKeypair, }); await signSendWait(ctx.context, initTxs, signer); console.log("Initialized ntt at", manager.program.programId.toString()); @@ -582,24 +597,40 @@ async function deploySolana(ctx: Ctx): Promise { wormhole: whTransceiver.programId.toString(), }, manager: manager.program.programId.toString(), + instance: instanceKeypair.publicKey.toBase58(), token: mint.toString(), svmShims: {}, - }, + } as Ntt.Contracts, }; } async function setupPeer(targetCtx: Ctx, peerCtx: Ctx) { const target = targetCtx.context; const peer = peerCtx.context; + const peerContracts = peerCtx.contracts! as Ntt.Contracts & { + instance?: string; + }; const { manager, transceiver: { wormhole: transceiver }, - } = peerCtx.contracts!; - - const peerManager = Wormhole.chainAddress(peer.chain, manager); + instance, + } = peerContracts; + + // v4 multi-host on Solana puts the per-instance Config pubkey on the wire + // as the NTT manager identity (not the program ID), and scopes the emitter + // PDA by that same Config pubkey. EVM/Sui peers must register against + // those instance-scoped values. + const isPeerSolanaV4 = chainToPlatform(peer.chain) === "Solana" && instance; + const peerManagerAddress = isPeerSolanaV4 ? instance! : manager; + const peerManager = Wormhole.chainAddress(peer.chain, peerManagerAddress); const whTransceiver = chainToPlatform(peer.chain) === "Solana" - ? NTT.transceiverPdas(transceiver!).emitterAccount().toString() + ? NTT.transceiverPdas( + transceiver!, + instance ? new PublicKey(instance) : undefined + ) + .emitterAccount() + .toString() : transceiver!; const peerTransceiver = Wormhole.chainAddress(peer.chain, whTransceiver); diff --git a/solana/Cargo.lock b/solana/Cargo.lock index e4ad2edec..b4bd577b2 100644 --- a/solana/Cargo.lock +++ b/solana/Cargo.lock @@ -1403,7 +1403,7 @@ checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" [[package]] name = "dummy-transfer-hook" -version = "3.0.0" +version = "4.0.0" dependencies = [ "anchor-lang", "anchor-spl", @@ -1562,7 +1562,7 @@ checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" [[package]] name = "example-native-token-transfers" -version = "3.0.0" +version = "4.0.0" dependencies = [ "anchor-lang", "anchor-spl", @@ -2482,7 +2482,7 @@ checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" [[package]] name = "ntt-messages" -version = "3.0.0" +version = "4.0.0" dependencies = [ "anchor-lang", "hex", @@ -2492,7 +2492,7 @@ dependencies = [ [[package]] name = "ntt-transceiver" -version = "3.0.0" +version = "4.0.0" dependencies = [ "anchor-lang", "anchor-spl", @@ -6440,7 +6440,7 @@ dependencies = [ [[package]] name = "wormhole-governance" -version = "3.0.0" +version = "4.0.0" dependencies = [ "anchor-lang", "hex", diff --git a/solana/README.md b/solana/README.md index ca7ba9c34..16ce1d129 100644 --- a/solana/README.md +++ b/solana/README.md @@ -81,6 +81,58 @@ Program log: Instruction: ReleaseInboundMint Program log: Instruction: ReleaseInboundUnlock ``` +## Trust Model + +Version 4 changes the Solana program from a single-instance model to a multi-instance model. This section describes the instances, the isolation between them, and the authorities that an operator must trust. + +### Instances + +In version 3, one program held one NTT deployment. The program ID was the manager identity in each message. + +In version 4, one program can hold many NTT deployments. Each deployment is an _instance_. An instance is a `Config` account. The operator supplies a new keypair for this account. The operator signs the [`initialize`] instruction with this keypair. The program does not derive the `Config` account as a PDA. + +The public key of the `Config` account is the manager identity of the instance. A peer on another chain registers this key as the Solana manager address. The program writes this key into each outbound message. The program checks this key on each inbound message. + +Version 4 conforms to the NTT specification. The wire format does not change. The manager identity is still a 32-byte address. Therefore an operator can register a version 4 instance into an existing NTT mesh. The peers on the foreign chains do not need a contract upgrade. Each foreign chain registers the `Config` key as a new peer. + +> The operator chooses the `Config` keypair. Therefore two instances in the same program are fully independent. One program can hold many tokens and many owners at the same time. + +### Isolation between instances + +The program keeps the state of each instance separate. Every per-instance account holds the `Config` key in its PDA seeds. This rule applies to the rate limits, the peers, the registered transceivers, the token authority, the session authority, the emitter, and the inbox items. + +The program computes each address from the seeds. Therefore an account of one instance cannot take the place of an account of another instance. The program rejects a wrong account. + +The program also binds each message and each inbox or outbox item to its instance: + +- [`receive_message`] accepts a VAA only when the `recipient_ntt_manager` field equals the `Config` key. The program rejects a VAA that names a different instance. +- [`redeem`] checks the `transceiver_message` seeds against the `Config` key. +- [`release_inbound_mint`] and [`release_inbound_unlock`] accept an inbox item only when the stored `config` field equals the `Config` key. An inbox item from one instance cannot release funds from another instance. +- [`release_outbound`] accepts an outbox item only when the `manager` field equals the `Config` key. A transceiver of one instance cannot release an outbox item of another instance. + +> These checks are necessary because two instances can manage the same token mint. Without these checks, a message or an item from one instance could move funds through another instance. + +### Instance ownership and the program upgrade authority + +Version 4 separates two authorities. Version 3 held these two authorities together. + +The _instance owner_ is the `owner` field of the `Config` account. The owner controls one instance. The owner can set the peers, set the threshold, pause the instance, and transfer the ownership. A transfer of ownership changes the data in the `Config` account. The [`transfer_ownership`] and [`claim_ownership`] instructions do not touch the BPF loader. + +The _program upgrade authority_ is the BPF loader upgrade authority of the program. This authority can replace the program code. New code applies to every instance in the program at the same time. + +> In version 3, one party held both the instance ownership and the program upgrade authority. In version 4, the two are independent. An instance owner has no control over the program code. The program upgrade authority has no ownership of an instance. + +### What an operator must trust + +All instances in one program run the same code. Therefore the party that holds the program upgrade authority can change the behavior of every instance. This party can mint tokens, unlock custody, or stop transfers for all instances. + +An operator that deploys an instance into a shared program must trust the party that holds the program upgrade authority. To reduce this trust, do one of these steps after the deployment: + +- Set the program upgrade authority to `null`. The program becomes immutable. +- Give the program upgrade authority to a multisig or a governance program. All tenants must accept this party. + +An instance owner does not need to trust the other instance owners. The isolation rules limit each owner to one instance. + ## Message Customization See the [NttManager](../docs/NttManager.md) doc for wire format details. diff --git a/solana/modules/ntt-messages/Cargo.toml b/solana/modules/ntt-messages/Cargo.toml index f757a24c2..f52c17b9a 100644 --- a/solana/modules/ntt-messages/Cargo.toml +++ b/solana/modules/ntt-messages/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ntt-messages" -version = "3.0.0" +version = "4.0.0" edition = "2021" [features] diff --git a/solana/programs/dummy-transfer-hook/Cargo.toml b/solana/programs/dummy-transfer-hook/Cargo.toml index a7b50b7b8..83c921a6c 100644 --- a/solana/programs/dummy-transfer-hook/Cargo.toml +++ b/solana/programs/dummy-transfer-hook/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dummy-transfer-hook" -version = "3.0.0" +version = "4.0.0" description = "Created with Anchor" edition = "2021" diff --git a/solana/programs/example-native-token-transfers/Cargo.toml b/solana/programs/example-native-token-transfers/Cargo.toml index ddb851fbe..b70a17f5e 100644 --- a/solana/programs/example-native-token-transfers/Cargo.toml +++ b/solana/programs/example-native-token-transfers/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "example-native-token-transfers" -version = "3.0.0" +version = "4.0.0" description = "Example implementation of native token transfer standard" edition = "2021" diff --git a/solana/programs/example-native-token-transfers/src/config.rs b/solana/programs/example-native-token-transfers/src/config.rs index efd374a53..b060bce0b 100644 --- a/solana/programs/example-native-token-transfers/src/config.rs +++ b/solana/programs/example-native-token-transfers/src/config.rs @@ -21,28 +21,33 @@ pub mod anchor_reexports { } } +// TODO(v4-rename): consider renaming `Config` → `Instance` to reflect the v4 +// semantics — there can be many of these per program; each is the on-the-wire +// "manager identity" for one NTT deployment. Held off for now to keep the v4 +// diff small and reviewable. #[account] #[derive(InitSpace)] pub struct Config { - pub bump: u8, - /// Owner of the program. + /// Owner of this instance. Distinct from the program's upgrade authority — + /// instance ownership transfers are pure data mutations and never touch the + /// BPF loader (v4 trust model). pub owner: Pubkey, /// Pending next owner (before claiming ownership). pub pending_owner: Option, - /// Mint address of the token managed by this program. + /// Mint address of the token managed by this instance. pub mint: Pubkey, /// Address of the token program (token or token22). This could always be queried /// from the [`mint`] account's owner, but storing it here avoids an indirection /// on the client side. pub token_program: Pubkey, - /// The mode that this program is running in. This is used to determine + /// The mode that this instance is running in. This is used to determine /// whether the program is burning tokens or locking tokens. pub mode: Mode, /// The chain id of the chain that this program is running on. We don't /// hardcode this so that the program is deployable on any potential SVM /// forks. pub chain_id: ChainId, - /// The next transceiver id to use when registering an transceiver. + /// The next transceiver id to use when registering a transceiver under this instance. pub next_transceiver_id: u8, /// The number of transceivers that must attest to a transfer before it is /// accepted. @@ -56,10 +61,6 @@ pub struct Config { pub custody: Pubkey, } -impl Config { - pub const SEED_PREFIX: &'static [u8] = b"config"; -} - #[derive(Accounts)] pub struct NotPausedConfig<'info> { #[account( @@ -68,6 +69,14 @@ pub struct NotPausedConfig<'info> { pub config: Account<'info, Config>, } +impl<'info> NotPausedConfig<'info> { + /// Pass-through to the inner `Account`'s key. + /// Useful in v4 where PDA seeds and signer-seed slices need `config.key()`. + pub fn key(&self) -> Pubkey { + self.config.key() + } +} + impl<'info> Deref for NotPausedConfig<'info> { type Target = Config; diff --git a/solana/programs/example-native-token-transfers/src/error.rs b/solana/programs/example-native-token-transfers/src/error.rs index 9f1b957c9..e26101a27 100644 --- a/solana/programs/example-native-token-transfers/src/error.rs +++ b/solana/programs/example-native-token-transfers/src/error.rs @@ -65,6 +65,10 @@ pub enum NTTError { ThresholdTooHigh, #[msg("InvalidTransceiverProgram")] InvalidTransceiverProgram, + #[msg("InvalidOutboxItem")] + InvalidOutboxItem, + #[msg("InvalidInboxItem")] + InvalidInboxItem, } impl From for NTTError { diff --git a/solana/programs/example-native-token-transfers/src/instructions/admin/mod.rs b/solana/programs/example-native-token-transfers/src/instructions/admin/mod.rs index 375c64271..87e6a19e5 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/admin/mod.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/admin/mod.rs @@ -35,7 +35,7 @@ pub struct SetPeer<'info> { init_if_needed, space = 8 + NttManagerPeer::INIT_SPACE, payer = payer, - seeds = [NttManagerPeer::SEED_PREFIX, args.chain_id.id.to_be_bytes().as_ref()], + seeds = [NttManagerPeer::SEED_PREFIX, config.key().as_ref(), args.chain_id.id.to_be_bytes().as_ref()], bump )] pub peer: Account<'info, NttManagerPeer>, @@ -46,6 +46,7 @@ pub struct SetPeer<'info> { payer = payer, seeds = [ InboxRateLimit::SEED_PREFIX, + config.key().as_ref(), args.chain_id.id.to_be_bytes().as_ref() ], bump, @@ -113,7 +114,7 @@ pub struct RegisterTransceiver<'info> { init_if_needed, space = 8 + RegisteredTransceiver::INIT_SPACE, payer = payer, - seeds = [RegisteredTransceiver::SEED_PREFIX, transceiver.key().as_ref()], + seeds = [RegisteredTransceiver::SEED_PREFIX, config.key().as_ref(), transceiver.key().as_ref()], bump )] pub registered_transceiver: Account<'info, RegisteredTransceiver>, @@ -154,7 +155,7 @@ pub struct DeregisterTransceiver<'info> { pub owner: Signer<'info>, #[account( - seeds = [RegisteredTransceiver::SEED_PREFIX, registered_transceiver.transceiver_address.as_ref()], + seeds = [RegisteredTransceiver::SEED_PREFIX, config.key().as_ref(), registered_transceiver.transceiver_address.as_ref()], bump, constraint = config.enabled_transceivers.get(registered_transceiver.id)? @ NTTError::DisabledTransceiver, )] @@ -190,7 +191,11 @@ pub struct SetOutboundLimit<'info> { pub owner: Signer<'info>, - #[account(mut)] + #[account( + mut, + seeds = [OutboxRateLimit::SEED_PREFIX, config.key().as_ref()], + bump, + )] pub rate_limit: Account<'info, OutboxRateLimit>, } @@ -221,6 +226,7 @@ pub struct SetInboundLimit<'info> { mut, seeds = [ InboxRateLimit::SEED_PREFIX, + config.key().as_ref(), args.chain_id.id.to_be_bytes().as_ref() ], bump = rate_limit.bump diff --git a/solana/programs/example-native-token-transfers/src/instructions/admin/transfer_ownership.rs b/solana/programs/example-native-token-transfers/src/instructions/admin/transfer_ownership.rs index d19bc99d5..7c81c6180 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/admin/transfer_ownership.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/admin/transfer_ownership.rs @@ -1,8 +1,4 @@ use anchor_lang::prelude::*; -use wormhole_solana_utils::cpi::bpf_loader_upgradeable::{self, BpfLoaderUpgradeable}; - -#[cfg(feature = "idl-build")] -use crate::messages::Hack; use crate::{config::Config, error::NTTError}; @@ -21,6 +17,11 @@ use crate::{config::Config, error::NTTError}; /// cannot actually sign transactions (due to setting the wrong address), the program will be /// permanently locked. If the intention is to transfer ownership to a program using this instruction, /// take extra care to ensure that the owner is a PDA, not the program address itself. +/// +/// v4 trust model: instance ownership is decoupled from the program upgrade +/// authority. Transferring instance ownership is a pure data mutation; the BPF +/// loader is never touched. Operators are expected to manage the program +/// upgrade authority separately (typically null'd or held by a multisig). #[derive(Accounts)] pub struct TransferOwnership<'info> { #[account( @@ -36,69 +37,17 @@ pub struct TransferOwnership<'info> { // The intention of new_owner is that it could be an arbitrary account so no constraints are // required here. new_owner: UncheckedAccount<'info>, - - #[account( - seeds = [b"upgrade_lock"], - bump, - )] - /// CHECK: The seeds constraint enforces that this is the correct address - upgrade_lock: UncheckedAccount<'info>, - - #[account( - mut, - seeds = [crate::ID.as_ref()], - bump, - seeds::program = bpf_loader_upgradeable_program, - )] - program_data: Account<'info, ProgramData>, - - bpf_loader_upgradeable_program: Program<'info, BpfLoaderUpgradeable>, } pub fn transfer_ownership(ctx: Context) -> Result<()> { ctx.accounts.config.pending_owner = Some(ctx.accounts.new_owner.key()); - - // only transfer authority when the authority is not already the upgrade lock - if ctx.accounts.program_data.upgrade_authority_address != Some(ctx.accounts.upgrade_lock.key()) - { - return bpf_loader_upgradeable::set_upgrade_authority_checked( - CpiContext::new_with_signer( - ctx.accounts - .bpf_loader_upgradeable_program - .to_account_info(), - bpf_loader_upgradeable::SetUpgradeAuthorityChecked { - program_data: ctx.accounts.program_data.to_account_info(), - current_authority: ctx.accounts.owner.to_account_info(), - new_authority: ctx.accounts.upgrade_lock.to_account_info(), - }, - &[&[b"upgrade_lock", &[ctx.bumps.upgrade_lock]]], - ), - &crate::ID, - ); - } Ok(()) } pub fn transfer_ownership_one_step_unchecked(ctx: Context) -> Result<()> { ctx.accounts.config.pending_owner = None; ctx.accounts.config.owner = ctx.accounts.new_owner.key(); - - // NOTE: unlike in `transfer_ownership`, we use the unchecked version of the - // `set_upgrade_authority` instruction here. The checked version requires - // the new owner to be a signer, which is what we want to avoid here. - bpf_loader_upgradeable::set_upgrade_authority( - CpiContext::new( - ctx.accounts - .bpf_loader_upgradeable_program - .to_account_info(), - bpf_loader_upgradeable::SetUpgradeAuthority { - program_data: ctx.accounts.program_data.to_account_info(), - current_authority: ctx.accounts.owner.to_account_info(), - new_authority: Some(ctx.accounts.new_owner.to_account_info()), - }, - ), - &crate::ID, - ) + Ok(()) } // * Claim ownership @@ -114,42 +63,11 @@ pub struct ClaimOwnership<'info> { )] pub config: Account<'info, Config>, - #[account( - seeds = [b"upgrade_lock"], - bump, - )] - /// CHECK: The seeds constraint enforces that this is the correct address - upgrade_lock: UncheckedAccount<'info>, - pub new_owner: Signer<'info>, - - #[account( - mut, - seeds = [crate::ID.as_ref()], - bump, - seeds::program = bpf_loader_upgradeable_program, - )] - program_data: Account<'info, ProgramData>, - - bpf_loader_upgradeable_program: Program<'info, BpfLoaderUpgradeable>, } pub fn claim_ownership(ctx: Context) -> Result<()> { ctx.accounts.config.pending_owner = None; ctx.accounts.config.owner = ctx.accounts.new_owner.key(); - - bpf_loader_upgradeable::set_upgrade_authority_checked( - CpiContext::new_with_signer( - ctx.accounts - .bpf_loader_upgradeable_program - .to_account_info(), - bpf_loader_upgradeable::SetUpgradeAuthorityChecked { - program_data: ctx.accounts.program_data.to_account_info(), - current_authority: ctx.accounts.upgrade_lock.to_account_info(), - new_authority: ctx.accounts.new_owner.to_account_info(), - }, - &[&[b"upgrade_lock", &[ctx.bumps.upgrade_lock]]], - ), - &crate::ID, - ) + Ok(()) } diff --git a/solana/programs/example-native-token-transfers/src/instructions/admin/transfer_token_authority.rs b/solana/programs/example-native-token-transfers/src/instructions/admin/transfer_token_authority.rs index f2f59a3cd..7b339136a 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/admin/transfer_token_authority.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/admin/transfer_token_authority.rs @@ -20,7 +20,7 @@ pub struct AcceptTokenAuthorityBase<'info> { pub mint: InterfaceAccount<'info, token_interface::Mint>, #[account( - seeds = [crate::TOKEN_AUTHORITY_SEED], + seeds = [crate::TOKEN_AUTHORITY_SEED, config.key().as_ref()], bump, )] /// CHECK: The constraints enforce this is valid mint authority @@ -120,7 +120,7 @@ pub struct SetTokenAuthority<'info> { pub mint: InterfaceAccount<'info, token_interface::Mint>, #[account( - seeds = [crate::TOKEN_AUTHORITY_SEED], + seeds = [crate::TOKEN_AUTHORITY_SEED, config.key().as_ref()], bump, )] /// CHECK: The constraints enforce this is valid mint authority @@ -147,12 +147,14 @@ pub struct SetTokenAuthorityUnchecked<'info> { pub fn set_token_authority_one_step_unchecked( ctx: Context, ) -> Result<()> { + let config_key = ctx.accounts.common.config.key(); match &ctx.accounts.common.multisig_token_authority { Some(multisig_token_authority) => claim_from_multisig_token_authority( ctx.accounts.token_program.to_account_info(), ctx.accounts.common.mint.to_account_info(), multisig_token_authority.to_account_info(), ctx.accounts.common.token_authority.to_account_info(), + config_key, ctx.bumps.common.token_authority, ctx.accounts.common.new_authority.key(), ), @@ -160,6 +162,7 @@ pub fn set_token_authority_one_step_unchecked( ctx.accounts.token_program.to_account_info(), ctx.accounts.common.mint.to_account_info(), ctx.accounts.common.token_authority.to_account_info(), + config_key, ctx.bumps.common.token_authority, ctx.accounts.common.new_authority.key(), ), @@ -185,7 +188,7 @@ pub struct SetTokenAuthorityChecked<'info> { init_if_needed, space = 8 + PendingTokenAuthority::INIT_SPACE, payer = rent_payer, - seeds = [PendingTokenAuthority::SEED_PREFIX], + seeds = [PendingTokenAuthority::SEED_PREFIX, common.config.key().as_ref()], bump )] pub pending_token_authority: Account<'info, PendingTokenAuthority>, @@ -223,7 +226,7 @@ pub struct ClaimTokenAuthorityBase<'info> { pub mint: InterfaceAccount<'info, token_interface::Mint>, #[account( - seeds = [crate::TOKEN_AUTHORITY_SEED], + seeds = [crate::TOKEN_AUTHORITY_SEED, config.key().as_ref()], bump, )] /// CHECK: The seeds constraint enforces that this is the correct address @@ -242,7 +245,7 @@ pub struct ClaimTokenAuthorityBase<'info> { #[account( mut, - seeds = [PendingTokenAuthority::SEED_PREFIX], + seeds = [PendingTokenAuthority::SEED_PREFIX, config.key().as_ref()], bump = pending_token_authority.bump, has_one = rent_payer @ NTTError::IncorrectRentPayer, close = rent_payer @@ -280,12 +283,14 @@ pub struct ClaimTokenAuthority<'info> { } pub fn claim_token_authority(ctx: Context) -> Result<()> { + let config_key = ctx.accounts.common.config.key(); match &ctx.accounts.common.multisig_token_authority { Some(multisig_token_authority) => claim_from_multisig_token_authority( ctx.accounts.common.token_program.to_account_info(), ctx.accounts.common.mint.to_account_info(), multisig_token_authority.to_account_info(), ctx.accounts.common.token_authority.to_account_info(), + config_key, ctx.bumps.common.token_authority, ctx.accounts.new_authority.key(), ), @@ -293,6 +298,7 @@ pub fn claim_token_authority(ctx: Context) -> Result<()> { ctx.accounts.common.token_program.to_account_info(), ctx.accounts.common.mint.to_account_info(), ctx.accounts.common.token_authority.to_account_info(), + config_key, ctx.bumps.common.token_authority, ctx.accounts.new_authority.key(), ), @@ -326,12 +332,14 @@ pub fn claim_token_authority_to_multisig( )?; } + let config_key = ctx.accounts.common.config.key(); match &ctx.accounts.common.multisig_token_authority { Some(multisig_token_authority) => claim_from_multisig_token_authority( ctx.accounts.common.token_program.to_account_info(), ctx.accounts.common.mint.to_account_info(), multisig_token_authority.to_account_info(), ctx.accounts.common.token_authority.to_account_info(), + config_key, ctx.bumps.common.token_authority, ctx.accounts.new_multisig_authority.key(), ), @@ -339,6 +347,7 @@ pub fn claim_token_authority_to_multisig( ctx.accounts.common.token_program.to_account_info(), ctx.accounts.common.mint.to_account_info(), ctx.accounts.common.token_authority.to_account_info(), + config_key, ctx.bumps.common.token_authority, ctx.accounts.new_multisig_authority.key(), ), @@ -349,6 +358,7 @@ fn claim_from_token_authority<'info>( token_program: AccountInfo<'info>, mint: AccountInfo<'info>, token_authority: AccountInfo<'info>, + config_key: Pubkey, token_authority_bump: u8, new_authority: Pubkey, ) -> Result<()> { @@ -359,7 +369,11 @@ fn claim_from_token_authority<'info>( account_or_mint: mint, current_authority: token_authority, }, - &[&[crate::TOKEN_AUTHORITY_SEED, &[token_authority_bump]]], + &[&[ + crate::TOKEN_AUTHORITY_SEED, + config_key.as_ref(), + &[token_authority_bump], + ]], ), AuthorityType::MintTokens, Some(new_authority), @@ -372,6 +386,7 @@ fn claim_from_multisig_token_authority<'info>( mint: AccountInfo<'info>, multisig_token_authority: AccountInfo<'info>, token_authority: AccountInfo<'info>, + config_key: Pubkey, token_authority_bump: u8, new_authority: Pubkey, ) -> Result<()> { @@ -385,7 +400,11 @@ fn claim_from_multisig_token_authority<'info>( &[&token_authority.key()], )?, &[mint, multisig_token_authority, token_authority], - &[&[crate::TOKEN_AUTHORITY_SEED, &[token_authority_bump]]], + &[&[ + crate::TOKEN_AUTHORITY_SEED, + config_key.as_ref(), + &[token_authority_bump], + ]], )?; Ok(()) } diff --git a/solana/programs/example-native-token-transfers/src/instructions/initialize.rs b/solana/programs/example-native-token-transfers/src/instructions/initialize.rs index ccd470e33..001c7dd9d 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/initialize.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/initialize.rs @@ -1,10 +1,6 @@ use anchor_lang::prelude::*; use anchor_spl::{associated_token::AssociatedToken, token_interface}; use ntt_messages::{chain_id::ChainId, mode::Mode}; -use wormhole_solana_utils::cpi::bpf_loader_upgradeable::BpfLoaderUpgradeable; - -#[cfg(feature = "idl-build")] -use crate::messages::Hack; use crate::{ bitmap::Bitmap, @@ -20,22 +16,17 @@ pub struct Initialize<'info> { #[account(mut)] pub payer: Signer<'info>, - #[account(address = program_data.upgrade_authority_address.unwrap_or_default())] - pub deployer: Signer<'info>, - - #[account( - seeds = [crate::ID.as_ref()], - bump, - seeds::program = bpf_loader_upgradeable_program, - )] - program_data: Account<'info, ProgramData>, + /// The owner of the new instance. Distinct from the program's upgrade + /// authority — see the v4 trust-model note in the README. + pub owner: Signer<'info>, + /// The instance account itself. Caller-provided keypair, must sign. + // TODO(v4-rename): consider renaming this field `instance` if/when we + // rename `Config` → `Instance`. #[account( init, - space = 8 + Config::INIT_SPACE, payer = payer, - seeds = [Config::SEED_PREFIX], - bump + space = 8 + Config::INIT_SPACE, )] pub config: Box>, @@ -52,18 +43,17 @@ pub struct Initialize<'info> { init, payer = payer, space = 8 + OutboxRateLimit::INIT_SPACE, - seeds = [OutboxRateLimit::SEED_PREFIX], + seeds = [OutboxRateLimit::SEED_PREFIX, config.key().as_ref()], bump, )] pub rate_limit: Account<'info, OutboxRateLimit>, #[account( - seeds = [crate::TOKEN_AUTHORITY_SEED], + seeds = [crate::TOKEN_AUTHORITY_SEED, config.key().as_ref()], bump, )] - /// CHECK: [`token_authority`] is checked against the custody account and the [`mint`]'s mint_authority - /// In any case, this function is used to set the Config and initialize the program so we - /// assume the caller of this function will have total control over the program. + /// CHECK: [`token_authority`] is checked against the custody account and the [`mint`]'s mint_authority. + /// Per-instance token authority lets each instance manage its own mint independently. /// /// TODO: Using `UncheckedAccount` here leads to "Access violation in stack frame ...". /// Could refactor code to use `Box<_>` to reduce stack size. @@ -93,7 +83,6 @@ pub struct Initialize<'info> { /// associated token account for the given mint. pub token_program: Interface<'info, token_interface::TokenInterface>, pub associated_token_program: Program<'info, AssociatedToken>, - bpf_loader_upgradeable_program: Program<'info, BpfLoaderUpgradeable>, system_program: Program<'info, System>, } @@ -106,29 +95,21 @@ pub struct InitializeArgs { } pub fn initialize(ctx: Context, args: InitializeArgs) -> Result<()> { - initialize_config_and_rate_limit( - ctx.accounts, - ctx.bumps.config, - args.chain_id, - args.limit, - args.mode, - ) + initialize_config_and_rate_limit(ctx.accounts, args.chain_id, args.limit, args.mode) } fn initialize_config_and_rate_limit( common: &mut Initialize<'_>, - config_bump: u8, chain_id: u16, limit: u64, mode: ntt_messages::mode::Mode, ) -> Result<()> { common.config.set_inner(crate::config::Config { - bump: config_bump, mint: common.mint.key(), token_program: common.token_program.key(), mode, chain_id: ChainId { id: chain_id }, - owner: common.deployer.key(), + owner: common.owner.key(), pending_owner: None, paused: false, next_transceiver_id: 0, diff --git a/solana/programs/example-native-token-transfers/src/instructions/luts.rs b/solana/programs/example-native-token-transfers/src/instructions/luts.rs index 37e5d5ec7..df89cbd09 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/luts.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/luts.rs @@ -47,7 +47,7 @@ pub struct InitializeLUT<'info> { pub owner: Signer<'info>, #[account( - seeds = [b"lut_authority"], + seeds = [b"lut_authority", entries.config.key().as_ref()], bump )] /// CHECK: The seeds constraint enforces that this is the correct account. @@ -66,7 +66,7 @@ pub struct InitializeLUT<'info> { init_if_needed, payer = payer, space = 8 + LUT::INIT_SPACE, - seeds = [b"lut"], + seeds = [b"lut", entries.config.key().as_ref()], bump )] pub lut: Account<'info, LUT>, @@ -105,12 +105,16 @@ pub struct Entries<'info> { pub mint: UncheckedAccount<'info>, #[account( - seeds = [crate::TOKEN_AUTHORITY_SEED], + seeds = [crate::TOKEN_AUTHORITY_SEED, config.key().as_ref()], bump, )] /// CHECK: The seeds constraint enforces that this is the correct account. pub token_authority: UncheckedAccount<'info>, + #[account( + seeds = [OutboxRateLimit::SEED_PREFIX, config.key().as_ref()], + bump, + )] pub outbox_rate_limit: Account<'info, OutboxRateLimit>, // NOTE: this includes the system program so we don't need to add it in the outer context @@ -177,7 +181,11 @@ pub fn initialize_lut(ctx: Context, recent_slot: u64) -> Result<( ctx.accounts.payer.to_account_info(), ctx.accounts.system_program.to_account_info(), ], - &[&[b"lut_authority", &[ctx.bumps.authority]]], + &[&[ + b"lut_authority", + ctx.accounts.entries.config.key().as_ref(), + &[ctx.bumps.authority], + ]], )?; Ok(()) diff --git a/solana/programs/example-native-token-transfers/src/instructions/mark_outbox_item_as_released.rs b/solana/programs/example-native-token-transfers/src/instructions/mark_outbox_item_as_released.rs index a74225b5b..289a5fac4 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/mark_outbox_item_as_released.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/mark_outbox_item_as_released.rs @@ -10,7 +10,7 @@ pub const OUTBOX_ITEM_SIGNER_SEED: &[u8] = b"outbox_item_signer"; #[derive(Accounts)] pub struct MarkOutboxItemAsReleased<'info> { #[account( - seeds = [OUTBOX_ITEM_SIGNER_SEED], + seeds = [OUTBOX_ITEM_SIGNER_SEED, config.key().as_ref()], seeds::program = transceiver.transceiver_address, bump )] @@ -20,11 +20,14 @@ pub struct MarkOutboxItemAsReleased<'info> { #[account( mut, + constraint = outbox_item.manager == config.key() @ NTTError::InvalidOutboxItem, constraint = !outbox_item.released.get(transceiver.id)? @ NTTError::MessageAlreadySent, )] pub outbox_item: Account<'info, OutboxItem>, #[account( + seeds = [RegisteredTransceiver::SEED_PREFIX, config.key().as_ref(), transceiver.transceiver_address.as_ref()], + bump, constraint = config.enabled_transceivers.get(transceiver.id)? @ NTTError::DisabledTransceiver )] pub transceiver: Account<'info, RegisteredTransceiver>, diff --git a/solana/programs/example-native-token-transfers/src/instructions/redeem.rs b/solana/programs/example-native-token-transfers/src/instructions/redeem.rs index ab594e292..d1859cbe5 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/redeem.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/redeem.rs @@ -29,7 +29,7 @@ pub struct Redeem<'info> { pub config: Account<'info, Config>, #[account( - seeds = [NttManagerPeer::SEED_PREFIX, ValidatedTransceiverMessage::>::from_chain(&transceiver_message)?.id.to_be_bytes().as_ref()], + seeds = [NttManagerPeer::SEED_PREFIX, config.key().as_ref(), ValidatedTransceiverMessage::>::from_chain(&transceiver_message)?.id.to_be_bytes().as_ref()], constraint = peer.address == ValidatedTransceiverMessage::>::message(&transceiver_message.try_borrow_data()?[..])?.source_ntt_manager() @ NTTError::InvalidNttManagerPeer, bump = peer.bump, )] @@ -39,9 +39,24 @@ pub struct Redeem<'info> { // check that the message is targeted to this chain constraint = ValidatedTransceiverMessage::>::message(&transceiver_message.try_borrow_data()?[..])?.ntt_manager_payload().payload.to_chain == config.chain_id @ NTTError::InvalidChainId, // check that we're the intended recipient - constraint = ValidatedTransceiverMessage::>::message(&transceiver_message.try_borrow_data()?[..])?.recipient_ntt_manager() == crate::ID.to_bytes() @ NTTError::InvalidRecipientNttManager, + // v4: the on-the-wire manager identity is the instance's `config` pubkey. + constraint = ValidatedTransceiverMessage::>::message(&transceiver_message.try_borrow_data()?[..])?.recipient_ntt_manager() == config.key().to_bytes() @ NTTError::InvalidRecipientNttManager, // NOTE: we don't replay protect VAAs. Instead, we replay protect // executing the messages themselves with the [`released`] flag. + // Cryptographically bind this account to the instance: since + // receive_message seeds transceiver_message with config.key(), a message + // created for instance A cannot be passed here for instance B. + seeds = [ + ValidatedTransceiverMessage::>::SEED_PREFIX, + config.key().as_ref(), + ValidatedTransceiverMessage::>::from_chain(&transceiver_message)?.id.to_be_bytes().as_ref(), + ValidatedTransceiverMessage::>::message(&transceiver_message.try_borrow_data()?[..])?.ntt_manager_payload().id.as_ref(), + ], + seeds::program = transceiver.transceiver_address, + bump, + // `owner` is implied by the seeds check above (a valid PDA of + // `transceiver.transceiver_address` must be owned by it), but kept + // here for clarity. owner = transceiver.transceiver_address )] /// CHECK: `transceiver_message` has to be manually deserialized as Anchor @@ -49,6 +64,8 @@ pub struct Redeem<'info> { pub transceiver_message: UncheckedAccount<'info>, #[account( + seeds = [RegisteredTransceiver::SEED_PREFIX, config.key().as_ref(), transceiver.transceiver_address.as_ref()], + bump, constraint = config.enabled_transceivers.get(transceiver.id)? @ NTTError::DisabledTransceiver )] pub transceiver: Account<'info, RegisteredTransceiver>, @@ -64,6 +81,7 @@ pub struct Redeem<'info> { space = 8 + InboxItem::INIT_SPACE, seeds = [ InboxItem::SEED_PREFIX, + config.key().as_ref(), ValidatedTransceiverMessage::>::message(&transceiver_message.try_borrow_data()?[..])?.ntt_manager_payload().keccak256( ValidatedTransceiverMessage::>::from_chain(&transceiver_message)? ).as_ref(), @@ -88,13 +106,18 @@ pub struct Redeem<'info> { mut, seeds = [ InboxRateLimit::SEED_PREFIX, + config.key().as_ref(), ValidatedTransceiverMessage::>::from_chain(&transceiver_message)?.id.to_be_bytes().as_ref(), ], bump, )] pub inbox_rate_limit: Account<'info, InboxRateLimit>, - #[account(mut)] + #[account( + mut, + seeds = [OutboxRateLimit::SEED_PREFIX, config.key().as_ref()], + bump, + )] pub outbox_rate_limit: Account<'info, OutboxRateLimit>, pub system_program: Program<'info, System>, @@ -127,10 +150,12 @@ pub fn redeem(ctx: Context, _args: RedeemArgs) -> Result<()> { if !accs.inbox_item.init { let recipient_address = Pubkey::try_from(message.payload.to).map_err(|_| NTTError::InvalidRecipientAddress)?; + let config = accs.config.key(); accs.inbox_item.set_inner(InboxItem { init: true, bump: ctx.bumps.inbox_item, + config, amount, recipient_address, release_status: ReleaseStatus::NotApproved, diff --git a/solana/programs/example-native-token-transfers/src/instructions/release_inbound.rs b/solana/programs/example-native-token-transfers/src/instructions/release_inbound.rs index 298e446bc..3610caf9e 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/release_inbound.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/release_inbound.rs @@ -17,7 +17,16 @@ pub struct ReleaseInbound<'info> { pub config: NotPausedConfig<'info>, - #[account(mut)] + // SECURITY: the inbox item is created (in [`crate::instructions::redeem`]) as a PDA seeded by + // the instance's `config` key, but here it arrives as an already-allocated account with no way + // to re-derive those seeds (we don't have the original message). Without binding it back to + // `config`, an inbox item validated under one instance could be released against a *different* + // instance, whose `mint`/`custody`/`token_authority` are the ones actually used below — minting + // or unlocking that instance's tokens for a transfer it never received. + #[account( + mut, + constraint = inbox_item.config == config.key() @ NTTError::InvalidInboxItem, + )] pub inbox_item: Account<'info, InboxItem>, #[account( @@ -29,7 +38,7 @@ pub struct ReleaseInbound<'info> { pub recipient: InterfaceAccount<'info, token_interface::TokenAccount>, #[account( - seeds = [crate::TOKEN_AUTHORITY_SEED], + seeds = [crate::TOKEN_AUTHORITY_SEED, config.key().as_ref()], bump, )] /// CHECK: The seeds constraint ensures that this is the correct address @@ -112,8 +121,10 @@ pub fn release_inbound_mint<'info>( // The [`transfer_burn`] function operates in a similar way // (transfer to custody from sender, *then* burn). + let config_key = ctx.accounts.common.config.key(); let token_authority_sig: &[&[&[u8]]] = &[&[ crate::TOKEN_AUTHORITY_SEED, + config_key.as_ref(), &[ctx.bumps.common.token_authority], ]]; @@ -233,6 +244,7 @@ pub fn release_inbound_unlock<'info>( let inbox_item = inbox_item.unwrap(); assert!(inbox_item.release_status == ReleaseStatus::Released); + let config_key = ctx.accounts.common.config.key(); onchain::invoke_transfer_checked( &ctx.accounts.common.token_program.key(), ctx.accounts.common.custody.to_account_info(), @@ -244,6 +256,7 @@ pub fn release_inbound_unlock<'info>( ctx.accounts.common.mint.decimals, &[&[ crate::TOKEN_AUTHORITY_SEED, + config_key.as_ref(), &[ctx.bumps.common.token_authority], ]], )?; diff --git a/solana/programs/example-native-token-transfers/src/instructions/transfer.rs b/solana/programs/example-native-token-transfers/src/instructions/transfer.rs index cab1dc524..70a66b5c2 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/transfer.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/transfer.rs @@ -67,7 +67,11 @@ pub struct Transfer<'info> { )] pub outbox_item: Account<'info, OutboxItem>, - #[account(mut)] + #[account( + mut, + seeds = [OutboxRateLimit::SEED_PREFIX, config.key().as_ref()], + bump, + )] pub outbox_rate_limit: Account<'info, OutboxRateLimit>, #[account( @@ -119,7 +123,7 @@ pub struct TransferBurn<'info> { #[account( mut, - seeds = [InboxRateLimit::SEED_PREFIX, args.recipient_chain.id.to_be_bytes().as_ref()], + seeds = [InboxRateLimit::SEED_PREFIX, common.config.key().as_ref(), args.recipient_chain.id.to_be_bytes().as_ref()], bump = inbox_rate_limit.bump, )] // NOTE: it would be nice to put these into `common`, but that way we don't @@ -127,7 +131,7 @@ pub struct TransferBurn<'info> { pub inbox_rate_limit: Account<'info, InboxRateLimit>, #[account( - seeds = [NttManagerPeer::SEED_PREFIX, args.recipient_chain.id.to_be_bytes().as_ref()], + seeds = [NttManagerPeer::SEED_PREFIX, common.config.key().as_ref(), args.recipient_chain.id.to_be_bytes().as_ref()], bump = peer.bump, )] pub peer: Account<'info, NttManagerPeer>, @@ -135,6 +139,7 @@ pub struct TransferBurn<'info> { #[account( seeds = [ crate::SESSION_AUTHORITY_SEED, + common.config.key().as_ref(), common.from.owner.as_ref(), args.keccak256().as_ref() ], @@ -145,7 +150,7 @@ pub struct TransferBurn<'info> { pub session_authority: UncheckedAccount<'info>, #[account( - seeds = [crate::TOKEN_AUTHORITY_SEED], + seeds = [crate::TOKEN_AUTHORITY_SEED, common.config.key().as_ref()], bump, )] /// CHECK: The seeds constraint enforces that this is the correct account. @@ -204,6 +209,7 @@ pub fn transfer_burn<'info>( accs.common.mint.decimals, &[&[ crate::SESSION_AUTHORITY_SEED, + accs.common.config.key().as_ref(), accs.common.from.owner.as_ref(), args.keccak256().as_ref(), &[ctx.bumps.session_authority], @@ -219,7 +225,11 @@ pub fn transfer_burn<'info>( from: accs.common.custody.to_account_info(), authority: accs.token_authority.to_account_info(), }, - &[&[crate::TOKEN_AUTHORITY_SEED, &[ctx.bumps.token_authority]]], + &[&[ + crate::TOKEN_AUTHORITY_SEED, + accs.common.config.key().as_ref(), + &[ctx.bumps.token_authority], + ]], ), amount, )?; @@ -267,7 +277,7 @@ pub struct TransferLock<'info> { #[account( mut, - seeds = [InboxRateLimit::SEED_PREFIX, args.recipient_chain.id.to_be_bytes().as_ref()], + seeds = [InboxRateLimit::SEED_PREFIX, common.config.key().as_ref(), args.recipient_chain.id.to_be_bytes().as_ref()], bump = inbox_rate_limit.bump, )] // NOTE: it would be nice to put these into `common`, but that way we don't @@ -275,7 +285,7 @@ pub struct TransferLock<'info> { pub inbox_rate_limit: Account<'info, InboxRateLimit>, #[account( - seeds = [NttManagerPeer::SEED_PREFIX, args.recipient_chain.id.to_be_bytes().as_ref()], + seeds = [NttManagerPeer::SEED_PREFIX, common.config.key().as_ref(), args.recipient_chain.id.to_be_bytes().as_ref()], bump = peer.bump, )] pub peer: Account<'info, NttManagerPeer>, @@ -283,6 +293,7 @@ pub struct TransferLock<'info> { #[account( seeds = [ crate::SESSION_AUTHORITY_SEED, + common.config.key().as_ref(), common.from.owner.as_ref(), args.keccak256().as_ref() ], @@ -330,6 +341,7 @@ pub fn transfer_lock<'info>( accs.common.mint.decimals, &[&[ crate::SESSION_AUTHORITY_SEED, + accs.common.config.key().as_ref(), accs.common.from.owner.as_ref(), args.keccak256().as_ref(), &[ctx.bumps.session_authority], @@ -392,6 +404,7 @@ fn insert_into_outbox( }; common.outbox_item.set_inner(OutboxItem { + manager: common.config.key(), amount: trimmed_amount, sender: common.from.owner, recipient_chain, diff --git a/solana/programs/example-native-token-transfers/src/lib.rs b/solana/programs/example-native-token-transfers/src/lib.rs index 49d6d802f..27424296b 100644 --- a/solana/programs/example-native-token-transfers/src/lib.rs +++ b/solana/programs/example-native-token-transfers/src/lib.rs @@ -65,7 +65,7 @@ pub const TOKEN_AUTHORITY_SEED: &[u8] = b"token_authority"; /// user, atomically). pub const SESSION_AUTHORITY_SEED: &[u8] = b"session_authority"; -pub const VERSION: &str = "3.0.0"; +pub const VERSION: &str = "4.0.0"; #[program] pub mod example_native_token_transfers { diff --git a/solana/programs/example-native-token-transfers/src/queue/inbox.rs b/solana/programs/example-native-token-transfers/src/queue/inbox.rs index e05cf3b26..3948335ad 100644 --- a/solana/programs/example-native-token-transfers/src/queue/inbox.rs +++ b/solana/programs/example-native-token-transfers/src/queue/inbox.rs @@ -13,6 +13,14 @@ pub struct InboxItem { // to guard against modifications to the `bump` and `amounts` fields. pub init: bool, pub bump: u8, + /// The instance (`config`) this inbox item belongs to. In [`crate::instructions::redeem`] + /// the inbox item PDA is seeded by `config.key()`, so this is implied by the address; we + /// also store it explicitly so that [`crate::instructions::release_inbound`] — which only + /// receives the already-created account and has no message to re-derive the seeds from — can + /// verify the binding. Without it, a transfer validated under one instance could be released + /// against another instance's mint/custody/token authority. + /// SECURITY: set once at creation, behind the [`init`] guard, and never mutated afterwards. + pub config: Pubkey, pub amount: u64, pub recipient_address: Pubkey, pub votes: Bitmap, diff --git a/solana/programs/example-native-token-transfers/src/queue/outbox.rs b/solana/programs/example-native-token-transfers/src/queue/outbox.rs index efdde23a9..f5980c5f8 100644 --- a/solana/programs/example-native-token-transfers/src/queue/outbox.rs +++ b/solana/programs/example-native-token-transfers/src/queue/outbox.rs @@ -10,6 +10,11 @@ use super::rate_limit::RateLimitState; #[derive(InitSpace, Debug, PartialEq, Eq)] // TODO: generalise this to arbitrary outbound messages (via a generic parameter in place of amount and recipient info) pub struct OutboxItem { + /// v4: the `Config` (instance) that produced this outbox item. Used to + /// bind the item to its source instance so transceivers can verify they're + /// releasing/marking an item that belongs to the instance they're working + /// on (`released` is a bitmap keyed by per-instance transceiver_id). + pub manager: Pubkey, pub amount: TrimmedAmount, pub sender: Pubkey, pub recipient_chain: ChainId, diff --git a/solana/programs/example-native-token-transfers/src/transceivers/wormhole/accounts.rs b/solana/programs/example-native-token-transfers/src/transceivers/wormhole/accounts.rs index 97f6dd11f..10a631c46 100644 --- a/solana/programs/example-native-token-transfers/src/transceivers/wormhole/accounts.rs +++ b/solana/programs/example-native-token-transfers/src/transceivers/wormhole/accounts.rs @@ -42,12 +42,17 @@ pub struct WormholeAccounts<'info> { /// and [`WormholeAccounts::sequence`] must be checked by the Wormhole core bridge. /// SECURITY: Signer checks are disabled. The only valid sender is the /// [`wormhole::PostMessage::emitter`], enforced by the [`CpiContext`] below. +/// `config` is the per-instance Config pubkey embedded in the emitter PDA +/// seed list (v4). Callers pass `accs.config.key()` so the CPI signer-seed +/// slice matches the on-chain `[b"emitter", config.key()]` PDA layout. +#[allow(clippy::too_many_arguments)] pub fn post_message<'info, A: TypePrefixedPayload>( wormhole: &WormholeAccounts<'info>, payer: AccountInfo<'info>, message: AccountInfo<'info>, emitter: AccountInfo<'info>, emitter_bump: u8, + config: Pubkey, payload: &A, additional_seeds: &[&[&[u8]]], ) -> Result<()> { @@ -68,7 +73,7 @@ pub fn post_message<'info, A: TypePrefixedPayload>( }; let seeds: &[&[&[&[u8]]]] = &[ - &[&[b"emitter".as_slice(), &[emitter_bump]]], + &[&[b"emitter".as_slice(), config.as_ref(), &[emitter_bump]]], additional_seeds, ]; diff --git a/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/admin.rs b/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/admin.rs index 0ead95ba5..4053df8c5 100644 --- a/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/admin.rs +++ b/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/admin.rs @@ -20,7 +20,7 @@ pub struct SetTransceiverPeer<'info> { init, space = 8 + TransceiverPeer::INIT_SPACE, payer = payer, - seeds = [TransceiverPeer::SEED_PREFIX, args.chain_id.id.to_be_bytes().as_ref()], + seeds = [TransceiverPeer::SEED_PREFIX, config.key().as_ref(), args.chain_id.id.to_be_bytes().as_ref()], bump )] pub peer: Account<'info, TransceiverPeer>, diff --git a/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/broadcast_id.rs b/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/broadcast_id.rs index 78bde1995..3055bfa6e 100644 --- a/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/broadcast_id.rs +++ b/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/broadcast_id.rs @@ -21,7 +21,7 @@ pub struct BroadcastId<'info> { pub wormhole_message: Signer<'info>, #[account( - seeds = [b"emitter"], + seeds = [b"emitter", config.key().as_ref()], bump )] /// CHECK: The only valid sender is the [`wormhole::PostMessage::emitter`] @@ -34,8 +34,10 @@ pub struct BroadcastId<'info> { pub fn broadcast_id(ctx: Context) -> Result<()> { let accs = ctx.accounts; + // v4: the on-the-wire manager identity is the instance's `config` pubkey, + // not the program ID. EVM/Sui peers register against `config.key()`. let message = WormholeTransceiverInfo { - manager_address: accs.config.to_account_info().owner.to_bytes(), + manager_address: accs.config.key().to_bytes(), manager_mode: accs.config.mode, token_address: accs.mint.to_account_info().key.to_bytes(), token_decimals: accs.mint.decimals, @@ -48,6 +50,7 @@ pub fn broadcast_id(ctx: Context) -> Result<()> { accs.wormhole_message.to_account_info(), accs.emitter.to_account_info(), ctx.bumps.emitter, + accs.config.key(), &message, &[], )?; diff --git a/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/broadcast_peer.rs b/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/broadcast_peer.rs index c181fb6d0..e43c697ec 100644 --- a/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/broadcast_peer.rs +++ b/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/broadcast_peer.rs @@ -15,7 +15,7 @@ pub struct BroadcastPeer<'info> { pub config: Account<'info, Config>, #[account( - seeds = [TransceiverPeer::SEED_PREFIX, args.chain_id.to_be_bytes().as_ref()], + seeds = [TransceiverPeer::SEED_PREFIX, config.key().as_ref(), args.chain_id.to_be_bytes().as_ref()], bump )] pub peer: Account<'info, TransceiverPeer>, @@ -25,7 +25,7 @@ pub struct BroadcastPeer<'info> { pub wormhole_message: Signer<'info>, #[account( - seeds = [b"emitter"], + seeds = [b"emitter", config.key().as_ref()], bump )] /// CHECK: The seeds constraint ensures that this is the correct address @@ -57,6 +57,7 @@ pub fn broadcast_peer(ctx: Context, args: BroadcastPeerArgs) -> R accs.wormhole_message.to_account_info(), accs.emitter.to_account_info(), ctx.bumps.emitter, + accs.config.key(), &message, &[], )?; diff --git a/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/receive_message.rs b/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/receive_message.rs index ae3db77e0..7e62d907f 100644 --- a/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/receive_message.rs +++ b/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/receive_message.rs @@ -21,7 +21,7 @@ pub struct ReceiveMessage<'info> { pub config: NotPausedConfig<'info>, #[account( - seeds = [TransceiverPeer::SEED_PREFIX, vaa.emitter_chain().to_be_bytes().as_ref()], + seeds = [TransceiverPeer::SEED_PREFIX, config.key().as_ref(), vaa.emitter_chain().to_be_bytes().as_ref()], constraint = peer.address == *vaa.emitter_address() @ NTTError::InvalidTransceiverPeer, bump = peer.bump, )] @@ -33,6 +33,15 @@ pub struct ReceiveMessage<'info> { #[account( // check that the messages is targeted to this chain constraint = vaa.message().ntt_manager_payload.payload.to_chain == config.chain_id @ NTTError::InvalidChainId, + // check that the message is addressed to *this* instance. The + // `transceiver_message` PDA below is scoped by `config.key()`, but + // nothing otherwise binds the message's `recipient_ntt_manager` to it, + // so a VAA addressed to instance A could be used to create a (redundant, + // and ultimately unredeemable) transceiver message under instance B. + // [`crate::instructions::redeem`] enforces this for fund safety; we also + // check it here so the binding is local and the mis-scoped account is + // never created in the first place. + constraint = vaa.message().recipient_ntt_manager == config.key().to_bytes() @ NTTError::InvalidRecipientNttManager, // NOTE: we don't replay protect VAAs. Instead, we replay protect // executing the messages themselves with the [`released`] flag. )] @@ -47,6 +56,7 @@ pub struct ReceiveMessage<'info> { space = 8 + ValidatedTransceiverMessage::>>::INIT_SPACE, seeds = [ ValidatedTransceiverMessage::>>::SEED_PREFIX, + config.key().as_ref(), vaa.emitter_chain().to_be_bytes().as_ref(), vaa.message().ntt_manager_payload.id.as_ref(), ], diff --git a/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/release_outbound.rs b/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/release_outbound.rs index 6c4ebbb20..87072ed66 100644 --- a/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/release_outbound.rs +++ b/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/release_outbound.rs @@ -19,11 +19,14 @@ pub struct ReleaseOutbound<'info> { #[account( mut, + constraint = outbox_item.manager == config.key() @ NTTError::InvalidOutboxItem, constraint = !outbox_item.released.get(transceiver.id)? @ NTTError::MessageAlreadySent, )] pub outbox_item: Account<'info, OutboxItem>, #[account( + seeds = [RegisteredTransceiver::SEED_PREFIX, config.key().as_ref(), transceiver.transceiver_address.as_ref()], + bump, constraint = transceiver.transceiver_address == crate::ID, constraint = config.enabled_transceivers.get(transceiver.id)? @ NTTError::DisabledTransceiver )] @@ -38,7 +41,7 @@ pub struct ReleaseOutbound<'info> { pub wormhole_message: UncheckedAccount<'info>, #[account( - seeds = [b"emitter"], + seeds = [b"emitter", config.key().as_ref()], bump )] // TODO: do we want to put anything in here? @@ -68,8 +71,10 @@ pub fn release_outbound(ctx: Context, args: ReleaseOutboundArgs assert!(accs.outbox_item.released.get(accs.transceiver.id)?); let message: TransceiverMessage> = TransceiverMessage::new( - // TODO: should we just put the ntt id here statically? - accs.outbox_item.to_account_info().owner.to_bytes(), + // v4: the on-the-wire manager identity is the instance's `config` + // pubkey, not the program ID. Each instance is independently + // peered with remote chains. + accs.config.key().to_bytes(), accs.outbox_item.recipient_ntt_manager, NttManagerMessage { id: accs.outbox_item.key().to_bytes(), @@ -91,6 +96,7 @@ pub fn release_outbound(ctx: Context, args: ReleaseOutboundArgs accs.wormhole_message.to_account_info(), accs.emitter.to_account_info(), ctx.bumps.emitter, + accs.config.key(), &message, &[&[ b"message", diff --git a/solana/programs/example-native-token-transfers/tests/admin.rs b/solana/programs/example-native-token-transfers/tests/admin.rs index 1884afaff..a1ae33cd9 100644 --- a/solana/programs/example-native-token-transfers/tests/admin.rs +++ b/solana/programs/example-native-token-transfers/tests/admin.rs @@ -22,6 +22,7 @@ use test_utils::{ #[tokio::test] async fn test_invalid_transceiver() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); // try registering system program let err = register_transceiver( @@ -47,6 +48,8 @@ async fn test_invalid_transceiver() { #[tokio::test] async fn test_reregister_all_transceivers() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); // Transceivers are expected to be executable which requires them to be added on setup // Thus, we pass all available executable program IDs as dummy_transceivers @@ -138,6 +141,8 @@ async fn test_reregister_all_transceivers() { #[tokio::test] async fn test_deregister_last_enabled_transceiver() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); // attempt to deregister only enabled transceiver (baked-in transceiver) let err = deregister_transceiver( @@ -207,6 +212,7 @@ async fn test_deregister_last_enabled_transceiver() { #[tokio::test] async fn test_zero_threshold() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let err = set_threshold( &good_ntt, @@ -230,6 +236,7 @@ async fn test_zero_threshold() { #[tokio::test] async fn test_threshold_too_high() { let (mut ctx, test_data) = setup(Mode::Burning).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let err = set_threshold( &good_ntt, diff --git a/solana/programs/example-native-token-transfers/tests/broadcast.rs b/solana/programs/example-native-token-transfers/tests/broadcast.rs index eeccf1918..cf60025db 100644 --- a/solana/programs/example-native-token-transfers/tests/broadcast.rs +++ b/solana/programs/example-native-token-transfers/tests/broadcast.rs @@ -30,7 +30,9 @@ use wormhole_anchor_sdk::wormhole::PostedVaa; #[tokio::test] async fn test_broadcast_peer() { - let (mut ctx, _test_data) = setup(Mode::Locking).await; + let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let wh_message = Keypair::new(); @@ -63,6 +65,8 @@ async fn test_broadcast_peer() { #[tokio::test] async fn test_broadcast_id() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let wh_message = Keypair::new(); @@ -86,7 +90,8 @@ async fn test_broadcast_id() { assert_eq!( *msg.data(), WormholeTransceiverInfo { - manager_address: good_ntt.program().to_bytes(), + // v4: on-the-wire manager identity is the instance pubkey, not the program ID. + manager_address: good_ntt.config().to_bytes(), manager_mode: Mode::Locking, token_address: test_data.mint.to_bytes(), token_decimals: 9, diff --git a/solana/programs/example-native-token-transfers/tests/cancel_flow.rs b/solana/programs/example-native-token-transfers/tests/cancel_flow.rs index 8f1e4a4a4..b2db44d7b 100644 --- a/solana/programs/example-native-token-transfers/tests/cancel_flow.rs +++ b/solana/programs/example-native-token-transfers/tests/cancel_flow.rs @@ -31,6 +31,8 @@ use wormhole_sdk::Address; async fn test_cancel() { let recipient = Keypair::new(); let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let msg0 = make_transfer_message(&good_ntt, [0u8; 32], 1000, &recipient.pubkey()); let msg1 = make_transfer_message(&good_ntt, [1u8; 32], 2000, &recipient.pubkey()); diff --git a/solana/programs/example-native-token-transfers/tests/governance.rs b/solana/programs/example-native-token-transfers/tests/governance.rs index ec188591c..37db61107 100644 --- a/solana/programs/example-native-token-transfers/tests/governance.rs +++ b/solana/programs/example-native-token-transfers/tests/governance.rs @@ -27,7 +27,6 @@ use wormhole_governance::{ instructions::{GovernanceMessage, ReplayProtection, OWNER}, }; use wormhole_sdk::{Address, Vaa, GOVERNANCE_EMITTER}; -use wormhole_solana_utils::cpi::bpf_loader_upgradeable; async fn post_governance_vaa( ctx: &mut ProgramTestContext, @@ -67,6 +66,7 @@ async fn transfer_ownership_to_gov_program( core::result::Result, BanksClientError>, Instruction, ) { + let good_ntt = good_ntt(test_data.instance.pubkey()); let governance_pda = test_data.governance.governance(); // step 1. transfer ownership to governance @@ -76,9 +76,6 @@ async fn transfer_ownership_to_gov_program( config: good_ntt.config(), owner: test_data.program_owner.pubkey(), new_owner: governance_pda, - upgrade_lock: good_ntt.upgrade_lock(), - program_data: good_ntt.program_data(), - bpf_loader_upgradeable_program: bpf_loader_upgradeable::id(), }; Instruction { @@ -95,9 +92,6 @@ async fn transfer_ownership_to_gov_program( let inner_ix_accs = example_native_token_transfers::accounts::ClaimOwnership { new_owner: OWNER, config: good_ntt.config(), - upgrade_lock: good_ntt.upgrade_lock(), - program_data: good_ntt.program_data(), - bpf_loader_upgradeable_program: bpf_loader_upgradeable::id(), }; let inner_ix: Instruction = Instruction { @@ -126,6 +120,7 @@ async fn transfer_ownership_to_gov_program( #[tokio::test] async fn test_governance() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); transfer_ownership_to_gov_program(&mut ctx, &test_data, None) .await @@ -152,6 +147,7 @@ async fn test_governance() { #[tokio::test] async fn test_governance_one_step_transfer() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let governance_pda = test_data.governance.governance(); @@ -162,9 +158,6 @@ async fn test_governance_one_step_transfer() { config: good_ntt.config(), owner: test_data.program_owner.pubkey(), new_owner: governance_pda, - upgrade_lock: good_ntt.upgrade_lock(), - program_data: good_ntt.program_data(), - bpf_loader_upgradeable_program: bpf_loader_upgradeable::id(), }; Instruction { @@ -199,6 +192,7 @@ async fn test_governance_one_step_transfer() { #[tokio::test] async fn test_governance_bad_emitter() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let err = wrap_governance( &mut ctx, @@ -242,6 +236,7 @@ async fn test_governance_bad_governance_contract() { #[tokio::test] async fn test_governance_replay() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let (vaa, inner_ix) = transfer_ownership_to_gov_program(&mut ctx, &test_data, None).await; diff --git a/solana/programs/example-native-token-transfers/tests/receive.rs b/solana/programs/example-native-token-transfers/tests/receive.rs index 8b7fa0ce0..e660ff523 100644 --- a/solana/programs/example-native-token-transfers/tests/receive.rs +++ b/solana/programs/example-native-token-transfers/tests/receive.rs @@ -5,7 +5,7 @@ use anchor_lang::prelude::*; use anchor_spl::token::{Token, TokenAccount}; use example_native_token_transfers::{ error::NTTError, - instructions::{RedeemArgs, ReleaseInboundArgs}, + instructions::{InitializeArgs, RedeemArgs, ReleaseInboundArgs}, }; use ntt_messages::mode::Mode; use solana_program::instruction::InstructionError; @@ -16,7 +16,7 @@ use solana_sdk::{ use spl_associated_token_account::get_associated_token_address_with_program_id; use test_utils::{ common::{ - fixtures::{ANOTHER_CHAIN, OTHER_CHAIN, OTHER_TRANSCEIVER}, + fixtures::{ANOTHER_CHAIN, OTHER_CHAIN, OTHER_TRANSCEIVER, OUTBOUND_LIMIT, THIS_CHAIN}, query::GetAccountDataAnchor, submit::Submittable, }, @@ -26,6 +26,7 @@ use test_utils::{ sdk::{ accounts::{good_ntt, NTTAccounts}, instructions::{ + initialize::{initialize_with_token_program_id, Initialize}, redeem::redeem, release_inbound::{release_inbound_unlock, ReleaseInbound}, }, @@ -40,6 +41,8 @@ use wormhole_sdk::Address; async fn test_receive() { let recipient = Keypair::new(); let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); // transfer tokens to custody account spl_token::instruction::transfer_checked( @@ -167,7 +170,9 @@ async fn test_receive() { #[tokio::test] async fn test_double_receive() { let recipient = Keypair::new(); - let (mut ctx, _test_data) = setup(Mode::Locking).await; + let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let msg = make_transfer_message(&good_ntt, [0u8; 32], 1000, &recipient.pubkey()); @@ -229,6 +234,8 @@ async fn test_double_receive() { async fn test_wrong_recipient_ntt_manager() { let recipient = Keypair::new(); let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let mut msg = make_transfer_message(&good_ntt, [0u8; 32], 1000, &recipient.pubkey()); @@ -243,7 +250,10 @@ async fn test_wrong_recipient_ntt_manager() { ) .await; - receive_message( + // v4: the recipient_ntt_manager binding is now checked at receive time, so a + // message addressed to a different instance is rejected before it can create + // a (mis-scoped) transceiver message — rather than later at redeem. + let err = receive_message( &good_ntt, &good_ntt_transceiver, init_receive_message_accs( @@ -256,22 +266,6 @@ async fn test_wrong_recipient_ntt_manager() { ) .submit(&mut ctx) .await - .unwrap(); - - let err = redeem( - &good_ntt, - init_redeem_accs( - &good_ntt, - &good_ntt_transceiver, - &mut ctx, - &test_data, - OTHER_CHAIN, - msg.ntt_manager_payload.clone(), - ), - RedeemArgs {}, - ) - .submit(&mut ctx) - .await .unwrap_err(); assert_eq!( @@ -286,7 +280,9 @@ async fn test_wrong_recipient_ntt_manager() { #[tokio::test] async fn test_wrong_transceiver_peer() { let recipient = Keypair::new(); - let (mut ctx, _test_data) = setup(Mode::Locking).await; + let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let msg = make_transfer_message(&good_ntt, [0u8; 32], 1000, &recipient.pubkey()); @@ -327,6 +323,8 @@ async fn test_wrong_transceiver_peer() { async fn test_wrong_manager_peer() { let recipient = Keypair::new(); let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let mut msg = make_transfer_message(&good_ntt, [0u8; 32], 1000, &recipient.pubkey()); @@ -385,6 +383,8 @@ async fn test_wrong_manager_peer() { async fn test_wrong_inbox_item() { let recipient = Keypair::new(); let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let msg = make_transfer_message(&good_ntt, [0u8; 32], 1000, &recipient.pubkey()); @@ -436,3 +436,121 @@ async fn test_wrong_inbox_item() { ) ); } + +/// An inbox item is content-addressed and seeded by the *instance* it was +/// redeemed under, but `release_inbound` receives it as an already-allocated +/// account with no way to re-derive those seeds. Without an explicit binding, +/// instance A's released-ready inbox item could be released against a second +/// instance B — minting/unlocking B's tokens for a transfer B never received. +/// (Instance creation is permissionless, so the attacker can supply their own +/// instance A.) This test pins the binding: releasing A's inbox item against B +/// must fail with `InvalidInboxItem`. +#[tokio::test] +async fn test_release_inbound_rejects_cross_instance_inbox_item() { + let recipient = Keypair::new(); + // Instance A: the deployment `setup` stands up, in locking mode. + let (mut ctx, test_data) = setup(Mode::Locking).await; + let ntt_a = good_ntt(test_data.instance.pubkey()); + let xcvr_a = good_ntt_transceiver(test_data.instance.pubkey()); + + spl_associated_token_account::instruction::create_associated_token_account( + &ctx.payer.pubkey(), + &recipient.pubkey(), + &test_data.mint, + &Token::id(), + ) + .submit(&mut ctx) + .await + .unwrap(); + + let recipient_token_account = get_associated_token_address_with_program_id( + &recipient.pubkey(), + &test_data.mint, + &Token::id(), + ); + + // Validate an inbound transfer under instance A. This creates (and, at + // threshold 1, approves) `inbox_item_A`, ready to be released. + let msg = make_transfer_message(&ntt_a, [0u8; 32], 1000, &recipient.pubkey()); + let vaa = post_vaa_helper( + &ntt_a, + OTHER_CHAIN.into(), + Address(OTHER_TRANSCEIVER), + msg.clone(), + &mut ctx, + ) + .await; + receive_message( + &ntt_a, + &xcvr_a, + init_receive_message_accs(&xcvr_a, &mut ctx, vaa, OTHER_CHAIN, [0u8; 32]), + ) + .submit(&mut ctx) + .await + .unwrap(); + redeem( + &ntt_a, + init_redeem_accs( + &ntt_a, + &xcvr_a, + &mut ctx, + &test_data, + OTHER_CHAIN, + msg.ntt_manager_payload.clone(), + ), + RedeemArgs {}, + ) + .submit(&mut ctx) + .await + .unwrap(); + + // Instance B: a second, independent instance sharing the same mint. Its + // `config`/`token_authority`/`custody` are all distinct from A's, and + // `initialize` allocates B's custody, so every account in the release below + // is valid — only the inbox item belongs to the wrong instance. + let instance_b = Keypair::new(); + let ntt_b = good_ntt(instance_b.pubkey()); + initialize_with_token_program_id( + &ntt_b, + Initialize { + payer: ctx.payer.pubkey(), + owner: test_data.program_owner.pubkey(), + mint: test_data.mint, + multisig_token_authority: None, + }, + InitializeArgs { + chain_id: THIS_CHAIN, + limit: OUTBOUND_LIMIT, + mode: Mode::Locking, + }, + &Token::id(), + ) + .submit_with_signers(&[&test_data.program_owner, &instance_b], &mut ctx) + .await + .unwrap(); + + // Attack: release instance A's inbox item against instance B. + let err = release_inbound_unlock( + &ntt_b, + ReleaseInbound { + payer: ctx.payer.pubkey(), + inbox_item: ntt_a.inbox_item(OTHER_CHAIN, msg.ntt_manager_payload.clone()), + mint: test_data.mint, + recipient: recipient_token_account, + }, + ReleaseInboundArgs { + revert_when_not_ready: false, + }, + ) + .submit(&mut ctx) + .await + .unwrap_err(); + + assert_eq!( + err.unwrap(), + TransactionError::InstructionError( + 0, + InstructionError::Custom(NTTError::InvalidInboxItem.into()) + ) + ); +} diff --git a/solana/programs/example-native-token-transfers/tests/transfer.rs b/solana/programs/example-native-token-transfers/tests/transfer.rs index f1ca9dc26..8297e6b20 100644 --- a/solana/programs/example-native-token-transfers/tests/transfer.rs +++ b/solana/programs/example-native-token-transfers/tests/transfer.rs @@ -84,6 +84,8 @@ pub async fn test_transfer_burning_with_transfer_fee() { /// This tests the happy path of a transfer, with all the relevant account checks. /// Written as a helper function so both modes can be tested. async fn test_transfer(ctx: &mut ProgramTestContext, test_data: &TestData, mode: Mode) { + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let outbox_item = Keypair::new(); let clock: Clock = ctx.banks_client.get_sysvar().await.unwrap(); @@ -110,6 +112,8 @@ async fn test_transfer(ctx: &mut ProgramTestContext, test_data: &TestData, mode: assert_eq!( outbox_item_account, OutboxItem { + // v4: outbox item is bound to its source instance. + manager: good_ntt.config(), amount: TrimmedAmount { amount: 1, decimals: 7 @@ -165,7 +169,8 @@ async fn test_transfer(ctx: &mut ProgramTestContext, test_data: &TestData, mode: assert_eq!( transceiver_message, &TransceiverMessage::new( - example_native_token_transfers::ID.to_bytes(), + // v4: source_ntt_manager is the instance pubkey, not the program ID. + good_ntt.config().to_bytes(), OTHER_MANAGER, NttManagerMessage { id: outbox_item.pubkey().to_bytes(), @@ -192,6 +197,7 @@ async fn test_transfer_with_transfer_fee( mode: Mode, error_code: u32, ) { + let good_ntt = good_ntt(test_data.instance.pubkey()); let outbox_item = Keypair::new(); let (accs, args) = @@ -220,6 +226,7 @@ async fn test_transfer_with_transfer_fee( #[tokio::test] async fn test_burn_mode_burns_tokens() { let (mut ctx, test_data) = setup(Mode::Burning).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let outbox_item = Keypair::new(); @@ -271,6 +278,7 @@ async fn test_burn_mode_burns_tokens() { #[tokio::test] async fn locking_mode_locks_tokens() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let outbox_item = Keypair::new(); @@ -333,6 +341,7 @@ async fn locking_mode_locks_tokens() { #[tokio::test] async fn test_bad_mint() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let outbox_item = Keypair::new(); @@ -388,21 +397,29 @@ async fn test_bad_mint() { async fn test_invalid_peer() { // in this test we send to 'OTHER_CHAIN' but use the peer account for // 'ANOTHER_CHAIN'. - struct BadNTT {} + struct BadNTT { + instance: Pubkey, + } impl NTTAccounts for BadNTT { + fn config(&self) -> Pubkey { + self.instance + } fn peer(&self, _chain_id: u16) -> Pubkey { // return 'ANOTHER_CHAIN' peer account - good_ntt.peer(ANOTHER_CHAIN) + good_ntt(self.instance).peer(ANOTHER_CHAIN) } } let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let outbox_item = Keypair::new(); let (accs, args) = init_transfer_accs_args( - &BadNTT {}, + &BadNTT { + instance: test_data.instance.pubkey(), + }, &mut ctx, &test_data, outbox_item.pubkey(), @@ -420,10 +437,17 @@ async fn test_invalid_peer() { .await .unwrap(); - let err = transfer(&BadNTT {}, accs, args, Mode::Locking) - .submit_with_signers(&[&outbox_item], &mut ctx) - .await - .unwrap_err(); + let err = transfer( + &BadNTT { + instance: test_data.instance.pubkey(), + }, + accs, + args, + Mode::Locking, + ) + .submit_with_signers(&[&outbox_item], &mut ctx) + .await + .unwrap_err(); assert_eq!( err.unwrap(), @@ -437,21 +461,29 @@ async fn test_invalid_peer() { #[tokio::test] async fn test_unregistered_peer_cant_transfer() { // in this test we try to transfer with unregistered peer - struct BadNTT {} + struct BadNTT { + instance: Pubkey, + } impl NTTAccounts for BadNTT { + fn config(&self) -> Pubkey { + self.instance + } fn peer(&self, _chain_id: u16) -> Pubkey { // return 'UNREGISTERED_CHAIN' peer account - good_ntt.peer(UNREGISTERED_CHAIN) + good_ntt(self.instance).peer(UNREGISTERED_CHAIN) } } let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let outbox_item = Keypair::new(); let (accs, args) = init_transfer_accs_args( - &BadNTT {}, + &BadNTT { + instance: test_data.instance.pubkey(), + }, &mut ctx, &test_data, outbox_item.pubkey(), @@ -469,10 +501,17 @@ async fn test_unregistered_peer_cant_transfer() { .await .unwrap(); - let err = transfer(&BadNTT {}, accs, args, Mode::Locking) - .submit_with_signers(&[&outbox_item], &mut ctx) - .await - .unwrap_err(); + let err = transfer( + &BadNTT { + instance: test_data.instance.pubkey(), + }, + accs, + args, + Mode::Locking, + ) + .submit_with_signers(&[&outbox_item], &mut ctx) + .await + .unwrap_err(); // should err as peer account is not initialized assert_eq!( @@ -487,6 +526,7 @@ async fn test_unregistered_peer_cant_transfer() { #[tokio::test] async fn test_cant_transfer_to_unregistered_peer() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let outbox_item = Keypair::new(); @@ -535,6 +575,7 @@ async fn test_cant_transfer_to_unregistered_peer() { #[tokio::test] async fn test_rate_limit() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let outbox_item = Keypair::new(); let clock: Clock = ctx.banks_client.get_sysvar().await.unwrap(); @@ -579,6 +620,7 @@ async fn test_rate_limit() { #[tokio::test] async fn test_transfer_wrong_mode() { let (mut ctx, test_data) = setup(Mode::Burning).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let outbox_item = Keypair::new(); let (accs, args) = init_transfer_accs_args( @@ -617,6 +659,7 @@ async fn test_transfer_wrong_mode() { #[tokio::test] async fn test_cant_transfer_more_than_balance() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let outbox_item = Keypair::new(); @@ -675,6 +718,7 @@ async fn test_cant_transfer_more_than_balance() { #[tokio::test] async fn test_large_tx_queue() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let outbox_item = Keypair::new(); @@ -720,6 +764,7 @@ async fn test_large_tx_queue() { #[tokio::test] async fn test_cant_transfer_when_paused() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let outbox_item = Keypair::new(); @@ -792,6 +837,7 @@ async fn test_cant_transfer_when_paused() { #[tokio::test] async fn test_large_tx_no_queue() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let outbox_item = Keypair::new(); @@ -832,6 +878,8 @@ async fn test_large_tx_no_queue() { #[tokio::test] async fn test_cant_release_queued() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let outbox_item = Keypair::new(); @@ -916,6 +964,8 @@ async fn test_cant_release_queued() { #[tokio::test] async fn test_cant_release_twice() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let outbox_item = Keypair::new(); diff --git a/solana/programs/ntt-transceiver/Cargo.toml b/solana/programs/ntt-transceiver/Cargo.toml index fefbc0fd0..d7a6e8786 100644 --- a/solana/programs/ntt-transceiver/Cargo.toml +++ b/solana/programs/ntt-transceiver/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ntt-transceiver" -version = "3.0.0" +version = "4.0.0" description = "Example implementation of native token transfer transceiver using Wormhole Post Message Shim and Verify VAA Shim" edition = "2021" diff --git a/solana/programs/ntt-transceiver/src/vaa_body.rs b/solana/programs/ntt-transceiver/src/vaa_body.rs index 50f958357..22d3ebc62 100644 --- a/solana/programs/ntt-transceiver/src/vaa_body.rs +++ b/solana/programs/ntt-transceiver/src/vaa_body.rs @@ -47,6 +47,16 @@ impl<'a> VaaBodyBytes<'a> { self.span[10..42].try_into().unwrap() } + /// The `recipient_ntt_manager` of the wrapped `TransceiverMessage`. + /// Layout within the VAA body: the Wormhole payload starts at byte 51, then + /// the `TransceiverMessage` is `prefix[4] ‖ source_ntt_manager[32] ‖ + /// recipient_ntt_manager[32] ‖ payload_len[2] ‖ ntt_manager_payload…`, so + /// `recipient_ntt_manager` lives at `51 + 4 + 32 = 87..119` (immediately + /// before [`id`], which the existing `121..153` slice confirms). + pub fn recipient_ntt_manager(&self) -> &[u8; 32] { + self.span[87..119].try_into().unwrap() + } + pub fn id(&self) -> &[u8; 32] { self.span[121..153].try_into().unwrap() } diff --git a/solana/programs/ntt-transceiver/src/wormhole/accounts.rs b/solana/programs/ntt-transceiver/src/wormhole/accounts.rs index 30a455a5f..e0306d75f 100644 --- a/solana/programs/ntt-transceiver/src/wormhole/accounts.rs +++ b/solana/programs/ntt-transceiver/src/wormhole/accounts.rs @@ -37,12 +37,16 @@ pub struct WormholeAccounts<'info> { /// and [`WormholeAccounts::sequence`] must be checked by the Wormhole core bridge. /// SECURITY: Signer checks are disabled. The only valid sender is the /// [`wormhole::PostMessage::emitter`], enforced by the [`CpiContext`] below. +/// `config` is the per-instance Config pubkey embedded in the emitter PDA +/// seed list (v4). Callers pass `accs.config.key()` so the CPI signer-seed +/// slice matches the on-chain `[b"emitter", config.key()]` PDA layout. pub fn post_message<'info, A: TypePrefixedPayload>( wormhole: &WormholeAccounts<'info>, payer: AccountInfo<'info>, message: AccountInfo<'info>, emitter: AccountInfo<'info>, emitter_bump: u8, + config: Pubkey, payload: &A, ) -> Result<()> { let batch_id = 0; @@ -65,7 +69,7 @@ pub fn post_message<'info, A: TypePrefixedPayload>( program: wormhole.post_message_shim.to_account_info(), event_authority: wormhole.wormhole_post_message_shim_ea.to_account_info(), }, - &[&[b"emitter", &[emitter_bump]]], + &[&[b"emitter", config.as_ref(), &[emitter_bump]]], ), batch_id, Finality::Finalized, diff --git a/solana/programs/ntt-transceiver/src/wormhole/instructions/admin.rs b/solana/programs/ntt-transceiver/src/wormhole/instructions/admin.rs index b606212ff..94d5f8186 100644 --- a/solana/programs/ntt-transceiver/src/wormhole/instructions/admin.rs +++ b/solana/programs/ntt-transceiver/src/wormhole/instructions/admin.rs @@ -20,7 +20,7 @@ pub struct SetTransceiverPeer<'info> { init, space = 8 + TransceiverPeer::INIT_SPACE, payer = payer, - seeds = [TransceiverPeer::SEED_PREFIX, args.chain_id.id.to_be_bytes().as_ref()], + seeds = [TransceiverPeer::SEED_PREFIX, config.key().as_ref(), args.chain_id.id.to_be_bytes().as_ref()], bump )] pub peer: Account<'info, TransceiverPeer>, diff --git a/solana/programs/ntt-transceiver/src/wormhole/instructions/broadcast_id.rs b/solana/programs/ntt-transceiver/src/wormhole/instructions/broadcast_id.rs index 732931b9a..0fc6c935c 100644 --- a/solana/programs/ntt-transceiver/src/wormhole/instructions/broadcast_id.rs +++ b/solana/programs/ntt-transceiver/src/wormhole/instructions/broadcast_id.rs @@ -21,7 +21,7 @@ pub struct BroadcastId<'info> { pub wormhole_message: UncheckedAccount<'info>, #[account( - seeds = [b"emitter"], + seeds = [b"emitter", config.key().as_ref()], bump )] /// CHECK: The only valid sender is the [`wormhole::PostMessage::emitter`] @@ -34,8 +34,10 @@ pub struct BroadcastId<'info> { pub fn broadcast_id(ctx: Context) -> Result<()> { let accs = ctx.accounts; + // v4: the on-the-wire manager identity is the instance's `config` pubkey, + // not the program ID. EVM/Sui peers register against `config.key()`. let message = WormholeTransceiverInfo { - manager_address: accs.config.to_account_info().owner.to_bytes(), + manager_address: accs.config.key().to_bytes(), manager_mode: accs.config.mode, token_address: accs.mint.to_account_info().key.to_bytes(), token_decimals: accs.mint.decimals, @@ -48,6 +50,7 @@ pub fn broadcast_id(ctx: Context) -> Result<()> { accs.wormhole_message.to_account_info(), accs.emitter.to_account_info(), ctx.bumps.emitter, + accs.config.key(), &message, )?; diff --git a/solana/programs/ntt-transceiver/src/wormhole/instructions/broadcast_peer.rs b/solana/programs/ntt-transceiver/src/wormhole/instructions/broadcast_peer.rs index 04289a1bb..9b437b602 100644 --- a/solana/programs/ntt-transceiver/src/wormhole/instructions/broadcast_peer.rs +++ b/solana/programs/ntt-transceiver/src/wormhole/instructions/broadcast_peer.rs @@ -12,7 +12,7 @@ pub struct BroadcastPeer<'info> { pub config: Account<'info, Config>, #[account( - seeds = [TransceiverPeer::SEED_PREFIX, args.chain_id.to_be_bytes().as_ref()], + seeds = [TransceiverPeer::SEED_PREFIX, config.key().as_ref(), args.chain_id.to_be_bytes().as_ref()], bump )] pub peer: Account<'info, TransceiverPeer>, @@ -22,7 +22,7 @@ pub struct BroadcastPeer<'info> { pub wormhole_message: UncheckedAccount<'info>, #[account( - seeds = [b"emitter"], + seeds = [b"emitter", config.key().as_ref()], bump )] /// CHECK: The seeds constraint ensures that this is the correct address @@ -54,6 +54,7 @@ pub fn broadcast_peer(ctx: Context, args: BroadcastPeerArgs) -> R accs.wormhole_message.to_account_info(), accs.emitter.to_account_info(), ctx.bumps.emitter, + accs.config.key(), &message, )?; diff --git a/solana/programs/ntt-transceiver/src/wormhole/instructions/receive_message.rs b/solana/programs/ntt-transceiver/src/wormhole/instructions/receive_message.rs index c9ba1e73c..9f02b58e4 100644 --- a/solana/programs/ntt-transceiver/src/wormhole/instructions/receive_message.rs +++ b/solana/programs/ntt-transceiver/src/wormhole/instructions/receive_message.rs @@ -27,11 +27,19 @@ pub struct ReceiveMessageInstructionData<'info> { #[account( // check that the messages is targeted to this chain constraint = vaa_body.as_vaa_body_bytes().to_chain() == config.chain_id @ NTTError::InvalidChainId, + // check that the message is addressed to *this* instance. The + // `transceiver_message` PDA below is scoped by `config.key()`, but nothing + // otherwise binds the message's `recipient_ntt_manager` to it, so a VAA + // addressed to instance A could be used to create a (redundant, and + // ultimately unredeemable) transceiver message under instance B. `redeem` + // enforces this for fund safety; we also check it here so the binding is + // local and the mis-scoped account is never created in the first place. + constraint = *vaa_body.as_vaa_body_bytes().recipient_ntt_manager() == config.key().to_bytes() @ NTTError::InvalidRecipientNttManager, )] pub config: NotPausedConfig<'info>, #[account( - seeds = [TransceiverPeer::SEED_PREFIX, vaa_body.as_vaa_body_bytes().emitter_chain().to_be_bytes().as_ref()], + seeds = [TransceiverPeer::SEED_PREFIX, config.key().as_ref(), vaa_body.as_vaa_body_bytes().emitter_chain().to_be_bytes().as_ref()], constraint = peer.address == *vaa_body.as_vaa_body_bytes().emitter_address() @ NTTError::InvalidTransceiverPeer, bump = peer.bump, )] @@ -43,6 +51,7 @@ pub struct ReceiveMessageInstructionData<'info> { space = 8 + ValidatedTransceiverMessage::>>::INIT_SPACE, seeds = [ ValidatedTransceiverMessage::>>::SEED_PREFIX, + config.key().as_ref(), vaa_body.as_vaa_body_bytes().emitter_chain().to_be_bytes().as_ref(), vaa_body.as_vaa_body_bytes().id(), ], @@ -113,11 +122,14 @@ pub struct ReceiveMessageAccount<'info> { #[account( // check that the messages is targeted to this chain constraint = message.as_vaa_body_bytes().to_chain() == config.chain_id @ NTTError::InvalidChainId, + // check that the message is addressed to *this* instance (see the note on + // the equivalent constraint in `ReceiveMessageInstructionData`). + constraint = *message.as_vaa_body_bytes().recipient_ntt_manager() == config.key().to_bytes() @ NTTError::InvalidRecipientNttManager, )] pub config: NotPausedConfig<'info>, #[account( - seeds = [TransceiverPeer::SEED_PREFIX, message.as_vaa_body_bytes().emitter_chain().to_be_bytes().as_ref()], + seeds = [TransceiverPeer::SEED_PREFIX, config.key().as_ref(), message.as_vaa_body_bytes().emitter_chain().to_be_bytes().as_ref()], constraint = peer.address == *message.as_vaa_body_bytes().emitter_address() @ NTTError::InvalidTransceiverPeer, bump = peer.bump, )] @@ -143,6 +155,7 @@ pub struct ReceiveMessageAccount<'info> { space = 8 + ValidatedTransceiverMessage::>>::INIT_SPACE, seeds = [ ValidatedTransceiverMessage::>>::SEED_PREFIX, + config.key().as_ref(), message.as_vaa_body_bytes().emitter_chain().to_be_bytes().as_ref(), message.as_vaa_body_bytes().id(), ], diff --git a/solana/programs/ntt-transceiver/src/wormhole/instructions/release_outbound.rs b/solana/programs/ntt-transceiver/src/wormhole/instructions/release_outbound.rs index cd085999a..d49ad0722 100644 --- a/solana/programs/ntt-transceiver/src/wormhole/instructions/release_outbound.rs +++ b/solana/programs/ntt-transceiver/src/wormhole/instructions/release_outbound.rs @@ -23,11 +23,22 @@ pub struct ReleaseOutbound<'info> { #[account( mut, + // sanity check: the outbox item must belong to this instance. This is + // already enforced by the `mark_outbox_item_as_released` CPI below, but + // mirror the standalone transceiver and check it here as a second layer. + constraint = outbox_item.manager == config.key() @ NTTError::InvalidOutboxItem, constraint = !outbox_item.released.get(transceiver.id)? @ NTTError::MessageAlreadySent, )] pub outbox_item: Account<'info, OutboxItem>, #[account( + // sanity check: derive the RegisteredTransceiver PDA as a second layer. + // It's a manager-owned account (hence `seeds::program` points at the + // manager, unlike the standalone transceiver which lives in the manager + // program), and is already validated by the CPI below. + seeds = [RegisteredTransceiver::SEED_PREFIX, config.key().as_ref(), transceiver.transceiver_address.as_ref()], + bump, + seeds::program = example_native_token_transfers::ID, constraint = transceiver.transceiver_address == crate::ID, constraint = config.enabled_transceivers.get(transceiver.id)? @ NTTError::DisabledTransceiver )] @@ -38,7 +49,7 @@ pub struct ReleaseOutbound<'info> { pub wormhole_message: UncheckedAccount<'info>, #[account( - seeds = [b"emitter"], + seeds = [b"emitter", config.key().as_ref()], bump )] // TODO: do we want to put anything in here? @@ -52,7 +63,7 @@ pub struct ReleaseOutbound<'info> { pub manager: Program<'info, ExampleNativeTokenTransfers>, #[account( - seeds = [OUTBOX_ITEM_SIGNER_SEED], + seeds = [OUTBOX_ITEM_SIGNER_SEED, config.key().as_ref()], bump )] /// CHECK: this PDA is used to sign the CPI into NTT manager program @@ -61,6 +72,7 @@ pub struct ReleaseOutbound<'info> { impl<'info> ReleaseOutbound<'info> { pub fn mark_outbox_item_as_released(&self, bump_seed: u8) -> Result { + let config_key = self.config.key(); let result = example_native_token_transfers::cpi::mark_outbox_item_as_released( CpiContext::new_with_signer( self.manager.to_account_info(), @@ -73,7 +85,7 @@ impl<'info> ReleaseOutbound<'info> { transceiver: self.transceiver.to_account_info(), }, // signer seeds - &[&[OUTBOX_ITEM_SIGNER_SEED, &[bump_seed]]], + &[&[OUTBOX_ITEM_SIGNER_SEED, config_key.as_ref(), &[bump_seed]]], ), )?; Ok(result.get()) @@ -100,10 +112,12 @@ pub fn release_outbound(ctx: Context, args: ReleaseOutboundArgs accs.outbox_item.reload()?; assert!(accs.outbox_item.released.get(accs.transceiver.id)?); + // v4: source manager identity is the instance's `config` pubkey, not the + // program ID. Matches the `recipient_ntt_manager` check in `redeem` and the + // `manager_address` field in `broadcast_id`. let message: TransceiverMessage> = TransceiverMessage::new( - // TODO: should we just put the ntt id here statically? - accs.outbox_item.to_account_info().owner.to_bytes(), + accs.config.key().to_bytes(), accs.outbox_item.recipient_ntt_manager, NttManagerMessage { id: accs.outbox_item.key().to_bytes(), @@ -125,6 +139,7 @@ pub fn release_outbound(ctx: Context, args: ReleaseOutboundArgs accs.wormhole_message.to_account_info(), accs.emitter.to_account_info(), ctx.bumps.emitter, + accs.config.key(), &message, )?; diff --git a/solana/programs/ntt-transceiver/tests/admin.rs b/solana/programs/ntt-transceiver/tests/admin.rs index 0e150a7dd..c4be4d57c 100644 --- a/solana/programs/ntt-transceiver/tests/admin.rs +++ b/solana/programs/ntt-transceiver/tests/admin.rs @@ -23,6 +23,7 @@ use wormhole_svm_definitions::solana::{POST_MESSAGE_SHIM_PROGRAM_ID, VERIFY_VAA_ #[tokio::test] async fn test_invalid_transceiver() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); // try registering system program let err = register_transceiver( @@ -48,6 +49,8 @@ async fn test_invalid_transceiver() { #[tokio::test] async fn test_reregister_all_transceivers() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); // Transceivers are expected to be executable which requires them to be added on setup // Thus, we pass all available executable program IDs as dummy_transceivers @@ -141,6 +144,8 @@ async fn test_reregister_all_transceivers() { #[tokio::test] async fn test_deregister_last_enabled_transceiver() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); // attempt to deregister only enabled transceiver (standalone transceiver) let err = deregister_transceiver( diff --git a/solana/programs/ntt-transceiver/tests/broadcast.rs b/solana/programs/ntt-transceiver/tests/broadcast.rs index 861a62be7..2f453ea11 100644 --- a/solana/programs/ntt-transceiver/tests/broadcast.rs +++ b/solana/programs/ntt-transceiver/tests/broadcast.rs @@ -30,7 +30,9 @@ use wormhole_svm_definitions::{EncodeFinality, Finality::Finalized}; #[tokio::test] async fn test_broadcast_peer() { - let (mut ctx, _test_data) = setup(Mode::Locking).await; + let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let ix = broadcast_peer( &good_ntt, @@ -65,6 +67,8 @@ async fn test_broadcast_peer() { #[tokio::test] async fn test_broadcast_id() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let ix = broadcast_id( &good_ntt, @@ -90,7 +94,8 @@ async fn test_broadcast_id() { assert_eq!( WormholeTransceiverInfo::deserialize(&mut &msg.payload[..]).unwrap(), WormholeTransceiverInfo { - manager_address: good_ntt.program().to_bytes(), + // v4: on-the-wire manager identity is the instance pubkey, not the program ID. + manager_address: good_ntt.config().to_bytes(), manager_mode: Mode::Locking, token_address: test_data.mint.to_bytes(), token_decimals: 9, diff --git a/solana/programs/ntt-transceiver/tests/cancel_flow.rs b/solana/programs/ntt-transceiver/tests/cancel_flow.rs index f54e0dc66..6060f8d80 100644 --- a/solana/programs/ntt-transceiver/tests/cancel_flow.rs +++ b/solana/programs/ntt-transceiver/tests/cancel_flow.rs @@ -34,6 +34,8 @@ use wormhole_sdk::Address; async fn test_cancel() { let recipient = Keypair::new(); let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let msg0 = make_transfer_message(&good_ntt, [0u8; 32], 1000, &recipient.pubkey()); let msg1 = make_transfer_message(&good_ntt, [1u8; 32], 2000, &recipient.pubkey()); diff --git a/solana/programs/ntt-transceiver/tests/receive.rs b/solana/programs/ntt-transceiver/tests/receive.rs index d456c2725..c8544dbed 100644 --- a/solana/programs/ntt-transceiver/tests/receive.rs +++ b/solana/programs/ntt-transceiver/tests/receive.rs @@ -48,6 +48,8 @@ use wormhole_sdk::{vaa::digest, Address}; async fn test_receive_instruction_data() { let recipient = Keypair::new(); let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); // transfer tokens to custody account spl_token::instruction::transfer_checked( @@ -181,6 +183,8 @@ async fn test_receive_instruction_data() { async fn test_receive_message_account() { let recipient = Keypair::new(); let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); // transfer tokens to custody account spl_token::instruction::transfer_checked( @@ -328,7 +332,9 @@ async fn test_receive_message_account() { #[tokio::test] async fn test_double_receive() { let recipient = Keypair::new(); - let (mut ctx, _test_data) = setup(Mode::Locking).await; + let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let msg = make_transfer_message(&good_ntt, [0u8; 32], 1000, &recipient.pubkey()); @@ -399,6 +405,8 @@ async fn test_double_receive() { async fn test_wrong_recipient_ntt_manager() { let recipient = Keypair::new(); let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let mut msg = make_transfer_message(&good_ntt, [0u8; 32], 1000, &recipient.pubkey()); @@ -413,7 +421,10 @@ async fn test_wrong_recipient_ntt_manager() { ) .await; - receive_message_instruction_data( + // v4: the recipient_ntt_manager binding is now checked at receive time, so a + // message addressed to a different instance is rejected before it can create + // a (mis-scoped) transceiver message — rather than later at redeem. + let err = receive_message_instruction_data( &good_ntt, &good_ntt_transceiver, init_receive_message_accs( @@ -429,24 +440,6 @@ async fn test_wrong_recipient_ntt_manager() { ) .submit(&mut ctx) .await - .unwrap(); - - close_signatures(&good_ntt_transceiver, &mut ctx, &guardian_signatures).await; - - let err = redeem( - &good_ntt, - init_redeem_accs( - &good_ntt, - &good_ntt_transceiver, - &mut ctx, - &test_data, - OTHER_CHAIN, - msg.ntt_manager_payload.clone(), - ), - RedeemArgs {}, - ) - .submit(&mut ctx) - .await .unwrap_err(); assert_eq!( @@ -461,7 +454,9 @@ async fn test_wrong_recipient_ntt_manager() { #[tokio::test] async fn test_wrong_transceiver_peer() { let recipient = Keypair::new(); - let (mut ctx, _test_data) = setup(Mode::Locking).await; + let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let msg = make_transfer_message(&good_ntt, [0u8; 32], 1000, &recipient.pubkey()); @@ -507,6 +502,8 @@ async fn test_wrong_transceiver_peer() { async fn test_wrong_manager_peer() { let recipient = Keypair::new(); let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let mut msg = make_transfer_message(&good_ntt, [0u8; 32], 1000, &recipient.pubkey()); @@ -570,6 +567,8 @@ async fn test_wrong_manager_peer() { async fn test_wrong_inbox_item() { let recipient = Keypair::new(); let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let msg = make_transfer_message(&good_ntt, [0u8; 32], 1000, &recipient.pubkey()); diff --git a/solana/programs/ntt-transceiver/tests/transfer.rs b/solana/programs/ntt-transceiver/tests/transfer.rs index b04def154..9036db956 100644 --- a/solana/programs/ntt-transceiver/tests/transfer.rs +++ b/solana/programs/ntt-transceiver/tests/transfer.rs @@ -49,6 +49,8 @@ pub async fn test_transfer_burning() { /// This tests the happy path of a transfer, with all the relevant account checks. /// Written as a helper function so both modes can be tested. async fn test_transfer(ctx: &mut ProgramTestContext, test_data: &TestData, mode: Mode) { + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let outbox_item = Keypair::new(); let clock: Clock = ctx.banks_client.get_sysvar().await.unwrap(); @@ -75,6 +77,8 @@ async fn test_transfer(ctx: &mut ProgramTestContext, test_data: &TestData, mode: assert_eq!( outbox_item_account, OutboxItem { + // v4: outbox item is bound to its source instance. + manager: good_ntt.config(), amount: TrimmedAmount { amount: 1, decimals: 7 @@ -123,7 +127,8 @@ async fn test_transfer(ctx: &mut ProgramTestContext, test_data: &TestData, mode: ) .unwrap(), TransceiverMessage::new( - example_native_token_transfers::ID.to_bytes(), + // v4: source_ntt_manager is the instance pubkey, not the program ID. + good_ntt.config().to_bytes(), OTHER_MANAGER, NttManagerMessage { id: outbox_item.pubkey().to_bytes(), @@ -147,6 +152,8 @@ async fn test_transfer(ctx: &mut ProgramTestContext, test_data: &TestData, mode: #[tokio::test] async fn test_cant_release_queued() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let outbox_item = Keypair::new(); @@ -222,6 +229,8 @@ async fn test_cant_release_queued() { #[tokio::test] async fn test_cant_release_twice() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let outbox_item = Keypair::new(); diff --git a/solana/programs/wormhole-governance/Cargo.toml b/solana/programs/wormhole-governance/Cargo.toml index e0433ea9b..9bce5d0cf 100644 --- a/solana/programs/wormhole-governance/Cargo.toml +++ b/solana/programs/wormhole-governance/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wormhole-governance" -version = "3.0.0" +version = "4.0.0" description = "Governance for programs controlled by Wormhole Guardians" edition = "2021" diff --git a/solana/tests/anchor/anchor.test.ts b/solana/tests/anchor/anchor.test.ts index 7e340d342..3eac57899 100644 --- a/solana/tests/anchor/anchor.test.ts +++ b/solana/tests/anchor/anchor.test.ts @@ -49,11 +49,15 @@ registerSolanaNtt(); /** * Test Config Constants + * + * v4 multi-host: the deployed program ID is shared, and each NTT deployment + * is identified by an `instance` keypair-created account. The instance pubkey + * is the on-the-wire NTT manager identity (replaces program ID in v3). */ // Native ESM (jest useESM) has no __dirname; derive it from import.meta.url. const __dirname = fileURLToPath(new URL(".", import.meta.url)); const SOLANA_ROOT_DIR = `${__dirname}/../../`; -const VERSION: IdlVersion = "3.0.0"; +const VERSION: IdlVersion = "4.0.0"; const TOKEN_PROGRAM = spl.TOKEN_2022_PROGRAM_ID; const GUARDIAN_KEY = "cfb12303a19cde580bb4dd771639b0d26bc68353645571a8cff516ab2ee113a0"; @@ -90,6 +94,12 @@ const payerAddress = new SolanaAddress(payer.publicKey); const mint = $.keypair.generate(); const mintAuthority = $.keypair.generate(); +/** + * v4 Instance keypair. The pubkey is the on-the-wire NTT manager identity for + * this deployment, and the seed scope for every per-instance PDA. + */ +const instanceKeypair = $.keypair.generate(); + /** * Contract Config */ @@ -159,7 +169,9 @@ describe("example-native-token-transfers", () => { mintAuthority ); - // create our contract client + // create our contract client. v4: pass the instance pubkey in contracts.ntt; + // it's the per-deployment scope used to derive all PDAs and serves as the + // on-the-wire manager identity. ntt = new SolanaNtt( "Devnet", "Solana", @@ -169,6 +181,7 @@ describe("example-native-token-transfers", () => { ntt: { token: testMint.address.toBase58(), manager: NTT_ADDRESS.toBase58(), + instance: instanceKeypair.publicKey.toBase58(), transceiver: { wormhole: nttTransceivers["wormhole"].programId.toBase58(), }, @@ -183,7 +196,9 @@ describe("example-native-token-transfers", () => { let multisigTokenAuthority: anchor.web3.PublicKey; beforeAll(async () => { - // set multisigTokenAuthority as mint authority + // set multisigTokenAuthority as mint authority. The token authority is + // per-instance in v4 — `ntt.pdas.tokenAuthority()` returns the + // instance-scoped PDA because the SDK was constructed with `instance`. multisigTokenAuthority = await $.multisig.create(payer, 1, [ mintAuthority.publicKey, ntt.pdas.tokenAuthority(), @@ -194,12 +209,14 @@ describe("example-native-token-transfers", () => { mintAuthority ); - // init + // init. v4 takes the instance keypair as an additional signer; the + // Anchor `init` allocates the Instance account at that pubkey. const initTxs = ntt.initialize(sender, { mint: testMint.address, outboundLimit: 1_000_000n, mode: "burning", multisigTokenAuthority, + instance: instanceKeypair, }); await signSendWait(ctx, initTxs, signer); @@ -257,13 +274,18 @@ describe("example-native-token-transfers", () => { const txId = txIds![Number(wrapNative)]!; // assert that released bitmap has transceiver bits set - const outboxItemInfo = await ntt.program.account.outboxItem.fetch( + const outboxItemInfo = await ntt.program.account.outboxItem!.fetch( outboxItem.publicKey ); assert .bn(outboxItemInfo.released.map) .setBits(Object.keys(nttTransceivers).length); + // v4: outbox item is bound to its source instance + expect(outboxItemInfo.manager.toBase58()).toEqual( + instanceKeypair.publicKey.toBase58() + ); + // parse event and instruction data to re-build message const [data] = await testWormholePostMessageShim.getMessageData(txId); @@ -283,6 +305,12 @@ describe("example-native-token-transfers", () => { data!.payload ); + // v4: source manager identity over the wire is the instance pubkey, + // not the program ID. + expect(transceiverMessage.sourceNttManager.toUint8Array()).toEqual( + instanceKeypair.publicKey.toBytes() + ); + // assert that amount is what we expect expect( transceiverMessage.nttManagerPayload.payload.trimmedAmount @@ -315,7 +343,8 @@ describe("example-native-token-transfers", () => { owner: nttOwner, newAuthority: newAuthority.publicKey, multisigTokenAuthority, - } + }, + ntt.pdas ), payer ) @@ -341,7 +370,8 @@ describe("example-native-token-transfers", () => { owner: nttOwner, newAuthority: newAuthority.publicKey, multisigTokenAuthority, - } + }, + ntt.pdas ), payer ); @@ -356,7 +386,8 @@ describe("example-native-token-transfers", () => { await ntt.getConfig(), { currentAuthority: newAuthority.publicKey, - } + }, + ntt.pdas ), payer, newAuthority @@ -377,7 +408,8 @@ describe("example-native-token-transfers", () => { rentPayer: nttOwner, owner: nttOwner, newAuthority: newMultisigAuthority, - } + }, + ntt.pdas ), payer ); @@ -394,7 +426,8 @@ describe("example-native-token-transfers", () => { newAuthority.publicKey, mintAuthority.publicKey, ], - } + }, + ntt.pdas ), payer, newAuthority, @@ -416,7 +449,8 @@ describe("example-native-token-transfers", () => { mintAuthority.publicKey, ], multisigTokenAuthority, - } + }, + ntt.pdas ), payer, newAuthority, @@ -443,14 +477,15 @@ describe("example-native-token-transfers", () => { owner: nttOwner, newAuthority: newAuthority.publicKey, multisigTokenAuthority, - } + }, + ntt.pdas ), payer, newAuthority ); const pendingTokenAuthorityRentExemptAmount = await $.connection.getMinimumBalanceForRentExemption( - ntt.program.account.pendingTokenAuthority.size + ntt.program.account.pendingTokenAuthority!.size ); await assert .nativeBalance($.connection, newAuthority.publicKey) @@ -467,7 +502,8 @@ describe("example-native-token-transfers", () => { rentPayer: newAuthority.publicKey, owner: nttOwner, multisigTokenAuthority, - } + }, + ntt.pdas ), payer ); @@ -486,7 +522,8 @@ describe("example-native-token-transfers", () => { rentPayer: newAuthority.publicKey, newAuthority: newAuthority.publicKey, multisigTokenAuthority, - } + }, + ntt.pdas ), payer, newAuthority @@ -516,10 +553,11 @@ describe("example-native-token-transfers", () => { const guardians = new testing.mocks.MockGuardians(0, [GUARDIAN_KEY]); + // v4: recipientNttManager is the instance pubkey, not the program ID. const sendingTransceiverMessage = { sourceNttManager: remoteMgr.address as UniversalAddress, recipientNttManager: new UniversalAddress( - ntt.program.programId.toBytes() + instanceKeypair.publicKey.toBytes() ), nttManagerPayload: { id: encoding.bytes.encode("sequence1".padEnd(32, "0")), @@ -570,6 +608,7 @@ describe("example-native-token-transfers", () => { Solana: { token: mint.publicKey.toBase58(), manager: NTT_ADDRESS.toBase58(), + instance: instanceKeypair.publicKey.toBase58(), transceiver: { wormhole: nttTransceivers["wormhole"].programId.toBase58(), }, @@ -592,10 +631,16 @@ describe("example-native-token-transfers", () => { }); test("It initializes from constructor", async () => { - const ntt = new SolanaNtt("Devnet", "Solana", $.connection, { - ...ctx.config.contracts, - ...{ ntt: overrides["Solana"] }, - }); + const ntt = new SolanaNtt( + "Devnet", + "Solana", + $.connection, + { + ...ctx.config.contracts, + ...{ ntt: overrides["Solana"] }, + }, + VERSION + ); expect(ntt).toBeTruthy(); }); @@ -605,29 +650,46 @@ describe("example-native-token-transfers", () => { { ntt: overrides["Solana"] }, payerAddress ); - expect(version).toBe("3.0.0"); + expect(version).toBe("4.0.0"); }); test("It initializes using `emitterAccount` as transceiver address", async () => { const overrideEmitter: (typeof overrides)["Solana"] = JSON.parse( JSON.stringify(overrides["Solana"]) ); - overrideEmitter.transceiver.wormhole = NTT.transceiverPdas(NTT_ADDRESS) + // v4: the emitter is scoped by the instance pubkey, so derive + // accordingly. + overrideEmitter.transceiver.wormhole = NTT.transceiverPdas( + NTT_ADDRESS, + instanceKeypair.publicKey + ) .emitterAccount() .toBase58(); - const ntt = new SolanaNtt("Devnet", "Solana", $.connection, { - ...ctx.config.contracts, - ...{ ntt: overrideEmitter }, - }); + const ntt = new SolanaNtt( + "Devnet", + "Solana", + $.connection, + { + ...ctx.config.contracts, + ...{ ntt: overrideEmitter }, + }, + VERSION + ); expect(ntt).toBeTruthy(); }); test("It gets the correct transceiver type", async () => { - const ntt = new SolanaNtt("Devnet", "Solana", $.connection, { - ...ctx.config.contracts, - ...{ ntt: overrides["Solana"] }, - }); + const ntt = new SolanaNtt( + "Devnet", + "Solana", + $.connection, + { + ...ctx.config.contracts, + ...{ ntt: overrides["Solana"] }, + }, + VERSION + ); const whTransceiver = await ntt.getWormholeTransceiver(); expect(whTransceiver).toBeTruthy(); const transceiverType = diff --git a/solana/tests/anchor/multi_instance.test.ts b/solana/tests/anchor/multi_instance.test.ts new file mode 100644 index 000000000..6ae2c57fa --- /dev/null +++ b/solana/tests/anchor/multi_instance.test.ts @@ -0,0 +1,489 @@ +import * as anchor from "@coral-xyz/anchor"; +import * as spl from "@solana/spl-token"; +import { + AccountAddress, + ChainAddress, + ChainContext, + Signer, + UniversalAddress, + Wormhole, + contracts, + deserialize, + encoding, + serialize, + serializePayload, +} from "@wormhole-foundation/sdk"; +import { + Ntt, + register as registerDefinitionsNtt, +} from "@wormhole-foundation/sdk-definitions-ntt"; +import * as testing from "@wormhole-foundation/sdk-definitions/testing"; +import { + SolanaAddress, + SolanaPlatform, + getSolanaSignAndSendSigner, +} from "@wormhole-foundation/sdk-solana"; +import { IdlVersion, getTransceiverProgram } from "../../ts/index.js"; +import { + SolanaNtt, + register as registerSolanaNtt, +} from "../../ts/sdk/index.js"; +import { NTT } from "../../ts/index.js"; +import { TestHelper, TestMint, assert, signSendWait } from "./utils/helpers.js"; +import { fileURLToPath } from "url"; + +registerDefinitionsNtt(); +registerSolanaNtt(); + +// Native ESM (jest useESM) has no __dirname; derive it from import.meta.url. +const __dirname = fileURLToPath(new URL(".", import.meta.url)); +const SOLANA_ROOT_DIR = `${__dirname}/../../`; +const VERSION: IdlVersion = "4.0.0"; +const TOKEN_PROGRAM = spl.TOKEN_2022_PROGRAM_ID; +const GUARDIAN_KEY = + "cfb12303a19cde580bb4dd771639b0d26bc68353645571a8cff516ab2ee113a0"; +const CORE_BRIDGE_ADDRESS = contracts.coreBridge("Mainnet", "Solana"); +const NTT_ADDRESS: anchor.web3.PublicKey = + anchor.workspace.ExampleNativeTokenTransfers.programId; +const WH_TRANSCEIVER_ADDRESS: anchor.web3.PublicKey = + anchor.workspace.NttTransceiver.programId; +const SOLANA_WORMHOLE_SHIMS: Ntt.Contracts["svmShims"] = {}; + +const $ = new TestHelper("confirmed", TOKEN_PROGRAM); + +const payer = $.keypair.read(`${SOLANA_ROOT_DIR}/keys/test.json`); +const payerAddress = new SolanaAddress(payer.publicKey); + +const w = new Wormhole("Devnet", [SolanaPlatform], { + chains: { Solana: { contracts: { coreBridge: CORE_BRIDGE_ADDRESS } } }, +}); +const ctx: ChainContext<"Devnet", "Solana"> = w + .getPlatform("Solana") + .getChain("Solana", $.connection); + +const remoteMgr: ChainAddress = $.chainAddress.generateFromValue( + "Ethereum", + "nttManager" +); +const remoteXcvr: ChainAddress = $.chainAddress.generateFromValue( + "Ethereum", + "transceiver" +); + +/** + * Helper: build a fresh `SolanaNtt` for a given (mint, instance) pair under + * the shared program. Mirrors the v4 multi-host setup pattern. + */ +function makeNtt( + mintAddr: anchor.web3.PublicKey, + instancePubkey: anchor.web3.PublicKey +): SolanaNtt<"Devnet", "Solana"> { + return new SolanaNtt( + "Devnet", + "Solana", + $.connection, + { + ...ctx.config.contracts, + ntt: { + token: mintAddr.toBase58(), + manager: NTT_ADDRESS.toBase58(), + instance: instancePubkey.toBase58(), + transceiver: { + wormhole: WH_TRANSCEIVER_ADDRESS.toBase58(), + }, + svmShims: SOLANA_WORMHOLE_SHIMS, + }, + }, + VERSION + ); +} + +async function registerRemoteWormholePath( + ntt: SolanaNtt<"Devnet", "Solana">, + owner: AccountAddress<"Solana">, + signer: Signer +) { + await signSendWait( + ctx, + ntt.registerWormholeTransceiver({ + payer: owner, + owner, + }), + signer + ); + await signSendWait( + ctx, + ntt.setWormholeTransceiverPeer(remoteXcvr, owner), + signer + ); + await signSendWait( + ctx, + ntt.setPeer(remoteMgr, 18, 1_000_000n, owner), + signer + ); +} + +/** + * Multi-instance v4 isolation tests. Two independent NTT deployments coexist + * under the same Solana program ID, distinguished only by their `instance` + * keypair. Each has its own owner, mint, custody, peers, and rate limits. + */ +describe("multi-instance", () => { + let signer: Signer; + let sender: AccountAddress<"Solana">; + + // Instance A + const mintAuthorityA = $.keypair.generate(); + const instanceA = $.keypair.generate(); + let testMintA: TestMint; + let nttA: SolanaNtt<"Devnet", "Solana">; + let multisigTokenAuthorityA: anchor.web3.PublicKey; + + // Instance B + const mintAuthorityB = $.keypair.generate(); + const instanceB = $.keypair.generate(); + let testMintB: TestMint; + let nttB: SolanaNtt<"Devnet", "Solana">; + let multisigTokenAuthorityB: anchor.web3.PublicKey; + + beforeAll(async () => { + signer = await getSolanaSignAndSendSigner($.connection, payer); + sender = Wormhole.parseAddress("Solana", signer.address()); + + // Stand up two independent v4 instances under the same program. + testMintA = await TestMint.create( + $.connection, + payer, + mintAuthorityA, + 9, + TOKEN_PROGRAM, + spl.ASSOCIATED_TOKEN_PROGRAM_ID + ); + testMintB = await TestMint.create( + $.connection, + payer, + mintAuthorityB, + 9, + TOKEN_PROGRAM, + spl.ASSOCIATED_TOKEN_PROGRAM_ID + ); + + nttA = makeNtt(testMintA.address, instanceA.publicKey); + nttB = makeNtt(testMintB.address, instanceB.publicKey); + + multisigTokenAuthorityA = await $.multisig.create(payer, 1, [ + mintAuthorityA.publicKey, + nttA.pdas.tokenAuthority(), + ]); + await testMintA.setMintAuthority( + payer, + multisigTokenAuthorityA, + mintAuthorityA + ); + + multisigTokenAuthorityB = await $.multisig.create(payer, 1, [ + mintAuthorityB.publicKey, + nttB.pdas.tokenAuthority(), + ]); + await testMintB.setMintAuthority( + payer, + multisigTokenAuthorityB, + mintAuthorityB + ); + + await signSendWait( + ctx, + nttA.initialize(sender, { + mint: testMintA.address, + outboundLimit: 1_000_000n, + mode: "burning", + multisigTokenAuthority: multisigTokenAuthorityA, + instance: instanceA, + }), + signer + ); + + await signSendWait( + ctx, + nttB.initialize(sender, { + mint: testMintB.address, + outboundLimit: 1_000_000n, + mode: "burning", + multisigTokenAuthority: multisigTokenAuthorityB, + instance: instanceB, + }), + signer + ); + }); + + it("derives distinct PDAs for each instance", () => { + const sampleTransfer = NTT.transferArgs(123n, remoteMgr, false); + + // Sanity-check the seed-scoping: every per-instance PDA differs between A and B. + expect(nttA.pdas.configAccount().toBase58()).not.toEqual( + nttB.pdas.configAccount().toBase58() + ); + expect(nttA.pdas.tokenAuthority().toBase58()).not.toEqual( + nttB.pdas.tokenAuthority().toBase58() + ); + expect(nttA.pdas.outboxRateLimitAccount().toBase58()).not.toEqual( + nttB.pdas.outboxRateLimitAccount().toBase58() + ); + expect(nttA.pdas.lutAccount().toBase58()).not.toEqual( + nttB.pdas.lutAccount().toBase58() + ); + expect( + nttA.pdas.sessionAuthority(payer.publicKey, sampleTransfer).toBase58() + ).not.toEqual( + nttB.pdas.sessionAuthority(payer.publicKey, sampleTransfer).toBase58() + ); + + // configAccount() in v4 just returns the instance pubkey. + expect(nttA.pdas.configAccount().toBase58()).toEqual( + instanceA.publicKey.toBase58() + ); + expect(nttB.pdas.configAccount().toBase58()).toEqual( + instanceB.publicKey.toBase58() + ); + }); + + it("isolates owner state per instance (A pause does not pause B)", async () => { + await signSendWait(ctx, nttA.pause(payerAddress), signer); + expect(await nttA.isPaused()).toBe(true); + expect(await nttB.isPaused()).toBe(false); + await signSendWait(ctx, nttA.unpause(payerAddress), signer); + expect(await nttA.isPaused()).toBe(false); + }); + + it("rejects cross-instance VAA replay (recipient_ntt_manager check)", async () => { + // Register peers for both instances so the redeem path can validate them. + await registerRemoteWormholePath(nttA, payerAddress, signer); + await registerRemoteWormholePath(nttB, payerAddress, signer); + + // Build a VAA targeting instance A's pubkey as the recipient manager. + const emitter = new testing.mocks.MockEmitter( + remoteXcvr.address as UniversalAddress, + "Ethereum", + 0n + ); + const guardians = new testing.mocks.MockGuardians(0, [GUARDIAN_KEY]); + + const sendingMessage = { + sourceNttManager: remoteMgr.address as UniversalAddress, + recipientNttManager: new UniversalAddress(instanceA.publicKey.toBytes()), + nttManagerPayload: { + id: encoding.bytes.encode("xinst-replay-1".padEnd(32, "0")), + sender: new UniversalAddress("FACE".padStart(64, "0")), + payload: { + trimmedAmount: { amount: 10_000n, decimals: 8 }, + sourceToken: new UniversalAddress("FAFA".padStart(64, "0")), + recipientAddress: new UniversalAddress(payer.publicKey.toBytes()), + recipientChain: "Solana", + additionalPayload: new Uint8Array(), + }, + }, + transceiverPayload: new Uint8Array(), + } as const; + + const serialized = serializePayload("Ntt:WormholeTransfer", sendingMessage); + const published = emitter.publishMessage(0, serialized, 200); + const rawVaa = guardians.addSignatures(published, [0]); + const vaa = deserialize("Ntt:WormholeTransfer", serialize(rawVaa)); + + // Redeeming against instance B must fail — its + // `recipient_ntt_manager == config.key()` constraint compares against + // `instanceB.publicKey`, which doesn't match the VAA's `instanceA`. + let redeemErr: any = undefined; + try { + await signSendWait(ctx, nttB.redeem([vaa], sender), signer); + } catch (e: any) { + redeemErr = e; + } + expect(redeemErr).toBeDefined(); + }); + + it("rejects cross-instance approval reuse for the same mint", async () => { + const sharedMintAuthority = $.keypair.generate(); + const sharedInstanceA = $.keypair.generate(); + const sharedInstanceB = $.keypair.generate(); + const sharedMint = await TestMint.create( + $.connection, + payer, + sharedMintAuthority, + 9, + TOKEN_PROGRAM, + spl.ASSOCIATED_TOKEN_PROGRAM_ID + ); + + const sharedNttA = makeNtt(sharedMint.address, sharedInstanceA.publicKey); + const sharedNttB = makeNtt(sharedMint.address, sharedInstanceB.publicKey); + + const holderTokenAccount = await sharedMint.mint( + payer, + payer.publicKey, + 10_000n, + sharedMintAuthority + ); + + const sharedMultisigTokenAuthority = await $.multisig.create(payer, 1, [ + sharedMintAuthority.publicKey, + sharedNttA.pdas.tokenAuthority(), + sharedNttB.pdas.tokenAuthority(), + ]); + await sharedMint.setMintAuthority( + payer, + sharedMultisigTokenAuthority, + sharedMintAuthority + ); + + await signSendWait( + ctx, + sharedNttA.initialize(sender, { + mint: sharedMint.address, + outboundLimit: 1_000_000n, + mode: "burning", + multisigTokenAuthority: sharedMultisigTokenAuthority, + instance: sharedInstanceA, + }), + signer + ); + await signSendWait( + ctx, + sharedNttB.initialize(sender, { + mint: sharedMint.address, + outboundLimit: 1_000_000n, + mode: "burning", + multisigTokenAuthority: sharedMultisigTokenAuthority, + instance: sharedInstanceB, + }), + signer + ); + await registerRemoteWormholePath(sharedNttB, payerAddress, signer); + + const transferArgs = NTT.transferArgs(1_000n, remoteMgr, false); + await $.sendAndConfirm( + spl.createApproveInstruction( + holderTokenAccount, + sharedNttA.pdas.sessionAuthority(payer.publicKey, transferArgs), + payer.publicKey, + 1_000n, + [], + TOKEN_PROGRAM + ), + payer + ); + + const outboxItem = $.keypair.generate(); + const xfer = NTT.createTransferBurnInstruction( + sharedNttB.program, + await sharedNttB.getConfig(), + { + payer: payer.publicKey, + from: holderTokenAccount, + fromAuthority: payer.publicKey, + transferArgs, + outboxItem: outboxItem.publicKey, + }, + sharedNttB.pdas + ); + + await assert + .promise($.sendAndConfirm(await xfer, payer, outboxItem)) + .fails(); + }); + + it("uses instance-scoped inbox lookups in v4 helpers", async () => { + const inboundMintAuthority = $.keypair.generate(); + const inboundInstance = $.keypair.generate(); + const inboundMint = await TestMint.create( + $.connection, + payer, + inboundMintAuthority, + 9, + TOKEN_PROGRAM, + spl.ASSOCIATED_TOKEN_PROGRAM_ID + ); + + const inboundNtt = makeNtt(inboundMint.address, inboundInstance.publicKey); + const inboundMultisigTokenAuthority = await $.multisig.create(payer, 1, [ + inboundMintAuthority.publicKey, + inboundNtt.pdas.tokenAuthority(), + ]); + await inboundMint.setMintAuthority( + payer, + inboundMultisigTokenAuthority, + inboundMintAuthority + ); + + await signSendWait( + ctx, + inboundNtt.initialize(sender, { + mint: inboundMint.address, + outboundLimit: 1_000_000n, + mode: "burning", + multisigTokenAuthority: inboundMultisigTokenAuthority, + instance: inboundInstance, + }), + signer + ); + await registerRemoteWormholePath(inboundNtt, payerAddress, signer); + + const emitter = new testing.mocks.MockEmitter( + remoteXcvr.address as UniversalAddress, + "Ethereum", + 0n + ); + const guardians = new testing.mocks.MockGuardians(0, [GUARDIAN_KEY]); + const inboundMessage = { + sourceNttManager: remoteMgr.address as UniversalAddress, + recipientNttManager: new UniversalAddress( + inboundInstance.publicKey.toBytes() + ), + nttManagerPayload: { + id: encoding.bytes.encode("scoped-inbox-lookup".padEnd(32, "0")), + sender: new UniversalAddress("BEEF".padStart(64, "0")), + payload: { + trimmedAmount: { amount: 25_000n, decimals: 8 }, + sourceToken: new UniversalAddress("ABCD".padStart(64, "0")), + recipientAddress: new UniversalAddress(payer.publicKey.toBytes()), + recipientChain: "Solana", + additionalPayload: new Uint8Array(), + }, + }, + transceiverPayload: new Uint8Array(), + } as const; + + const published = emitter.publishMessage( + 0, + serializePayload("Ntt:WormholeTransfer", inboundMessage), + 200 + ); + const rawVaa = guardians.addSignatures(published, [0]); + const vaa = deserialize("Ntt:WormholeTransfer", serialize(rawVaa)); + + await signSendWait(ctx, inboundNtt.redeem([vaa], sender), signer); + + const inboxItem = await NTT.getInboxItem( + inboundNtt.program, + "Ethereum", + vaa.payload.nttManagerPayload, + inboundNtt.pdas + ); + expect(inboxItem.recipientAddress.toBase58()).toEqual( + payer.publicKey.toBase58() + ); + + const releaseIx = await NTT.createReleaseInboundMintInstruction( + inboundNtt.program, + await inboundNtt.getConfig(), + { + payer: payer.publicKey, + chain: "Ethereum", + nttMessage: vaa.payload.nttManagerPayload, + revertWhenNotReady: false, + }, + inboundNtt.pdas + ); + expect(releaseIx).toBeDefined(); + }); +}); diff --git a/solana/tests/anchor/upgrade_authority_decoupling.test.ts b/solana/tests/anchor/upgrade_authority_decoupling.test.ts new file mode 100644 index 000000000..ddb04e260 --- /dev/null +++ b/solana/tests/anchor/upgrade_authority_decoupling.test.ts @@ -0,0 +1,185 @@ +import * as anchor from "@coral-xyz/anchor"; +import * as spl from "@solana/spl-token"; +import { + AccountAddress, + ChainContext, + Signer, + Wormhole, + contracts, +} from "@wormhole-foundation/sdk"; +import { + Ntt, + register as registerDefinitionsNtt, +} from "@wormhole-foundation/sdk-definitions-ntt"; +import { + SolanaAddress, + SolanaPlatform, + getSolanaSignAndSendSigner, +} from "@wormhole-foundation/sdk-solana"; +import { IdlVersion } from "../../ts/index.js"; +import { + SolanaNtt, + register as registerSolanaNtt, +} from "../../ts/sdk/index.js"; +import { TestHelper, TestMint, signSendWait } from "./utils/helpers.js"; +import { + BPF_LOADER_UPGRADEABLE_PROGRAM_ID, + programDataAddress, +} from "../../ts/lib/utils.js"; +import { fileURLToPath } from "url"; + +registerDefinitionsNtt(); +registerSolanaNtt(); + +// Native ESM (jest useESM) has no __dirname; derive it from import.meta.url. +const __dirname = fileURLToPath(new URL(".", import.meta.url)); +const SOLANA_ROOT_DIR = `${__dirname}/../../`; +const VERSION: IdlVersion = "4.0.0"; +const TOKEN_PROGRAM = spl.TOKEN_2022_PROGRAM_ID; +const CORE_BRIDGE_ADDRESS = contracts.coreBridge("Mainnet", "Solana"); +const NTT_ADDRESS: anchor.web3.PublicKey = + anchor.workspace.ExampleNativeTokenTransfers.programId; +const WH_TRANSCEIVER_ADDRESS: anchor.web3.PublicKey = + anchor.workspace.NttTransceiver.programId; +const SOLANA_WORMHOLE_SHIMS: Ntt.Contracts["svmShims"] = {}; + +const $ = new TestHelper("confirmed", TOKEN_PROGRAM); +const payer = $.keypair.read(`${SOLANA_ROOT_DIR}/keys/test.json`); +const payerAddress = new SolanaAddress(payer.publicKey); + +const w = new Wormhole("Devnet", [SolanaPlatform], { + chains: { Solana: { contracts: { coreBridge: CORE_BRIDGE_ADDRESS } } }, +}); +const ctx: ChainContext<"Devnet", "Solana"> = w + .getPlatform("Solana") + .getChain("Solana", $.connection); + +/** + * Decode the upgrade-authority address out of the BPF-loader-upgradeable + * `program_data` account. Layout (Solana 1.x bpf_loader_upgradeable): + * - 4 bytes state discriminator + * - 8 bytes slot (u64 LE) + * - 1 byte `Option` tag + * - 32 bytes pubkey when tag == 1 + */ +async function readProgramUpgradeAuthority( + connection: anchor.web3.Connection, + programId: anchor.web3.PublicKey +): Promise { + const programData = programDataAddress(programId); + const info = await connection.getAccountInfo(programData); + if (info === null) { + throw new Error( + `program_data account not found for ${programId.toBase58()}` + ); + } + // Sanity: the program_data account is owned by the BPF loader upgradeable. + expect(info.owner.toBase58()).toEqual( + BPF_LOADER_UPGRADEABLE_PROGRAM_ID.toBase58() + ); + const optionTag = info.data.readUInt8(4 + 8); + if (optionTag === 0) return null; + return new anchor.web3.PublicKey( + info.data.subarray(4 + 8 + 1, 4 + 8 + 1 + 32) + ); +} + +/** + * v4 ownership-decoupling regression test. + * + * In v3 the singleton `transfer_ownership` instruction also performed a CPI + * into the BPF-loader-upgradeable program to hand the program upgrade + * authority over to (or via) the `[b"upgrade_lock"]` PDA. v4 deliberately + * decouples instance ownership from the program upgrade authority — instance + * ownership transfers are pure data mutations on the `Config` account. + * + * This test asserts that calling `transferOwnership` does NOT touch the + * `program_data` account's upgrade_authority_address. + */ +describe("v4 upgrade authority decoupling", () => { + let signer: Signer; + let sender: AccountAddress<"Solana">; + const mintAuthority = $.keypair.generate(); + const instanceKeypair = $.keypair.generate(); + let testMint: TestMint; + let ntt: SolanaNtt<"Devnet", "Solana">; + + beforeAll(async () => { + signer = await getSolanaSignAndSendSigner($.connection, payer); + sender = Wormhole.parseAddress("Solana", signer.address()); + + testMint = await TestMint.create( + $.connection, + payer, + mintAuthority, + 9, + TOKEN_PROGRAM, + spl.ASSOCIATED_TOKEN_PROGRAM_ID + ); + + ntt = new SolanaNtt( + "Devnet", + "Solana", + $.connection, + { + ...ctx.config.contracts, + ntt: { + token: testMint.address.toBase58(), + manager: NTT_ADDRESS.toBase58(), + instance: instanceKeypair.publicKey.toBase58(), + transceiver: { wormhole: WH_TRANSCEIVER_ADDRESS.toBase58() }, + svmShims: SOLANA_WORMHOLE_SHIMS, + }, + }, + VERSION + ); + + const multisigTokenAuthority = await $.multisig.create(payer, 1, [ + mintAuthority.publicKey, + ntt.pdas.tokenAuthority(), + ]); + await testMint.setMintAuthority( + payer, + multisigTokenAuthority, + mintAuthority + ); + + await signSendWait( + ctx, + ntt.initialize(sender, { + mint: testMint.address, + outboundLimit: 1_000_000n, + mode: "burning", + multisigTokenAuthority, + instance: instanceKeypair, + }), + signer + ); + }); + + it("transferOwnership does not touch the program upgrade authority", async () => { + const before = await readProgramUpgradeAuthority($.connection, NTT_ADDRESS); + + const newOwner = $.keypair.generate(); + await signSendWait( + ctx, + ntt.setOwner(newOwner.publicKey, payerAddress), + signer + ); + + const after = await readProgramUpgradeAuthority($.connection, NTT_ADDRESS); + + // The instance owner has been queued for transfer (pending_owner is set), + // but the program upgrade authority must be byte-identical. + if (before === null) { + expect(after).toBeNull(); + } else { + expect(after).not.toBeNull(); + expect(after!.toBase58()).toEqual(before.toBase58()); + } + + // And the on-chain Instance shows the pending owner. + const cfg = await ntt.getConfig(); + expect(cfg.pendingOwner?.toBase58()).toEqual(newOwner.publicKey.toBase58()); + }); +}); diff --git a/solana/tests/anchor/utils/helpers.ts b/solana/tests/anchor/utils/helpers.ts index 4eb8fa6e4..10a6e805a 100644 --- a/solana/tests/anchor/utils/helpers.ts +++ b/solana/tests/anchor/utils/helpers.ts @@ -785,13 +785,9 @@ export const signSendWait = async ( chain: ChainContext, txs: AsyncGenerator, signer: Signer -): Promise => { - try { - const txIds = await ssw(chain, txs, signer); - return txIds.map(({ txid }) => txid); - } catch (e) { - console.error(e); - } +): Promise => { + const txIds = await ssw(chain, txs, signer); + return txIds.map(({ txid }) => txid); }; /** diff --git a/solana/tests/cargo/src/common/fixtures.rs b/solana/tests/cargo/src/common/fixtures.rs index 5a8e911fe..7152d47bb 100644 --- a/solana/tests/cargo/src/common/fixtures.rs +++ b/solana/tests/cargo/src/common/fixtures.rs @@ -22,6 +22,11 @@ pub const UNREGISTERED_CHAIN: u16 = u16::MAX; pub struct TestData { pub governance: Governance, pub program_owner: Keypair, + /// v4: keypair-created Instance account. Generated per setup; its pubkey + /// is registered as the test-wide instance via + /// `sdk::accounts::ntt::set_test_instance` and used everywhere `good_ntt` + /// derives a per-instance PDA. + pub instance: Keypair, pub mint_authority: Keypair, pub mint: Pubkey, pub bad_mint_authority: Keypair, diff --git a/solana/tests/cargo/src/helpers/admin.rs b/solana/tests/cargo/src/helpers/admin.rs index e68e01338..505e48bd0 100644 --- a/solana/tests/cargo/src/helpers/admin.rs +++ b/solana/tests/cargo/src/helpers/admin.rs @@ -7,6 +7,8 @@ use solana_program_test::ProgramTestContext; use crate::{common::query::GetAccountDataAnchor, sdk::accounts::NTT}; pub async fn assert_threshold(ntt: &NTT, ctx: &mut ProgramTestContext, expected_threshold: u8) { + // v4: the Config account lives at the instance pubkey (keypair-created), + // not at a `[b"config"]` PDA. let config_account: Config = ctx.get_account_data_anchor(ntt.config()).await; assert_eq!(config_account.threshold, expected_threshold); } diff --git a/solana/tests/cargo/src/helpers/setup.rs b/solana/tests/cargo/src/helpers/setup.rs index e3ccf799e..43a88d931 100644 --- a/solana/tests/cargo/src/helpers/setup.rs +++ b/solana/tests/cargo/src/helpers/setup.rs @@ -170,6 +170,11 @@ pub async fn setup_ntt_with_token_program_id( mode: Mode, token_program_id: &Pubkey, ) { + // v4: bind the SDK helpers to this test's instance pubkey. All PDA + // helpers on `ntt` and `xcvr` derive against this instance. + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); + if mode == Mode::Burning { // we set the mint authority to the ntt contract in burn/mint mode spl_token_2022::instruction::set_authority( @@ -190,7 +195,7 @@ pub async fn setup_ntt_with_token_program_id( &good_ntt, Initialize { payer: ctx.payer.pubkey(), - deployer: test_data.program_owner.pubkey(), + owner: test_data.program_owner.pubkey(), mint: test_data.mint, multisig_token_authority: None, }, @@ -202,7 +207,9 @@ pub async fn setup_ntt_with_token_program_id( }, token_program_id, ) - .submit_with_signers(&[&test_data.program_owner], ctx) + // v4: the Instance account is keypair-allocated by Anchor's `init`, so the + // instance keypair must sign alongside the payer/owner. + .submit_with_signers(&[&test_data.program_owner, &test_data.instance], ctx) .await .unwrap(); @@ -347,11 +354,17 @@ pub async fn setup_accounts(ctx: &mut ProgramTestContext, program_owner: Keypair .await .unwrap(); + // v4: generate the per-test-binary instance keypair. Each SDK helper takes + // the instance pubkey as an explicit argument; tests pass + // `test_data.instance.pubkey()` (or a wrong one for negative tests). + let instance = Keypair::new(); + TestData { governance: Governance { program: wormhole_governance::ID, }, program_owner, + instance, mint_authority, mint: mint.pubkey(), bad_mint_authority, @@ -447,11 +460,17 @@ pub async fn setup_accounts_with_transfer_fee( .await .unwrap(); + // v4: generate the per-test-binary instance keypair. Each SDK helper takes + // the instance pubkey as an explicit argument; tests pass + // `test_data.instance.pubkey()` (or a wrong one for negative tests). + let instance = Keypair::new(); + TestData { governance: Governance { program: wormhole_governance::ID, }, program_owner, + instance, mint_authority, mint: mint.pubkey(), bad_mint_authority, diff --git a/solana/tests/cargo/src/helpers/transfer.rs b/solana/tests/cargo/src/helpers/transfer.rs index 9fce2085a..2fd200284 100644 --- a/solana/tests/cargo/src/helpers/transfer.rs +++ b/solana/tests/cargo/src/helpers/transfer.rs @@ -44,6 +44,9 @@ pub fn init_transfer_accs_args( (accs, args) } +/// v4: `recipient_ntt_manager` in the constructed transceiver message is the +/// destination instance pubkey (not the program ID), because that's what the +/// on-chain redeem path validates against (`recipient_ntt_manager == config.key()`). pub fn make_transfer_message( ntt: &NTT, id: [u8; 32], @@ -67,7 +70,7 @@ pub fn make_transfer_message( TransceiverMessage::new( OTHER_MANAGER, - ntt.program().to_bytes(), + ntt.config().to_bytes(), ntt_manager_message.clone(), vec![], ) diff --git a/solana/tests/cargo/src/sdk/accounts/ntt.rs b/solana/tests/cargo/src/sdk/accounts/ntt.rs index 323a8652a..1e965de96 100644 --- a/solana/tests/cargo/src/sdk/accounts/ntt.rs +++ b/solana/tests/cargo/src/sdk/accounts/ntt.rs @@ -1,6 +1,5 @@ use anchor_lang::{prelude::Pubkey, Id}; use example_native_token_transfers::{ - config::Config, instructions::TransferArgs, queue::{ inbox::{InboxItem, InboxRateLimit}, @@ -13,7 +12,6 @@ use example_native_token_transfers::{ use ntt_messages::{ntt::NativeTokenTransfer, ntt_manager::NttManagerMessage}; use sha3::{Digest, Keccak256}; use wormhole_io::TypePrefixedPayload; -use wormhole_solana_utils::cpi::bpf_loader_upgradeable; use crate::sdk::transceivers::accounts::ntt_transceiver::NTTTransceiver; @@ -21,6 +19,11 @@ use super::wormhole::Wormhole; pub type NTT = dyn NTTAccounts; +/// v4 test SDK: every per-instance PDA derivation flows through `config()`, +/// which returns the keypair-created Instance account pubkey for the +/// deployment under test. Implementors hold the pubkey on themselves; the +/// trait abstraction is preserved so negative tests can swap in a `BadNTT` +/// that returns intentionally-wrong addresses. pub trait NTTAccounts { fn program(&self) -> Pubkey { example_native_token_transfers::ID @@ -32,25 +35,33 @@ pub trait NTTAccounts { } } - fn config(&self) -> Pubkey { - let (config, _) = Pubkey::find_program_address(&[Config::SEED_PREFIX], &self.program()); - config - } + /// The instance pubkey for this NTT deployment. In v4 this is also the + /// on-the-wire NTT manager identity, and the seed scope for every other + /// per-instance PDA. + fn config(&self) -> Pubkey; fn outbox_rate_limit(&self) -> Pubkey { - let (outbox_rate_limit, _) = - Pubkey::find_program_address(&[OutboxRateLimit::SEED_PREFIX], &self.program()); + let (outbox_rate_limit, _) = Pubkey::find_program_address( + &[OutboxRateLimit::SEED_PREFIX, self.config().as_ref()], + &self.program(), + ); outbox_rate_limit } fn inbox_rate_limit(&self, chain: u16) -> Pubkey { let (inbox_rate_limit, _) = Pubkey::find_program_address( - &[InboxRateLimit::SEED_PREFIX, &chain.to_be_bytes()], + &[ + InboxRateLimit::SEED_PREFIX, + self.config().as_ref(), + &chain.to_be_bytes(), + ], &self.program(), ); inbox_rate_limit } + /// session_authority is per-(sender, transfer_args) — already unique + /// without instance scoping (matches the program-side seeds). fn session_authority(&self, sender: &Pubkey, args: &TransferArgs) -> Pubkey { let TransferArgs { amount, @@ -66,7 +77,12 @@ pub trait NTTAccounts { hasher.update([*should_queue as u8]); let (session_authority, _) = Pubkey::find_program_address( - &[SESSION_AUTHORITY_SEED, sender.as_ref(), &hasher.finalize()], + &[ + SESSION_AUTHORITY_SEED, + self.config().as_ref(), + sender.as_ref(), + &hasher.finalize(), + ], &self.program(), ); session_authority @@ -82,21 +98,31 @@ pub trait NTTAccounts { hasher.update(&TypePrefixedPayload::to_vec_payload(&ntt_manager_message)); let (inbox_item, _) = Pubkey::find_program_address( - &[InboxItem::SEED_PREFIX, &hasher.finalize()], + &[ + InboxItem::SEED_PREFIX, + self.config().as_ref(), + &hasher.finalize(), + ], &self.program(), ); inbox_item } fn token_authority(&self) -> Pubkey { - let (token_authority, _) = - Pubkey::find_program_address(&[TOKEN_AUTHORITY_SEED], &self.program()); + let (token_authority, _) = Pubkey::find_program_address( + &[TOKEN_AUTHORITY_SEED, self.config().as_ref()], + &self.program(), + ); token_authority } fn registered_transceiver(&self, transceiver: &Pubkey) -> Pubkey { let (registered_transceiver, _) = Pubkey::find_program_address( - &[RegisteredTransceiver::SEED_PREFIX, transceiver.as_ref()], + &[ + RegisteredTransceiver::SEED_PREFIX, + self.config().as_ref(), + transceiver.as_ref(), + ], &self.program(), ); registered_transceiver @@ -104,7 +130,11 @@ pub trait NTTAccounts { fn peer(&self, chain: u16) -> Pubkey { let (peer, _) = Pubkey::find_program_address( - &[b"peer".as_ref(), &chain.to_be_bytes()], + &[ + b"peer".as_ref(), + self.config().as_ref(), + &chain.to_be_bytes(), + ], &self.program(), ); peer @@ -123,26 +153,26 @@ pub trait NTTAccounts { } fn wormhole_sequence(&self, ntt_transceiver: &NTTTransceiver) -> Pubkey { + // ntt_transceiver carries its own instance binding internally. self.wormhole().sequence(&ntt_transceiver.emitter()) } +} - fn program_data(&self) -> Pubkey { - let (addr, _) = - Pubkey::find_program_address(&[self.program().as_ref()], &bpf_loader_upgradeable::id()); - addr - } +/// Implements the account derivations correctly for tests. Holds the instance +/// pubkey for this deployment. +pub struct GoodNTT { + pub instance: Pubkey, +} - fn upgrade_lock(&self) -> Pubkey { - let (addr, _) = Pubkey::find_program_address(&[b"upgrade_lock"], &self.program()); - addr +impl NTTAccounts for GoodNTT { + fn config(&self) -> Pubkey { + self.instance } } -/// This implements the account derivations correctly. For negative tests, other -/// implementations will implement them incorrectly. -pub struct GoodNTT {} - -#[allow(non_upper_case_globals)] -pub const good_ntt: GoodNTT = GoodNTT {}; - -impl NTTAccounts for GoodNTT {} +/// Construct a [`GoodNTT`] bound to `instance`. Tests use it like +/// `let ntt = good_ntt(test_data.instance.pubkey());` and then pass `&ntt` +/// to SDK helpers. +pub fn good_ntt(instance: Pubkey) -> GoodNTT { + GoodNTT { instance } +} diff --git a/solana/tests/cargo/src/sdk/instructions/initialize.rs b/solana/tests/cargo/src/sdk/instructions/initialize.rs index 28d446471..10fe344ce 100644 --- a/solana/tests/cargo/src/sdk/instructions/initialize.rs +++ b/solana/tests/cargo/src/sdk/instructions/initialize.rs @@ -2,13 +2,15 @@ use anchor_lang::{prelude::Pubkey, system_program::System, Id, InstructionData, use anchor_spl::{associated_token::AssociatedToken, token::Token}; use example_native_token_transfers::instructions::InitializeArgs; use solana_sdk::instruction::Instruction; -use wormhole_solana_utils::cpi::bpf_loader_upgradeable::BpfLoaderUpgradeable; use crate::sdk::accounts::NTT; +/// v4 initialize accounts. The Instance account is keypair-allocated at +/// `ntt.config()`; the caller is responsible for adding that keypair as a +/// signer on the transaction. pub struct Initialize { pub payer: Pubkey, - pub deployer: Pubkey, + pub owner: Pubkey, pub mint: Pubkey, pub multisig_token_authority: Option, } @@ -25,11 +27,9 @@ pub fn initialize_with_token_program_id( ) -> Instruction { let data = example_native_token_transfers::instruction::Initialize { args }; - let bpf_loader_upgradeable_program = BpfLoaderUpgradeable::id(); let accounts = example_native_token_transfers::accounts::Initialize { payer: accounts.payer, - deployer: accounts.deployer, - program_data: ntt.program_data(), + owner: accounts.owner, config: ntt.config(), mint: accounts.mint, rate_limit: ntt.outbox_rate_limit(), @@ -38,11 +38,9 @@ pub fn initialize_with_token_program_id( custody: ntt.custody_with_token_program_id(&accounts.mint, token_program_id), token_program: *token_program_id, associated_token_program: AssociatedToken::id(), - bpf_loader_upgradeable_program, system_program: System::id(), }; - // fetch account Instruction { program_id: ntt.program(), accounts: accounts.to_account_metas(None), diff --git a/solana/tests/cargo/src/sdk/instructions/release_inbound.rs b/solana/tests/cargo/src/sdk/instructions/release_inbound.rs index c25305818..30185a9fd 100644 --- a/solana/tests/cargo/src/sdk/instructions/release_inbound.rs +++ b/solana/tests/cargo/src/sdk/instructions/release_inbound.rs @@ -21,6 +21,8 @@ pub fn release_inbound_unlock( let accounts = example_native_token_transfers::accounts::ReleaseInboundUnlock { common: example_native_token_transfers::accounts::ReleaseInbound { payer: accounts.payer, + // anchor's client-side NotPausedConfig holds a Pubkey, not a typed + // Account; instruction construction only needs the address. config: NotPausedConfig { config: ntt.config(), }, diff --git a/solana/tests/cargo/src/sdk/transceivers/legacy/accounts/ntt_transceiver.rs b/solana/tests/cargo/src/sdk/transceivers/legacy/accounts/ntt_transceiver.rs index fc1637177..44146a235 100644 --- a/solana/tests/cargo/src/sdk/transceivers/legacy/accounts/ntt_transceiver.rs +++ b/solana/tests/cargo/src/sdk/transceivers/legacy/accounts/ntt_transceiver.rs @@ -2,13 +2,22 @@ use anchor_lang::prelude::Pubkey; pub type NTTTransceiver = dyn NTTTransceiverAccounts; +/// v4: emitter, transceiver_peer, transceiver_message PDAs are scoped by the +/// instance pubkey; implementors carry it. wormhole_message and +/// unverified_message_account are content-addressed and don't need instance +/// scoping. pub trait NTTTransceiverAccounts { fn program(&self) -> Pubkey { example_native_token_transfers::ID } + fn config(&self) -> Pubkey; + fn emitter(&self) -> Pubkey { - let (emitter, _) = Pubkey::find_program_address(&[b"emitter".as_ref()], &self.program()); + let (emitter, _) = Pubkey::find_program_address( + &[b"emitter".as_ref(), self.config().as_ref()], + &self.program(), + ); emitter } @@ -22,7 +31,11 @@ pub trait NTTTransceiverAccounts { fn transceiver_peer(&self, chain: u16) -> Pubkey { let (peer, _) = Pubkey::find_program_address( - &[b"transceiver_peer".as_ref(), &chain.to_be_bytes()], + &[ + b"transceiver_peer".as_ref(), + self.config().as_ref(), + &chain.to_be_bytes(), + ], &self.program(), ); peer @@ -30,7 +43,12 @@ pub trait NTTTransceiverAccounts { fn transceiver_message(&self, chain: u16, id: [u8; 32]) -> Pubkey { let (transceiver_message, _) = Pubkey::find_program_address( - &[b"transceiver_message".as_ref(), &chain.to_be_bytes(), &id], + &[ + b"transceiver_message".as_ref(), + self.config().as_ref(), + &chain.to_be_bytes(), + &id, + ], &self.program(), ); transceiver_message @@ -45,11 +63,18 @@ pub trait NTTTransceiverAccounts { } } -/// This implements the account derivations correctly. For negative tests, other -/// implementations will implement them incorrectly. -pub struct GoodNTTTransceiver {} +/// Holds the instance pubkey for this deployment. For negative tests, other +/// implementations can return wrong addresses. +pub struct GoodNTTTransceiver { + pub instance: Pubkey, +} -#[allow(non_upper_case_globals)] -pub const good_ntt_transceiver: GoodNTTTransceiver = GoodNTTTransceiver {}; +impl NTTTransceiverAccounts for GoodNTTTransceiver { + fn config(&self) -> Pubkey { + self.instance + } +} -impl NTTTransceiverAccounts for GoodNTTTransceiver {} +pub fn good_ntt_transceiver(instance: Pubkey) -> GoodNTTTransceiver { + GoodNTTTransceiver { instance } +} diff --git a/solana/tests/cargo/src/sdk/transceivers/shim/accounts/ntt_transceiver.rs b/solana/tests/cargo/src/sdk/transceivers/shim/accounts/ntt_transceiver.rs index b240b6a70..8004987b0 100644 --- a/solana/tests/cargo/src/sdk/transceivers/shim/accounts/ntt_transceiver.rs +++ b/solana/tests/cargo/src/sdk/transceivers/shim/accounts/ntt_transceiver.rs @@ -5,11 +5,15 @@ use super::post_message_shim::PostMessageShim; pub type NTTTransceiver = dyn NTTTransceiverAccounts; +/// v4: emitter, transceiver_peer, transceiver_message, outbox_item_signer +/// PDAs are all instance-scoped. wormhole_message piggy-backs on emitter. pub trait NTTTransceiverAccounts { fn program(&self) -> Pubkey { ntt_transceiver::ID } + fn config(&self) -> Pubkey; + fn post_message_shim(&self) -> PostMessageShim { PostMessageShim { program: POST_MESSAGE_SHIM_PROGRAM_ID, @@ -21,13 +25,18 @@ pub trait NTTTransceiverAccounts { } fn emitter(&self) -> Pubkey { - let (emitter, _) = Pubkey::find_program_address(&[b"emitter".as_ref()], &self.program()); + let (emitter, _) = Pubkey::find_program_address( + &[b"emitter".as_ref(), self.config().as_ref()], + &self.program(), + ); emitter } fn outbox_item_signer(&self) -> Pubkey { - let (outbox_item_signer, _) = - Pubkey::find_program_address(&[b"outbox_item_signer".as_ref()], &self.program()); + let (outbox_item_signer, _) = Pubkey::find_program_address( + &[b"outbox_item_signer".as_ref(), self.config().as_ref()], + &self.program(), + ); outbox_item_signer } @@ -41,7 +50,11 @@ pub trait NTTTransceiverAccounts { fn transceiver_peer(&self, chain: u16) -> Pubkey { let (peer, _) = Pubkey::find_program_address( - &[b"transceiver_peer".as_ref(), &chain.to_be_bytes()], + &[ + b"transceiver_peer".as_ref(), + self.config().as_ref(), + &chain.to_be_bytes(), + ], &self.program(), ); peer @@ -49,7 +62,12 @@ pub trait NTTTransceiverAccounts { fn transceiver_message(&self, chain: u16, id: [u8; 32]) -> Pubkey { let (transceiver_message, _) = Pubkey::find_program_address( - &[b"transceiver_message".as_ref(), &chain.to_be_bytes(), &id], + &[ + b"transceiver_message".as_ref(), + self.config().as_ref(), + &chain.to_be_bytes(), + &id, + ], &self.program(), ); transceiver_message @@ -64,11 +82,16 @@ pub trait NTTTransceiverAccounts { } } -/// This implements the account derivations correctly. For negative tests, other -/// implementations will implement them incorrectly. -pub struct GoodNTTTransceiver {} +pub struct GoodNTTTransceiver { + pub instance: Pubkey, +} -#[allow(non_upper_case_globals)] -pub const good_ntt_transceiver: GoodNTTTransceiver = GoodNTTTransceiver {}; +impl NTTTransceiverAccounts for GoodNTTTransceiver { + fn config(&self) -> Pubkey { + self.instance + } +} -impl NTTTransceiverAccounts for GoodNTTTransceiver {} +pub fn good_ntt_transceiver(instance: Pubkey) -> GoodNTTTransceiver { + GoodNTTTransceiver { instance } +} diff --git a/solana/ts/idl/4_0_0/json/dummy_transfer_hook.json b/solana/ts/idl/4_0_0/json/dummy_transfer_hook.json new file mode 100644 index 000000000..21ade4d20 --- /dev/null +++ b/solana/ts/idl/4_0_0/json/dummy_transfer_hook.json @@ -0,0 +1,110 @@ +{ + "version": "3.0.0", + "name": "dummy_transfer_hook", + "instructions": [ + { + "name": "initializeExtraAccountMetaList", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "extraAccountMetaList", + "isMut": true, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "associatedTokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "counter", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "transferHook", + "accounts": [ + { + "name": "sourceToken", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "destinationToken", + "isMut": false, + "isSigner": false + }, + { + "name": "authority", + "isMut": false, + "isSigner": false + }, + { + "name": "extraAccountMetaList", + "isMut": false, + "isSigner": false + }, + { + "name": "dummyAccount", + "isMut": false, + "isSigner": false, + "docs": [ + "computes and the on-chain code correctly passes on the PDA." + ] + }, + { + "name": "counter", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + } + ] + } + ], + "accounts": [ + { + "name": "Counter", + "type": { + "kind": "struct", + "fields": [ + { + "name": "count", + "type": "u64" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/solana/ts/idl/4_0_0/json/example_native_token_transfers.json b/solana/ts/idl/4_0_0/json/example_native_token_transfers.json new file mode 100644 index 000000000..5d0dd027e --- /dev/null +++ b/solana/ts/idl/4_0_0/json/example_native_token_transfers.json @@ -0,0 +1,2408 @@ +{ + "version": "4.0.0", + "name": "example_native_token_transfers", + "instructions": [ + { + "name": "initialize", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "owner", + "isMut": false, + "isSigner": true, + "docs": [ + "The owner of the new instance. Distinct from the program's upgrade", + "authority — see the v4 trust-model note in the README." + ] + }, + { + "name": "config", + "isMut": true, + "isSigner": true, + "docs": [ + "The instance account itself. Caller-provided keypair, must sign." + ] + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "rateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false, + "docs": [ + "Per-instance token authority lets each instance manage its own mint independently.", + "", + "TODO: Using `UncheckedAccount` here leads to \"Access violation in stack frame ...\".", + "Could refactor code to use `Box<_>` to reduce stack size." + ] + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "custody", + "isMut": true, + "isSigner": false, + "docs": [ + "The custody account that holds tokens in locking mode and temporarily", + "holds tokens in burning mode.", + "function if the token account has already been created." + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false, + "docs": [ + "associated token account for the given mint." + ] + }, + { + "name": "associatedTokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "InitializeArgs" + } + } + ] + }, + { + "name": "initializeLut", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "authority", + "isMut": false, + "isSigner": false + }, + { + "name": "lutAddress", + "isMut": true, + "isSigner": false + }, + { + "name": "lut", + "isMut": true, + "isSigner": false + }, + { + "name": "lutProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "entries", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "custody", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxRateLimit", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ] + } + ], + "args": [ + { + "name": "recentSlot", + "type": "u64" + } + ] + }, + { + "name": "version", + "accounts": [], + "args": [], + "returns": "string" + }, + { + "name": "transferBurn", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "from", + "isMut": true, + "isSigner": false, + "docs": [ + "account can spend these tokens." + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": true + }, + { + "name": "outboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "custody", + "isMut": true, + "isSigner": false, + "docs": [ + "Tokens are always transferred to the custody account first regardless of", + "the mode.", + "For an explanation, see the note in [`transfer_burn`]." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "inboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "sessionAuthority", + "isMut": false, + "isSigner": false, + "docs": [ + "See [`crate::SESSION_AUTHORITY_SEED`] for an explanation of the flow." + ] + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "TransferArgs" + } + } + ] + }, + { + "name": "transferLock", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "from", + "isMut": true, + "isSigner": false, + "docs": [ + "account can spend these tokens." + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": true + }, + { + "name": "outboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "custody", + "isMut": true, + "isSigner": false, + "docs": [ + "Tokens are always transferred to the custody account first regardless of", + "the mode.", + "For an explanation, see the note in [`transfer_burn`]." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "inboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "sessionAuthority", + "isMut": false, + "isSigner": false, + "docs": [ + "See [`crate::SESSION_AUTHORITY_SEED`] for an explanation of the flow." + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "TransferArgs" + } + } + ] + }, + { + "name": "redeem", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "transceiverMessage", + "isMut": false, + "isSigner": false, + "docs": [ + "`Account` and `owner` constraints are mutually-exclusive" + ] + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "inboxItem", + "isMut": true, + "isSigner": false, + "docs": [ + "NOTE: This account is content-addressed (PDA seeded by the message hash).", + "This is because in a multi-transceiver configuration, the different", + "transceivers \"vote\" on messages (by delivering them). By making the inbox", + "items content-addressed, we can ensure that disagreeing votes don't", + "interfere with each other.", + "On the first call to [`redeem()`], [`InboxItem`] will be allocated and initialized with", + "default values.", + "On subsequent calls, we want to modify the `InboxItem` by \"voting\" on it. Therefore the", + "program should not fail which would occur when using the `init` constraint.", + "The [`InboxItem::init`] field is used to guard against malicious or accidental modification", + "InboxItem fields that should remain constant." + ] + }, + { + "name": "inboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "outboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "RedeemArgs" + } + } + ] + }, + { + "name": "releaseInboundMint", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "inboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "recipient", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "custody", + "isMut": true, + "isSigner": false + } + ] + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ReleaseInboundArgs" + } + } + ] + }, + { + "name": "releaseInboundUnlock", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "inboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "recipient", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "custody", + "isMut": true, + "isSigner": false + } + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ReleaseInboundArgs" + } + } + ] + }, + { + "name": "transferOwnership", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "newOwner", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "transferOwnershipOneStepUnchecked", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "newOwner", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "claimOwnership", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "newOwner", + "isMut": false, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "acceptTokenAuthority", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "currentAuthority", + "isMut": false, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "acceptTokenAuthorityFromMultisig", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "currentMultisigAuthority", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setTokenAuthorityOneStepUnchecked", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "newAuthority", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setTokenAuthority", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "newAuthority", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": true + }, + { + "name": "pendingTokenAuthority", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "revertTokenAuthority", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": false + }, + { + "name": "pendingTokenAuthority", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "claimTokenAuthority", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": false + }, + { + "name": "pendingTokenAuthority", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "newAuthority", + "isMut": false, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "claimTokenAuthorityToMultisig", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": false + }, + { + "name": "pendingTokenAuthority", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "newMultisigAuthority", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setPaused", + "accounts": [ + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "config", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "pause", + "type": "bool" + } + ] + }, + { + "name": "setPeer", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "peer", + "isMut": true, + "isSigner": false + }, + { + "name": "inboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetPeerArgs" + } + } + ] + }, + { + "name": "registerTransceiver", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false, + "docs": [ + "used here that wraps the Transceiver account type." + ] + }, + { + "name": "registeredTransceiver", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "deregisterTransceiver", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "registeredTransceiver", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setOutboundLimit", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "rateLimit", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetOutboundLimitArgs" + } + } + ] + }, + { + "name": "setInboundLimit", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "rateLimit", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetInboundLimitArgs" + } + } + ] + }, + { + "name": "markOutboxItemAsReleased", + "accounts": [ + { + "name": "signer", + "isMut": false, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false + } + ], + "args": [], + "returns": "bool" + }, + { + "name": "setThreshold", + "accounts": [ + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "config", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "threshold", + "type": "u8" + } + ] + }, + { + "name": "setWormholePeer", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "peer", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetTransceiverPeerArgs" + } + } + ] + }, + { + "name": "receiveWormholeMessage", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "vaa", + "isMut": false, + "isSigner": false + }, + { + "name": "transceiverMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "releaseWormholeOutbound", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ReleaseOutboundArgs" + } + } + ] + }, + { + "name": "broadcastWormholeId", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": true + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false, + "docs": [ + "enforced by the [`CpiContext`] call in [`post_message`].", + "The seeds constraint ensures that this is the correct address" + ] + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [] + }, + { + "name": "broadcastWormholePeer", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": true + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "BroadcastPeerArgs" + } + } + ] + } + ], + "accounts": [ + { + "name": "Config", + "type": { + "kind": "struct", + "fields": [ + { + "name": "owner", + "docs": [ + "Owner of this instance. Distinct from the program's upgrade authority —", + "instance ownership transfers are pure data mutations and never touch the", + "BPF loader (v4 trust model)." + ], + "type": "publicKey" + }, + { + "name": "pendingOwner", + "docs": [ + "Pending next owner (before claiming ownership)." + ], + "type": { + "option": "publicKey" + } + }, + { + "name": "mint", + "docs": [ + "Mint address of the token managed by this instance." + ], + "type": "publicKey" + }, + { + "name": "tokenProgram", + "docs": [ + "Address of the token program (token or token22). This could always be queried", + "from the [`mint`] account's owner, but storing it here avoids an indirection", + "on the client side." + ], + "type": "publicKey" + }, + { + "name": "mode", + "docs": [ + "The mode that this instance is running in. This is used to determine", + "whether the program is burning tokens or locking tokens." + ], + "type": { + "defined": "Mode" + } + }, + { + "name": "chainId", + "docs": [ + "The chain id of the chain that this program is running on. We don't", + "hardcode this so that the program is deployable on any potential SVM", + "forks." + ], + "type": { + "defined": "ChainId" + } + }, + { + "name": "nextTransceiverId", + "docs": [ + "The next transceiver id to use when registering a transceiver under this instance." + ], + "type": "u8" + }, + { + "name": "threshold", + "docs": [ + "The number of transceivers that must attest to a transfer before it is", + "accepted." + ], + "type": "u8" + }, + { + "name": "enabledTransceivers", + "docs": [ + "Bitmap of enabled transceivers.", + "The maximum number of transceivers is equal to [`Bitmap::BITS`]." + ], + "type": { + "defined": "Bitmap" + } + }, + { + "name": "paused", + "docs": [ + "Pause the program. This is useful for upgrades and other maintenance." + ], + "type": "bool" + }, + { + "name": "custody", + "docs": [ + "The custody account that holds tokens in locking mode." + ], + "type": "publicKey" + } + ] + } + }, + { + "name": "LUT", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "address", + "type": "publicKey" + } + ] + } + }, + { + "name": "NttManagerPeer", + "docs": [ + "A peer on another chain. Stored in a PDA seeded by the chain id." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "tokenDecimals", + "type": "u8" + } + ] + } + }, + { + "name": "PendingTokenAuthority", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "pendingAuthority", + "type": "publicKey" + }, + { + "name": "rentPayer", + "type": "publicKey" + } + ] + } + }, + { + "name": "InboxItem", + "type": { + "kind": "struct", + "fields": [ + { + "name": "init", + "type": "bool" + }, + { + "name": "bump", + "type": "u8" + }, + { + "name": "config", + "type": "publicKey" + }, + { + "name": "amount", + "type": "u64" + }, + { + "name": "recipientAddress", + "type": "publicKey" + }, + { + "name": "votes", + "type": { + "defined": "Bitmap" + } + }, + { + "name": "releaseStatus", + "type": { + "defined": "ReleaseStatus" + } + } + ] + } + }, + { + "name": "InboxRateLimit", + "docs": [ + "Inbound rate limit per chain.", + "SECURITY: must check the PDA (since there are multiple PDAs, namely one for each chain.)" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "rateLimit", + "type": { + "defined": "RateLimitState" + } + } + ] + } + }, + { + "name": "OutboxItem", + "type": { + "kind": "struct", + "fields": [ + { + "name": "manager", + "docs": [ + "v4: the `Config` (instance) that produced this outbox item. Used to", + "bind the item to its source instance so transceivers can verify they're", + "releasing/marking an item that belongs to the instance they're working", + "on (`released` is a bitmap keyed by per-instance transceiver_id)." + ], + "type": "publicKey" + }, + { + "name": "amount", + "type": { + "defined": "TrimmedAmount" + } + }, + { + "name": "sender", + "type": "publicKey" + }, + { + "name": "recipientChain", + "type": { + "defined": "ChainId" + } + }, + { + "name": "recipientNttManager", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "recipientAddress", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "releaseTimestamp", + "type": "i64" + }, + { + "name": "released", + "type": { + "defined": "Bitmap" + } + } + ] + } + }, + { + "name": "OutboxRateLimit", + "type": { + "kind": "struct", + "fields": [ + { + "name": "rateLimit", + "type": { + "defined": "RateLimitState" + } + } + ] + } + }, + { + "name": "RegisteredTransceiver", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "id", + "type": "u8" + }, + { + "name": "transceiverAddress", + "type": "publicKey" + } + ] + } + }, + { + "name": "TransceiverPeer", + "docs": [ + "A peer on another chain. Stored in a PDA seeded by the chain id." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + } + ], + "types": [ + { + "name": "Bitmap", + "type": { + "kind": "struct", + "fields": [ + { + "name": "map", + "type": "u128" + } + ] + } + }, + { + "name": "SetInboundLimitArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "limit", + "type": "u64" + }, + { + "name": "chainId", + "type": { + "defined": "ChainId" + } + } + ] + } + }, + { + "name": "SetOutboundLimitArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "limit", + "type": "u64" + } + ] + } + }, + { + "name": "SetPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": { + "defined": "ChainId" + } + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "limit", + "type": "u64" + }, + { + "name": "tokenDecimals", + "docs": [ + "The token decimals on the peer chain." + ], + "type": "u8" + } + ] + } + }, + { + "name": "InitializeArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": "u16" + }, + { + "name": "limit", + "type": "u64" + }, + { + "name": "mode", + "type": { + "defined": "Mode" + } + } + ] + } + }, + { + "name": "RedeemArgs", + "type": { + "kind": "struct", + "fields": [] + } + }, + { + "name": "ReleaseInboundArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "revertWhenNotReady", + "type": "bool" + } + ] + } + }, + { + "name": "TransferArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "recipientChain", + "type": { + "defined": "ChainId" + } + }, + { + "name": "recipientAddress", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "shouldQueue", + "type": "bool" + } + ] + } + }, + { + "name": "ReleaseStatus", + "docs": [ + "The status of an InboxItem. This determines whether the tokens are minted/unlocked to the recipient. As", + "such, this must be used as a state machine that moves forward in a linear manner. A state", + "should never \"move backward\" to a previous state (e.g. should never move from `Released` to", + "`ReleaseAfter`)." + ], + "type": { + "kind": "enum", + "variants": [ + { + "name": "NotApproved" + }, + { + "name": "ReleaseAfter", + "fields": [ + "i64" + ] + }, + { + "name": "Released" + } + ] + } + }, + { + "name": "RateLimitState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "limit", + "docs": [ + "The maximum capacity of the rate limiter." + ], + "type": "u64" + }, + { + "name": "capacityAtLastTx", + "docs": [ + "The capacity of the rate limiter at `last_tx_timestamp`.", + "The actual current capacity is calculated in `capacity_at`, by", + "accounting for the time that has passed since `last_tx_timestamp` and", + "the refill rate." + ], + "type": "u64" + }, + { + "name": "lastTxTimestamp", + "docs": [ + "The timestamp of the last transaction that counted towards the current", + "capacity. Transactions that exceeded the capacity do not count, they are", + "just delayed." + ], + "type": "i64" + } + ] + } + }, + { + "name": "SetTransceiverPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": { + "defined": "ChainId" + } + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + }, + { + "name": "BroadcastPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": "u16" + } + ] + } + }, + { + "name": "ReleaseOutboundArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "revertOnDelay", + "type": "bool" + } + ] + } + }, + { + "name": "ChainId", + "type": { + "kind": "struct", + "fields": [ + { + "name": "id", + "type": "u16" + } + ] + } + }, + { + "name": "Mode", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Locking" + }, + { + "name": "Burning" + } + ] + } + }, + { + "name": "TrimmedAmount", + "type": { + "kind": "struct", + "fields": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "decimals", + "type": "u8" + } + ] + } + } + ], + "errors": [ + { + "code": 6000, + "name": "CantReleaseYet", + "msg": "CantReleaseYet" + }, + { + "code": 6001, + "name": "InvalidPendingOwner", + "msg": "InvalidPendingOwner" + }, + { + "code": 6002, + "name": "InvalidChainId", + "msg": "InvalidChainId" + }, + { + "code": 6003, + "name": "InvalidRecipientAddress", + "msg": "InvalidRecipientAddress" + }, + { + "code": 6004, + "name": "InvalidTransceiverPeer", + "msg": "InvalidTransceiverPeer" + }, + { + "code": 6005, + "name": "InvalidNttManagerPeer", + "msg": "InvalidNttManagerPeer" + }, + { + "code": 6006, + "name": "InvalidRecipientNttManager", + "msg": "InvalidRecipientNttManager" + }, + { + "code": 6007, + "name": "TransferAlreadyRedeemed", + "msg": "TransferAlreadyRedeemed" + }, + { + "code": 6008, + "name": "TransferCannotBeRedeemed", + "msg": "TransferCannotBeRedeemed" + }, + { + "code": 6009, + "name": "TransferNotApproved", + "msg": "TransferNotApproved" + }, + { + "code": 6010, + "name": "MessageAlreadySent", + "msg": "MessageAlreadySent" + }, + { + "code": 6011, + "name": "InvalidMode", + "msg": "InvalidMode" + }, + { + "code": 6012, + "name": "InvalidMintAuthority", + "msg": "InvalidMintAuthority" + }, + { + "code": 6013, + "name": "TransferExceedsRateLimit", + "msg": "TransferExceedsRateLimit" + }, + { + "code": 6014, + "name": "Paused", + "msg": "Paused" + }, + { + "code": 6015, + "name": "DisabledTransceiver", + "msg": "DisabledTransceiver" + }, + { + "code": 6016, + "name": "InvalidDeployer", + "msg": "InvalidDeployer" + }, + { + "code": 6017, + "name": "BadAmountAfterTransfer", + "msg": "BadAmountAfterTransfer" + }, + { + "code": 6018, + "name": "BadAmountAfterBurn", + "msg": "BadAmountAfterBurn" + }, + { + "code": 6019, + "name": "ZeroThreshold", + "msg": "ZeroThreshold" + }, + { + "code": 6020, + "name": "OverflowExponent", + "msg": "OverflowExponent" + }, + { + "code": 6021, + "name": "OverflowScaledAmount", + "msg": "OverflowScaledAmount" + }, + { + "code": 6022, + "name": "BitmapIndexOutOfBounds", + "msg": "BitmapIndexOutOfBounds" + }, + { + "code": 6023, + "name": "NoRegisteredTransceivers", + "msg": "NoRegisteredTransceivers" + }, + { + "code": 6024, + "name": "NotPaused", + "msg": "NotPaused" + }, + { + "code": 6025, + "name": "InvalidPendingTokenAuthority", + "msg": "InvalidPendingTokenAuthority" + }, + { + "code": 6026, + "name": "IncorrectRentPayer", + "msg": "IncorrectRentPayer" + }, + { + "code": 6027, + "name": "InvalidMultisig", + "msg": "InvalidMultisig" + }, + { + "code": 6028, + "name": "ThresholdTooHigh", + "msg": "ThresholdTooHigh" + }, + { + "code": 6029, + "name": "InvalidTransceiverProgram", + "msg": "InvalidTransceiverProgram" + }, + { + "code": 6030, + "name": "InvalidOutboxItem", + "msg": "InvalidOutboxItem" + }, + { + "code": 6031, + "name": "InvalidInboxItem", + "msg": "InvalidInboxItem" + } + ] +} diff --git a/solana/ts/idl/4_0_0/json/ntt_quoter.json b/solana/ts/idl/4_0_0/json/ntt_quoter.json new file mode 100644 index 000000000..ecf37793e --- /dev/null +++ b/solana/ts/idl/4_0_0/json/ntt_quoter.json @@ -0,0 +1,588 @@ +{ + "version": "3.0.0", + "name": "ntt_quoter", + "instructions": [ + { + "name": "requestRelay", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredChain", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredNtt", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxItem", + "isMut": false, + "isSigner": false, + "docs": [ + "and checking the release constraint into a single function" + ] + }, + { + "name": "relayRequest", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "RequestRelayArgs" + } + } + ] + }, + { + "name": "closeRelay", + "accounts": [ + { + "name": "authority", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "feeRecipient", + "isMut": true, + "isSigner": false + }, + { + "name": "relayRequest", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "initialize", + "accounts": [ + { + "name": "owner", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": true, + "isSigner": false + }, + { + "name": "feeRecipient", + "isMut": false, + "isSigner": false + }, + { + "name": "programData", + "isMut": true, + "isSigner": false, + "docs": [ + "We use the program data to make sure this owner is the upgrade authority (the true owner,", + "who deployed this program)." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setAssistant", + "accounts": [ + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": true, + "isSigner": false + }, + { + "name": "assistant", + "isMut": false, + "isSigner": false, + "isOptional": true + } + ], + "args": [] + }, + { + "name": "setFeeRecipient", + "accounts": [ + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": true, + "isSigner": false + }, + { + "name": "feeRecipient", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "registerChain", + "accounts": [ + { + "name": "authority", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredChain", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "RegisterChainArgs" + } + } + ] + }, + { + "name": "registerNtt", + "accounts": [ + { + "name": "authority", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredNtt", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "RegisterNttArgs" + } + } + ] + }, + { + "name": "deregisterNtt", + "accounts": [ + { + "name": "authority", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredNtt", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "DeregisterNttArgs" + } + } + ] + }, + { + "name": "updateSolPrice", + "accounts": [ + { + "name": "authority", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "UpdateSolPriceArgs" + } + } + ] + }, + { + "name": "updateChainPrices", + "accounts": [ + { + "name": "authority", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredChain", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "UpdateChainPricesArgs" + } + } + ] + }, + { + "name": "updateChainParams", + "accounts": [ + { + "name": "authority", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredChain", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "UpdateChainParamsArgs" + } + } + ] + } + ], + "accounts": [ + { + "name": "Instance", + "type": { + "kind": "struct", + "fields": [ + { + "name": "owner", + "type": "publicKey" + }, + { + "name": "assistant", + "type": "publicKey" + }, + { + "name": "feeRecipient", + "type": "publicKey" + }, + { + "name": "solPrice", + "type": "u64" + } + ] + } + }, + { + "name": "RegisteredChain", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "maxGasDropoff", + "type": "u64" + }, + { + "name": "basePrice", + "type": "u64" + }, + { + "name": "nativePrice", + "type": "u64" + }, + { + "name": "gasPrice", + "type": "u64" + } + ] + } + }, + { + "name": "RegisteredNtt", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "wormholeTransceiverIndex", + "type": "u8" + }, + { + "name": "gasCost", + "type": "u32" + } + ] + } + }, + { + "name": "RelayRequest", + "type": { + "kind": "struct", + "fields": [ + { + "name": "requestedGasDropoff", + "type": "u64" + } + ] + } + } + ], + "types": [ + { + "name": "RegisterChainArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": "u16" + } + ] + } + }, + { + "name": "RegisterNttArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "nttProgramId", + "type": "publicKey" + }, + { + "name": "wormholeTransceiverIndex", + "type": "u8" + }, + { + "name": "gasCost", + "type": "u32" + } + ] + } + }, + { + "name": "DeregisterNttArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "nttProgramId", + "type": "publicKey" + } + ] + } + }, + { + "name": "RequestRelayArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "gasDropoff", + "type": "u64" + }, + { + "name": "maxFee", + "type": "u64" + } + ] + } + }, + { + "name": "UpdateSolPriceArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "solPrice", + "type": "u64" + } + ] + } + }, + { + "name": "UpdateChainPricesArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "nativePrice", + "type": "u64" + }, + { + "name": "gasPrice", + "type": "u64" + } + ] + } + }, + { + "name": "UpdateChainParamsArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "maxGasDropoff", + "type": "u64" + }, + { + "name": "basePrice", + "type": "u64" + } + ] + } + } + ], + "errors": [ + { + "code": 6001, + "name": "ExceedsUserMaxFee", + "msg": "Relay fees exceeds specified max" + }, + { + "code": 6002, + "name": "ExceedsMaxGasDropoff", + "msg": "Requested gas dropoff exceeds max allowed for chain" + }, + { + "code": 6003, + "name": "InvalidFeeRecipient", + "msg": "The specified fee recipient does not match the address in the instance accound" + }, + { + "code": 6004, + "name": "RelayingToChainDisabled", + "msg": "Relaying to the specified chain is disabled" + }, + { + "code": 6005, + "name": "OutboxItemNotReleased", + "msg": "Relaying to the specified chain is disabled" + }, + { + "code": 6006, + "name": "ScalingOverflow", + "msg": "Scaled value exceeds u64::MAX" + }, + { + "code": 6007, + "name": "DivByZero", + "msg": "Cannot divide by zero" + }, + { + "code": 6257, + "name": "FeeRecipientCannotBeDefault", + "msg": "The fee recipient cannot be the default address (0x0)" + }, + { + "code": 6258, + "name": "NotAuthorized", + "msg": "Must be owner or assistant" + }, + { + "code": 6259, + "name": "PriceCannotBeZero", + "msg": "The price cannot be zero" + } + ] +} diff --git a/solana/ts/idl/4_0_0/json/ntt_transceiver.json b/solana/ts/idl/4_0_0/json/ntt_transceiver.json new file mode 100644 index 000000000..793474192 --- /dev/null +++ b/solana/ts/idl/4_0_0/json/ntt_transceiver.json @@ -0,0 +1,885 @@ +{ + "version": "3.0.0", + "name": "ntt_transceiver", + "instructions": [ + { + "name": "transceiverType", + "accounts": [], + "args": [], + "returns": "string" + }, + { + "name": "setWormholePeer", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "peer", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetTransceiverPeerArgs" + } + } + ] + }, + { + "name": "receiveWormholeMessageInstructionData", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "transceiverMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "guardianSet", + "isMut": false, + "isSigner": false, + "docs": [ + "Derivation is checked by the shim." + ] + }, + { + "name": "guardianSignatures", + "isMut": false, + "isSigner": false, + "docs": [ + "Ownership ownership and discriminator is checked by the shim." + ] + }, + { + "name": "verifyVaaShim", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "guardianSetBump", + "type": "u8" + }, + { + "name": "vaaBody", + "type": { + "defined": "VaaBodyData" + } + } + ] + }, + { + "name": "postUnverifiedWormholeMessageAccount", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "message", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "PostUnverifiedMessageAccountArgs" + } + } + ] + }, + { + "name": "closeUnverifiedWormholeMessageAccount", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "message", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "seed", + "type": "u64" + } + ] + }, + { + "name": "receiveWormholeMessageAccount", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "message", + "isMut": true, + "isSigner": false + }, + { + "name": "transceiverMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "guardianSet", + "isMut": false, + "isSigner": false, + "docs": [ + "Derivation is checked by the shim." + ] + }, + { + "name": "guardianSignatures", + "isMut": false, + "isSigner": false, + "docs": [ + "Ownership ownership and discriminator is checked by the shim." + ] + }, + { + "name": "verifyVaaShim", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "guardianSetBump", + "type": "u8" + }, + { + "name": "seed", + "type": "u64" + } + ] + }, + { + "name": "releaseWormholeOutbound", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "postMessageShim", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholePostMessageShimEa", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "manager", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxItemSigner", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ReleaseOutboundArgs" + } + } + ] + }, + { + "name": "broadcastWormholeId", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false, + "docs": [ + "enforced by the [`CpiContext`] call in [`post_message`].", + "The seeds constraint ensures that this is the correct address" + ] + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "postMessageShim", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholePostMessageShimEa", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [] + }, + { + "name": "broadcastWormholePeer", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "postMessageShim", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholePostMessageShimEa", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "BroadcastPeerArgs" + } + } + ] + } + ], + "accounts": [ + { + "name": "Config", + "type": { + "kind": "struct", + "fields": [ + { + "name": "owner", + "docs": [ + "Owner of this instance. Distinct from the program's upgrade authority —", + "instance ownership transfers are pure data mutations and never touch the", + "BPF loader (v4 trust model)." + ], + "type": "publicKey" + }, + { + "name": "pendingOwner", + "docs": [ + "Pending next owner (before claiming ownership)." + ], + "type": { + "option": "publicKey" + } + }, + { + "name": "mint", + "docs": [ + "Mint address of the token managed by this instance." + ], + "type": "publicKey" + }, + { + "name": "tokenProgram", + "docs": [ + "Address of the token program (token or token22). This could always be queried", + "from the [`mint`] account's owner, but storing it here avoids an indirection", + "on the client side." + ], + "type": "publicKey" + }, + { + "name": "mode", + "docs": [ + "The mode that this instance is running in. This is used to determine", + "whether the program is burning tokens or locking tokens." + ], + "type": { + "defined": "Mode" + } + }, + { + "name": "chainId", + "docs": [ + "The chain id of the chain that this program is running on. We don't", + "hardcode this so that the program is deployable on any potential SVM", + "forks." + ], + "type": { + "defined": "ChainId" + } + }, + { + "name": "nextTransceiverId", + "docs": [ + "The next transceiver id to use when registering a transceiver under this instance." + ], + "type": "u8" + }, + { + "name": "threshold", + "docs": [ + "The number of transceivers that must attest to a transfer before it is", + "accepted." + ], + "type": "u8" + }, + { + "name": "enabledTransceivers", + "docs": [ + "Bitmap of enabled transceivers.", + "The maximum number of transceivers is equal to [`Bitmap::BITS`]." + ], + "type": { + "defined": "Bitmap" + } + }, + { + "name": "paused", + "docs": [ + "Pause the program. This is useful for upgrades and other maintenance." + ], + "type": "bool" + }, + { + "name": "custody", + "docs": [ + "The custody account that holds tokens in locking mode." + ], + "type": "publicKey" + } + ] + } + }, + { + "name": "OutboxItem", + "type": { + "kind": "struct", + "fields": [ + { + "name": "manager", + "docs": [ + "v4: the `Config` (instance) that produced this outbox item. Used to", + "bind the item to its source instance so transceivers can verify they're", + "releasing/marking an item that belongs to the instance they're working", + "on (`released` is a bitmap keyed by per-instance transceiver_id)." + ], + "type": "publicKey" + }, + { + "name": "amount", + "type": { + "defined": "TrimmedAmount" + } + }, + { + "name": "sender", + "type": "publicKey" + }, + { + "name": "recipientChain", + "type": { + "defined": "ChainId" + } + }, + { + "name": "recipientNttManager", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "recipientAddress", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "releaseTimestamp", + "type": "i64" + }, + { + "name": "released", + "type": { + "defined": "Bitmap" + } + } + ] + } + }, + { + "name": "RegisteredTransceiver", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "id", + "type": "u8" + }, + { + "name": "transceiverAddress", + "type": "publicKey" + } + ] + } + }, + { + "name": "TransceiverPeer", + "docs": [ + "A peer on another chain. Stored in a PDA seeded by the chain id." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + }, + { + "name": "VaaBody", + "type": { + "kind": "struct", + "fields": [ + { + "name": "span", + "type": "bytes" + } + ] + } + } + ], + "types": [ + { + "name": "Bitmap", + "type": { + "kind": "struct", + "fields": [ + { + "name": "map", + "type": "u128" + } + ] + } + }, + { + "name": "ChainId", + "type": { + "kind": "struct", + "fields": [ + { + "name": "id", + "type": "u16" + } + ] + } + }, + { + "name": "Mode", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Locking" + }, + { + "name": "Burning" + } + ] + } + }, + { + "name": "TrimmedAmount", + "type": { + "kind": "struct", + "fields": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "decimals", + "type": "u8" + } + ] + } + }, + { + "name": "VaaBodyData", + "type": { + "kind": "struct", + "fields": [ + { + "name": "span", + "type": "bytes" + } + ] + } + }, + { + "name": "SetTransceiverPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": { + "defined": "ChainId" + } + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + }, + { + "name": "BroadcastPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": "u16" + } + ] + } + }, + { + "name": "ReleaseOutboundArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "revertOnDelay", + "type": "bool" + } + ] + } + }, + { + "name": "PostUnverifiedMessageAccountArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "seed", + "type": "u64" + }, + { + "name": "offset", + "type": "u32" + }, + { + "name": "chunk", + "type": "bytes" + }, + { + "name": "messageSize", + "type": "u32" + } + ] + } + } + ] +} diff --git a/solana/ts/idl/4_0_0/json/wormhole_governance.json b/solana/ts/idl/4_0_0/json/wormhole_governance.json new file mode 100644 index 000000000..f6f61f6ec --- /dev/null +++ b/solana/ts/idl/4_0_0/json/wormhole_governance.json @@ -0,0 +1,76 @@ +{ + "version": "3.0.0", + "name": "wormhole_governance", + "instructions": [ + { + "name": "governance", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "governance", + "isMut": true, + "isSigner": false, + "docs": [ + "governed program. This account is validated by Wormhole, not this program." + ] + }, + { + "name": "vaa", + "isMut": false, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "replay", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + } + ], + "accounts": [ + { + "name": "ReplayProtection", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + } + ] + } + } + ], + "errors": [ + { + "code": 6000, + "name": "InvalidGovernanceChain", + "msg": "InvalidGovernanceChain" + }, + { + "code": 6001, + "name": "InvalidGovernanceEmitter", + "msg": "InvalidGovernanceEmitter" + }, + { + "code": 6002, + "name": "InvalidGovernanceProgram", + "msg": "InvalidGovernanceProgram" + } + ] +} \ No newline at end of file diff --git a/solana/ts/idl/4_0_0/ts/dummy_transfer_hook.ts b/solana/ts/idl/4_0_0/ts/dummy_transfer_hook.ts new file mode 100644 index 000000000..1c494bbd4 --- /dev/null +++ b/solana/ts/idl/4_0_0/ts/dummy_transfer_hook.ts @@ -0,0 +1,221 @@ +export type DummyTransferHook = { + "version": "3.0.0", + "name": "dummy_transfer_hook", + "instructions": [ + { + "name": "initializeExtraAccountMetaList", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "extraAccountMetaList", + "isMut": true, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "associatedTokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "counter", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "transferHook", + "accounts": [ + { + "name": "sourceToken", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "destinationToken", + "isMut": false, + "isSigner": false + }, + { + "name": "authority", + "isMut": false, + "isSigner": false + }, + { + "name": "extraAccountMetaList", + "isMut": false, + "isSigner": false + }, + { + "name": "dummyAccount", + "isMut": false, + "isSigner": false, + "docs": [ + "computes and the on-chain code correctly passes on the PDA." + ] + }, + { + "name": "counter", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + } + ] + } + ], + "accounts": [ + { + "name": "counter", + "type": { + "kind": "struct", + "fields": [ + { + "name": "count", + "type": "u64" + } + ] + } + } + ] +} +export const IDL: DummyTransferHook = { + "version": "3.0.0", + "name": "dummy_transfer_hook", + "instructions": [ + { + "name": "initializeExtraAccountMetaList", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "extraAccountMetaList", + "isMut": true, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "associatedTokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "counter", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "transferHook", + "accounts": [ + { + "name": "sourceToken", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "destinationToken", + "isMut": false, + "isSigner": false + }, + { + "name": "authority", + "isMut": false, + "isSigner": false + }, + { + "name": "extraAccountMetaList", + "isMut": false, + "isSigner": false + }, + { + "name": "dummyAccount", + "isMut": false, + "isSigner": false, + "docs": [ + "computes and the on-chain code correctly passes on the PDA." + ] + }, + { + "name": "counter", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + } + ] + } + ], + "accounts": [ + { + "name": "counter", + "type": { + "kind": "struct", + "fields": [ + { + "name": "count", + "type": "u64" + } + ] + } + } + ] +} + diff --git a/solana/ts/idl/4_0_0/ts/example_native_token_transfers.ts b/solana/ts/idl/4_0_0/ts/example_native_token_transfers.ts new file mode 100644 index 000000000..95476b6be --- /dev/null +++ b/solana/ts/idl/4_0_0/ts/example_native_token_transfers.ts @@ -0,0 +1,4817 @@ +export type ExampleNativeTokenTransfers = { + "version": "4.0.0", + "name": "example_native_token_transfers", + "instructions": [ + { + "name": "initialize", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "owner", + "isMut": false, + "isSigner": true, + "docs": [ + "The owner of the new instance. Distinct from the program's upgrade", + "authority — see the v4 trust-model note in the README." + ] + }, + { + "name": "config", + "isMut": true, + "isSigner": true, + "docs": [ + "The instance account itself. Caller-provided keypair, must sign." + ] + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "rateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false, + "docs": [ + "Per-instance token authority lets each instance manage its own mint independently.", + "", + "TODO: Using `UncheckedAccount` here leads to \"Access violation in stack frame ...\".", + "Could refactor code to use `Box<_>` to reduce stack size." + ] + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "custody", + "isMut": true, + "isSigner": false, + "docs": [ + "The custody account that holds tokens in locking mode and temporarily", + "holds tokens in burning mode.", + "function if the token account has already been created." + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false, + "docs": [ + "associated token account for the given mint." + ] + }, + { + "name": "associatedTokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "InitializeArgs" + } + } + ] + }, + { + "name": "initializeLut", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "authority", + "isMut": false, + "isSigner": false + }, + { + "name": "lutAddress", + "isMut": true, + "isSigner": false + }, + { + "name": "lut", + "isMut": true, + "isSigner": false + }, + { + "name": "lutProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "entries", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "custody", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxRateLimit", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ] + } + ], + "args": [ + { + "name": "recentSlot", + "type": "u64" + } + ] + }, + { + "name": "version", + "accounts": [], + "args": [], + "returns": "string" + }, + { + "name": "transferBurn", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "from", + "isMut": true, + "isSigner": false, + "docs": [ + "account can spend these tokens." + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": true + }, + { + "name": "outboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "custody", + "isMut": true, + "isSigner": false, + "docs": [ + "Tokens are always transferred to the custody account first regardless of", + "the mode.", + "For an explanation, see the note in [`transfer_burn`]." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "inboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "sessionAuthority", + "isMut": false, + "isSigner": false, + "docs": [ + "See [`crate::SESSION_AUTHORITY_SEED`] for an explanation of the flow." + ] + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "TransferArgs" + } + } + ] + }, + { + "name": "transferLock", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "from", + "isMut": true, + "isSigner": false, + "docs": [ + "account can spend these tokens." + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": true + }, + { + "name": "outboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "custody", + "isMut": true, + "isSigner": false, + "docs": [ + "Tokens are always transferred to the custody account first regardless of", + "the mode.", + "For an explanation, see the note in [`transfer_burn`]." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "inboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "sessionAuthority", + "isMut": false, + "isSigner": false, + "docs": [ + "See [`crate::SESSION_AUTHORITY_SEED`] for an explanation of the flow." + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "TransferArgs" + } + } + ] + }, + { + "name": "redeem", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "transceiverMessage", + "isMut": false, + "isSigner": false, + "docs": [ + "`Account` and `owner` constraints are mutually-exclusive" + ] + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "inboxItem", + "isMut": true, + "isSigner": false, + "docs": [ + "NOTE: This account is content-addressed (PDA seeded by the message hash).", + "This is because in a multi-transceiver configuration, the different", + "transceivers \"vote\" on messages (by delivering them). By making the inbox", + "items content-addressed, we can ensure that disagreeing votes don't", + "interfere with each other.", + "On the first call to [`redeem()`], [`InboxItem`] will be allocated and initialized with", + "default values.", + "On subsequent calls, we want to modify the `InboxItem` by \"voting\" on it. Therefore the", + "program should not fail which would occur when using the `init` constraint.", + "The [`InboxItem::init`] field is used to guard against malicious or accidental modification", + "InboxItem fields that should remain constant." + ] + }, + { + "name": "inboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "outboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "RedeemArgs" + } + } + ] + }, + { + "name": "releaseInboundMint", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "inboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "recipient", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "custody", + "isMut": true, + "isSigner": false + } + ] + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ReleaseInboundArgs" + } + } + ] + }, + { + "name": "releaseInboundUnlock", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "inboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "recipient", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "custody", + "isMut": true, + "isSigner": false + } + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ReleaseInboundArgs" + } + } + ] + }, + { + "name": "transferOwnership", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "newOwner", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "transferOwnershipOneStepUnchecked", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "newOwner", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "claimOwnership", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "newOwner", + "isMut": false, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "acceptTokenAuthority", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "currentAuthority", + "isMut": false, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "acceptTokenAuthorityFromMultisig", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "currentMultisigAuthority", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setTokenAuthorityOneStepUnchecked", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "newAuthority", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setTokenAuthority", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "newAuthority", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": true + }, + { + "name": "pendingTokenAuthority", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "revertTokenAuthority", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": false + }, + { + "name": "pendingTokenAuthority", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "claimTokenAuthority", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": false + }, + { + "name": "pendingTokenAuthority", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "newAuthority", + "isMut": false, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "claimTokenAuthorityToMultisig", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": false + }, + { + "name": "pendingTokenAuthority", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "newMultisigAuthority", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setPaused", + "accounts": [ + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "config", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "pause", + "type": "bool" + } + ] + }, + { + "name": "setPeer", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "peer", + "isMut": true, + "isSigner": false + }, + { + "name": "inboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetPeerArgs" + } + } + ] + }, + { + "name": "registerTransceiver", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false, + "docs": [ + "used here that wraps the Transceiver account type." + ] + }, + { + "name": "registeredTransceiver", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "deregisterTransceiver", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "registeredTransceiver", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setOutboundLimit", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "rateLimit", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetOutboundLimitArgs" + } + } + ] + }, + { + "name": "setInboundLimit", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "rateLimit", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetInboundLimitArgs" + } + } + ] + }, + { + "name": "markOutboxItemAsReleased", + "accounts": [ + { + "name": "signer", + "isMut": false, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false + } + ], + "args": [], + "returns": "bool" + }, + { + "name": "setThreshold", + "accounts": [ + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "config", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "threshold", + "type": "u8" + } + ] + }, + { + "name": "setWormholePeer", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "peer", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetTransceiverPeerArgs" + } + } + ] + }, + { + "name": "receiveWormholeMessage", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "vaa", + "isMut": false, + "isSigner": false + }, + { + "name": "transceiverMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "releaseWormholeOutbound", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ReleaseOutboundArgs" + } + } + ] + }, + { + "name": "broadcastWormholeId", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": true + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false, + "docs": [ + "enforced by the [`CpiContext`] call in [`post_message`].", + "The seeds constraint ensures that this is the correct address" + ] + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [] + }, + { + "name": "broadcastWormholePeer", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": true + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "BroadcastPeerArgs" + } + } + ] + } + ], + "accounts": [ + { + "name": "config", + "type": { + "kind": "struct", + "fields": [ + { + "name": "owner", + "docs": [ + "Owner of this instance. Distinct from the program's upgrade authority —", + "instance ownership transfers are pure data mutations and never touch the", + "BPF loader (v4 trust model)." + ], + "type": "publicKey" + }, + { + "name": "pendingOwner", + "docs": [ + "Pending next owner (before claiming ownership)." + ], + "type": { + "option": "publicKey" + } + }, + { + "name": "mint", + "docs": [ + "Mint address of the token managed by this instance." + ], + "type": "publicKey" + }, + { + "name": "tokenProgram", + "docs": [ + "Address of the token program (token or token22). This could always be queried", + "from the [`mint`] account's owner, but storing it here avoids an indirection", + "on the client side." + ], + "type": "publicKey" + }, + { + "name": "mode", + "docs": [ + "The mode that this instance is running in. This is used to determine", + "whether the program is burning tokens or locking tokens." + ], + "type": { + "defined": "Mode" + } + }, + { + "name": "chainId", + "docs": [ + "The chain id of the chain that this program is running on. We don't", + "hardcode this so that the program is deployable on any potential SVM", + "forks." + ], + "type": { + "defined": "ChainId" + } + }, + { + "name": "nextTransceiverId", + "docs": [ + "The next transceiver id to use when registering a transceiver under this instance." + ], + "type": "u8" + }, + { + "name": "threshold", + "docs": [ + "The number of transceivers that must attest to a transfer before it is", + "accepted." + ], + "type": "u8" + }, + { + "name": "enabledTransceivers", + "docs": [ + "Bitmap of enabled transceivers.", + "The maximum number of transceivers is equal to [`Bitmap::BITS`]." + ], + "type": { + "defined": "Bitmap" + } + }, + { + "name": "paused", + "docs": [ + "Pause the program. This is useful for upgrades and other maintenance." + ], + "type": "bool" + }, + { + "name": "custody", + "docs": [ + "The custody account that holds tokens in locking mode." + ], + "type": "publicKey" + } + ] + } + }, + { + "name": "LUT", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "address", + "type": "publicKey" + } + ] + } + }, + { + "name": "nttManagerPeer", + "docs": [ + "A peer on another chain. Stored in a PDA seeded by the chain id." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "tokenDecimals", + "type": "u8" + } + ] + } + }, + { + "name": "pendingTokenAuthority", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "pendingAuthority", + "type": "publicKey" + }, + { + "name": "rentPayer", + "type": "publicKey" + } + ] + } + }, + { + "name": "inboxItem", + "type": { + "kind": "struct", + "fields": [ + { + "name": "init", + "type": "bool" + }, + { + "name": "bump", + "type": "u8" + }, + { + "name": "config", + "type": "publicKey" + }, + { + "name": "amount", + "type": "u64" + }, + { + "name": "recipientAddress", + "type": "publicKey" + }, + { + "name": "votes", + "type": { + "defined": "Bitmap" + } + }, + { + "name": "releaseStatus", + "type": { + "defined": "ReleaseStatus" + } + } + ] + } + }, + { + "name": "inboxRateLimit", + "docs": [ + "Inbound rate limit per chain.", + "SECURITY: must check the PDA (since there are multiple PDAs, namely one for each chain.)" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "rateLimit", + "type": { + "defined": "RateLimitState" + } + } + ] + } + }, + { + "name": "outboxItem", + "type": { + "kind": "struct", + "fields": [ + { + "name": "manager", + "docs": [ + "v4: the `Config` (instance) that produced this outbox item. Used to", + "bind the item to its source instance so transceivers can verify they're", + "releasing/marking an item that belongs to the instance they're working", + "on (`released` is a bitmap keyed by per-instance transceiver_id)." + ], + "type": "publicKey" + }, + { + "name": "amount", + "type": { + "defined": "TrimmedAmount" + } + }, + { + "name": "sender", + "type": "publicKey" + }, + { + "name": "recipientChain", + "type": { + "defined": "ChainId" + } + }, + { + "name": "recipientNttManager", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "recipientAddress", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "releaseTimestamp", + "type": "i64" + }, + { + "name": "released", + "type": { + "defined": "Bitmap" + } + } + ] + } + }, + { + "name": "outboxRateLimit", + "type": { + "kind": "struct", + "fields": [ + { + "name": "rateLimit", + "type": { + "defined": "RateLimitState" + } + } + ] + } + }, + { + "name": "registeredTransceiver", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "id", + "type": "u8" + }, + { + "name": "transceiverAddress", + "type": "publicKey" + } + ] + } + }, + { + "name": "transceiverPeer", + "docs": [ + "A peer on another chain. Stored in a PDA seeded by the chain id." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + } + ], + "types": [ + { + "name": "Bitmap", + "type": { + "kind": "struct", + "fields": [ + { + "name": "map", + "type": "u128" + } + ] + } + }, + { + "name": "SetInboundLimitArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "limit", + "type": "u64" + }, + { + "name": "chainId", + "type": { + "defined": "ChainId" + } + } + ] + } + }, + { + "name": "SetOutboundLimitArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "limit", + "type": "u64" + } + ] + } + }, + { + "name": "SetPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": { + "defined": "ChainId" + } + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "limit", + "type": "u64" + }, + { + "name": "tokenDecimals", + "docs": [ + "The token decimals on the peer chain." + ], + "type": "u8" + } + ] + } + }, + { + "name": "InitializeArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": "u16" + }, + { + "name": "limit", + "type": "u64" + }, + { + "name": "mode", + "type": { + "defined": "Mode" + } + } + ] + } + }, + { + "name": "RedeemArgs", + "type": { + "kind": "struct", + "fields": [] + } + }, + { + "name": "ReleaseInboundArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "revertWhenNotReady", + "type": "bool" + } + ] + } + }, + { + "name": "TransferArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "recipientChain", + "type": { + "defined": "ChainId" + } + }, + { + "name": "recipientAddress", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "shouldQueue", + "type": "bool" + } + ] + } + }, + { + "name": "ReleaseStatus", + "docs": [ + "The status of an InboxItem. This determines whether the tokens are minted/unlocked to the recipient. As", + "such, this must be used as a state machine that moves forward in a linear manner. A state", + "should never \"move backward\" to a previous state (e.g. should never move from `Released` to", + "`ReleaseAfter`)." + ], + "type": { + "kind": "enum", + "variants": [ + { + "name": "NotApproved" + }, + { + "name": "ReleaseAfter", + "fields": [ + "i64" + ] + }, + { + "name": "Released" + } + ] + } + }, + { + "name": "RateLimitState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "limit", + "docs": [ + "The maximum capacity of the rate limiter." + ], + "type": "u64" + }, + { + "name": "capacityAtLastTx", + "docs": [ + "The capacity of the rate limiter at `last_tx_timestamp`.", + "The actual current capacity is calculated in `capacity_at`, by", + "accounting for the time that has passed since `last_tx_timestamp` and", + "the refill rate." + ], + "type": "u64" + }, + { + "name": "lastTxTimestamp", + "docs": [ + "The timestamp of the last transaction that counted towards the current", + "capacity. Transactions that exceeded the capacity do not count, they are", + "just delayed." + ], + "type": "i64" + } + ] + } + }, + { + "name": "SetTransceiverPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": { + "defined": "ChainId" + } + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + }, + { + "name": "BroadcastPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": "u16" + } + ] + } + }, + { + "name": "ReleaseOutboundArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "revertOnDelay", + "type": "bool" + } + ] + } + }, + { + "name": "ChainId", + "type": { + "kind": "struct", + "fields": [ + { + "name": "id", + "type": "u16" + } + ] + } + }, + { + "name": "Mode", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Locking" + }, + { + "name": "Burning" + } + ] + } + }, + { + "name": "TrimmedAmount", + "type": { + "kind": "struct", + "fields": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "decimals", + "type": "u8" + } + ] + } + } + ], + "errors": [ + { + "code": 6000, + "name": "CantReleaseYet", + "msg": "CantReleaseYet" + }, + { + "code": 6001, + "name": "InvalidPendingOwner", + "msg": "InvalidPendingOwner" + }, + { + "code": 6002, + "name": "InvalidChainId", + "msg": "InvalidChainId" + }, + { + "code": 6003, + "name": "InvalidRecipientAddress", + "msg": "InvalidRecipientAddress" + }, + { + "code": 6004, + "name": "InvalidTransceiverPeer", + "msg": "InvalidTransceiverPeer" + }, + { + "code": 6005, + "name": "InvalidNttManagerPeer", + "msg": "InvalidNttManagerPeer" + }, + { + "code": 6006, + "name": "InvalidRecipientNttManager", + "msg": "InvalidRecipientNttManager" + }, + { + "code": 6007, + "name": "TransferAlreadyRedeemed", + "msg": "TransferAlreadyRedeemed" + }, + { + "code": 6008, + "name": "TransferCannotBeRedeemed", + "msg": "TransferCannotBeRedeemed" + }, + { + "code": 6009, + "name": "TransferNotApproved", + "msg": "TransferNotApproved" + }, + { + "code": 6010, + "name": "MessageAlreadySent", + "msg": "MessageAlreadySent" + }, + { + "code": 6011, + "name": "InvalidMode", + "msg": "InvalidMode" + }, + { + "code": 6012, + "name": "InvalidMintAuthority", + "msg": "InvalidMintAuthority" + }, + { + "code": 6013, + "name": "TransferExceedsRateLimit", + "msg": "TransferExceedsRateLimit" + }, + { + "code": 6014, + "name": "Paused", + "msg": "Paused" + }, + { + "code": 6015, + "name": "DisabledTransceiver", + "msg": "DisabledTransceiver" + }, + { + "code": 6016, + "name": "InvalidDeployer", + "msg": "InvalidDeployer" + }, + { + "code": 6017, + "name": "BadAmountAfterTransfer", + "msg": "BadAmountAfterTransfer" + }, + { + "code": 6018, + "name": "BadAmountAfterBurn", + "msg": "BadAmountAfterBurn" + }, + { + "code": 6019, + "name": "ZeroThreshold", + "msg": "ZeroThreshold" + }, + { + "code": 6020, + "name": "OverflowExponent", + "msg": "OverflowExponent" + }, + { + "code": 6021, + "name": "OverflowScaledAmount", + "msg": "OverflowScaledAmount" + }, + { + "code": 6022, + "name": "BitmapIndexOutOfBounds", + "msg": "BitmapIndexOutOfBounds" + }, + { + "code": 6023, + "name": "NoRegisteredTransceivers", + "msg": "NoRegisteredTransceivers" + }, + { + "code": 6024, + "name": "NotPaused", + "msg": "NotPaused" + }, + { + "code": 6025, + "name": "InvalidPendingTokenAuthority", + "msg": "InvalidPendingTokenAuthority" + }, + { + "code": 6026, + "name": "IncorrectRentPayer", + "msg": "IncorrectRentPayer" + }, + { + "code": 6027, + "name": "InvalidMultisig", + "msg": "InvalidMultisig" + }, + { + "code": 6028, + "name": "ThresholdTooHigh", + "msg": "ThresholdTooHigh" + }, + { + "code": 6029, + "name": "InvalidTransceiverProgram", + "msg": "InvalidTransceiverProgram" + }, + { + "code": 6030, + "name": "InvalidOutboxItem", + "msg": "InvalidOutboxItem" + }, + { + "code": 6031, + "name": "InvalidInboxItem", + "msg": "InvalidInboxItem" + } + ] +} +export const IDL: ExampleNativeTokenTransfers = { + "version": "4.0.0", + "name": "example_native_token_transfers", + "instructions": [ + { + "name": "initialize", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "owner", + "isMut": false, + "isSigner": true, + "docs": [ + "The owner of the new instance. Distinct from the program's upgrade", + "authority — see the v4 trust-model note in the README." + ] + }, + { + "name": "config", + "isMut": true, + "isSigner": true, + "docs": [ + "The instance account itself. Caller-provided keypair, must sign." + ] + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "rateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false, + "docs": [ + "Per-instance token authority lets each instance manage its own mint independently.", + "", + "TODO: Using `UncheckedAccount` here leads to \"Access violation in stack frame ...\".", + "Could refactor code to use `Box<_>` to reduce stack size." + ] + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "custody", + "isMut": true, + "isSigner": false, + "docs": [ + "The custody account that holds tokens in locking mode and temporarily", + "holds tokens in burning mode.", + "function if the token account has already been created." + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false, + "docs": [ + "associated token account for the given mint." + ] + }, + { + "name": "associatedTokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "InitializeArgs" + } + } + ] + }, + { + "name": "initializeLut", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "authority", + "isMut": false, + "isSigner": false + }, + { + "name": "lutAddress", + "isMut": true, + "isSigner": false + }, + { + "name": "lut", + "isMut": true, + "isSigner": false + }, + { + "name": "lutProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "entries", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "custody", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxRateLimit", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ] + } + ], + "args": [ + { + "name": "recentSlot", + "type": "u64" + } + ] + }, + { + "name": "version", + "accounts": [], + "args": [], + "returns": "string" + }, + { + "name": "transferBurn", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "from", + "isMut": true, + "isSigner": false, + "docs": [ + "account can spend these tokens." + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": true + }, + { + "name": "outboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "custody", + "isMut": true, + "isSigner": false, + "docs": [ + "Tokens are always transferred to the custody account first regardless of", + "the mode.", + "For an explanation, see the note in [`transfer_burn`]." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "inboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "sessionAuthority", + "isMut": false, + "isSigner": false, + "docs": [ + "See [`crate::SESSION_AUTHORITY_SEED`] for an explanation of the flow." + ] + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "TransferArgs" + } + } + ] + }, + { + "name": "transferLock", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "from", + "isMut": true, + "isSigner": false, + "docs": [ + "account can spend these tokens." + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": true + }, + { + "name": "outboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "custody", + "isMut": true, + "isSigner": false, + "docs": [ + "Tokens are always transferred to the custody account first regardless of", + "the mode.", + "For an explanation, see the note in [`transfer_burn`]." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "inboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "sessionAuthority", + "isMut": false, + "isSigner": false, + "docs": [ + "See [`crate::SESSION_AUTHORITY_SEED`] for an explanation of the flow." + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "TransferArgs" + } + } + ] + }, + { + "name": "redeem", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "transceiverMessage", + "isMut": false, + "isSigner": false, + "docs": [ + "`Account` and `owner` constraints are mutually-exclusive" + ] + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "inboxItem", + "isMut": true, + "isSigner": false, + "docs": [ + "NOTE: This account is content-addressed (PDA seeded by the message hash).", + "This is because in a multi-transceiver configuration, the different", + "transceivers \"vote\" on messages (by delivering them). By making the inbox", + "items content-addressed, we can ensure that disagreeing votes don't", + "interfere with each other.", + "On the first call to [`redeem()`], [`InboxItem`] will be allocated and initialized with", + "default values.", + "On subsequent calls, we want to modify the `InboxItem` by \"voting\" on it. Therefore the", + "program should not fail which would occur when using the `init` constraint.", + "The [`InboxItem::init`] field is used to guard against malicious or accidental modification", + "InboxItem fields that should remain constant." + ] + }, + { + "name": "inboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "outboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "RedeemArgs" + } + } + ] + }, + { + "name": "releaseInboundMint", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "inboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "recipient", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "custody", + "isMut": true, + "isSigner": false + } + ] + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ReleaseInboundArgs" + } + } + ] + }, + { + "name": "releaseInboundUnlock", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "inboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "recipient", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "custody", + "isMut": true, + "isSigner": false + } + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ReleaseInboundArgs" + } + } + ] + }, + { + "name": "transferOwnership", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "newOwner", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "transferOwnershipOneStepUnchecked", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "newOwner", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "claimOwnership", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "newOwner", + "isMut": false, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "acceptTokenAuthority", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "currentAuthority", + "isMut": false, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "acceptTokenAuthorityFromMultisig", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "currentMultisigAuthority", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setTokenAuthorityOneStepUnchecked", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "newAuthority", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setTokenAuthority", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "newAuthority", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": true + }, + { + "name": "pendingTokenAuthority", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "revertTokenAuthority", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": false + }, + { + "name": "pendingTokenAuthority", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "claimTokenAuthority", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": false + }, + { + "name": "pendingTokenAuthority", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "newAuthority", + "isMut": false, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "claimTokenAuthorityToMultisig", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": false + }, + { + "name": "pendingTokenAuthority", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "newMultisigAuthority", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setPaused", + "accounts": [ + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "config", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "pause", + "type": "bool" + } + ] + }, + { + "name": "setPeer", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "peer", + "isMut": true, + "isSigner": false + }, + { + "name": "inboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetPeerArgs" + } + } + ] + }, + { + "name": "registerTransceiver", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false, + "docs": [ + "used here that wraps the Transceiver account type." + ] + }, + { + "name": "registeredTransceiver", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "deregisterTransceiver", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "registeredTransceiver", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setOutboundLimit", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "rateLimit", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetOutboundLimitArgs" + } + } + ] + }, + { + "name": "setInboundLimit", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "rateLimit", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetInboundLimitArgs" + } + } + ] + }, + { + "name": "markOutboxItemAsReleased", + "accounts": [ + { + "name": "signer", + "isMut": false, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false + } + ], + "args": [], + "returns": "bool" + }, + { + "name": "setThreshold", + "accounts": [ + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "config", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "threshold", + "type": "u8" + } + ] + }, + { + "name": "setWormholePeer", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "peer", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetTransceiverPeerArgs" + } + } + ] + }, + { + "name": "receiveWormholeMessage", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "vaa", + "isMut": false, + "isSigner": false + }, + { + "name": "transceiverMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "releaseWormholeOutbound", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ReleaseOutboundArgs" + } + } + ] + }, + { + "name": "broadcastWormholeId", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": true + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false, + "docs": [ + "enforced by the [`CpiContext`] call in [`post_message`].", + "The seeds constraint ensures that this is the correct address" + ] + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [] + }, + { + "name": "broadcastWormholePeer", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": true + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "BroadcastPeerArgs" + } + } + ] + } + ], + "accounts": [ + { + "name": "config", + "type": { + "kind": "struct", + "fields": [ + { + "name": "owner", + "docs": [ + "Owner of this instance. Distinct from the program's upgrade authority —", + "instance ownership transfers are pure data mutations and never touch the", + "BPF loader (v4 trust model)." + ], + "type": "publicKey" + }, + { + "name": "pendingOwner", + "docs": [ + "Pending next owner (before claiming ownership)." + ], + "type": { + "option": "publicKey" + } + }, + { + "name": "mint", + "docs": [ + "Mint address of the token managed by this instance." + ], + "type": "publicKey" + }, + { + "name": "tokenProgram", + "docs": [ + "Address of the token program (token or token22). This could always be queried", + "from the [`mint`] account's owner, but storing it here avoids an indirection", + "on the client side." + ], + "type": "publicKey" + }, + { + "name": "mode", + "docs": [ + "The mode that this instance is running in. This is used to determine", + "whether the program is burning tokens or locking tokens." + ], + "type": { + "defined": "Mode" + } + }, + { + "name": "chainId", + "docs": [ + "The chain id of the chain that this program is running on. We don't", + "hardcode this so that the program is deployable on any potential SVM", + "forks." + ], + "type": { + "defined": "ChainId" + } + }, + { + "name": "nextTransceiverId", + "docs": [ + "The next transceiver id to use when registering a transceiver under this instance." + ], + "type": "u8" + }, + { + "name": "threshold", + "docs": [ + "The number of transceivers that must attest to a transfer before it is", + "accepted." + ], + "type": "u8" + }, + { + "name": "enabledTransceivers", + "docs": [ + "Bitmap of enabled transceivers.", + "The maximum number of transceivers is equal to [`Bitmap::BITS`]." + ], + "type": { + "defined": "Bitmap" + } + }, + { + "name": "paused", + "docs": [ + "Pause the program. This is useful for upgrades and other maintenance." + ], + "type": "bool" + }, + { + "name": "custody", + "docs": [ + "The custody account that holds tokens in locking mode." + ], + "type": "publicKey" + } + ] + } + }, + { + "name": "LUT", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "address", + "type": "publicKey" + } + ] + } + }, + { + "name": "nttManagerPeer", + "docs": [ + "A peer on another chain. Stored in a PDA seeded by the chain id." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "tokenDecimals", + "type": "u8" + } + ] + } + }, + { + "name": "pendingTokenAuthority", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "pendingAuthority", + "type": "publicKey" + }, + { + "name": "rentPayer", + "type": "publicKey" + } + ] + } + }, + { + "name": "inboxItem", + "type": { + "kind": "struct", + "fields": [ + { + "name": "init", + "type": "bool" + }, + { + "name": "bump", + "type": "u8" + }, + { + "name": "config", + "type": "publicKey" + }, + { + "name": "amount", + "type": "u64" + }, + { + "name": "recipientAddress", + "type": "publicKey" + }, + { + "name": "votes", + "type": { + "defined": "Bitmap" + } + }, + { + "name": "releaseStatus", + "type": { + "defined": "ReleaseStatus" + } + } + ] + } + }, + { + "name": "inboxRateLimit", + "docs": [ + "Inbound rate limit per chain.", + "SECURITY: must check the PDA (since there are multiple PDAs, namely one for each chain.)" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "rateLimit", + "type": { + "defined": "RateLimitState" + } + } + ] + } + }, + { + "name": "outboxItem", + "type": { + "kind": "struct", + "fields": [ + { + "name": "manager", + "docs": [ + "v4: the `Config` (instance) that produced this outbox item. Used to", + "bind the item to its source instance so transceivers can verify they're", + "releasing/marking an item that belongs to the instance they're working", + "on (`released` is a bitmap keyed by per-instance transceiver_id)." + ], + "type": "publicKey" + }, + { + "name": "amount", + "type": { + "defined": "TrimmedAmount" + } + }, + { + "name": "sender", + "type": "publicKey" + }, + { + "name": "recipientChain", + "type": { + "defined": "ChainId" + } + }, + { + "name": "recipientNttManager", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "recipientAddress", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "releaseTimestamp", + "type": "i64" + }, + { + "name": "released", + "type": { + "defined": "Bitmap" + } + } + ] + } + }, + { + "name": "outboxRateLimit", + "type": { + "kind": "struct", + "fields": [ + { + "name": "rateLimit", + "type": { + "defined": "RateLimitState" + } + } + ] + } + }, + { + "name": "registeredTransceiver", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "id", + "type": "u8" + }, + { + "name": "transceiverAddress", + "type": "publicKey" + } + ] + } + }, + { + "name": "transceiverPeer", + "docs": [ + "A peer on another chain. Stored in a PDA seeded by the chain id." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + } + ], + "types": [ + { + "name": "Bitmap", + "type": { + "kind": "struct", + "fields": [ + { + "name": "map", + "type": "u128" + } + ] + } + }, + { + "name": "SetInboundLimitArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "limit", + "type": "u64" + }, + { + "name": "chainId", + "type": { + "defined": "ChainId" + } + } + ] + } + }, + { + "name": "SetOutboundLimitArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "limit", + "type": "u64" + } + ] + } + }, + { + "name": "SetPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": { + "defined": "ChainId" + } + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "limit", + "type": "u64" + }, + { + "name": "tokenDecimals", + "docs": [ + "The token decimals on the peer chain." + ], + "type": "u8" + } + ] + } + }, + { + "name": "InitializeArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": "u16" + }, + { + "name": "limit", + "type": "u64" + }, + { + "name": "mode", + "type": { + "defined": "Mode" + } + } + ] + } + }, + { + "name": "RedeemArgs", + "type": { + "kind": "struct", + "fields": [] + } + }, + { + "name": "ReleaseInboundArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "revertWhenNotReady", + "type": "bool" + } + ] + } + }, + { + "name": "TransferArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "recipientChain", + "type": { + "defined": "ChainId" + } + }, + { + "name": "recipientAddress", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "shouldQueue", + "type": "bool" + } + ] + } + }, + { + "name": "ReleaseStatus", + "docs": [ + "The status of an InboxItem. This determines whether the tokens are minted/unlocked to the recipient. As", + "such, this must be used as a state machine that moves forward in a linear manner. A state", + "should never \"move backward\" to a previous state (e.g. should never move from `Released` to", + "`ReleaseAfter`)." + ], + "type": { + "kind": "enum", + "variants": [ + { + "name": "NotApproved" + }, + { + "name": "ReleaseAfter", + "fields": [ + "i64" + ] + }, + { + "name": "Released" + } + ] + } + }, + { + "name": "RateLimitState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "limit", + "docs": [ + "The maximum capacity of the rate limiter." + ], + "type": "u64" + }, + { + "name": "capacityAtLastTx", + "docs": [ + "The capacity of the rate limiter at `last_tx_timestamp`.", + "The actual current capacity is calculated in `capacity_at`, by", + "accounting for the time that has passed since `last_tx_timestamp` and", + "the refill rate." + ], + "type": "u64" + }, + { + "name": "lastTxTimestamp", + "docs": [ + "The timestamp of the last transaction that counted towards the current", + "capacity. Transactions that exceeded the capacity do not count, they are", + "just delayed." + ], + "type": "i64" + } + ] + } + }, + { + "name": "SetTransceiverPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": { + "defined": "ChainId" + } + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + }, + { + "name": "BroadcastPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": "u16" + } + ] + } + }, + { + "name": "ReleaseOutboundArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "revertOnDelay", + "type": "bool" + } + ] + } + }, + { + "name": "ChainId", + "type": { + "kind": "struct", + "fields": [ + { + "name": "id", + "type": "u16" + } + ] + } + }, + { + "name": "Mode", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Locking" + }, + { + "name": "Burning" + } + ] + } + }, + { + "name": "TrimmedAmount", + "type": { + "kind": "struct", + "fields": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "decimals", + "type": "u8" + } + ] + } + } + ], + "errors": [ + { + "code": 6000, + "name": "CantReleaseYet", + "msg": "CantReleaseYet" + }, + { + "code": 6001, + "name": "InvalidPendingOwner", + "msg": "InvalidPendingOwner" + }, + { + "code": 6002, + "name": "InvalidChainId", + "msg": "InvalidChainId" + }, + { + "code": 6003, + "name": "InvalidRecipientAddress", + "msg": "InvalidRecipientAddress" + }, + { + "code": 6004, + "name": "InvalidTransceiverPeer", + "msg": "InvalidTransceiverPeer" + }, + { + "code": 6005, + "name": "InvalidNttManagerPeer", + "msg": "InvalidNttManagerPeer" + }, + { + "code": 6006, + "name": "InvalidRecipientNttManager", + "msg": "InvalidRecipientNttManager" + }, + { + "code": 6007, + "name": "TransferAlreadyRedeemed", + "msg": "TransferAlreadyRedeemed" + }, + { + "code": 6008, + "name": "TransferCannotBeRedeemed", + "msg": "TransferCannotBeRedeemed" + }, + { + "code": 6009, + "name": "TransferNotApproved", + "msg": "TransferNotApproved" + }, + { + "code": 6010, + "name": "MessageAlreadySent", + "msg": "MessageAlreadySent" + }, + { + "code": 6011, + "name": "InvalidMode", + "msg": "InvalidMode" + }, + { + "code": 6012, + "name": "InvalidMintAuthority", + "msg": "InvalidMintAuthority" + }, + { + "code": 6013, + "name": "TransferExceedsRateLimit", + "msg": "TransferExceedsRateLimit" + }, + { + "code": 6014, + "name": "Paused", + "msg": "Paused" + }, + { + "code": 6015, + "name": "DisabledTransceiver", + "msg": "DisabledTransceiver" + }, + { + "code": 6016, + "name": "InvalidDeployer", + "msg": "InvalidDeployer" + }, + { + "code": 6017, + "name": "BadAmountAfterTransfer", + "msg": "BadAmountAfterTransfer" + }, + { + "code": 6018, + "name": "BadAmountAfterBurn", + "msg": "BadAmountAfterBurn" + }, + { + "code": 6019, + "name": "ZeroThreshold", + "msg": "ZeroThreshold" + }, + { + "code": 6020, + "name": "OverflowExponent", + "msg": "OverflowExponent" + }, + { + "code": 6021, + "name": "OverflowScaledAmount", + "msg": "OverflowScaledAmount" + }, + { + "code": 6022, + "name": "BitmapIndexOutOfBounds", + "msg": "BitmapIndexOutOfBounds" + }, + { + "code": 6023, + "name": "NoRegisteredTransceivers", + "msg": "NoRegisteredTransceivers" + }, + { + "code": 6024, + "name": "NotPaused", + "msg": "NotPaused" + }, + { + "code": 6025, + "name": "InvalidPendingTokenAuthority", + "msg": "InvalidPendingTokenAuthority" + }, + { + "code": 6026, + "name": "IncorrectRentPayer", + "msg": "IncorrectRentPayer" + }, + { + "code": 6027, + "name": "InvalidMultisig", + "msg": "InvalidMultisig" + }, + { + "code": 6028, + "name": "ThresholdTooHigh", + "msg": "ThresholdTooHigh" + }, + { + "code": 6029, + "name": "InvalidTransceiverProgram", + "msg": "InvalidTransceiverProgram" + }, + { + "code": 6030, + "name": "InvalidOutboxItem", + "msg": "InvalidOutboxItem" + }, + { + "code": 6031, + "name": "InvalidInboxItem", + "msg": "InvalidInboxItem" + } + ] +} + diff --git a/solana/ts/idl/4_0_0/ts/ntt_quoter.ts b/solana/ts/idl/4_0_0/ts/ntt_quoter.ts new file mode 100644 index 000000000..161bf03aa --- /dev/null +++ b/solana/ts/idl/4_0_0/ts/ntt_quoter.ts @@ -0,0 +1,1177 @@ +export type NttQuoter = { + "version": "3.0.0", + "name": "ntt_quoter", + "instructions": [ + { + "name": "requestRelay", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredChain", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredNtt", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxItem", + "isMut": false, + "isSigner": false, + "docs": [ + "and checking the release constraint into a single function" + ] + }, + { + "name": "relayRequest", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "RequestRelayArgs" + } + } + ] + }, + { + "name": "closeRelay", + "accounts": [ + { + "name": "authority", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "feeRecipient", + "isMut": true, + "isSigner": false + }, + { + "name": "relayRequest", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "initialize", + "accounts": [ + { + "name": "owner", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": true, + "isSigner": false + }, + { + "name": "feeRecipient", + "isMut": false, + "isSigner": false + }, + { + "name": "programData", + "isMut": true, + "isSigner": false, + "docs": [ + "We use the program data to make sure this owner is the upgrade authority (the true owner,", + "who deployed this program)." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setAssistant", + "accounts": [ + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": true, + "isSigner": false + }, + { + "name": "assistant", + "isMut": false, + "isSigner": false, + "isOptional": true + } + ], + "args": [] + }, + { + "name": "setFeeRecipient", + "accounts": [ + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": true, + "isSigner": false + }, + { + "name": "feeRecipient", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "registerChain", + "accounts": [ + { + "name": "authority", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredChain", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "RegisterChainArgs" + } + } + ] + }, + { + "name": "registerNtt", + "accounts": [ + { + "name": "authority", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredNtt", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "RegisterNttArgs" + } + } + ] + }, + { + "name": "deregisterNtt", + "accounts": [ + { + "name": "authority", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredNtt", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "DeregisterNttArgs" + } + } + ] + }, + { + "name": "updateSolPrice", + "accounts": [ + { + "name": "authority", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "UpdateSolPriceArgs" + } + } + ] + }, + { + "name": "updateChainPrices", + "accounts": [ + { + "name": "authority", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredChain", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "UpdateChainPricesArgs" + } + } + ] + }, + { + "name": "updateChainParams", + "accounts": [ + { + "name": "authority", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredChain", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "UpdateChainParamsArgs" + } + } + ] + } + ], + "accounts": [ + { + "name": "instance", + "type": { + "kind": "struct", + "fields": [ + { + "name": "owner", + "type": "publicKey" + }, + { + "name": "assistant", + "type": "publicKey" + }, + { + "name": "feeRecipient", + "type": "publicKey" + }, + { + "name": "solPrice", + "type": "u64" + } + ] + } + }, + { + "name": "registeredChain", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "maxGasDropoff", + "type": "u64" + }, + { + "name": "basePrice", + "type": "u64" + }, + { + "name": "nativePrice", + "type": "u64" + }, + { + "name": "gasPrice", + "type": "u64" + } + ] + } + }, + { + "name": "registeredNtt", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "wormholeTransceiverIndex", + "type": "u8" + }, + { + "name": "gasCost", + "type": "u32" + } + ] + } + }, + { + "name": "relayRequest", + "type": { + "kind": "struct", + "fields": [ + { + "name": "requestedGasDropoff", + "type": "u64" + } + ] + } + } + ], + "types": [ + { + "name": "RegisterChainArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": "u16" + } + ] + } + }, + { + "name": "RegisterNttArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "nttProgramId", + "type": "publicKey" + }, + { + "name": "wormholeTransceiverIndex", + "type": "u8" + }, + { + "name": "gasCost", + "type": "u32" + } + ] + } + }, + { + "name": "DeregisterNttArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "nttProgramId", + "type": "publicKey" + } + ] + } + }, + { + "name": "RequestRelayArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "gasDropoff", + "type": "u64" + }, + { + "name": "maxFee", + "type": "u64" + } + ] + } + }, + { + "name": "UpdateSolPriceArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "solPrice", + "type": "u64" + } + ] + } + }, + { + "name": "UpdateChainPricesArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "nativePrice", + "type": "u64" + }, + { + "name": "gasPrice", + "type": "u64" + } + ] + } + }, + { + "name": "UpdateChainParamsArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "maxGasDropoff", + "type": "u64" + }, + { + "name": "basePrice", + "type": "u64" + } + ] + } + } + ], + "errors": [ + { + "code": 6001, + "name": "ExceedsUserMaxFee", + "msg": "Relay fees exceeds specified max" + }, + { + "code": 6002, + "name": "ExceedsMaxGasDropoff", + "msg": "Requested gas dropoff exceeds max allowed for chain" + }, + { + "code": 6003, + "name": "InvalidFeeRecipient", + "msg": "The specified fee recipient does not match the address in the instance accound" + }, + { + "code": 6004, + "name": "RelayingToChainDisabled", + "msg": "Relaying to the specified chain is disabled" + }, + { + "code": 6005, + "name": "OutboxItemNotReleased", + "msg": "Relaying to the specified chain is disabled" + }, + { + "code": 6006, + "name": "ScalingOverflow", + "msg": "Scaled value exceeds u64::MAX" + }, + { + "code": 6007, + "name": "DivByZero", + "msg": "Cannot divide by zero" + }, + { + "code": 6257, + "name": "FeeRecipientCannotBeDefault", + "msg": "The fee recipient cannot be the default address (0x0)" + }, + { + "code": 6258, + "name": "NotAuthorized", + "msg": "Must be owner or assistant" + }, + { + "code": 6259, + "name": "PriceCannotBeZero", + "msg": "The price cannot be zero" + } + ] +} +export const IDL: NttQuoter = { + "version": "3.0.0", + "name": "ntt_quoter", + "instructions": [ + { + "name": "requestRelay", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredChain", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredNtt", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxItem", + "isMut": false, + "isSigner": false, + "docs": [ + "and checking the release constraint into a single function" + ] + }, + { + "name": "relayRequest", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "RequestRelayArgs" + } + } + ] + }, + { + "name": "closeRelay", + "accounts": [ + { + "name": "authority", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "feeRecipient", + "isMut": true, + "isSigner": false + }, + { + "name": "relayRequest", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "initialize", + "accounts": [ + { + "name": "owner", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": true, + "isSigner": false + }, + { + "name": "feeRecipient", + "isMut": false, + "isSigner": false + }, + { + "name": "programData", + "isMut": true, + "isSigner": false, + "docs": [ + "We use the program data to make sure this owner is the upgrade authority (the true owner,", + "who deployed this program)." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setAssistant", + "accounts": [ + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": true, + "isSigner": false + }, + { + "name": "assistant", + "isMut": false, + "isSigner": false, + "isOptional": true + } + ], + "args": [] + }, + { + "name": "setFeeRecipient", + "accounts": [ + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": true, + "isSigner": false + }, + { + "name": "feeRecipient", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "registerChain", + "accounts": [ + { + "name": "authority", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredChain", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "RegisterChainArgs" + } + } + ] + }, + { + "name": "registerNtt", + "accounts": [ + { + "name": "authority", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredNtt", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "RegisterNttArgs" + } + } + ] + }, + { + "name": "deregisterNtt", + "accounts": [ + { + "name": "authority", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredNtt", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "DeregisterNttArgs" + } + } + ] + }, + { + "name": "updateSolPrice", + "accounts": [ + { + "name": "authority", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "UpdateSolPriceArgs" + } + } + ] + }, + { + "name": "updateChainPrices", + "accounts": [ + { + "name": "authority", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredChain", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "UpdateChainPricesArgs" + } + } + ] + }, + { + "name": "updateChainParams", + "accounts": [ + { + "name": "authority", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredChain", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "UpdateChainParamsArgs" + } + } + ] + } + ], + "accounts": [ + { + "name": "instance", + "type": { + "kind": "struct", + "fields": [ + { + "name": "owner", + "type": "publicKey" + }, + { + "name": "assistant", + "type": "publicKey" + }, + { + "name": "feeRecipient", + "type": "publicKey" + }, + { + "name": "solPrice", + "type": "u64" + } + ] + } + }, + { + "name": "registeredChain", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "maxGasDropoff", + "type": "u64" + }, + { + "name": "basePrice", + "type": "u64" + }, + { + "name": "nativePrice", + "type": "u64" + }, + { + "name": "gasPrice", + "type": "u64" + } + ] + } + }, + { + "name": "registeredNtt", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "wormholeTransceiverIndex", + "type": "u8" + }, + { + "name": "gasCost", + "type": "u32" + } + ] + } + }, + { + "name": "relayRequest", + "type": { + "kind": "struct", + "fields": [ + { + "name": "requestedGasDropoff", + "type": "u64" + } + ] + } + } + ], + "types": [ + { + "name": "RegisterChainArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": "u16" + } + ] + } + }, + { + "name": "RegisterNttArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "nttProgramId", + "type": "publicKey" + }, + { + "name": "wormholeTransceiverIndex", + "type": "u8" + }, + { + "name": "gasCost", + "type": "u32" + } + ] + } + }, + { + "name": "DeregisterNttArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "nttProgramId", + "type": "publicKey" + } + ] + } + }, + { + "name": "RequestRelayArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "gasDropoff", + "type": "u64" + }, + { + "name": "maxFee", + "type": "u64" + } + ] + } + }, + { + "name": "UpdateSolPriceArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "solPrice", + "type": "u64" + } + ] + } + }, + { + "name": "UpdateChainPricesArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "nativePrice", + "type": "u64" + }, + { + "name": "gasPrice", + "type": "u64" + } + ] + } + }, + { + "name": "UpdateChainParamsArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "maxGasDropoff", + "type": "u64" + }, + { + "name": "basePrice", + "type": "u64" + } + ] + } + } + ], + "errors": [ + { + "code": 6001, + "name": "ExceedsUserMaxFee", + "msg": "Relay fees exceeds specified max" + }, + { + "code": 6002, + "name": "ExceedsMaxGasDropoff", + "msg": "Requested gas dropoff exceeds max allowed for chain" + }, + { + "code": 6003, + "name": "InvalidFeeRecipient", + "msg": "The specified fee recipient does not match the address in the instance accound" + }, + { + "code": 6004, + "name": "RelayingToChainDisabled", + "msg": "Relaying to the specified chain is disabled" + }, + { + "code": 6005, + "name": "OutboxItemNotReleased", + "msg": "Relaying to the specified chain is disabled" + }, + { + "code": 6006, + "name": "ScalingOverflow", + "msg": "Scaled value exceeds u64::MAX" + }, + { + "code": 6007, + "name": "DivByZero", + "msg": "Cannot divide by zero" + }, + { + "code": 6257, + "name": "FeeRecipientCannotBeDefault", + "msg": "The fee recipient cannot be the default address (0x0)" + }, + { + "code": 6258, + "name": "NotAuthorized", + "msg": "Must be owner or assistant" + }, + { + "code": 6259, + "name": "PriceCannotBeZero", + "msg": "The price cannot be zero" + } + ] +} + diff --git a/solana/ts/idl/4_0_0/ts/ntt_transceiver.ts b/solana/ts/idl/4_0_0/ts/ntt_transceiver.ts new file mode 100644 index 000000000..972bdfdd3 --- /dev/null +++ b/solana/ts/idl/4_0_0/ts/ntt_transceiver.ts @@ -0,0 +1,1771 @@ +export type NttTransceiver = { + "version": "3.0.0", + "name": "ntt_transceiver", + "instructions": [ + { + "name": "transceiverType", + "accounts": [], + "args": [], + "returns": "string" + }, + { + "name": "setWormholePeer", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "peer", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetTransceiverPeerArgs" + } + } + ] + }, + { + "name": "receiveWormholeMessageInstructionData", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "transceiverMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "guardianSet", + "isMut": false, + "isSigner": false, + "docs": [ + "Derivation is checked by the shim." + ] + }, + { + "name": "guardianSignatures", + "isMut": false, + "isSigner": false, + "docs": [ + "Ownership ownership and discriminator is checked by the shim." + ] + }, + { + "name": "verifyVaaShim", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "guardianSetBump", + "type": "u8" + }, + { + "name": "vaaBody", + "type": { + "defined": "VaaBodyData" + } + } + ] + }, + { + "name": "postUnverifiedWormholeMessageAccount", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "message", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "PostUnverifiedMessageAccountArgs" + } + } + ] + }, + { + "name": "closeUnverifiedWormholeMessageAccount", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "message", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "seed", + "type": "u64" + } + ] + }, + { + "name": "receiveWormholeMessageAccount", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "message", + "isMut": true, + "isSigner": false + }, + { + "name": "transceiverMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "guardianSet", + "isMut": false, + "isSigner": false, + "docs": [ + "Derivation is checked by the shim." + ] + }, + { + "name": "guardianSignatures", + "isMut": false, + "isSigner": false, + "docs": [ + "Ownership ownership and discriminator is checked by the shim." + ] + }, + { + "name": "verifyVaaShim", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "guardianSetBump", + "type": "u8" + }, + { + "name": "seed", + "type": "u64" + } + ] + }, + { + "name": "releaseWormholeOutbound", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "postMessageShim", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholePostMessageShimEa", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "manager", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxItemSigner", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ReleaseOutboundArgs" + } + } + ] + }, + { + "name": "broadcastWormholeId", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false, + "docs": [ + "enforced by the [`CpiContext`] call in [`post_message`].", + "The seeds constraint ensures that this is the correct address" + ] + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "postMessageShim", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholePostMessageShimEa", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [] + }, + { + "name": "broadcastWormholePeer", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "postMessageShim", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholePostMessageShimEa", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "BroadcastPeerArgs" + } + } + ] + } + ], + "accounts": [ + { + "name": "config", + "type": { + "kind": "struct", + "fields": [ + { + "name": "owner", + "docs": [ + "Owner of this instance. Distinct from the program's upgrade authority —", + "instance ownership transfers are pure data mutations and never touch the", + "BPF loader (v4 trust model)." + ], + "type": "publicKey" + }, + { + "name": "pendingOwner", + "docs": [ + "Pending next owner (before claiming ownership)." + ], + "type": { + "option": "publicKey" + } + }, + { + "name": "mint", + "docs": [ + "Mint address of the token managed by this instance." + ], + "type": "publicKey" + }, + { + "name": "tokenProgram", + "docs": [ + "Address of the token program (token or token22). This could always be queried", + "from the [`mint`] account's owner, but storing it here avoids an indirection", + "on the client side." + ], + "type": "publicKey" + }, + { + "name": "mode", + "docs": [ + "The mode that this instance is running in. This is used to determine", + "whether the program is burning tokens or locking tokens." + ], + "type": { + "defined": "Mode" + } + }, + { + "name": "chainId", + "docs": [ + "The chain id of the chain that this program is running on. We don't", + "hardcode this so that the program is deployable on any potential SVM", + "forks." + ], + "type": { + "defined": "ChainId" + } + }, + { + "name": "nextTransceiverId", + "docs": [ + "The next transceiver id to use when registering a transceiver under this instance." + ], + "type": "u8" + }, + { + "name": "threshold", + "docs": [ + "The number of transceivers that must attest to a transfer before it is", + "accepted." + ], + "type": "u8" + }, + { + "name": "enabledTransceivers", + "docs": [ + "Bitmap of enabled transceivers.", + "The maximum number of transceivers is equal to [`Bitmap::BITS`]." + ], + "type": { + "defined": "Bitmap" + } + }, + { + "name": "paused", + "docs": [ + "Pause the program. This is useful for upgrades and other maintenance." + ], + "type": "bool" + }, + { + "name": "custody", + "docs": [ + "The custody account that holds tokens in locking mode." + ], + "type": "publicKey" + } + ] + } + }, + { + "name": "outboxItem", + "type": { + "kind": "struct", + "fields": [ + { + "name": "manager", + "docs": [ + "v4: the `Config` (instance) that produced this outbox item. Used to", + "bind the item to its source instance so transceivers can verify they're", + "releasing/marking an item that belongs to the instance they're working", + "on (`released` is a bitmap keyed by per-instance transceiver_id)." + ], + "type": "publicKey" + }, + { + "name": "amount", + "type": { + "defined": "TrimmedAmount" + } + }, + { + "name": "sender", + "type": "publicKey" + }, + { + "name": "recipientChain", + "type": { + "defined": "ChainId" + } + }, + { + "name": "recipientNttManager", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "recipientAddress", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "releaseTimestamp", + "type": "i64" + }, + { + "name": "released", + "type": { + "defined": "Bitmap" + } + } + ] + } + }, + { + "name": "registeredTransceiver", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "id", + "type": "u8" + }, + { + "name": "transceiverAddress", + "type": "publicKey" + } + ] + } + }, + { + "name": "transceiverPeer", + "docs": [ + "A peer on another chain. Stored in a PDA seeded by the chain id." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + }, + { + "name": "vaaBody", + "type": { + "kind": "struct", + "fields": [ + { + "name": "span", + "type": "bytes" + } + ] + } + } + ], + "types": [ + { + "name": "Bitmap", + "type": { + "kind": "struct", + "fields": [ + { + "name": "map", + "type": "u128" + } + ] + } + }, + { + "name": "ChainId", + "type": { + "kind": "struct", + "fields": [ + { + "name": "id", + "type": "u16" + } + ] + } + }, + { + "name": "Mode", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Locking" + }, + { + "name": "Burning" + } + ] + } + }, + { + "name": "TrimmedAmount", + "type": { + "kind": "struct", + "fields": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "decimals", + "type": "u8" + } + ] + } + }, + { + "name": "VaaBodyData", + "type": { + "kind": "struct", + "fields": [ + { + "name": "span", + "type": "bytes" + } + ] + } + }, + { + "name": "SetTransceiverPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": { + "defined": "ChainId" + } + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + }, + { + "name": "BroadcastPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": "u16" + } + ] + } + }, + { + "name": "ReleaseOutboundArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "revertOnDelay", + "type": "bool" + } + ] + } + }, + { + "name": "PostUnverifiedMessageAccountArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "seed", + "type": "u64" + }, + { + "name": "offset", + "type": "u32" + }, + { + "name": "chunk", + "type": "bytes" + }, + { + "name": "messageSize", + "type": "u32" + } + ] + } + } + ] +} +export const IDL: NttTransceiver = { + "version": "3.0.0", + "name": "ntt_transceiver", + "instructions": [ + { + "name": "transceiverType", + "accounts": [], + "args": [], + "returns": "string" + }, + { + "name": "setWormholePeer", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "peer", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetTransceiverPeerArgs" + } + } + ] + }, + { + "name": "receiveWormholeMessageInstructionData", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "transceiverMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "guardianSet", + "isMut": false, + "isSigner": false, + "docs": [ + "Derivation is checked by the shim." + ] + }, + { + "name": "guardianSignatures", + "isMut": false, + "isSigner": false, + "docs": [ + "Ownership ownership and discriminator is checked by the shim." + ] + }, + { + "name": "verifyVaaShim", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "guardianSetBump", + "type": "u8" + }, + { + "name": "vaaBody", + "type": { + "defined": "VaaBodyData" + } + } + ] + }, + { + "name": "postUnverifiedWormholeMessageAccount", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "message", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "PostUnverifiedMessageAccountArgs" + } + } + ] + }, + { + "name": "closeUnverifiedWormholeMessageAccount", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "message", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "seed", + "type": "u64" + } + ] + }, + { + "name": "receiveWormholeMessageAccount", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "message", + "isMut": true, + "isSigner": false + }, + { + "name": "transceiverMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "guardianSet", + "isMut": false, + "isSigner": false, + "docs": [ + "Derivation is checked by the shim." + ] + }, + { + "name": "guardianSignatures", + "isMut": false, + "isSigner": false, + "docs": [ + "Ownership ownership and discriminator is checked by the shim." + ] + }, + { + "name": "verifyVaaShim", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "guardianSetBump", + "type": "u8" + }, + { + "name": "seed", + "type": "u64" + } + ] + }, + { + "name": "releaseWormholeOutbound", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "postMessageShim", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholePostMessageShimEa", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "manager", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxItemSigner", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ReleaseOutboundArgs" + } + } + ] + }, + { + "name": "broadcastWormholeId", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false, + "docs": [ + "enforced by the [`CpiContext`] call in [`post_message`].", + "The seeds constraint ensures that this is the correct address" + ] + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "postMessageShim", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholePostMessageShimEa", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [] + }, + { + "name": "broadcastWormholePeer", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "postMessageShim", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholePostMessageShimEa", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "BroadcastPeerArgs" + } + } + ] + } + ], + "accounts": [ + { + "name": "config", + "type": { + "kind": "struct", + "fields": [ + { + "name": "owner", + "docs": [ + "Owner of this instance. Distinct from the program's upgrade authority —", + "instance ownership transfers are pure data mutations and never touch the", + "BPF loader (v4 trust model)." + ], + "type": "publicKey" + }, + { + "name": "pendingOwner", + "docs": [ + "Pending next owner (before claiming ownership)." + ], + "type": { + "option": "publicKey" + } + }, + { + "name": "mint", + "docs": [ + "Mint address of the token managed by this instance." + ], + "type": "publicKey" + }, + { + "name": "tokenProgram", + "docs": [ + "Address of the token program (token or token22). This could always be queried", + "from the [`mint`] account's owner, but storing it here avoids an indirection", + "on the client side." + ], + "type": "publicKey" + }, + { + "name": "mode", + "docs": [ + "The mode that this instance is running in. This is used to determine", + "whether the program is burning tokens or locking tokens." + ], + "type": { + "defined": "Mode" + } + }, + { + "name": "chainId", + "docs": [ + "The chain id of the chain that this program is running on. We don't", + "hardcode this so that the program is deployable on any potential SVM", + "forks." + ], + "type": { + "defined": "ChainId" + } + }, + { + "name": "nextTransceiverId", + "docs": [ + "The next transceiver id to use when registering a transceiver under this instance." + ], + "type": "u8" + }, + { + "name": "threshold", + "docs": [ + "The number of transceivers that must attest to a transfer before it is", + "accepted." + ], + "type": "u8" + }, + { + "name": "enabledTransceivers", + "docs": [ + "Bitmap of enabled transceivers.", + "The maximum number of transceivers is equal to [`Bitmap::BITS`]." + ], + "type": { + "defined": "Bitmap" + } + }, + { + "name": "paused", + "docs": [ + "Pause the program. This is useful for upgrades and other maintenance." + ], + "type": "bool" + }, + { + "name": "custody", + "docs": [ + "The custody account that holds tokens in locking mode." + ], + "type": "publicKey" + } + ] + } + }, + { + "name": "outboxItem", + "type": { + "kind": "struct", + "fields": [ + { + "name": "manager", + "docs": [ + "v4: the `Config` (instance) that produced this outbox item. Used to", + "bind the item to its source instance so transceivers can verify they're", + "releasing/marking an item that belongs to the instance they're working", + "on (`released` is a bitmap keyed by per-instance transceiver_id)." + ], + "type": "publicKey" + }, + { + "name": "amount", + "type": { + "defined": "TrimmedAmount" + } + }, + { + "name": "sender", + "type": "publicKey" + }, + { + "name": "recipientChain", + "type": { + "defined": "ChainId" + } + }, + { + "name": "recipientNttManager", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "recipientAddress", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "releaseTimestamp", + "type": "i64" + }, + { + "name": "released", + "type": { + "defined": "Bitmap" + } + } + ] + } + }, + { + "name": "registeredTransceiver", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "id", + "type": "u8" + }, + { + "name": "transceiverAddress", + "type": "publicKey" + } + ] + } + }, + { + "name": "transceiverPeer", + "docs": [ + "A peer on another chain. Stored in a PDA seeded by the chain id." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + }, + { + "name": "vaaBody", + "type": { + "kind": "struct", + "fields": [ + { + "name": "span", + "type": "bytes" + } + ] + } + } + ], + "types": [ + { + "name": "Bitmap", + "type": { + "kind": "struct", + "fields": [ + { + "name": "map", + "type": "u128" + } + ] + } + }, + { + "name": "ChainId", + "type": { + "kind": "struct", + "fields": [ + { + "name": "id", + "type": "u16" + } + ] + } + }, + { + "name": "Mode", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Locking" + }, + { + "name": "Burning" + } + ] + } + }, + { + "name": "TrimmedAmount", + "type": { + "kind": "struct", + "fields": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "decimals", + "type": "u8" + } + ] + } + }, + { + "name": "VaaBodyData", + "type": { + "kind": "struct", + "fields": [ + { + "name": "span", + "type": "bytes" + } + ] + } + }, + { + "name": "SetTransceiverPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": { + "defined": "ChainId" + } + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + }, + { + "name": "BroadcastPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": "u16" + } + ] + } + }, + { + "name": "ReleaseOutboundArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "revertOnDelay", + "type": "bool" + } + ] + } + }, + { + "name": "PostUnverifiedMessageAccountArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "seed", + "type": "u64" + }, + { + "name": "offset", + "type": "u32" + }, + { + "name": "chunk", + "type": "bytes" + }, + { + "name": "messageSize", + "type": "u32" + } + ] + } + } + ] +} + diff --git a/solana/ts/idl/4_0_0/ts/wormhole_governance.ts b/solana/ts/idl/4_0_0/ts/wormhole_governance.ts new file mode 100644 index 000000000..13289f7b7 --- /dev/null +++ b/solana/ts/idl/4_0_0/ts/wormhole_governance.ts @@ -0,0 +1,153 @@ +export type WormholeGovernance = { + "version": "3.0.0", + "name": "wormhole_governance", + "instructions": [ + { + "name": "governance", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "governance", + "isMut": true, + "isSigner": false, + "docs": [ + "governed program. This account is validated by Wormhole, not this program." + ] + }, + { + "name": "vaa", + "isMut": false, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "replay", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + } + ], + "accounts": [ + { + "name": "replayProtection", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + } + ] + } + } + ], + "errors": [ + { + "code": 6000, + "name": "InvalidGovernanceChain", + "msg": "InvalidGovernanceChain" + }, + { + "code": 6001, + "name": "InvalidGovernanceEmitter", + "msg": "InvalidGovernanceEmitter" + }, + { + "code": 6002, + "name": "InvalidGovernanceProgram", + "msg": "InvalidGovernanceProgram" + } + ] +} +export const IDL: WormholeGovernance = { + "version": "3.0.0", + "name": "wormhole_governance", + "instructions": [ + { + "name": "governance", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "governance", + "isMut": true, + "isSigner": false, + "docs": [ + "governed program. This account is validated by Wormhole, not this program." + ] + }, + { + "name": "vaa", + "isMut": false, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "replay", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + } + ], + "accounts": [ + { + "name": "replayProtection", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + } + ] + } + } + ], + "errors": [ + { + "code": 6000, + "name": "InvalidGovernanceChain", + "msg": "InvalidGovernanceChain" + }, + { + "code": 6001, + "name": "InvalidGovernanceEmitter", + "msg": "InvalidGovernanceEmitter" + }, + { + "code": 6002, + "name": "InvalidGovernanceProgram", + "msg": "InvalidGovernanceProgram" + } + ] +} + diff --git a/solana/ts/lib/anchor-idl/4_0_0.ts b/solana/ts/lib/anchor-idl/4_0_0.ts new file mode 100644 index 000000000..738752430 --- /dev/null +++ b/solana/ts/lib/anchor-idl/4_0_0.ts @@ -0,0 +1,29 @@ +import { type ExampleNativeTokenTransfers } from "../../idl/4_0_0/ts/example_native_token_transfers.js"; +import { IDL as ntt } from "../../idl/4_0_0/ts/example_native_token_transfers.js"; +// The transceiver/quoter/governance programs are independent of the NTT manager +// and were not changed in v4. Re-use the 3.0.0 IDL for those until they need +// their own version bumps. (The quoter IDL was only emitted under 4_0_0; the +// program is unchanged, so it's equivalent.) +import { type NttTransceiver } from "../../idl/3_0_0/ts/ntt_transceiver.js"; +import { IDL as transceiver } from "../../idl/3_0_0/ts/ntt_transceiver.js"; +import { type NttTransceiverLegacy } from "../../idl/3_0_0/ts/ntt_transceiver_legacy.js"; +import { IDL as transceiverLegacy } from "../../idl/3_0_0/ts/ntt_transceiver_legacy.js"; +import { type NttQuoter } from "../../idl/4_0_0/ts/ntt_quoter.js"; +import { IDL as quoter } from "../../idl/4_0_0/ts/ntt_quoter.js"; +import { type WormholeGovernance } from "../../idl/3_0_0/ts/wormhole_governance.js"; +import { IDL as governance } from "../../idl/3_0_0/ts/wormhole_governance.js"; + +export namespace _4_0_0 { + export const idl = { + ntt, + transceiver, + transceiverLegacy, + quoter, + governance, + }; + export type RawExampleNativeTokenTransfers = ExampleNativeTokenTransfers; + export type RawNttTransceiver = NttTransceiver; + export type RawNttTransceiverLegacy = NttTransceiverLegacy; + export type RawNttQuoter = NttQuoter; + export type RawWormholeGovernance = WormholeGovernance; +} diff --git a/solana/ts/lib/anchor-idl/index.ts b/solana/ts/lib/anchor-idl/index.ts index 6ad25e2d3..1d96710fb 100644 --- a/solana/ts/lib/anchor-idl/index.ts +++ b/solana/ts/lib/anchor-idl/index.ts @@ -1,3 +1,4 @@ export * from "./1_0_0.js"; export * from "./2_0_0.js"; export * from "./3_0_0.js"; +export * from "./4_0_0.js"; diff --git a/solana/ts/lib/bindings.ts b/solana/ts/lib/bindings.ts index 741b0febd..0fbe294cc 100644 --- a/solana/ts/lib/bindings.ts +++ b/solana/ts/lib/bindings.ts @@ -1,6 +1,6 @@ import { IdlAccounts, Program } from "@coral-xyz/anchor"; import { Connection } from "@solana/web3.js"; -import { _1_0_0, _2_0_0, _3_0_0 } from "./anchor-idl/index.js"; +import { _1_0_0, _2_0_0, _3_0_0, _4_0_0 } from "./anchor-idl/index.js"; import { Ntt } from "@wormhole-foundation/sdk-definitions-ntt"; export interface IdlBinding { @@ -15,6 +15,7 @@ export interface IdlBinding { // We check for the first match in descending order, allowing for higher minor and patch versions // being used by the live contract (these are supposed to still be compatible with older ABIs). export const IdlVersions = [ + ["4.0.0", _4_0_0], ["3.0.0", _3_0_0], ["2.0.0", _2_0_0], ["1.0.0", _1_0_0], @@ -27,19 +28,25 @@ export namespace NttBindings { ? _1_0_0.RawExampleNativeTokenTransfers : V extends "2.0.0" ? _2_0_0.RawExampleNativeTokenTransfers - : _3_0_0.RawExampleNativeTokenTransfers; + : V extends "3.0.0" + ? _3_0_0.RawExampleNativeTokenTransfers + : _4_0_0.RawExampleNativeTokenTransfers; export type Transceiver = V extends "1.0.0" ? _1_0_0.RawExampleNativeTokenTransfers : V extends "2.0.0" ? _2_0_0.RawExampleNativeTokenTransfers - : _3_0_0.RawNttTransceiver; + : V extends "3.0.0" + ? _3_0_0.RawNttTransceiver + : _4_0_0.RawNttTransceiver; export type TransceiverLegacy = V extends "1.0.0" ? _1_0_0.RawExampleNativeTokenTransfers : V extends "2.0.0" ? _2_0_0.RawExampleNativeTokenTransfers - : _3_0_0.RawNttTransceiverLegacy; + : V extends "3.0.0" + ? _3_0_0.RawNttTransceiverLegacy + : _4_0_0.RawNttTransceiverLegacy; type ProgramAccounts = IdlAccounts< NttBindings.NativeTokenTransfer diff --git a/solana/ts/lib/ntt.ts b/solana/ts/lib/ntt.ts index c0401773d..f0e63bf17 100644 --- a/solana/ts/lib/ntt.ts +++ b/solana/ts/lib/ntt.ts @@ -44,6 +44,7 @@ import { NttBindings, getNttProgram, } from "./bindings.js"; +import { _4_0_0 } from "./anchor-idl/index.js"; import { BPF_LOADER_UPGRADEABLE_PROGRAM_ID, chainToBytes, @@ -80,32 +81,64 @@ export namespace NTT { /** Type of object containing methods to compute program addresses */ export type Pdas = ReturnType; - /** pdas returns an object containing all functions to compute program addresses */ - export const pdas = (programId: PublicKeyInitData) => { - const configAccount = (): PublicKey => derivePda("config", programId); + /** + * pdas returns an object containing all functions to compute program addresses. + * + * - v3 (singleton): call as `pdas(programId)`. PDAs are derived in the legacy + * layout (e.g. `[b"config"]`). + * - v4 (multi-host): call as `pdas(programId, config)` where `config` is the + * per-instance Config account pubkey (the keypair the operator chose at + * `initialize`). All seed-bearing PDAs gain `config.toBytes()` as their + * first-after-prefix seed; `configAccount()` simply returns `config`. + * + * v4 also drops the legacy `[b"upgrade_lock"]` PDA — it has no v4 equivalent + * (program upgrade authority is decoupled from instance ownership). Calling + * `upgradeLock()` on a v4 pdas object throws. + */ + export const pdas = (programId: PublicKeyInitData, config?: PublicKey) => { + // When `config` is provided we prepend its bytes to the seeds of every + // PDA that's per-instance in v4. This keeps the v3 derivations bit-identical + // when called as `pdas(programId)`. + const scope: Uint8Array[] = config ? [config.toBytes()] : []; + + const configAccount = (): PublicKey => + config ?? derivePda("config", programId); const inboxRateLimitAccount = (chain: Chain): PublicKey => - derivePda(["inbox_rate_limit", chainToBytes(chain)], programId); + derivePda(["inbox_rate_limit", ...scope, chainToBytes(chain)], programId); const inboxItemAccount = ( chain: Chain, nttMessage: Ntt.Message ): PublicKey => derivePda( - ["inbox_item", Ntt.messageDigest(chain, nttMessage)], + ["inbox_item", ...scope, Ntt.messageDigest(chain, nttMessage)], programId ); - const upgradeLock = (): PublicKey => derivePda("upgrade_lock", programId); + const upgradeLock = (): PublicKey => { + if (config !== undefined) { + throw new Error( + "upgradeLock() is v3-only; v4 decouples program upgrade authority from instance ownership" + ); + } + return derivePda("upgrade_lock", programId); + }; const outboxRateLimitAccount = (): PublicKey => - derivePda("outbox_rate_limit", programId); + derivePda(["outbox_rate_limit", ...scope], programId); const tokenAuthority = (): PublicKey => - derivePda("token_authority", programId); + derivePda(["token_authority", ...scope], programId); const pendingTokenAuthority = (): PublicKey => - derivePda("pending_token_authority", programId); + derivePda(["pending_token_authority", ...scope], programId); const peerAccount = (chain: Chain): PublicKey => - derivePda(["peer", chainToBytes(chain)], programId); + derivePda(["peer", ...scope, chainToBytes(chain)], programId); const registeredTransceiver = (transceiver: PublicKey): PublicKey => - derivePda(["registered_transceiver", transceiver.toBytes()], programId); - const lutAccount = (): PublicKey => derivePda("lut", programId); - const lutAuthority = (): PublicKey => derivePda("lut_authority", programId); + derivePda( + ["registered_transceiver", ...scope, transceiver.toBytes()], + programId + ); + const lutAccount = (): PublicKey => derivePda(["lut", ...scope], programId); + const lutAuthority = (): PublicKey => + derivePda(["lut_authority", ...scope], programId); + // Scope session_authority by instance in v4 so an approval issued for one + // instance cannot be replayed against another instance managing the same mint. const sessionAuthority = ( sender: PublicKey, args: TransferArgs @@ -113,6 +146,7 @@ export namespace NTT { derivePda( [ "session_authority", + ...scope, sender.toBytes(), keccak256( encoding.bytes.concat( @@ -145,22 +179,38 @@ export namespace NTT { /** Type of object containing methods to compute program addresses */ export type TransceiverPdas = ReturnType; - /** pdas returns an object containing all functions to compute program addresses */ - export const transceiverPdas = (programId: PublicKeyInitData) => { - const emitterAccount = (): PublicKey => derivePda("emitter", programId); - const outboxItemSigner = () => derivePda(["outbox_item_signer"], programId); + /** pdas returns an object containing all functions to compute program addresses. + * v3: call as `transceiverPdas(programId)`. + * v4: call as `transceiverPdas(programId, config)` to scope by instance. + */ + export const transceiverPdas = ( + programId: PublicKeyInitData, + config?: PublicKey + ) => { + const scope: Uint8Array[] = config ? [config.toBytes()] : []; + + const emitterAccount = (): PublicKey => + derivePda(["emitter", ...scope], programId); + const outboxItemSigner = () => + derivePda(["outbox_item_signer", ...scope], programId); const transceiverPeerAccount = (chain: Chain): PublicKey => - derivePda(["transceiver_peer", chainToBytes(chain)], programId); + derivePda(["transceiver_peer", ...scope, chainToBytes(chain)], programId); const transceiverMessageAccount = ( chain: Chain, id: Uint8Array ): PublicKey => - derivePda(["transceiver_message", chainToBytes(chain), id], programId); + derivePda( + ["transceiver_message", ...scope, chainToBytes(chain), id], + programId + ); const unverifiedMessageAccount = (payer: PublicKey, seed: BN): PublicKey => derivePda( ["vaa_body", payer.toBytes(), new Uint8Array(seed.toArray("be"))], programId ); + // wormhole_message is content-addressed by `outbox_item.key()` which is + // already globally unique (OutboxItem is keypair-created), so no instance + // scoping is required here. const wormholeMessageAccount = (outboxItem: PublicKey): PublicKey => derivePda(["message", outboxItem.toBytes()], programId); const wormholeMessageWithShimAccount = ( @@ -257,16 +307,43 @@ export namespace NTT { mode: "burning" | "locking"; multisigTokenAuthority?: PublicKey; }, - pdas?: Pdas + pdas: Pdas ) { const [major, , ,] = parseVersion(program.idl.version); const mode: any = args.mode === "burning" ? { burning: {} } : { locking: {} }; const chainId = toChainId(args.chain); - pdas = pdas ?? NTT.pdas(program.programId); - const limit = new BN(args.outboundLimit.toString()); + + if (major >= 4) { + // v4: instance is keypair-created (not a PDA). `pdas.configAccount()` + // returns the instance pubkey when pdas was constructed with an + // instance arg. The caller must include both the payer and the + // instance keypair as signers on the transaction. Owner is a + // separate Signer, decoupled from the program upgrade authority. + return program.methods + .initialize({ chainId, limit: limit, mode }) + .accounts({ + payer: args.payer, + owner: args.owner, + config: pdas.configAccount(), + mint: args.mint, + rateLimit: pdas.outboxRateLimitAccount(), + tokenProgram: args.tokenProgram, + tokenAuthority: pdas.tokenAuthority(), + multisigTokenAuthority: args.multisigTokenAuthority ?? null, + custody: await NTT.custodyAccountAddress( + pdas, + args.mint, + args.tokenProgram + ), + associatedTokenProgram: splToken.ASSOCIATED_TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + }) + .instruction(); + } + return program.methods .initialize({ chainId, limit: limit, mode }) .accounts({ @@ -305,14 +382,12 @@ export namespace NTT { owner: PublicKey; wormholeId: PublicKey; }, - pdas?: Pdas + pdas: Pdas ) { // if the program is < major version 2.x.x, we don't need to initialize the LUT const [major, , ,] = parseVersion(program.idl.version); if (major < 2) return; - pdas = pdas ?? NTT.pdas(program.programId); - // TODO: find a more robust way of fetching a recent slot const slot = (await program.provider.connection.getSlot()) - 1; @@ -404,10 +479,8 @@ export namespace NTT { transferArgs: TransferArgs; outboxItem: PublicKey; }, - pdas?: Pdas + pdas: Pdas ): Promise { - pdas = pdas ?? NTT.pdas(program.programId); - const custody = await custodyAccountAddress(pdas, config); const recipientChain = toChain(args.transferArgs.recipientChain.id); const transferIx = await program.methods @@ -484,12 +557,10 @@ export namespace NTT { transferArgs: NTT.TransferArgs; outboxItem: PublicKey; }, - pdas?: Pdas + pdas: Pdas ): Promise { if (config.paused) throw new Error("Contract is paused"); - pdas = pdas ?? NTT.pdas(program.programId); - const chain = toChain(args.transferArgs.recipientChain.id); const custody = await custodyAccountAddress(pdas, config); const transferIx = await program.methods @@ -563,12 +634,10 @@ export namespace NTT { revertWhenNotReady: boolean; recipient?: PublicKey; }, - pdas?: Pdas + pdas: Pdas ): Promise { const [major, , ,] = parseVersion(program.idl.version); - pdas = pdas ?? NTT.pdas(program.programId); - const mintInfo = await splToken.getMint( program.provider.connection, config.mint, @@ -582,7 +651,7 @@ export namespace NTT { const recipientAddress = args.recipient ?? - (await getInboxItem(program, args.chain, args.nttMessage)) + (await getInboxItem(program, args.chain, args.nttMessage, pdas)) .recipientAddress; const transferIx = await program.methods @@ -656,14 +725,12 @@ export namespace NTT { revertWhenNotReady: boolean; recipient?: PublicKey; }, - pdas?: Pdas + pdas: Pdas ) { const recipientAddress = args.recipient ?? - (await NTT.getInboxItem(program, args.chain, args.nttMessage)) + (await NTT.getInboxItem(program, args.chain, args.nttMessage, pdas)) .recipientAddress; - - pdas = pdas ?? NTT.pdas(program.programId); const custody = await custodyAccountAddress(pdas, config); const transferIx = await program.methods @@ -737,9 +804,25 @@ export namespace NTT { owner: PublicKey; newOwner: PublicKey; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); + const [major, , ,] = parseVersion(program.idl.version); + if (major >= 4) { + // v4: ownership transfer is a pure data mutation on Config, no BPF-loader CPI. + // The function signature accepts a `Program` over the union of all IDL versions, + // so `accountsStrict` would otherwise require the v3 BPF-loader fields too. Narrow + // to the v4 IDL type now that we've established the major version at runtime. + const v4Program = + program as unknown as Program<_4_0_0.RawExampleNativeTokenTransfers>; + return v4Program.methods + .transferOwnershipOneStepUnchecked() + .accountsStrict({ + config: pdas.configAccount(), + owner: args.owner, + newOwner: args.newOwner, + }) + .instruction(); + } return program.methods .transferOwnershipOneStepUnchecked() .accountsStrict({ @@ -759,9 +842,21 @@ export namespace NTT { owner: PublicKey; newOwner: PublicKey; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); + const [major, , ,] = parseVersion(program.idl.version); + if (major >= 4) { + const v4Program = + program as unknown as Program<_4_0_0.RawExampleNativeTokenTransfers>; + return v4Program.methods + .transferOwnership() + .accountsStrict({ + config: pdas.configAccount(), + owner: args.owner, + newOwner: args.newOwner, + }) + .instruction(); + } return program.methods .transferOwnership() .accountsStrict({ @@ -780,9 +875,20 @@ export namespace NTT { args: { newOwner: PublicKey; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); + const [major, , ,] = parseVersion(program.idl.version); + if (major >= 4) { + const v4Program = + program as unknown as Program<_4_0_0.RawExampleNativeTokenTransfers>; + return v4Program.methods + .claimOwnership() + .accountsStrict({ + config: pdas.configAccount(), + newOwner: args.newOwner, + }) + .instruction(); + } return program.methods .claimOwnership() .accountsStrict({ @@ -802,9 +908,8 @@ export namespace NTT { currentAuthority: PublicKey; multisigTokenAuthority?: PublicKey; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); return program.methods .acceptTokenAuthority() .accountsStrict({ @@ -828,9 +933,8 @@ export namespace NTT { additionalSigners: readonly PublicKey[]; multisigTokenAuthority?: PublicKey; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); return program.methods .acceptTokenAuthorityFromMultisig() .accountsStrict({ @@ -861,9 +965,8 @@ export namespace NTT { newAuthority: PublicKey; multisigTokenAuthority?: PublicKey; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); return program.methods .setTokenAuthorityOneStepUnchecked() .accountsStrict({ @@ -889,9 +992,8 @@ export namespace NTT { newAuthority: PublicKey; multisigTokenAuthority?: PublicKey; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); return program.methods .setTokenAuthority() .accountsStrict({ @@ -918,9 +1020,8 @@ export namespace NTT { owner: PublicKey; multisigTokenAuthority?: PublicKey; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); return program.methods .revertTokenAuthority() .accountsStrict({ @@ -947,9 +1048,8 @@ export namespace NTT { newAuthority: PublicKey; multisigTokenAuthority?: PublicKey; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); return program.methods .claimTokenAuthority() .accountsStrict({ @@ -977,9 +1077,8 @@ export namespace NTT { additionalSigners: readonly PublicKey[]; multisigTokenAuthority?: PublicKey; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); return program.methods .claimTokenAuthorityToMultisig() .accountsStrict({ @@ -1015,9 +1114,8 @@ export namespace NTT { limit: BN; tokenDecimals: number; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); return program.methods .setPeer({ chainId: { id: toChainId(args.chain) }, @@ -1042,9 +1140,8 @@ export namespace NTT { owner: PublicKey; paused: boolean; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); return program.methods .setPaused(args.paused) .accountsStrict({ @@ -1060,9 +1157,8 @@ export namespace NTT { owner: PublicKey; threshold: number; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); return program.methods .setThreshold(args.threshold) .accountsStrict({ @@ -1078,9 +1174,8 @@ export namespace NTT { owner: PublicKey; limit: BN; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); return program.methods .setOutboundLimit({ limit: args.limit, @@ -1100,9 +1195,8 @@ export namespace NTT { chain: Chain; limit: BN; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); return program.methods .setInboundLimit({ chainId: { id: toChainId(args.chain) }, @@ -1124,13 +1218,9 @@ export namespace NTT { payer: PublicKey; vaa: VAA<"Ntt:WormholeTransfer">; }, - pdas?: Pdas, - transceiverPdas?: TransceiverPdas + pdas: Pdas, + transceiverPdas: TransceiverPdas ): Promise { - pdas = pdas ?? NTT.pdas(program.programId); - transceiverPdas = - transceiverPdas ?? NTT.transceiverPdas(transceiverProgramId); - const wormholeNTT = args.vaa; const nttMessage = wormholeNTT.payload.nttManagerPayload; const chain = wormholeNTT.emitterChain; @@ -1174,21 +1264,29 @@ export namespace NTT { export async function getInboxItem( program: Program>, fromChain: Chain, - nttMessage: Ntt.Message + nttMessage: Ntt.Message, + pdas?: Pdas ): Promise> { + const [major, , ,] = parseVersion(program.idl.version); + if (major >= 4 && !pdas) { + throw new Error( + "getInboxItem: v4 requires instance-scoped pdas to derive the inbox item account" + ); + } return program.account.inboxItem.fetch( - NTT.pdas(program.programId).inboxItemAccount(fromChain, nttMessage) + (pdas ?? NTT.pdas(program.programId)).inboxItemAccount( + fromChain, + nttMessage + ) ); } export async function getAddressLookupTable( program: Program>, - pdas?: Pdas + pdas: Pdas ): Promise { const [major, , ,] = parseVersion(program.idl.version); if (major < 2) return null; - - pdas = pdas ?? NTT.pdas(program.programId); // @ts-ignore // NOTE: lut is 'LUT' in the IDL, but 'lut' in the generated code // It needs to be upper-cased in the IDL to compute the anchor diff --git a/solana/ts/sdk/ntt.ts b/solana/ts/sdk/ntt.ts index fab43d147..cd9dcc673 100644 --- a/solana/ts/sdk/ntt.ts +++ b/solana/ts/sdk/ntt.ts @@ -74,7 +74,8 @@ export class SolanaNttWormholeTransceiver< readonly svmShims: Ntt.Contracts["svmShims"] = undefined ) { this.programId = program.programId; - this.pdas = NTT.transceiverPdas(program.programId); + // v4: scope transceiver PDAs by the manager instance pubkey when present. + this.pdas = NTT.transceiverPdas(program.programId, manager.instance); if (svmShims) { const postMessageShimAddress = @@ -107,6 +108,29 @@ export class SolanaNttWormholeTransceiver< } } + /** + * v4-aware version of the standard Wormhole CPI account bundle: bridge and + * fee_collector come from the wormhole core program ID, but `emitter` and + * `sequence` are scoped by the v4 instance pubkey embedded in `this.pdas`. + * The wormhole-anchor-sdk's `getWormholeDerivedAccounts` only knows the v3 + * singleton emitter (`[b"emitter"]`), so we recompute these two locally. + */ + private wormholeDerivedAccounts() { + const emitter = this.pdas.emitterAccount(); + const whAccs = utils.getWormholeDerivedAccounts( + this.program.programId, + this.manager.core.address + ); + return { + ...whAccs, + wormholeEmitter: emitter, + wormholeSequence: derivePda( + ["Sequence", emitter.toBytes()], + this.manager.core.address + ), + }; + } + async getPauser(): Promise | null> { return null; } @@ -421,10 +445,7 @@ export class SolanaNttWormholeTransceiver< "wormholeMessage must be passed in if Wormhole Post Message Shim is not configured" ); } - const whAccs = utils.getWormholeDerivedAccounts( - this.program.programId, - this.manager.core.address - ); + const whAccs = this.wormholeDerivedAccounts(); wormholeMessage = this.postMessageShim ? this.pdas.wormholeMessageWithShimAccount(this.postMessageShim.programId) @@ -464,10 +485,7 @@ export class SolanaNttWormholeTransceiver< "wormholeMessage must be passed in if Wormhole Post Message Shim is not configured" ); } - const whAccs = utils.getWormholeDerivedAccounts( - this.program.programId, - this.manager.core.address - ); + const whAccs = this.wormholeDerivedAccounts(); wormholeMessage = this.postMessageShim ? this.pdas.wormholeMessageWithShimAccount(this.postMessageShim.programId) @@ -503,10 +521,7 @@ export class SolanaNttWormholeTransceiver< revertOnDelay: boolean ): Promise { const [major, , ,] = parseVersion(this.version); - const whAccs = utils.getWormholeDerivedAccounts( - this.program.programId, - this.manager.core.address - ); + const whAccs = this.wormholeDerivedAccounts(); const wormholeMessage = this.postMessageShim ? this.pdas.wormholeMessageWithShimAccount(this.postMessageShim.programId) @@ -559,6 +574,13 @@ export class SolanaNtt config?: NttBindings.Config; addressLookupTable?: AddressLookupTableAccount; + /** + * v4 instance pubkey. Set when `contracts.ntt.instance` is provided. Drives + * instance-scoped PDA derivation throughout the SDK. Undefined for v3 + * (singleton) deployments. + */ + instance?: PublicKey; + // 0 = Wormhole xcvr transceivers: Program>[]; @@ -571,11 +593,42 @@ export class SolanaNtt readonly network: N, readonly chain: C, readonly connection: Connection, - readonly contracts: Contracts & { ntt?: Ntt.Contracts }, + readonly contracts: Contracts & { + ntt?: Ntt.Contracts & { instance?: string }; + }, readonly version: string = "3.0.0" ) { if (!contracts.ntt) throw new Error("Ntt contracts not found"); + // Lock the version → instance correspondence at the only place that knows + // both. Multi-tenant programs (≥ v4) scope every PDA by the per-deployment + // Instance pubkey; the PDA factory accepts an optional `config` arg for + // back-compat with the singleton (legacy) layout, so without this guard a + // caller who forgets to thread `instance` would silently derive the + // legacy PDAs against a multi-tenant manager and read/write the wrong + // on-chain accounts. + const [major] = parseVersion(version); + const multiTenant = major >= 4; + if (multiTenant && !contracts.ntt.instance) { + throw new Error( + `SolanaNtt: version ${version} is multi-tenant and requires ` + + `\`contracts.ntt.instance\` (the per-deployment Instance pubkey). ` + + `Without it the SDK would silently fall back to singleton ` + + `(legacy) PDA derivations.` + ); + } + if (!multiTenant && contracts.ntt.instance) { + throw new Error( + `SolanaNtt: \`contracts.ntt.instance\` was provided but version ` + + `${version} is a singleton (legacy) deployment. Drop the ` + + `\`instance\` field, or set the version to >= 4.0.0.` + ); + } + + if (contracts.ntt.instance) { + this.instance = new PublicKey(contracts.ntt.instance); + } + this.program = getNttProgram( connection, contracts.ntt.manager, @@ -603,9 +656,10 @@ export class SolanaNtt const transceiverKey = new PublicKey( contracts.ntt!.transceiver[transceiverType]! ); - // handle emitterAccount case separately + // handle emitterAccount case separately. In v4 the emitter PDA is + // scoped by the instance pubkey, so we pass `this.instance` when set. if ( - NTT.transceiverPdas(managerKey) + NTT.transceiverPdas(managerKey, this.instance) .emitterAccount() .equals(transceiverKey) || managerKey.equals(transceiverKey) @@ -644,7 +698,23 @@ export class SolanaNtt connection, contracts ); - this.pdas = NTT.pdas(this.program.programId); + this.pdas = NTT.pdas(this.program.programId, this.instance); + } + + /** + * The on-the-wire NTT manager identity of this deployment — the address a + * peer on another chain must register, and the value written into and checked + * against a message's `recipient_ntt_manager`. + * + * v4 (multi-tenant) uses the per-deployment Instance (Config) pubkey; v3 + * (singleton) uses the program ID. This is the single source of truth for + * that identity: callers deriving a peer address, verifying a registration, + * or reporting deployment state must use it rather than the raw program ID, + * otherwise a v4 instance would be registered under an address whose + * transfers it rejects (`recipient_ntt_manager != config.key()`). + */ + peerManagerAddress(): PublicKey { + return this.instance ?? this.program.programId; } async getTransceiver( @@ -686,10 +756,11 @@ export class SolanaNtt async *pause(payer: AccountAddress) { const sender = new SolanaAddress(payer).unwrap(); - const ix = await NTT.createSetPausedInstruction(this.program, { - owner: sender, - paused: true, - }); + const ix = await NTT.createSetPausedInstruction( + this.program, + { owner: sender, paused: true }, + this.pdas + ); const tx = new Transaction(); tx.feePayer = sender; @@ -699,10 +770,11 @@ export class SolanaNtt async *unpause(payer: AccountAddress) { const sender = new SolanaAddress(payer).unwrap(); - const ix = await NTT.createSetPausedInstruction(this.program, { - owner: sender, - paused: false, - }); + const ix = await NTT.createSetPausedInstruction( + this.program, + { owner: sender, paused: false }, + this.pdas + ); const tx = new Transaction(); tx.feePayer = sender; @@ -748,10 +820,14 @@ export class SolanaNtt async *setOwner(newOwner: AnySolanaAddress, payer: AccountAddress) { const sender = new SolanaAddress(payer).unwrap(); - const ix = await NTT.createTransferOwnershipInstruction(this.program, { - owner: new SolanaAddress(await this.getOwner()).unwrap(), - newOwner: new SolanaAddress(newOwner).unwrap(), - }); + const ix = await NTT.createTransferOwnershipInstruction( + this.program, + { + owner: new SolanaAddress(await this.getOwner()).unwrap(), + newOwner: new SolanaAddress(newOwner).unwrap(), + }, + this.pdas + ); const tx = new Transaction(); tx.feePayer = sender; @@ -801,8 +877,7 @@ export class SolanaNtt } async getConfig(): Promise> { - this.config = this.config ?? (await NTT.getConfig(this.program, this.pdas)); - return this.config!; + return await NTT.getConfig(this.program, this.pdas); } async getTokenDecimals(): Promise { @@ -861,6 +936,13 @@ export class SolanaNtt mode: Ntt.Mode; outboundLimit: bigint; multisigTokenAuthority?: PublicKey; + /** + * v4-only: the keypair that will own the new Instance account. Required + * when `this.instance` is set. The caller-provided keypair must sign the + * initialize transaction (Anchor `init` allocates the account at this + * pubkey). This keypair's public key must match `this.instance`. + */ + instance?: Keypair; } ) { const mintInfo = await this.connection.getAccountInfo(args.mint); @@ -871,6 +953,23 @@ export class SolanaNtt const payer = new SolanaAddress(sender).unwrap(); + if (this.instance) { + if (!args.instance) { + throw new Error( + "v4 initialize: `args.instance` (Keypair) is required when `contracts.ntt.instance` is set" + ); + } + if (!args.instance.publicKey.equals(this.instance)) { + throw new Error( + "v4 initialize: `args.instance.publicKey` does not match `contracts.ntt.instance`" + ); + } + } else if (args.instance) { + throw new Error( + "initialize: `args.instance` was passed but `contracts.ntt.instance` is unset (v3 deployment expected)" + ); + } + const ix = await NTT.createInitializeInstruction( this.program, { @@ -887,7 +986,10 @@ export class SolanaNtt const tx = new Transaction(); tx.feePayer = payer; tx.add(ix); - yield this.createUnsignedTx({ transaction: tx }, "Ntt.Initialize"); + // v4: attach the instance keypair as an extra signer so Anchor's `init` + // can allocate the Instance account at that pubkey. + const signers = args.instance ? [args.instance] : undefined; + yield this.createUnsignedTx({ transaction: tx, signers }, "Ntt.Initialize"); yield* this.initializeOrUpdateLUT({ payer, owner: payer }); } @@ -909,7 +1011,8 @@ export class SolanaNtt payer: args.payer, owner: args.owner, wormholeId: new PublicKey(this.core.address), - } + }, + this.pdas ); // Already up to date if (!ix) return; @@ -1025,14 +1128,18 @@ export class SolanaNtt ) { const sender = new SolanaAddress(payer).unwrap(); - const ix = await NTT.createSetPeerInstruction(this.program, { - payer: sender, - owner: sender, - chain: peer.chain, - address: peer.address.toUniversalAddress().toUint8Array(), - limit: new BN(inboundLimit.toString()), - tokenDecimals, - }); + const ix = await NTT.createSetPeerInstruction( + this.program, + { + payer: sender, + owner: sender, + chain: peer.chain, + address: peer.address.toUniversalAddress().toUint8Array(), + limit: new BN(inboundLimit.toString()), + tokenDecimals, + }, + this.pdas + ); const tx = new Transaction(); tx.feePayer = sender; @@ -1322,7 +1429,9 @@ export class SolanaNtt { payer: senderAddress, vaa: wormholeNTT, - } + }, + this.pdas, + whTransceiver.pdas ); const nttMessage = wormholeNTT.payload.nttManagerPayload; @@ -1340,12 +1449,18 @@ export class SolanaNtt }; let releaseIx = config.mode.locking != null - ? NTT.createReleaseInboundUnlockInstruction(this.program, config, { - ...releaseArgs, - }) - : NTT.createReleaseInboundMintInstruction(this.program, config, { - ...releaseArgs, - }); + ? NTT.createReleaseInboundUnlockInstruction( + this.program, + config, + { ...releaseArgs }, + this.pdas + ) + : NTT.createReleaseInboundMintInstruction( + this.program, + config, + { ...releaseArgs }, + this.pdas + ); const tx = new Transaction(); tx.feePayer = senderAddress; @@ -1386,10 +1501,11 @@ export class SolanaNtt async *setOutboundLimit(limit: bigint, payer: AccountAddress) { const sender = new SolanaAddress(payer).unwrap(); - const ix = await NTT.createSetOutboundLimitInstruction(this.program, { - owner: sender, - limit: new BN(limit.toString()), - }); + const ix = await NTT.createSetOutboundLimitInstruction( + this.program, + { owner: sender, limit: new BN(limit.toString()) }, + this.pdas + ); const tx = new Transaction(); tx.feePayer = sender; @@ -1422,11 +1538,11 @@ export class SolanaNtt payer: AccountAddress ) { const sender = new SolanaAddress(payer).unwrap(); - const ix = await NTT.createSetInboundLimitInstruction(this.program, { - owner: sender, - chain: fromChain, - limit: new BN(limit.toString()), - }); + const ix = await NTT.createSetInboundLimitInstruction( + this.program, + { owner: sender, chain: fromChain, limit: new BN(limit.toString()) }, + this.pdas + ); const tx = new Transaction(); tx.feePayer = sender; @@ -1517,12 +1633,14 @@ export class SolanaNtt ? NTT.createReleaseInboundUnlockInstruction( this.program, config, - releaseArgs + releaseArgs, + this.pdas ) : NTT.createReleaseInboundMintInstruction( this.program, config, - releaseArgs + releaseArgs, + this.pdas )) ); @@ -1580,7 +1698,7 @@ export class SolanaNtt manager: this.program.programId.toBase58(), token: (await this.getConfig()).mint.toBase58(), transceiver: { - wormhole: NTT.transceiverPdas(this.program.programId) + wormhole: NTT.transceiverPdas(this.program.programId, this.instance) .emitterAccount() .toBase58(), },