diff --git a/.github/workflows/sui.yml b/.github/workflows/sui.yml new file mode 100644 index 000000000..1831554ba --- /dev/null +++ b/.github/workflows/sui.yml @@ -0,0 +1,42 @@ +name: Sui CI + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + +# Cancel in-progress runs on new commits to same PR/branch +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + working-directory: ./sui + +jobs: + test: + name: sui move test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Sui CLI via suiup + run: | + SUIUP_COMMIT="10e17f534f437b2e3335d23ed82e3e72d27ca02c" + SUIUP_SHA256="fa328e7ff0c7219e4fb046580bd5dd44125507480bbce45393a339d52e6b4aab" + curl -sSfL "https://raw.githubusercontent.com/MystenLabs/suiup/${SUIUP_COMMIT}/install.sh" -o /tmp/suiup-install.sh + echo "${SUIUP_SHA256} /tmp/suiup-install.sh" | sha256sum -c - + sh /tmp/suiup-install.sh + export PATH="$HOME/.local/bin:$PATH" + suiup install -y sui@1.63.2 + echo "$HOME/.local/bin" >> $GITHUB_PATH + working-directory: . + + - name: Check Sui version + run: sui --version + + - name: Run Move tests + run: make test diff --git a/cli/src/commands/index.ts b/cli/src/commands/index.ts index 6ea54ab61..05957ac16 100644 --- a/cli/src/commands/index.ts +++ b/cli/src/commands/index.ts @@ -10,6 +10,7 @@ export { createPushCommand } from "./push"; export { createSetMintAuthorityCommand } from "./set-mint-authority"; export { createSolanaCommand } from "./solana"; export { createStatusCommand } from "./status"; +export { createSuiCommand } from "./sui"; export { createTransferOwnershipCommand } from "./transfer-ownership"; export { createUpdateCommand } from "./update"; export { createUpgradeCommand } from "./upgrade"; diff --git a/cli/src/commands/sui.ts b/cli/src/commands/sui.ts new file mode 100644 index 000000000..865bb9b68 --- /dev/null +++ b/cli/src/commands/sui.ts @@ -0,0 +1,330 @@ +import { execSync } from "child_process"; +import path from "path"; +import fs from "fs"; +import type { + WormholeConfigOverrides, + Network, +} from "@wormhole-foundation/sdk-connect"; +import { Wormhole, isNetwork, networks } from "@wormhole-foundation/sdk"; +import sui from "@wormhole-foundation/sdk/platforms/sui"; + +import { colors } from "../colors.js"; +import { getSigner } from "../signers/getSigner"; +import { loadConfig } from "../deployments"; +import { withSuiEnv } from "../sui/helpers"; +import { + discoverNttAddresses, + writePublishedTomls, + buildAndPublishGovernance, + transferGovernance, +} from "../suiGovernance"; + +/** + * Resolve network and NTT state ID for Sui governance commands. + * Reads from deployment.json (if it exists) and allows CLI overrides. + */ +function resolveSuiDeployment(argv: { + path?: string; + network?: string; + "state-id"?: string; +}): { network: Network; stateId: string } { + const deploymentPath = argv.path || "deployment.json"; + let fileNetwork: string | undefined; + let fileStateId: string | undefined; + + if (fs.existsSync(deploymentPath)) { + const config = loadConfig(deploymentPath); + fileNetwork = config.network; + fileStateId = config.chains?.Sui?.manager; + } + + const network = (argv.network || fileNetwork) as string | undefined; + const stateId = argv["state-id"] || fileStateId; + + if (!network || !isNetwork(network)) { + console.error( + "Could not determine network. Provide --network or ensure deployment.json exists." + ); + process.exit(1); + } + + if (network === "Devnet") { + console.error("Devnet is not supported for governance deployment"); + process.exit(1); + } + + if (!stateId) { + console.error( + "Could not determine NTT State ID. Provide --state-id or ensure deployment.json has chains.Sui.manager." + ); + process.exit(1); + } + + return { network, stateId }; +} + +export function createSuiCommand(overrides: WormholeConfigOverrides) { + return { + command: ["sui"] as const, + describe: "Sui commands", + builder: (yargs: any) => { + return yargs + .command( + "deploy-governance", + "Deploy the NTT governance package against an existing NTT deployment", + (yargs: any) => + yargs + .option("path", { + describe: "Path to deployment.json", + type: "string", + default: "deployment.json", + }) + .option("network", { + alias: "n", + describe: "Network (inferred from deployment.json if omitted)", + choices: networks, + type: "string", + }) + .option("state-id", { + describe: + "NTT State object ID (inferred from deployment.json if omitted)", + type: "string", + }) + .option("gas-budget", { + describe: "Gas budget for transactions", + type: "number", + default: 500000000, + }) + .option("package-path", { + describe: + "Path to project root containing sui/packages/ (default: cwd)", + type: "string", + }) + .option("transfer", { + describe: + "Also transfer AdminCap + UpgradeCap to the governance contract", + type: "boolean", + default: false, + }) + .option("admin-cap", { + describe: "AdminCap object ID override (only with --transfer)", + type: "string", + }) + .option("upgrade-cap", { + describe: + "UpgradeCap object ID override (only with --transfer)", + type: "string", + }), + async (argv: any) => { + const { network, stateId } = resolveSuiDeployment(argv); + const gasBudget = argv["gas-budget"] ?? 500000000; + const packagePath = argv["package-path"] || "."; + const doTransfer = argv["transfer"] ?? false; + + console.log(colors.blue("Deploying NTT Governance on Sui")); + console.log(`NTT State: ${stateId}`); + console.log(`Network: ${network}`); + console.log(`Gas budget: ${gasBudget}`); + + const wh = new Wormhole(network, [sui.Platform], overrides); + const ch = wh.getChain("Sui"); + const pwd = path.resolve(packagePath); + + await withSuiEnv(pwd, ch, async () => { + const signer = await getSigner(ch, "privateKey"); + const suiSigner = signer.signer as any; + const client = suiSigner.client; + + // ── Step 1: Discover addresses from State object ── + + console.log("Discovering package addresses from State object..."); + const addresses = await discoverNttAddresses(client, stateId); + console.log(`NTT Package: ${addresses.nttPackageId}`); + console.log( + `NTT Common Package: ${addresses.nttCommonPackageId}` + ); + + // ── Step 2: Generate Published.toml files ── + + const chainIdentifier = execSync("sui client chain-identifier", { + encoding: "utf8", + env: process.env, + }).trim(); + console.log(`Chain identifier: ${chainIdentifier}`); + + const buildEnv = network === "Mainnet" ? "mainnet" : "testnet"; + const packagesPath = `${pwd}/sui/packages`; + + const cleanupPublishedTomls = writePublishedTomls( + packagesPath, + buildEnv, + chainIdentifier, + addresses.nttPackageId, + addresses.nttCommonPackageId + ); + + try { + // ── Step 3: Build, publish, and make immutable ── + + const { govPackageId, govStateId } = + await buildAndPublishGovernance( + client, + suiSigner._signer, + packagesPath, + buildEnv, + gasBudget + ); + + console.log( + colors.green( + `Governance package published at: ${govPackageId}` + ) + ); + console.log(`GovernanceState created at: ${govStateId}`); + + // ── Step 4: Optionally transfer caps ── + + if (doTransfer) { + await transferGovernance( + client, + suiSigner._signer, + stateId, + govStateId, + { + adminCapOverride: argv["admin-cap"], + upgradeCapOverride: argv["upgrade-cap"], + gasBudget, + govPackageId, + } + ); + console.log( + colors.green( + "Caps received into GovernanceState successfully" + ) + ); + } + + // ── Summary ── + + console.log( + "\n" + + colors.green( + "Governance deployment completed successfully!" + ) + ); + console.log(`Governance Package ID: ${govPackageId}`); + console.log(`GovernanceState ID: ${govStateId}`); + console.log(`Package immutability: enforced`); + if (doTransfer) { + console.log( + `AdminCap + UpgradeCap transferred to GovernanceState` + ); + } else { + console.log( + colors.yellow("\nTo transfer caps to governance, run:") + ); + console.log(` ntt sui transfer-governance ${govStateId}`); + } + } finally { + cleanupPublishedTomls(); + } + }); + } + ) + .command( + "transfer-governance ", + "Transfer AdminCap + UpgradeCap to a deployed GovernanceState", + (yargs: any) => + yargs + .positional("governance-state-id", { + describe: "GovernanceState object ID", + type: "string", + demandOption: true, + }) + .option("path", { + describe: "Path to deployment.json", + type: "string", + default: "deployment.json", + }) + .option("network", { + alias: "n", + describe: "Network (inferred from deployment.json if omitted)", + choices: networks, + type: "string", + }) + .option("state-id", { + describe: + "NTT State object ID (inferred from deployment.json if omitted)", + type: "string", + }) + .option("admin-cap", { + describe: + "AdminCap object ID (auto-discovered from State if omitted)", + type: "string", + }) + .option("upgrade-cap", { + describe: + "UpgradeCap object ID (auto-discovered from State if omitted)", + type: "string", + }) + .option("gas-budget", { + describe: "Gas budget for transactions", + type: "number", + default: 500000000, + }) + .option("package-path", { + describe: + "Path to project root containing sui/packages/ (default: cwd)", + type: "string", + }) + .option("skip-verification", { + describe: + "Skip verifying that the governance contract targets the correct NTT", + type: "boolean", + default: false, + }), + async (argv: any) => { + const { network, stateId } = resolveSuiDeployment(argv); + const govStateId = argv["governance-state-id"]!; + const gasBudget = argv["gas-budget"] ?? 500000000; + const packagePath = argv["package-path"] || "."; + + console.log(colors.blue("Transferring caps to GovernanceState")); + console.log(`NTT State: ${stateId}`); + console.log(`GovernanceState: ${govStateId}`); + console.log(`Network: ${network}`); + + const wh = new Wormhole(network, [sui.Platform], overrides); + const ch = wh.getChain("Sui"); + const pwd = path.resolve(packagePath); + + await withSuiEnv(pwd, ch, async () => { + const signer = await getSigner(ch, "privateKey"); + const suiSigner = signer.signer as any; + + await transferGovernance( + suiSigner.client, + suiSigner._signer, + stateId, + govStateId, + { + adminCapOverride: argv["admin-cap"], + upgradeCapOverride: argv["upgrade-cap"], + gasBudget, + skipVerification: argv["skip-verification"], + } + ); + + console.log( + colors.green( + "\nCaps transferred to GovernanceState successfully!" + ) + ); + }); + } + ) + .demandCommand(); + }, + handler: () => {}, + }; +} diff --git a/cli/src/error.ts b/cli/src/error.ts index 088d9df7f..7e0564014 100644 --- a/cli/src/error.ts +++ b/cli/src/error.ts @@ -5,16 +5,14 @@ import { chainToPlatform } from "@wormhole-foundation/sdk-base"; const RPC_ERROR_KEYWORDS = [ "jsonrpc", "network error", - "rpc", - "connection", "unable to connect", "404 not found", "status code: 404", - "status code", - "not found", "could not connect", "failed to fetch", - "connect to", + "econnrefused", + "enotfound", + "etimedout", ] as const; function extractErrorDetails(error: unknown): { diff --git a/cli/src/index.ts b/cli/src/index.ts index b0ab6f6be..a68b93dae 100755 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -24,6 +24,7 @@ import { createSetMintAuthorityCommand, createSolanaCommand, createStatusCommand, + createSuiCommand, createTransferOwnershipCommand, createUpdateCommand, createUpgradeCommand, @@ -72,6 +73,7 @@ yargs(hideBin(process.argv)) .command(createSolanaCommand(overrides)) .command(createHypeCommand(overrides)) .command(createManualCommand(overrides)) + .command(createSuiCommand(overrides)) .help() .strict() .demandCommand() diff --git a/cli/src/sui/deploy.ts b/cli/src/sui/deploy.ts index 7da372900..0d8fb0492 100644 --- a/cli/src/sui/deploy.ts +++ b/cli/src/sui/deploy.ts @@ -1,4 +1,6 @@ import { execSync } from "child_process"; +import fs from "fs"; +import path from "path"; import { signSendWait, toUniversal, @@ -16,10 +18,17 @@ import { getSigner, type SignerType } from "../signers/getSigner"; import { handleDeploymentError } from "../error"; import { ensureNttRoot } from "../validation"; import type { SuiDeploymentResult } from "../commands/shared"; +import { promptLine } from "../prompts.js"; import { withSuiEnv, - updateMoveTomlForNetwork, performPackageUpgradeInPTB, + buildSuiPackage, + publishSuiPackage, + findCreatedObject, + parsePublishedToml, + movePublishedTomlToMainTree, + readSetupProgress, + saveSetupProgress, } from "./helpers"; export async function upgradeSui( @@ -35,8 +44,11 @@ export async function upgradeSui( // Setup Sui environment and execute upgrade await withSuiEnv(pwd, ctx, async () => { + // Determine build environment for Sui 1.63+ package system + const buildEnv = ctx.network === "Mainnet" ? "mainnet" : "testnet"; + // Build the updated packages - console.log("Building updated packages..."); + console.log(`Building updated packages for ${buildEnv} environment...`); const packagesToBuild = ["ntt_common", "ntt", "wormhole_transceiver"]; for (const packageName of packagesToBuild) { @@ -44,7 +56,7 @@ export async function upgradeSui( console.log(`Building package: ${packageName}`); try { - execSync(`sui move build`, { + execSync(`sui move build -e ${buildEnv}`, { cwd: packagePath, stdio: "inherit", env: process.env, @@ -89,7 +101,7 @@ export async function upgradeSui( const packagePath = `${pwd}/${pkg.path}`; console.log(`Building package at: ${packagePath}`); - execSync(`sui move build`, { + execSync(`sui move build -e ${buildEnv}`, { cwd: packagePath, stdio: "pipe", env: process.env, @@ -146,169 +158,147 @@ export async function deploySui( return await withSuiEnv(pwd, ch, async () => { const signer = await getSigner(ch, signerType); - // Build the Move packages - console.log("Building Move packages..."); const packagesPath = `${pwd}/${finalPackagePath}/packages`; + const mainTreePackagesPath = `${finalPackagePath}/packages`; + const suiPackageNames = ["ntt_common", "ntt", "wormhole_transceiver"]; - // Detect network type and update Move.toml files accordingly + // Determine build environment for Sui 1.63+ package system const networkType = ch.network; - const { restore } = updateMoveTomlForNetwork(packagesPath, networkType); + const buildEnv = networkType === "Mainnet" ? "mainnet" : "testnet"; + const progressPath = `${mainTreePackagesPath}/.sui-deploy-progress.${buildEnv}.json`; - // Ensure we restore files if deployment fails - try { - // Build ntt_common first (dependency) - try { - console.log("Building ntt_common package..."); - execSync(`cd ${packagesPath}/ntt_common && sui move build`, { - stdio: "inherit", - env: process.env, - }); - } catch (e) { - console.error("Failed to build ntt_common package"); - throw e; - } + // Check for existing Published.toml files from a previous deployment + const existingPublished = suiPackageNames.filter((p) => + fs.existsSync(`${mainTreePackagesPath}/${p}/Published.toml`) + ); - // Build ntt package - try { - console.log("Building ntt package..."); - execSync(`cd ${packagesPath}/ntt && sui move build`, { - stdio: "inherit", - env: process.env, - }); - } catch (e) { - console.error("Failed to build ntt package"); - throw e; - } - - // Build wormhole_transceiver package - try { - console.log("Building wormhole_transceiver package..."); - execSync(`cd ${packagesPath}/wormhole_transceiver && sui move build`, { - stdio: "inherit", - env: process.env, - }); - } catch (e) { - console.error("Failed to build wormhole_transceiver package"); - throw e; - } - - // Deploy packages in order - console.log("Deploying packages..."); - - // 1. Deploy ntt_common - console.log("Publishing ntt_common package..."); - const nttCommonResult = execSync( - `cd ${packagesPath}/ntt_common && sui client publish --gas-budget ${finalGasBudget} --json`, - { - encoding: "utf8", - env: process.env, - } + let skipPublish = false; + if (existingPublished.length > 0) { + console.log( + colors.yellow( + "\nFound existing Published.toml from a previous deployment:" + ) ); - - const nttCommonDeploy = JSON.parse(nttCommonResult); - if (!nttCommonDeploy.objectChanges) { - throw new Error("Failed to deploy ntt_common package"); - } - - const nttCommonPackageId = nttCommonDeploy.objectChanges.find( - (change: any) => change.type === "published" - )?.packageId; - - if (!nttCommonPackageId) { - throw new Error("Could not find ntt_common package ID"); + for (const p of existingPublished) { + console.log(` ${mainTreePackagesPath}/${p}/Published.toml`); } - - console.log(`ntt_common deployed at: ${nttCommonPackageId}`); - - // 2. Deploy ntt package - console.log("Publishing ntt package..."); - const nttResult = execSync( - `cd ${packagesPath}/ntt && sui client publish --gas-budget ${finalGasBudget} --json`, - { - encoding: "utf8", - env: process.env, - } + console.log(); + console.log( + " 1) Continue setup - packages already on-chain, re-run initialization" ); - - const nttDeploy = JSON.parse(nttResult); - if (!nttDeploy.objectChanges) { - throw new Error("Failed to deploy ntt package"); - } - - const nttPackageId = nttDeploy.objectChanges.find( - (change: any) => change.type === "published" - )?.packageId; - - if (!nttPackageId) { - throw new Error("Could not find ntt package ID"); + console.log( + " 2) Redeploy fresh - delete Published.toml and publish new packages" + ); + const choice = await promptLine("Choose [1/2]: "); + + if (choice.trim() === "2") { + // Delete from main tree + any worktree symlinks/files + for (const p of suiPackageNames) { + const mainPath = `${mainTreePackagesPath}/${p}/Published.toml`; + const wtPath = `${packagesPath}/${p}/Published.toml`; + try { + fs.unlinkSync(mainPath); + } catch {} + try { + fs.unlinkSync(wtPath); + } catch {} + } + // Also delete progress file + try { + fs.unlinkSync(progressPath); + } catch {} + console.log("Deleted Published.toml files. Redeploying..."); + } else { + // Re-create symlinks from worktree → main tree + for (const p of suiPackageNames) { + const mainPath = `${mainTreePackagesPath}/${p}/Published.toml`; + const wtPath = `${packagesPath}/${p}/Published.toml`; + if (fs.existsSync(mainPath)) { + fs.rmSync(wtPath, { force: true }); + fs.symlinkSync(path.resolve(mainPath), path.resolve(wtPath)); + } + } + skipPublish = true; + console.log("Continuing setup with existing packages..."); } + } - console.log(`ntt deployed at: ${nttPackageId}`); + try { + // ── Build + Publish phase (skipped when continuing a previous deployment) ── + if (!skipPublish) { + console.log("Building Move packages..."); + console.log(`Building for ${buildEnv} environment...`); + for (const pkg of suiPackageNames) { + buildSuiPackage(packagesPath, pkg, buildEnv); + } - // 3. Deploy wormhole_transceiver package - console.log("Publishing wormhole_transceiver package..."); - const whTransceiverResult = execSync( - `cd ${packagesPath}/wormhole_transceiver && sui client publish --gas-budget ${finalGasBudget} --json`, - { - encoding: "utf8", - env: process.env, + console.log("Deploying packages..."); + for (const pkg of suiPackageNames) { + publishSuiPackage(packagesPath, pkg, finalGasBudget); } - ); - const whTransceiverDeploy = JSON.parse(whTransceiverResult); - if (!whTransceiverDeploy.objectChanges) { - throw new Error("Failed to deploy wormhole_transceiver package"); + // Move Published.toml files to main tree and create symlinks + movePublishedTomlToMainTree( + packagesPath, + mainTreePackagesPath, + suiPackageNames + ); } - const whTransceiverPackageId = whTransceiverDeploy.objectChanges.find( - (change: any) => change.type === "published" - )?.packageId; - - if (!whTransceiverPackageId) { - throw new Error("Could not find wormhole_transceiver package ID"); - } + // ── Unified setup: both paths read from Published.toml ── - console.log( - `wormhole_transceiver deployed at: ${whTransceiverPackageId}` + // Parse Published.toml for package IDs and upgrade caps + const nttCommonInfo = parsePublishedToml( + `${mainTreePackagesPath}/ntt_common/Published.toml`, + buildEnv + ); + const nttInfo = parsePublishedToml( + `${mainTreePackagesPath}/ntt/Published.toml`, + buildEnv + ); + const whTransceiverInfo = parsePublishedToml( + `${mainTreePackagesPath}/wormhole_transceiver/Published.toml`, + buildEnv ); - // Initialize NTT manager - console.log("Initializing NTT manager..."); - - // 1. Get the deployer caps from deployment results - const nttDeployerCapId = nttDeploy.objectChanges.find( - (change: any) => - change.type === "created" && - change.objectType?.includes("setup::DeployerCap") - )?.objectId; - - if (!nttDeployerCapId) { - throw new Error("Could not find NTT DeployerCap object ID"); - } - - const whTransceiverDeployerCapId = whTransceiverDeploy.objectChanges.find( - (change: any) => - change.type === "created" && - change.objectType?.includes("DeployerCap") - )?.objectId; - - if (!whTransceiverDeployerCapId) { - throw new Error( - "Could not find Wormhole Transceiver DeployerCap object ID" - ); - } + const nttCommonPackageId = nttCommonInfo.packageId; + const nttPackageId = nttInfo.packageId; + const whTransceiverPackageId = whTransceiverInfo.packageId; + const nttUpgradeCapId = nttInfo.upgradeCap; + + console.log(`ntt_common package: ${nttCommonPackageId}`); + console.log(`ntt package: ${nttPackageId}`); + console.log(`wormhole_transceiver package: ${whTransceiverPackageId}`); + console.log(`ntt upgrade cap: ${nttUpgradeCapId}`); + + // Query chain for DeployerCaps to determine which setup steps remain + const suiSigner = signer.signer as any; + const client = suiSigner.client; + const ownerAddress = signer.address.address.toString(); + + const nttDeployerCaps = await client.getOwnedObjects({ + owner: ownerAddress, + filter: { + StructType: `${nttPackageId}::setup::DeployerCap`, + }, + options: { showType: true }, + }); + const nttDeployerCapId = nttDeployerCaps.data?.[0]?.data?.objectId; - // 2. Get the upgrade cap from NTT deployment - const nttUpgradeCapId = nttDeploy.objectChanges.find( - (change: any) => - change.type === "created" && change.objectType?.includes("UpgradeCap") - )?.objectId; + const whDeployerCaps = await client.getOwnedObjects({ + owner: ownerAddress, + filter: { + StructType: `${whTransceiverPackageId}::wormhole_transceiver::DeployerCap`, + }, + options: { showType: true }, + }); + const whTransceiverDeployerCapId = + whDeployerCaps.data?.[0]?.data?.objectId; - if (!nttUpgradeCapId) { - throw new Error("Could not find NTT UpgradeCap object ID"); - } + // Load setup progress from previous run (if any) + const progress = readSetupProgress(progressPath); - // 3. Get Wormhole core bridge state + // Get Wormhole core bridge state let wormholeStateObjectId: string | undefined; if (wormholeStateId) { wormholeStateObjectId = wormholeStateId; @@ -316,7 +306,6 @@ export async function deploySui( `Using provided Wormhole State ID: ${wormholeStateObjectId}` ); } else { - // Try to get the Wormhole state from the SDK configuration try { console.log( "No wormhole state ID provided, looking up from SDK configuration..." @@ -343,157 +332,123 @@ export async function deploySui( } } - // 4. Call setup::complete_burning or setup::complete_locking to initialize the NTT manager state - const chainId = ch.config.chainId; // Get numeric chain ID from config - const modeArg = mode === "locking" ? "Locking" : "Burning"; - - console.log( - `Completing NTT setup with mode: ${modeArg}, chain ID: ${chainId}` - ); - - // Build the transaction using Sui SDK - const tx = new Transaction(); + // ── Step 1: complete_burning / complete_locking ── + let nttStateId = progress.nttStateId; + let nttAdminCapId = progress.nttAdminCapId; - if (mode === "burning") { - // Call setup::complete_burning (which now requires treasury cap) - console.log("Attempting to call setup::complete_burning..."); - console.log("Package ID:", nttPackageId); + if (nttDeployerCapId) { + console.log("Initializing NTT manager..."); + const chainId = ch.config.chainId; + const modeArg = mode === "locking" ? "Locking" : "Burning"; console.log( - "Function target:", - `${nttPackageId}::setup::complete_burning` + `Completing NTT setup with mode: ${modeArg}, chain ID: ${chainId}` ); - console.log("Token type:", token); - // For burning mode, we need a treasury cap - if (!treasuryCapId) { - throw new Error( - "Burning mode deployment requires a treasury cap. Please provide --sui-treasury-cap " + const tx = new Transaction(); + + if (mode === "burning") { + console.log("Attempting to call setup::complete_burning..."); + if (!treasuryCapId) { + throw new Error( + "Burning mode deployment requires a treasury cap. Please provide --sui-treasury-cap " + ); + } + console.log("Treasury Cap ID:", treasuryCapId); + + const [adminCap, upgradeCapNtt] = tx.moveCall({ + target: `${nttPackageId}::setup::complete_burning`, + typeArguments: [token], + arguments: [ + tx.object(nttDeployerCapId), + tx.object(nttUpgradeCapId), + tx.pure.u16(chainId), + tx.object(treasuryCapId), + ], + }); + tx.transferObjects( + [adminCap, upgradeCapNtt], + tx.pure.address(ownerAddress) + ); + } else { + console.log("Attempting to call setup::complete_locking..."); + const [adminCap, upgradeCapNtt] = tx.moveCall({ + target: `${nttPackageId}::setup::complete_locking`, + typeArguments: [token], + arguments: [ + tx.object(nttDeployerCapId), + tx.object(nttUpgradeCapId), + tx.pure.u16(chainId), + ], + }); + tx.transferObjects( + [adminCap, upgradeCapNtt], + tx.pure.address(ownerAddress) ); } - console.log("Treasury Cap ID:", treasuryCapId); + tx.setGasBudget(finalGasBudget); - const [adminCap, upgradeCapNtt] = tx.moveCall({ - target: `${nttPackageId}::setup::complete_burning`, - typeArguments: [token], - arguments: [ - tx.object(nttDeployerCapId), - tx.object(nttUpgradeCapId), - tx.pure.u16(chainId), - tx.object(treasuryCapId), - ], + const setupResult = await client.signAndExecuteTransaction({ + signer: suiSigner._signer, + transaction: tx, + options: { showEffects: true, showObjectChanges: true }, }); - // Transfer both capability objects to the transaction sender - tx.transferObjects( - [adminCap, upgradeCapNtt], - tx.pure.address(signer.address.address.toString()) - ); - } else { - // Call setup::complete_locking - console.log("Attempting to call setup::complete_locking..."); - console.log("Package ID:", nttPackageId); + if (!setupResult.objectChanges) { + throw new Error("Failed to complete NTT setup"); + } + console.log( - "Function target:", - `${nttPackageId}::setup::complete_locking` + "Object changes:", + JSON.stringify(setupResult.objectChanges, null, 2) ); - console.log("Token type:", token); - const [adminCap, upgradeCapNtt] = tx.moveCall({ - target: `${nttPackageId}::setup::complete_locking`, - typeArguments: [token], // Use the original token format - arguments: [ - tx.object(nttDeployerCapId), - tx.object(nttUpgradeCapId), - tx.pure.u16(chainId), - ], - }); - - // Transfer both capability objects to the transaction sender - tx.transferObjects( - [adminCap, upgradeCapNtt], - tx.pure.address(signer.address.address.toString()) + nttStateId = findCreatedObject( + setupResult.objectChanges, + "state::State", + true ); - } - - // Set gas budget - tx.setGasBudget(finalGasBudget); - - // Execute the transaction using the signer's client - // TODO: clean this up - const suiSigner = signer.signer as any; // Cast to access internal client - const setupResult = await suiSigner.client.signAndExecuteTransaction({ - signer: suiSigner._signer, // Access the underlying Ed25519Keypair - transaction: tx, - options: { - showEffects: true, - showObjectChanges: true, - }, - }); - - const setupDeploy = setupResult; - if (!setupDeploy.objectChanges) { - throw new Error("Failed to complete NTT setup"); - } - - // Log all object changes and effects to debug - console.log( - "Transaction effects:", - JSON.stringify(setupDeploy.effects, null, 2) - ); - console.log( - "Object changes:", - JSON.stringify(setupDeploy.objectChanges, null, 2) - ); + if (!nttStateId) { + throw new Error("Could not find NTT State object ID"); + } - // Find the shared State object - const nttStateId = setupDeploy.objectChanges.find( - (change: any) => - change.type === "created" && - change.objectType?.includes("state::State") && - change.owner?.Shared - )?.objectId; - - if (!nttStateId) { - console.log("Looking for any shared objects..."); - const sharedObjects = setupDeploy.objectChanges.filter( - (change: any) => change.owner === "Shared" + nttAdminCapId = findCreatedObject( + setupResult.objectChanges, + "state::AdminCap" ); - console.log("Shared objects:", JSON.stringify(sharedObjects, null, 2)); - throw new Error("Could not find NTT State object ID"); - } - // Find the NTT AdminCap object ID for future reference - const nttAdminCapId = setupDeploy.objectChanges.find( - (change: any) => - change.type === "created" && - change.objectType?.includes("state::AdminCap") - )?.objectId; + console.log(`NTT State created at: ${nttStateId}`); + if (nttAdminCapId) { + console.log(`NTT AdminCap created at: ${nttAdminCapId}`); + } - if (nttAdminCapId) { - console.log(`NTT AdminCap created at: ${nttAdminCapId}`); + // Save progress + progress.nttStateId = nttStateId; + progress.nttAdminCapId = nttAdminCapId; + saveSetupProgress(progressPath, progress); + } else if (nttStateId) { + console.log(`NTT setup already completed (State: ${nttStateId})`); + } else { + throw new Error( + "NTT DeployerCap not found and no previous setup progress. " + + "The deployment may be in an inconsistent state." + ); } - console.log(`NTT State created at: ${nttStateId}`); + // ── Step 2: wormhole_transceiver::complete ── + let transceiverStateId = progress.transceiverStateId; + let whTransceiverAdminCapId = progress.whTransceiverAdminCapId; - // 5. Complete wormhole transceiver setup - let transceiverStateId: string | undefined; - let whTransceiverAdminCapId: string | undefined; - - if (wormholeStateObjectId) { + if (wormholeStateObjectId && whTransceiverDeployerCapId) { console.log("Completing Wormhole Transceiver setup..."); - // Build the transceiver setup transaction const transceiverTx = new Transaction(); console.log(` Package: ${whTransceiverPackageId}`); - console.log(` Module: wormhole_transceiver`); - console.log(` Function: complete`); console.log(` Type args: ${nttPackageId}::auth::ManagerAuth`); console.log(` Deployer cap: ${whTransceiverDeployerCapId}`); console.log(` Wormhole state: ${wormholeStateObjectId}`); - // Call wormhole_transceiver::complete and transfer the returned AdminCap const [adminCap] = transceiverTx.moveCall({ target: `${whTransceiverPackageId}::wormhole_transceiver::complete`, typeArguments: [`${nttPackageId}::auth::ManagerAuth`], @@ -503,163 +458,144 @@ export async function deploySui( ], }); - // Transfer the AdminCap to the signer to avoid UnusedValueWithoutDrop - transceiverTx.transferObjects( - [adminCap], - signer.address.address.toString() - ); - + transceiverTx.transferObjects([adminCap], ownerAddress); transceiverTx.setGasBudget(finalGasBudget); - // Execute the transceiver setup transaction using the same method as NTT setup - try { - // Wait a moment to allow the network to settle after NTT setup - await new Promise((resolve) => setTimeout(resolve, 1000)); + // Wait for network to settle after NTT setup + await new Promise((resolve) => setTimeout(resolve, 1000)); - const suiSigner = signer.signer as any; // Cast to access internal client - const transceiverSetupResult = - await suiSigner.client.signAndExecuteTransaction({ - signer: suiSigner._signer, // Access the underlying Ed25519Keypair - transaction: transceiverTx, - options: { - showEffects: true, - showObjectChanges: true, - }, - }); - - const transceiverSetupDeploy = transceiverSetupResult; - if (!transceiverSetupDeploy.objectChanges) { - throw new Error("Failed to complete Wormhole Transceiver setup"); - } + const transceiverResult = await client.signAndExecuteTransaction({ + signer: suiSigner._signer, + transaction: transceiverTx, + options: { showEffects: true, showObjectChanges: true }, + }); - console.log( - JSON.stringify(transceiverSetupDeploy.objectChanges, null, 2) + if (!transceiverResult.objectChanges) { + throw new Error("Failed to complete Wormhole Transceiver setup"); + } + + console.log(JSON.stringify(transceiverResult.objectChanges, null, 2)); + + transceiverStateId = findCreatedObject( + transceiverResult.objectChanges, + "::wormhole_transceiver::State", + true + ); + if (!transceiverStateId) { + throw new Error( + "Could not find Wormhole Transceiver State object ID" ); + } - // Find the transceiver state - look for State object that is shared - transceiverStateId = transceiverSetupDeploy.objectChanges.find( - (change: any) => - change.type === "created" && - change.objectType?.includes("::wormhole_transceiver::State") && - change.owner?.Shared - )?.objectId; - - if (!transceiverStateId) { - console.log("Looking for any State object (not just shared)..."); - const stateObject = transceiverSetupDeploy.objectChanges.find( - (change: any) => - change.type === "created" && - change.objectType?.includes("State") - ); - if (stateObject) { - console.log( - "Found State object:", - JSON.stringify(stateObject, null, 2) - ); - } - throw new Error( - "Could not find Wormhole Transceiver State object ID" - ); - } + console.log( + `Wormhole Transceiver State created at: ${transceiverStateId}` + ); + whTransceiverAdminCapId = findCreatedObject( + transceiverResult.objectChanges, + "::wormhole_transceiver::AdminCap" + ); + + if (whTransceiverAdminCapId) { console.log( - `Wormhole Transceiver State created at: ${transceiverStateId}` + `Wormhole Transceiver AdminCap created at: ${whTransceiverAdminCapId}` ); + } - // Find the AdminCap object ID for future reference - whTransceiverAdminCapId = transceiverSetupDeploy.objectChanges.find( - (change: any) => - change.type === "created" && - change.objectType?.includes("::wormhole_transceiver::AdminCap") - )?.objectId; + // Save progress + progress.transceiverStateId = transceiverStateId; + progress.whTransceiverAdminCapId = whTransceiverAdminCapId; + saveSetupProgress(progressPath, progress); + } else if (transceiverStateId) { + console.log( + `Wormhole Transceiver setup already completed (State: ${transceiverStateId})` + ); + } else if (!wormholeStateObjectId) { + console.log( + "Skipping Wormhole Transceiver setup (no wormhole state available)..." + ); + } else { + throw new Error( + "Wormhole Transceiver DeployerCap not found and no previous setup progress. " + + "The deployment may be in an inconsistent state." + ); + } - if (whTransceiverAdminCapId) { - console.log( - `Wormhole Transceiver AdminCap created at: ${whTransceiverAdminCapId}` - ); - } + // ── Step 3: Register transceiver with NTT manager ── + if ( + nttAdminCapId && + transceiverStateId && + !progress.transceiverRegistered + ) { + console.log("Registering wormhole transceiver with NTT manager..."); - // 6. Register the wormhole transceiver with the NTT manager - if (nttAdminCapId && transceiverStateId) { - console.log("Registering wormhole transceiver with NTT manager..."); + const registerTx = new Transaction(); - const registerTx = new Transaction(); + console.log(` NTT State: ${nttStateId}`); + console.log(` NTT AdminCap: ${nttAdminCapId}`); + console.log( + ` Transceiver Type: ${whTransceiverPackageId}::wormhole_transceiver::TransceiverAuth` + ); - console.log(` NTT State: ${nttStateId}`); - console.log(` NTT AdminCap: ${nttAdminCapId}`); - console.log( - ` Transceiver Type: ${whTransceiverPackageId}::wormhole_transceiver::TransceiverAuth` - ); + registerTx.moveCall({ + target: `${nttPackageId}::state::register_transceiver`, + typeArguments: [ + `${whTransceiverPackageId}::wormhole_transceiver::TransceiverAuth`, + token, + ], + arguments: [ + registerTx.object(nttStateId!), + registerTx.object(transceiverStateId), + registerTx.object(nttAdminCapId), + ], + }); - // Call state::register_transceiver to register the wormhole transceiver - registerTx.moveCall({ - target: `${nttPackageId}::state::register_transceiver`, - typeArguments: [ - `${whTransceiverPackageId}::wormhole_transceiver::TransceiverAuth`, // Transceiver type - token, // Token type - ], - arguments: [ - registerTx.object(nttStateId), // NTT state (mutable) - registerTx.object(transceiverStateId), - registerTx.object(nttAdminCapId), // AdminCap for authorization - ], - }); - - registerTx.setGasBudget(finalGasBudget); - - try { - await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait for network - - const registerResult = - await suiSigner.client.signAndExecuteTransaction({ - signer: suiSigner._signer, - transaction: registerTx, - options: { - showEffects: true, - showObjectChanges: true, - }, - }); - - if (registerResult.effects?.status?.status !== "success") { - throw new Error( - `Registration failed: ${JSON.stringify( - registerResult.effects?.status - )}` - ); - } - - console.log( - "✅ Wormhole transceiver successfully registered with NTT manager" - ); - } catch (error) { - console.error( - "❌ Failed to register wormhole transceiver with NTT manager:", - error - ); - // Don't throw here, let deployment continue, but warn the user - console.warn( - "⚠️ Deployment completed but transceiver registration failed. You may need to register it manually." - ); - } - } else { - console.warn( - "⚠️ Skipping transceiver registration: missing NTT AdminCap or transceiver state ID" + registerTx.setGasBudget(finalGasBudget); + + try { + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const registerResult = await client.signAndExecuteTransaction({ + signer: suiSigner._signer, + transaction: registerTx, + options: { showEffects: true, showObjectChanges: true }, + }); + + if (registerResult.effects?.status?.status !== "success") { + throw new Error( + `Registration failed: ${JSON.stringify( + registerResult.effects?.status + )}` ); } + + console.log( + "Wormhole transceiver successfully registered with NTT manager" + ); + progress.transceiverRegistered = true; + saveSetupProgress(progressPath, progress); } catch (error) { - console.error("Wormhole Transceiver setup failed:", error); - console.error("Error details:", JSON.stringify(error, null, 2)); - throw error; + console.error( + "Failed to register wormhole transceiver with NTT manager:", + error + ); + console.warn( + "Deployment completed but transceiver registration failed. You may need to register it manually." + ); } - } else { - console.log( - "Skipping Wormhole Transceiver setup (no wormhole state available)..." + } else if (progress.transceiverRegistered) { + console.log("Transceiver already registered with NTT manager."); + } else if (!nttAdminCapId) { + throw new Error( + "Cannot register transceiver: missing NTT AdminCap ID in progress/results." ); - console.log( - "Note: Wormhole state not found in SDK configuration. To manually specify, use --sui-wormhole-state parameter." + } else if (!transceiverStateId) { + console.warn( + "Skipping transceiver registration: no transceiver state available" ); } + // ── Done ── console.log(colors.green("Sui NTT deployment completed successfully!")); console.log(`NTT Package ID: ${nttPackageId}`); console.log(`NTT State ID: ${nttStateId}`); @@ -670,13 +606,14 @@ export async function deploySui( }` ); - // Restore original Move.toml files after successful deployment - restore(); + // Clean up progress file on success + try { + fs.unlinkSync(progressPath); + } catch {} - // Return the deployment information including AdminCaps and package IDs return { chain: ch.chain, - address: toUniversal(ch.chain, nttStateId), + address: toUniversal(ch.chain, nttStateId!), adminCaps: { wormholeTransceiver: whTransceiverAdminCapId, }, @@ -690,8 +627,6 @@ export async function deploySui( }, }; } catch (deploymentError) { - // Restore original Move.toml files if deployment fails - restore(); handleDeploymentError( deploymentError, ch.chain, diff --git a/cli/src/sui/helpers.ts b/cli/src/sui/helpers.ts index fe00db1b4..6727d7c26 100644 --- a/cli/src/sui/helpers.ts +++ b/cli/src/sui/helpers.ts @@ -2,7 +2,6 @@ import { execFileSync } from "child_process"; import fs from "fs"; import path from "path"; import { - signSendWait, type Chain, type ChainContext, type Network, @@ -11,12 +10,51 @@ import type { SuiChains } from "@wormhole-foundation/sdk-sui"; import type { SuiNtt } from "@wormhole-foundation/sdk-sui-ntt"; import { Transaction } from "@mysten/sui/transactions"; +const MIN_SUI_VERSION = "1.63.0"; + +function parseVersion(version: string): number[] { + return version.split(".").map(Number); +} + +function versionAtLeast(current: string, minimum: string): boolean { + const cur = parseVersion(current); + const min = parseVersion(minimum); + for (let i = 0; i < min.length; i++) { + if ((cur[i] ?? 0) > min[i]) return true; + if ((cur[i] ?? 0) < min[i]) return false; + } + return true; +} + +export function checkSuiVersion(): void { + let output: string; + try { + output = execFileSync("sui", ["--version"], { encoding: "utf8" }).trim(); + } catch { + throw new Error("Could not run 'sui --version'. Is the Sui CLI installed?"); + } + // Output format: "sui 1.63.2-abc123" + const match = output.match(/sui\s+(\d+\.\d+\.\d+)/); + if (!match) { + throw new Error(`Could not parse Sui version from: ${output}`); + } + const version = match[1]; + if (!versionAtLeast(version, MIN_SUI_VERSION)) { + throw new Error( + `Sui CLI version ${version} is too old. Minimum required: ${MIN_SUI_VERSION}. ` + + `Please update with: cargo install --locked --git https://github.com/MystenLabs/sui.git sui` + ); + } + console.log(`Sui CLI version: ${version}`); +} + // Setup Sui environment for consistent CLI usage with automatic cleanup export async function withSuiEnv( pwd: string, ch: ChainContext, fn: () => Promise ): Promise { + checkSuiVersion(); console.log("Setting up Sui environment..."); // Store original environment variable @@ -123,73 +161,6 @@ active_address: ~ } } -// Helper function to update Move.toml files for network-specific dependencies -export function updateMoveTomlForNetwork( - packagesPath: string, - networkType: Network -): { restore: () => void } { - const packages = ["ntt_common", "ntt", "wormhole_transceiver"]; - const backups: { [key: string]: string } = {}; - - // Determine the correct revisions based on network (with environment variable overrides) - const wormholeRev = - process.env.WORMHOLE_REV || - (networkType === "Mainnet" ? "sui/mainnet" : "sui/testnet"); - - // Devnet / localhost not supported — local validator setup is not yet implemented - if (networkType === "Devnet") { - throw new Error("devnet not supported yet"); - } - - console.log(`Updating Move.toml files for ${networkType} network...`); - console.log(` Wormhole revision: ${wormholeRev}`); - - for (const packageName of packages) { - const moveTomlPath = `${packagesPath}/${packageName}/Move.toml`; - - try { - // Backup original content - const originalContent = fs.readFileSync(moveTomlPath, "utf8"); - backups[moveTomlPath] = originalContent; - - let content = originalContent; - - // Update Wormhole revision - content = content.replace( - /rev = "sui\/(testnet|mainnet)"/g, - `rev = "${wormholeRev}"` - ); - - // Only write if content actually changed - if (content !== originalContent) { - fs.writeFileSync(moveTomlPath, content, "utf8"); - console.log(` Updated ${packageName}/Move.toml`); - } else { - console.log(` No changes needed for ${packageName}/Move.toml`); - } - } catch (error) { - console.warn( - ` Warning: Could not update ${packageName}/Move.toml: ${error}` - ); - // Don't throw error here to allow deployment to continue - } - } - - // Return restore function - return { - restore: () => { - console.log("Restoring original Move.toml files..."); - for (const [filePath, content] of Object.entries(backups)) { - try { - fs.writeFileSync(filePath, content, "utf8"); - } catch (error) { - console.warn(` Warning: Could not restore ${filePath}: ${error}`); - } - } - }, - }; -} - // Helper function to perform complete package upgrade in a single PTB export async function performPackageUpgradeInPTB< N extends Network, @@ -200,14 +171,25 @@ export async function performPackageUpgradeInPTB< upgradeCapId: string, ntt: SuiNtt ): Promise { + // Determine build environment for Sui 1.63+ package system + const buildEnv = ctx.network === "Mainnet" ? "mainnet" : "testnet"; + // Get build output with dependencies using the correct sui command console.log( - `Running sui move build --dump-bytecode-as-base64 for ${packagePath}...` + `Running sui move build --dump-bytecode-as-base64 -e ${buildEnv} for ${packagePath}...` ); const buildOutput = execFileSync( "sui", - ["move", "build", "--dump-bytecode-as-base64", "--path", packagePath], + [ + "move", + "build", + "--dump-bytecode-as-base64", + "-e", + buildEnv, + "--path", + packagePath, + ], { encoding: "utf-8", env: process.env, @@ -263,3 +245,156 @@ export async function performPackageUpgradeInPTB< description: "Package Upgrade PTB", }; } + +export function buildSuiPackage( + packagesPath: string, + packageName: string, + buildEnv: string +): void { + console.log(`Building ${packageName} package...`); + try { + execFileSync("sui", ["move", "build", "-e", buildEnv], { + cwd: path.join(packagesPath, packageName), + stdio: "inherit", + env: process.env, + }); + } catch (e) { + console.error(`Failed to build ${packageName} package`); + throw e; + } +} + +export interface SuiPublishResult { + packageId: string; + objectChanges: any[]; +} + +export function publishSuiPackage( + packagesPath: string, + packageName: string, + gasBudget: number +): SuiPublishResult { + console.log(`Publishing ${packageName} package...`); + const result = execFileSync( + "sui", + ["client", "publish", "--gas-budget", String(gasBudget), "--json"], + { + cwd: path.join(packagesPath, packageName), + encoding: "utf8", + env: process.env, + } + ); + const deploy = JSON.parse(result.substring(result.indexOf("{"))); + if (!deploy.objectChanges) { + throw new Error(`Failed to deploy ${packageName} package`); + } + const packageId = deploy.objectChanges.find( + (c: any) => c.type === "published" + )?.packageId; + if (!packageId) { + throw new Error( + `Could not find package ID for ${packageName} in publish result` + ); + } + console.log(`${packageName} deployed at: ${packageId}`); + return { packageId, objectChanges: deploy.objectChanges }; +} + +/** + * Find a created object in transaction objectChanges by type substring. + * If `shared` is true, only matches shared objects. + */ +export function findCreatedObject( + objectChanges: any[], + typeSubstring: string, + shared?: boolean +): string | undefined { + return objectChanges.find( + (c: any) => + c.type === "created" && + c.objectType?.includes(typeSubstring) && + (!shared || c.owner?.Shared) + )?.objectId; +} + +/** + * Generate Published.toml content for a Sui package. + * Tells the build system the package is already published at the given address. + */ +export function generatePublishedToml( + env: string, + chainId: string, + packageId: string +): string { + return `[published.${env}]\nchain-id = "${chainId}"\npublished-at = "${packageId}"\noriginal-id = "${packageId}"\nversion = 1\n`; +} + +export function parsePublishedToml( + filePath: string, + env: string +): { packageId: string; upgradeCap: string } { + const content = fs.readFileSync(filePath, "utf8"); + const section = content.match( + new RegExp(`\\[published\\.${env}\\][\\s\\S]*?(?=\\[|$)`) + ); + if (!section) throw new Error(`No [published.${env}] section in ${filePath}`); + const publishedAt = section[0].match(/published-at\s*=\s*"(0x[0-9a-f]+)"/); + const upgradeCap = section[0].match( + /upgrade-capability\s*=\s*"(0x[0-9a-f]+)"/ + ); + if (!publishedAt?.[1]) + throw new Error(`No published-at in [published.${env}] of ${filePath}`); + if (!upgradeCap?.[1]) + throw new Error( + `No upgrade-capability in [published.${env}] of ${filePath}` + ); + return { + packageId: publishedAt[1], + upgradeCap: upgradeCap[1], + }; +} + +export function movePublishedTomlToMainTree( + packagesPath: string, + mainTreePackagesPath: string, + packageNames: string[] +): void { + for (const pkg of packageNames) { + const worktreePath = `${packagesPath}/${pkg}/Published.toml`; + const mainTreePath = `${mainTreePackagesPath}/${pkg}/Published.toml`; + + if ( + fs.existsSync(worktreePath) && + !fs.lstatSync(worktreePath).isSymbolicLink() + ) { + fs.copyFileSync(worktreePath, mainTreePath); + fs.unlinkSync(worktreePath); + fs.rmSync(worktreePath, { force: true }); + fs.symlinkSync(path.resolve(mainTreePath), path.resolve(worktreePath)); + console.log(`Moved Published.toml for ${pkg} to ${mainTreePath}`); + } + } +} + +export interface SuiSetupProgress { + nttStateId?: string; + nttAdminCapId?: string; + transceiverStateId?: string; + whTransceiverAdminCapId?: string; + transceiverRegistered?: boolean; +} + +export function readSetupProgress(filePath: string): SuiSetupProgress { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return {}; + } +} + +export function saveSetupProgress( + filePath: string, + progress: SuiSetupProgress +): void { + fs.writeFileSync(filePath, JSON.stringify(progress, null, 2)); +} diff --git a/cli/src/suiGovernance.ts b/cli/src/suiGovernance.ts new file mode 100644 index 000000000..97a799d66 --- /dev/null +++ b/cli/src/suiGovernance.ts @@ -0,0 +1,457 @@ +import fs from "fs"; +import { Transaction } from "@mysten/sui/transactions"; +import { + generatePublishedToml, + buildSuiPackage, + publishSuiPackage, + findCreatedObject, +} from "./sui/helpers"; + +// ─── Types ─── + +export interface NttAddresses { + nttPackageId: string; + nttCommonPackageId: string; + adminCapId?: string; + upgradeCapId?: string; +} + +export interface GovernancePublishResult { + govPackageId: string; + govStateId: string; +} + +// ─── Address Helpers ─── + +function normalizeAddress(addr: string): string { + return addr.startsWith("0x") ? addr.toLowerCase() : `0x${addr}`.toLowerCase(); +} + +// ─── Address Discovery ─── + +/** + * Discover NTT package addresses and capability IDs from a State object. + * Extracts nttPackageId from the object type, nttCommonPackageId from the + * normalized struct's outbox field, and cap IDs from the state fields. + */ +export async function discoverNttAddresses( + client: any, + stateId: string +): Promise { + const stateObj = await client.getObject({ + id: stateId, + options: { showContent: true }, + }); + + if ( + !stateObj.data?.content || + stateObj.data.content.dataType !== "moveObject" + ) { + throw new Error( + `NTT State not found at ${stateId}. Verify the ID and network.` + ); + } + + // Extract nttPackageId from type string: "0x::state::State<...>" + const stateType = stateObj.data.content.type; + const nttPackageId = stateType.split("::")[0]; + if (!nttPackageId || !nttPackageId.startsWith("0x")) { + throw new Error("Could not extract NTT package ID from State object type"); + } + + // Extract ntt_common package ID via normalized struct + const normalizedStruct = (await client.call("sui_getNormalizedMoveStruct", [ + nttPackageId.replace(/^0x/, ""), + "state", + "State", + ])) as any; + + const outboxField = normalizedStruct.fields?.find( + (f: any) => f.name === "outbox" + ); + const rawNttCommon = + outboxField?.type?.Struct?.typeArguments?.[0]?.Struct?.address; + + if (!rawNttCommon) { + throw new Error( + "Could not extract ntt_common package ID from normalized struct. " + + "The outbox field type structure may have changed." + ); + } + + const nttCommonPackageId = rawNttCommon.startsWith("0x") + ? rawNttCommon + : `0x${rawNttCommon}`; + + // Extract cap IDs from state fields + const stateFields = stateObj.data.content.fields as any; + + return { + nttPackageId, + nttCommonPackageId, + adminCapId: stateFields?.admin_cap_id, + upgradeCapId: stateFields?.upgrade_cap_id, + }; +} + +/** + * Extract the governance package ID from a GovernanceState object's type string. + * Type format: "0x::governance::GovernanceState" + */ +export async function discoverGovernancePackageId( + client: any, + govStateId: string +): Promise { + const obj = await client.getObject({ + id: govStateId, + options: { showContent: true }, + }); + + if (!obj.data?.content || obj.data.content.dataType !== "moveObject") { + throw new Error( + `Object not found at ${govStateId}. Verify the ID and network.` + ); + } + + const objType: string = obj.data.content.type; + if (!objType.includes("::governance::GovernanceState")) { + throw new Error( + `Object at ${govStateId} is not a GovernanceState (type: ${objType})` + ); + } + + const packageId = objType.split("::")[0]; + if (!packageId || !packageId.startsWith("0x")) { + throw new Error("Could not extract package ID from GovernanceState type"); + } + + return packageId; +} + +/** + * Verify that a governance package was compiled against the expected NTT package. + * + * Inspects the normalized `receive_admin_cap` function to find the NTT package + * address baked into the `Receiving` parameter type. Since Sui resolves + * all imported types to their defining package addresses at compile time, this + * tells us exactly which NTT package the governance contract targets. + */ +export async function verifyGovernancePackageTarget( + client: any, + govPackageId: string, + expectedNttPackageId: string +): Promise { + const normalizedFn = (await client.call("sui_getNormalizedMoveFunction", [ + govPackageId.replace(/^0x/, ""), + "governance", + "receive_admin_cap", + ])) as any; + + // Find the Receiving parameter by scanning for a Struct named "Receiving" + const receivingParam = normalizedFn.parameters?.find((p: any) => + p?.MutableReference?.Struct?.name === "GovernanceState" + ? false + : p?.Struct?.name === "Receiving" + ); + + if (!receivingParam) { + throw new Error( + "Could not find Receiving parameter in receive_admin_cap. " + + "The governance module structure may have changed." + ); + } + + const adminCapStruct = receivingParam.Struct?.typeArguments?.[0]?.Struct; + if (!adminCapStruct?.address) { + throw new Error( + "Could not extract AdminCap package address from Receiving type argument" + ); + } + + const actualNttPackageId = normalizeAddress(adminCapStruct.address); + const expected = normalizeAddress(expectedNttPackageId); + + if (actualNttPackageId !== expected) { + throw new Error( + `Governance package targets NTT at ${actualNttPackageId}, ` + + `but expected ${expected}. ` + + "This governance contract was not compiled for this NTT deployment." + ); + } +} + +// ─── Governance Transfer (shared logic) ─── + +export interface TransferGovernanceOptions { + adminCapOverride?: string; + upgradeCapOverride?: string; + gasBudget: number; + skipVerification?: boolean; + /** When known (e.g. from deploy-governance), skip RPC discovery */ + govPackageId?: string; +} + +/** + * Verify and transfer NTT caps to a GovernanceState. Used by both + * `deploy-governance --transfer` and `transfer-governance`. + */ +export async function transferGovernance( + client: any, + keypair: any, + nttStateId: string, + govStateId: string, + opts: TransferGovernanceOptions +): Promise { + // Discover NTT addresses (and governance package ID if not provided) + console.log("Discovering NTT addresses from State object..."); + let govPackageId: string; + const addresses = await discoverNttAddresses(client, nttStateId); + if (opts.govPackageId) { + govPackageId = opts.govPackageId; + } else { + govPackageId = await discoverGovernancePackageId(client, govStateId); + } + console.log(`NTT Package: ${addresses.nttPackageId}`); + + // Verify governance targets the correct NTT + if (!opts.skipVerification) { + console.log("Verifying governance contract targets correct NTT..."); + await verifyGovernancePackageTarget( + client, + govPackageId, + addresses.nttPackageId + ); + console.log("Verification passed"); + } + + // Resolve cap IDs + const adminCapId = opts.adminCapOverride || addresses.adminCapId; + const upgradeCapId = opts.upgradeCapOverride || addresses.upgradeCapId; + + if (!adminCapId) { + throw new Error( + "Could not discover AdminCap ID from State object. Provide it with --admin-cap." + ); + } + if (!upgradeCapId) { + throw new Error( + "Could not discover UpgradeCap ID from State object. Provide it with --upgrade-cap." + ); + } + + console.log(`AdminCap: ${adminCapId}`); + console.log(`UpgradeCap: ${upgradeCapId}`); + + await transferCapsToGovernance( + client, + keypair, + govPackageId, + govStateId, + adminCapId, + upgradeCapId, + opts.gasBudget + ); +} + +// ─── Published.toml Management ─── + +/** + * Write temporary Published.toml files for ntt and ntt_common so that the + * governance package can build against already-deployed packages. Returns a + * cleanup function that restores the originals. + */ +export function writePublishedTomls( + packagesPath: string, + buildEnv: string, + chainId: string, + nttPackageId: string, + nttCommonPackageId: string +): () => void { + const nttPath = `${packagesPath}/ntt/Published.toml`; + const nttCommonPath = `${packagesPath}/ntt_common/Published.toml`; + + const nttBackup = fs.existsSync(nttPath) + ? fs.readFileSync(nttPath, "utf8") + : null; + const nttCommonBackup = fs.existsSync(nttCommonPath) + ? fs.readFileSync(nttCommonPath, "utf8") + : null; + + fs.writeFileSync( + nttPath, + generatePublishedToml(buildEnv, chainId, nttPackageId) + ); + fs.writeFileSync( + nttCommonPath, + generatePublishedToml(buildEnv, chainId, nttCommonPackageId) + ); + + return () => { + if (nttBackup !== null) { + fs.writeFileSync(nttPath, nttBackup); + } else if (fs.existsSync(nttPath)) { + fs.unlinkSync(nttPath); + } + if (nttCommonBackup !== null) { + fs.writeFileSync(nttCommonPath, nttCommonBackup); + } else if (fs.existsSync(nttCommonPath)) { + fs.unlinkSync(nttCommonPath); + } + }; +} + +// ─── Build & Publish ─── + +/** + * Build and publish the ntt_governance package, then call `governance::create` + * to make the package immutable and create the shared GovernanceState. + * + * The publish step creates a DeployerCap and an UpgradeCap. The create step + * consumes both, destroying the UpgradeCap via `make_immutable` to enforce + * governance package immutability. + */ +export async function buildAndPublishGovernance( + client: any, + keypair: any, + packagesPath: string, + buildEnv: string, + gasBudget: number +): Promise { + // Remove stale Published.toml so @ntt_governance compiles as 0x0 + // (gets replaced with actual package ID at publish time) + const govPublishedPath = `${packagesPath}/ntt_governance/Published.toml`; + if (fs.existsSync(govPublishedPath)) { + fs.unlinkSync(govPublishedPath); + } + + buildSuiPackage(packagesPath, "ntt_governance", buildEnv); + + const { packageId: govPackageId, objectChanges: publishChanges } = + publishSuiPackage(packagesPath, "ntt_governance", gasBudget); + + // Find DeployerCap and UpgradeCap from publish result + const deployerCapId = findCreatedObject( + publishChanges, + "governance::DeployerCap" + ); + if (!deployerCapId) { + throw new Error("Could not find DeployerCap in publish result"); + } + + const govUpgradeCapId = findCreatedObject( + publishChanges, + "0x2::package::UpgradeCap" + ); + if (!govUpgradeCapId) { + throw new Error("Could not find UpgradeCap in publish result"); + } + + // Wait for publish to finalize before using objects via SDK + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Call governance::create to make package immutable and create GovernanceState + console.log("Making governance package immutable..."); + const createTx = new Transaction(); + createTx.moveCall({ + target: `${govPackageId}::governance::create`, + arguments: [ + createTx.object(deployerCapId), + createTx.object(govUpgradeCapId), + ], + }); + createTx.setGasBudget(gasBudget); + + const createResult = await client.signAndExecuteTransaction({ + signer: keypair, + transaction: createTx, + options: { showEffects: true, showObjectChanges: true }, + }); + + if (createResult.effects?.status?.status !== "success") { + throw new Error( + `governance::create transaction failed: ${JSON.stringify(createResult.effects?.status)}` + ); + } + + const createChanges = createResult.objectChanges || []; + const govStateId = findCreatedObject( + createChanges, + "governance::GovernanceState", + true + ); + if (!govStateId) { + throw new Error( + "Could not find GovernanceState in create transaction result. " + + `Transaction digest: ${createResult.digest}. ` + + `Object changes: ${JSON.stringify(createChanges.map((c: any) => c.objectType))}` + ); + } + + return { govPackageId, govStateId }; +} + +// ─── Cap Transfer ─── + +/** + * Transfer AdminCap and UpgradeCap to a GovernanceState, then call + * receive_admin_cap and receive_upgrade_cap to store them as dynamic fields. + * Requires two transactions (transfer must finalize before receive). + */ +export async function transferCapsToGovernance( + client: any, + keypair: any, + govPackageId: string, + govStateId: string, + adminCapId: string, + upgradeCapId: string, + gasBudget: number +): Promise { + // TX1: Transfer caps to GovernanceState address + console.log("Transferring AdminCap and UpgradeCap to GovernanceState..."); + const transferTx = new Transaction(); + transferTx.transferObjects([transferTx.object(adminCapId)], govStateId); + transferTx.transferObjects([transferTx.object(upgradeCapId)], govStateId); + transferTx.setGasBudget(gasBudget); + + const transferResult = await client.signAndExecuteTransaction({ + signer: keypair, + transaction: transferTx, + options: { showEffects: true }, + }); + + if (transferResult.effects?.status?.status !== "success") { + throw new Error( + `Failed to transfer caps to GovernanceState: ${JSON.stringify(transferResult.effects?.status)}. ` + + "The signer may not own AdminCap/UpgradeCap." + ); + } + console.log("Caps transferred successfully"); + + // Wait for the transfer to finalize before receiving + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // TX2: Receive caps into GovernanceState + console.log("Receiving caps into GovernanceState..."); + const receiveTx = new Transaction(); + receiveTx.moveCall({ + target: `${govPackageId}::governance::receive_admin_cap`, + arguments: [receiveTx.object(govStateId), receiveTx.object(adminCapId)], + }); + receiveTx.moveCall({ + target: `${govPackageId}::governance::receive_upgrade_cap`, + arguments: [receiveTx.object(govStateId), receiveTx.object(upgradeCapId)], + }); + receiveTx.setGasBudget(gasBudget); + + const receiveResult = await client.signAndExecuteTransaction({ + signer: keypair, + transaction: receiveTx, + options: { showEffects: true }, + }); + + if (receiveResult.effects?.status?.status !== "success") { + throw new Error( + `Failed to receive caps into GovernanceState: ${JSON.stringify(receiveResult.effects?.status)}` + ); + } +} diff --git a/cli/src/tag.ts b/cli/src/tag.ts index 985fbfb0b..95525595c 100644 --- a/cli/src/tag.ts +++ b/cli/src/tag.ts @@ -1,6 +1,7 @@ import type { Platform } from "@wormhole-foundation/sdk"; import { execSync } from "child_process"; import fs from "fs"; +import path from "path"; import { colors } from "./colors.js"; import { askForConfirmation } from "./prompts.js"; @@ -83,12 +84,10 @@ export function createWorkTree(platform: Platform, version: string): string { // NOTE: we create this symlink whether or not the file exists. // this way, if it's created later, the symlink will be correct - execSync( - `ln -fs $(pwd)/overrides.json $(pwd)/${worktreeName}/overrides.json`, - { - stdio: "inherit", - } - ); + const overridesSrc = path.resolve("overrides.json"); + const overridesDst = path.resolve(worktreeName, "overrides.json"); + fs.rmSync(overridesDst, { force: true }); + fs.symlinkSync(overridesSrc, overridesDst); console.log( colors.green(`Created worktree at ${worktreeName} from tag ${tag}`) diff --git a/sui/Makefile b/sui/Makefile index 6f1adc8c5..2867e7a8e 100644 --- a/sui/Makefile +++ b/sui/Makefile @@ -1,6 +1,18 @@ +# Default environment for building/testing (testnet or mainnet) +ENV ?= testnet + test: - @for move_toml in $(shell find packages -name Move.toml); do \ + @set -e; \ + for move_toml in $(shell find packages -name Move.toml); do \ dir=$$(dirname "$$move_toml"); \ echo "Running tests in $$dir..."; \ - pushd "$$dir" && sui move test --skip-fetch-latest-git-deps -d; popd; \ + (cd "$$dir" && sui move test -e $(ENV)) || exit $$?; \ + done + +build: + @set -e; \ + for move_toml in $(shell find packages -name Move.toml); do \ + dir=$$(dirname "$$move_toml"); \ + echo "Building $$dir..."; \ + (cd "$$dir" && sui move build -e $(ENV)) || exit $$?; \ done diff --git a/sui/packages/ntt/Move.toml b/sui/packages/ntt/Move.toml index 62ec32ef5..abbc00c18 100644 --- a/sui/packages/ntt/Move.toml +++ b/sui/packages/ntt/Move.toml @@ -1,19 +1,9 @@ [package] -name = "Ntt" -edition = "2024.beta" # edition = "legacy" to use legacy (pre-2024) Move -license = "Apache 2.0" - -[dependencies.Wormhole] -git = "https://github.com/wormhole-foundation/wormhole.git" -rev = "sui/testnet" -subdir = "sui/wormhole" - -[dependencies.NttCommon] -local = "../ntt_common" - -[addresses] -ntt = "0x0" - -[dev-dependencies] - -[dev-addresses] +name = "ntt" +version = "1.0.0" +edition = "2024.beta" + +[dependencies] +# After wormhole PR #4639 merges, switch to: rev = "main" +wormhole = { git = "https://github.com/wormholelabs-xyz/wormhole.git", subdir = "sui/wormhole", rev = "sui-package-update" } +ntt_common = { local = "../ntt_common" } diff --git a/sui/packages/ntt/sources/upgrades.move b/sui/packages/ntt/sources/upgrades.move index 6aaf876de..cdb692881 100644 --- a/sui/packages/ntt/sources/upgrades.move +++ b/sui/packages/ntt/sources/upgrades.move @@ -1,6 +1,13 @@ module ntt::upgrades { use ntt::state::State; + /// IMPORTANT: This constant MUST be incremented in the new bytecode for + /// every package upgrade. `commit_upgrade` writes this value into the NTT + /// State, and `check_version` compares against it. If VERSION is not + /// incremented, the old (potentially vulnerable) package's functions will + /// continue to pass version checks alongside the new package. + /// + /// Before publishing an upgrade, verify that VERSION > previous VERSION. const VERSION: u64 = 0; public struct UpgradeCap has key, store { diff --git a/sui/packages/ntt/tests/ntt_scenario.move b/sui/packages/ntt/tests/ntt_scenario.move index 64f0d7af6..601a743bc 100644 --- a/sui/packages/ntt/tests/ntt_scenario.move +++ b/sui/packages/ntt/tests/ntt_scenario.move @@ -1,4 +1,5 @@ #[test_only] +#[allow(deprecated_usage)] /// This module implements ways to initialize NTT in a test scenario. /// It provides common setup functions and test utilities. module ntt::ntt_scenario { diff --git a/sui/packages/ntt/tests/ntt_tests.move b/sui/packages/ntt/tests/ntt_tests.move index 01aed0574..4060d40b5 100644 --- a/sui/packages/ntt/tests/ntt_tests.move +++ b/sui/packages/ntt/tests/ntt_tests.move @@ -244,7 +244,7 @@ module ntt::ntt_tests { ntt_scenario::return_state(state); ntt_scenario::return_clock(clock); ntt_scenario::return_coin_metadata(coin_meta); - sui::test_utils::destroy(dust); + std::unit_test::destroy(dust); test_scenario::end(scenario); } @@ -291,20 +291,20 @@ module ntt::ntt_tests { &clock ); - sui::test_utils::destroy(transceiver_a_message); + std::unit_test::destroy(transceiver_a_message); // this will fail, because transceiver a already released the message let transceiver_a_message = state.create_transceiver_message( message_id, &clock ); - sui::test_utils::destroy(transceiver_a_message); + std::unit_test::destroy(transceiver_a_message); // Clean up ntt_scenario::return_state(state); ntt_scenario::return_clock(clock); ntt_scenario::return_coin_metadata(coin_meta); - sui::test_utils::destroy(dust); + std::unit_test::destroy(dust); test_scenario::end(scenario); } @@ -391,7 +391,7 @@ module ntt::ntt_tests { ntt_scenario::return_state(state); ntt_scenario::return_clock(clock); ntt_scenario::return_coin_metadata(coin_meta); - sui::test_utils::destroy(coins); + std::unit_test::destroy(coins); scenario.end(); } @@ -691,7 +691,7 @@ module ntt::ntt_tests { ntt_scenario::return_state(state); ntt_scenario::return_clock(clock); ntt_scenario::return_coin_metadata(coin_meta); - sui::test_utils::destroy(dust); + std::unit_test::destroy(dust); scenario.end(); } @@ -817,7 +817,7 @@ module ntt::ntt_tests { ntt_scenario::return_state(state); ntt_scenario::return_clock(clock); ntt_scenario::return_coin_metadata(coin_meta); - sui::test_utils::destroy(dust); + std::unit_test::destroy(dust); scenario.end(); } diff --git a/sui/packages/ntt_common/Move.toml b/sui/packages/ntt_common/Move.toml index 8c202d478..0f8cc6d89 100644 --- a/sui/packages/ntt_common/Move.toml +++ b/sui/packages/ntt_common/Move.toml @@ -1,16 +1,8 @@ [package] -name = "NttCommon" -edition = "2024.beta" # edition = "legacy" to use legacy (pre-2024) Move -license = "Apache 2.0" +name = "ntt_common" +version = "1.0.0" +edition = "2024.beta" -[dependencies.Wormhole] -git = "https://github.com/wormhole-foundation/wormhole.git" -rev = "sui/testnet" -subdir = "sui/wormhole" - -[addresses] -ntt_common = "0x0" - -[dev-dependencies] - -[dev-addresses] +[dependencies] +# After wormhole PR #4639 merges, switch to: rev = "main" +wormhole = { git = "https://github.com/wormholelabs-xyz/wormhole.git", subdir = "sui/wormhole", rev = "sui-package-update" } diff --git a/sui/packages/ntt_common/sources/contract_auth.move b/sui/packages/ntt_common/sources/contract_auth.move index fcdfee466..6ec2a970e 100644 --- a/sui/packages/ntt_common/sources/contract_auth.move +++ b/sui/packages/ntt_common/sources/contract_auth.move @@ -50,12 +50,12 @@ module ntt_common::contract_auth { b"Invalid auth type"; public fun get_auth_address(type_name: vector): Option
{ - let fqt = type_name::get(); + let fqt = type_name::with_defining_ids(); - let address_hex = fqt.get_address().into_bytes(); + let address_hex = fqt.address_string().into_bytes(); let addy = address::from_bytes(hex::decode(address_hex)); - let mod = fqt.get_module().into_bytes(); + let mod = fqt.module_string().into_bytes(); let mut expected = address_hex; expected.append(b"::"); @@ -84,8 +84,8 @@ module ntt_common::contract_auth { public fun auth_as(_auth: &Auth, type_name: vector, obj: &State): address { let package_id = assert_auth_type(_auth, type_name); - let fqt = type_name::get(); - let state_package_id = address::from_bytes(hex::decode(fqt.get_address().into_bytes())); + let fqt = type_name::with_defining_ids(); + let state_package_id = address::from_bytes(hex::decode(fqt.address_string().into_bytes())); assert!(package_id == state_package_id, EInvalidAuthType); object::id_address(obj) diff --git a/sui/packages/ntt_common/sources/datatypes/bitmap.move b/sui/packages/ntt_common/sources/datatypes/bitmap.move index 29206c589..c01362103 100644 --- a/sui/packages/ntt_common/sources/datatypes/bitmap.move +++ b/sui/packages/ntt_common/sources/datatypes/bitmap.move @@ -37,7 +37,7 @@ module ntt_common::bitmap { count = count + 1; }; mask = mask << 1; - i = i + 1; + i = i + 1u64; }; count } diff --git a/sui/packages/ntt_common/sources/messages/ntt_manager_message.move b/sui/packages/ntt_common/sources/messages/ntt_manager_message.move index 2af001688..56013431f 100644 --- a/sui/packages/ntt_common/sources/messages/ntt_manager_message.move +++ b/sui/packages/ntt_common/sources/messages/ntt_manager_message.move @@ -58,7 +58,7 @@ module ntt_common::ntt_manager_message { message: NttManagerMessage> ): vector { let NttManagerMessage {id, sender, payload} = message; - assert!(vector::length(&payload) < (((1<<16)-1) as u64), E_PAYLOAD_TOO_LONG); + assert!(vector::length(&payload) < (((1u64<<16)-1) as u64), E_PAYLOAD_TOO_LONG); let payload_length = (vector::length(&payload) as u16); let mut buf: vector = vector::empty(); diff --git a/sui/packages/ntt_common/sources/messages/transceiver_message.move b/sui/packages/ntt_common/sources/messages/transceiver_message.move index 22977b2a3..5c7b00ec8 100644 --- a/sui/packages/ntt_common/sources/messages/transceiver_message.move +++ b/sui/packages/ntt_common/sources/messages/transceiver_message.move @@ -253,7 +253,7 @@ module ntt_common::transceiver_message_tests { 0xFE, 0xEB, 0xCA, 0xFE, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ])), - 2, + 17, option::none() ) ) diff --git a/sui/packages/ntt_governance/Move.lock b/sui/packages/ntt_governance/Move.lock new file mode 100644 index 000000000..6f0a817b2 --- /dev/null +++ b/sui/packages/ntt_governance/Move.lock @@ -0,0 +1,41 @@ +# Generated by move; do not edit +# This file should be checked in. + +[move] +version = 4 + +[pinned.testnet.MoveStdlib] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "22f9fc9781732d651e18384c9a8eb1dabddf73a6" } +use_environment = "testnet" +manifest_digest = "C4FE4C91DE74CBF223B2E380AE40F592177D21870DC2D7EB6227D2D694E05363" +deps = {} + +[pinned.testnet.Sui] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "22f9fc9781732d651e18384c9a8eb1dabddf73a6" } +use_environment = "testnet" +manifest_digest = "7AFB66695545775FBFBB2D3078ADFD084244D5002392E837FDE21D9EA1C6D01C" +deps = { MoveStdlib = "MoveStdlib" } + +[pinned.testnet.ntt] +source = { local = "../ntt" } +use_environment = "testnet" +manifest_digest = "2145D4FC4D67E6C1B1F01E8F796AD7197EE298EEF397BBB8539A1F5584C3CCD7" +deps = { ntt_common = "ntt_common", std = "MoveStdlib", sui = "Sui", wormhole = "wormhole" } + +[pinned.testnet.ntt_common] +source = { local = "../ntt_common" } +use_environment = "testnet" +manifest_digest = "4E70C850B8991E6B72B578F23CAA78F90A312CF4A5ACB1B790AB56B805C7E115" +deps = { std = "MoveStdlib", sui = "Sui", wormhole = "wormhole" } + +[pinned.testnet.ntt_governance] +source = { root = true } +use_environment = "testnet" +manifest_digest = "7FBFA7BAC9724714F3347384DD5E34279DDD326200930C56A8632A4206B0253C" +deps = { ntt = "ntt", std = "MoveStdlib", sui = "Sui", wormhole = "wormhole" } + +[pinned.testnet.wormhole] +source = { git = "https://github.com/wormholelabs-xyz/wormhole.git", subdir = "sui/wormhole", rev = "ce176c06c99a7dded905c85903646f99cb7ca9ca" } +use_environment = "testnet" +manifest_digest = "5745706258F61D6CE210904B3E6AE87A73CE9D31A6F93BE4718C442529332A87" +deps = { std = "MoveStdlib", sui = "Sui" } diff --git a/sui/packages/ntt_governance/Move.toml b/sui/packages/ntt_governance/Move.toml new file mode 100644 index 000000000..e63cc4e0c --- /dev/null +++ b/sui/packages/ntt_governance/Move.toml @@ -0,0 +1,9 @@ +[package] +name = "ntt_governance" +version = "1.0.0" +edition = "2024.beta" + +[dependencies] +# After wormhole PR #4639 merges, switch to: rev = "main" +wormhole = { git = "https://github.com/wormholelabs-xyz/wormhole.git", subdir = "sui/wormhole", rev = "sui-package-update" } +ntt = { local = "../ntt" } diff --git a/sui/packages/ntt_governance/README.md b/sui/packages/ntt_governance/README.md new file mode 100644 index 000000000..2aed51888 --- /dev/null +++ b/sui/packages/ntt_governance/README.md @@ -0,0 +1,231 @@ +# NTT Governance for Sui + +Wormhole governance contract that receives guardian-signed VAAs and executes +admin operations on NTT via the existing `AdminCap` interface. Deployed as a +separate package that stores the NTT `AdminCap` and `UpgradeCap`. + +## Why one governance per NTT + +On EVM, a single governance contract uses `call(callData)` to invoke arbitrary +functions on any governed contract — one governance instance governs many +contracts. On Solana, `invoke_signed()` provides similar capability via CPI. + +Sui intentionally prohibits dynamic dispatch. Every cross-module call must be +resolved at compile time with explicit type parameters and module paths. Since +each NTT deployment is a separate package with its own `State`, the +governance package must import a specific `ntt` package at compile time. There +is no way to write a generic governance contract that works with arbitrary NTT +deployments. Each NTT deployment requires its own governance package. + +## VAA wire format + +This contract uses the `GeneralPurposeGovernance` module identifier, shared +with the EVM and Solana governance contracts. The Wormhole governance packet +standard defines the envelope; the payload is platform-specific. + +``` +Envelope (handled by governance_message): + MODULE (32 bytes, "GeneralPurposeGovernance" left-padded) + ACTION (1 byte, SUI_CALL = 3) + CHAIN (2 bytes, Sui chain ID) + +Payload (parsed by this module): + GOVERNANCE_ID (32 bytes) | NTT_ACTION (1 byte) | ACTION_DATA (variable) +``` + +The governance action indices across runtimes: + +| Action | Value | Runtime | +| ----------- | ----- | ------- | +| UNDEFINED | 0 | — | +| EVM_CALL | 1 | EVM | +| SOLANA_CALL | 2 | Solana | +| SUI_CALL | 3 | Sui | + +Each runtime only accepts its own action index. The `NTT_ACTION` byte in the +payload identifies the specific NTT admin operation (1-11, see table below). + +## VAA verification flow + +The verification flow in `verify_and_consume`: + +1. Create a `DecreeTicket` with module `GeneralPurposeGovernance` and action `SUI_CALL` +2. Call `governance_message::verify_vaa` — checks guardian signatures, emitter + chain, emitter contract, module name, and action +3. Call `governance_message::take_payload` — consumes the VAA digest into + `ConsumedVAAs` for replay protection and returns the inner payload +4. Parse the payload: strip the 32-byte `GOVERNANCE_ID` (asserting it matches + this `GovernanceState` object's ID) and extract the `NTT_ACTION` byte +5. Return a `GovernanceDecree` hot-potato with the NTT action and remaining payload + +## GovernanceDecree hot-potato + +`GovernanceDecree` has no `drop` ability — it must be consumed by an +`execute_*` function in the same transaction. Each `execute_*` function +destructures the decree, asserts the action ID matches, and parses the +action-specific payload. + +The alternative design — a single `execute` function that calls +`verify_and_dispatch` inline — would make payload parsing untestable without +constructing guardian-signed VAAs in tests. By separating verification from +dispatch, the `new_decree` test helper can construct decrees directly, letting +tests exercise all 11 payload parsers and their edge cases (truncation, +trailing bytes, type mismatches) without VAA infrastructure. + +The hot-potato pattern preserves the same security guarantee: a decree +can only be created by `verify_and_consume` (in production) and must be +consumed in the same transaction. + +## GovernanceState singleton + +`GovernanceState` is created by `init()`, which runs exactly once at package +publish and creates a shared object with its own `ConsumedVAAs` set. + +If the constructor were public, anyone could create additional +`GovernanceState` instances. Each instance would have its own independent +`ConsumedVAAs`, allowing the same VAA to be replayed against multiple +instances. The `init()`-only pattern ensures exactly one instance exists per +package deployment. + +## Capability storage + +Capabilities (`AdminCap`, `UpgradeCap`) are stored as dynamic object fields +rather than direct struct fields. + +Direct struct fields would wrap the capabilities inside `GovernanceState`, +removing them from the top-level object store. This makes them invisible to +off-chain tools (explorers, indexers) and prevents `transfer::public_receive` +from working — they can't be transferred out without a custom extraction +function. + +Dynamic object fields preserve each capability's identity as a top-level +object. This means: + +- Off-chain tools can discover which caps a governance instance holds +- Capabilities can be extracted with `ofield::remove` and transferred via + `transfer::public_transfer` for ownership handoff +- The standard `Receiving` / `public_receive` pattern works for ingestion + +## Deployment flow + +``` +1. sui client publish → init() creates shared GovernanceState +2. sui client transfer-object AdminCap --to + sui client transfer-object UpgradeCap --to +3. PTB: governance::receive_admin_cap(gov, admin_receiving) + governance::receive_upgrade_cap(gov, upgrade_receiving) +``` + +The `receive_admin_cap` / `receive_upgrade_cap` functions are permissionless — +anyone can call them. This is safe because the capability must already have +been transferred to the `GovernanceState`'s address; `public_receive` enforces +this. A governance instance without caps is inert: action handlers abort with +`EFieldDoesNotExist` from dynamic object field access until caps are received. + +`receive_upgrade_cap` does not perform an explicit runtime package check. The +type system already guarantees correctness: `ntt::upgrades::UpgradeCap` is +bound to the specific NTT package at compile time, `public_receive` enforces +exact type matching (package ID is part of the type identity), and +`ntt::upgrades::new_upgrade_cap` validates the inner `sui::package::UpgradeCap` +via `assert_package_upgrade_cap` at creation time. + +After receiving caps, all admin operations require a guardian-signed VAA. + +## Ownership transfer (governance handoff) + +To migrate from governance A to governance B (e.g., upgrading the governance +contract itself): + +``` +Tx 1: Publish new governance package → creates GovernanceState B +Tx 2: VAA → verify_and_consume(gov_a) → execute_transfer_ownership(gov_a, decree) + (caps are transferred to gov_b's address) +Tx 3: governance_b::receive_admin_cap(gov_b, ...) + governance_b::receive_upgrade_cap(gov_b, ...) +``` + +After tx 2, governance A is inert (no caps). After tx 3, governance B controls +the NTT deployment. + +WARNING: `execute_transfer_ownership` sends caps to the raw address in the VAA +payload with no on-chain validation. An incorrect address permanently loses +admin control. Operators must verify the address in the governance proposal. + +**Partial receive before transfer:** If a capability has been transferred to +GovA's address but `receive_*` has not been called when +`execute_transfer_ownership` fires, GovA only transfers the caps it has stored +as dynamic object fields. The unreceived cap remains owned by GovA's address. +This is recoverable: `receive_*` is permissionless, so the unreceived cap can +be accepted after the fact, then a second governance VAA can transfer it. +Operators should still ensure both caps are received before initiating +ownership transfer to avoid the extra governance round-trip. + +## Upgrade flow + +NTT package upgrades use a two-phase hot-potato pattern matching Wormhole's +own upgrade mechanism: + +``` +PTB: + 1. vaa::parse_and_verify(wh_state, vaa_bytes, clock) → VAA + 2. governance::verify_and_consume(gov, wh_state, vaa) → GovernanceDecree + 3. governance::execute_authorize_upgrade(gov, decree) → UpgradeTicket + 4. sui::package::upgrade(ticket, modules, deps, policy) → UpgradeReceipt + 5. governance::execute_commit_upgrade(gov, ntt_state, receipt) +``` + +Steps 3-5 must occur in a single PTB. The `UpgradeTicket` and +`UpgradeReceipt` are hot-potatoes that enforce this. `execute_commit_upgrade` +does not require a separate VAA — authorisation was established in step 3. + +## NTT actions (payload-level) + +The `NTT_ACTION` byte in the payload identifies the specific operation. +`ACTION_DATA` format depends on the action: + +| # | Action | ACTION_DATA | +| --- | ------------------- | ----------------------------------------------------------------------------------- | +| 1 | SetPeer | `chain_id (u16) \| peer_address (32) \| token_decimals (u8) \| inbound_limit (u64)` | +| 2 | SetThreshold | `threshold (u8)` | +| 3 | SetOutboundLimit | `limit (u64)` | +| 4 | SetInboundLimit | `chain_id (u16) \| limit (u64)` | +| 5 | Pause | _(empty)_ | +| 6 | Unpause | _(empty)_ | +| 7 | RegisterTransceiver | `state_object_id (32) \| type_name_len (u16) \| type_name (utf8)` | +| 8 | EnableTransceiver | `transceiver_id (u8)` | +| 9 | DisableTransceiver | `transceiver_id (u8)` | +| 10 | AuthorizeUpgrade | `digest (32)` | +| 11 | TransferOwnership | `new_owner (32)` | + +The full payload parsed by this module is: +`GOVERNANCE_ID (32) | NTT_ACTION (1) | ACTION_DATA`. The `GOVERNANCE_ID` and +`NTT_ACTION` are stripped by `verify_and_consume` before the action-specific +data reaches `execute_*`. + +## Security: governance package UpgradeCap + +This package's own `sui::package::UpgradeCap` (created at publish) is NOT +stored or governed here. Whoever holds it can upgrade this module without +guardian approval — potentially modifying VAA verification, adding backdoors, +or extracting stored capabilities. + +After deployment, operators MUST either: + +1. Make the governance package immutable (`sui client upgrade --policy immutable`), or +2. Transfer its UpgradeCap to a trusted multisig or separate governance mechanism + +Failure to secure the governance UpgradeCap undermines the entire trust model. + +## Design decisions + +| Decision | Rationale | +| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | +| Separate package (not inline in NTT) | Governance can be added to existing NTT deployments without upgrading NTT | +| One governance per NTT | Sui prohibits dynamic dispatch; governance must import specific NTT package types at compile time | +| Hot-potato decree | Separates verification from dispatch, enabling test coverage of payload parsers without VAA infrastructure | +| `init()` singleton | Prevents VAA replay via duplicate instances with independent `ConsumedVAAs` | +| Dynamic object fields for caps | Preserves off-chain discoverability and enables extraction for ownership transfer | +| `GovernanceActionExecuted` event | On-chain audit trail; includes action ID and VAA digest | +| No governance over own UpgradeCap | Circular dependency; must be secured externally | +| `type_name::with_defining_ids` for RegisterTransceiver | Runtime type verification ensures PTB type parameter matches VAA-authorised type | +| `GOVERNANCE_ID` prefix in payload | Per-instance targeting; prevents a VAA intended for one NTT governance from being replayed against another | diff --git a/sui/packages/ntt_governance/sources/governance.move b/sui/packages/ntt_governance/sources/governance.move new file mode 100644 index 000000000..9bd12ccd4 --- /dev/null +++ b/sui/packages/ntt_governance/sources/governance.move @@ -0,0 +1,626 @@ +// SPDX-License-Identifier: Apache 2 + +/// Wormhole governance contract for NTT on Sui. +/// +/// Since Sui intentionally prohibits dynamic dispatch, and each NTT deployment +/// is a separate package with its own types, one governance package must be +/// deployed per NTT deployment. This package stores the NTT AdminCap and +/// UpgradeCap as dynamic object fields, and all admin operations require a +/// Wormhole governance VAA signed by the guardian set. +/// +/// Capabilities can be transferred to a new governance contract via +/// `execute_transfer_ownership` (VAA-gated). The new governance receives +/// them via `receive_admin_cap` / `receive_upgrade_cap`. +/// +/// ## Security: Governance Package Immutability +/// +/// The governance package enforces its own immutability at the contract level. +/// The only way to create a `GovernanceState` is through `create()`, which +/// consumes the package's `UpgradeCap` via `make_immutable`. The existence +/// of a `GovernanceState` proves the governance package cannot be upgraded. +/// +/// ## Governance VAA Format +/// +/// This contract uses the GeneralPurposeGovernance module identifier, shared +/// with the EVM and Solana governance contracts. The wire format follows the +/// Wormhole governance packet standard: +/// +/// The Wormhole governance message envelope (handled by `governance_message`): +/// MODULE (32 bytes, "GeneralPurposeGovernance" left-padded) +/// ACTION (1 byte, SUI_CALL = 3) +/// CHAIN (2 bytes, Sui chain ID) +/// +/// The payload returned by `take_payload` (parsed by this module): +/// GOVERNANCE_ID (32 bytes) | NTT_ACTION (1 byte) | ACTION_DATA (variable) +/// +/// The NTT_ACTION byte identifies the specific NTT admin operation (1-11). +/// +/// ## PTB Usage +/// +/// ``` +/// 0. Publish ntt_governance package → init() creates DeployerCap +/// 1. Call governance::create(deployer_cap, upgrade_cap) → makes package immutable, creates GovernanceState +/// 2. Transfer AdminCap + UpgradeCap to GovernanceState address +/// 3. Call receive_admin_cap() + receive_upgrade_cap() +/// 4. vaa::parse_and_verify(wormhole_state, vaa_bytes, clock) → VAA +/// 5. governance::verify_and_consume(gov_state, wormhole_state, vaa) → GovernanceDecree +/// 6. governance::execute_(gov_state, ntt_state, decree, ...) +/// ``` +/// +/// For upgrades (multi-step): +/// ``` +/// 1. vaa::parse_and_verify(...) → VAA +/// 2. governance::verify_and_consume(gov_state, wormhole_state, vaa) → GovernanceDecree +/// 3. governance::execute_authorize_upgrade(gov_state, decree) → UpgradeTicket +/// 4. sui::package::upgrade(ticket, modules, deps, policy) → UpgradeReceipt +/// 5. governance::execute_commit_upgrade(gov_state, ntt_state, receipt) +/// ``` +module ntt_governance::governance { + use sui::clock::Clock; + use sui::dynamic_object_field as ofield; + use sui::transfer::Receiving; + use std::type_name; + use wormhole::bytes; + use wormhole::bytes32; + use wormhole::consumed_vaas; + use wormhole::cursor; + use wormhole::external_address; + use wormhole::governance_message; + use wormhole::vaa::{Self, VAA}; + use ntt::state::AdminCap; + use ntt::upgrades; + use ntt::peer; + + // ─── Errors ─── + + #[error] + const EGovernanceIdMismatch: vector = + b"Governance ID in payload does not match this instance"; + + #[error] + const ETransceiverTypeMismatch: vector = + b"Transceiver type in payload does not match provided type parameter"; + + #[error] + const EAdminCapAlreadySet: vector = + b"AdminCap already stored in this governance instance"; + + #[error] + const EUpgradeCapAlreadySet: vector = + b"UpgradeCap already stored in this governance instance"; + + #[error] + const ENoCapToTransfer: vector = + b"No capability stored to transfer"; + + #[error] + const EActionMismatch: vector = + b"Decree action does not match expected action for this function"; + + // ─── Events ─── + + /// Emitted when a governance action is executed, providing an on-chain + /// audit trail for all governance operations. + public struct GovernanceActionExecuted has copy, drop { + /// The NTT action ID (1-11) from the payload + action: u8, + /// Digest of the VAA that authorised this action + vaa_digest: address, + } + + // ─── Constants ─── + + /// Wormhole governance module name, shared across EVM, Solana, and Sui. + /// Left-padded to 32 bytes via `bytes32::from_bytes`. + const MODULE_NAME: vector = b"GeneralPurposeGovernance"; + + /// Governance action for the Wormhole envelope. Each runtime has its own: + /// EVM_CALL = 1, SOLANA_CALL = 2, SUI_CALL = 3 + const ACTION_SUI_CALL: u8 = 3; + + // NTT action IDs encoded in the governance payload (after GOVERNANCE_ID) + const ACTION_SET_PEER: u8 = 1; + const ACTION_SET_THRESHOLD: u8 = 2; + const ACTION_SET_OUTBOUND_LIMIT: u8 = 3; + const ACTION_SET_INBOUND_LIMIT: u8 = 4; + const ACTION_PAUSE: u8 = 5; + const ACTION_UNPAUSE: u8 = 6; + const ACTION_REGISTER_TRANSCEIVER: u8 = 7; + const ACTION_ENABLE_TRANSCEIVER: u8 = 8; + const ACTION_DISABLE_TRANSCEIVER: u8 = 9; + const ACTION_AUTHORIZE_UPGRADE: u8 = 10; + const ACTION_TRANSFER_OWNERSHIP: u8 = 11; + + // ─── Types ─── + + /// Phantom witness for Wormhole `DecreeTicket`/`DecreeReceipt` + /// parameterisation. Only this module can instantiate it, ensuring only + /// this module can initiate governance VAA verification. + public struct GovernanceWitness has drop {} + + /// Hot-potato returned by `verify_and_consume`. Contains the verified + /// action ID and action-specific payload. Must be consumed by an + /// `execute_*` function in the same transaction. + public struct GovernanceDecree { + action: u8, + payload: vector, + } + + /// Key types for dynamic object fields storing capabilities. + public struct AdminCapKey has copy, drop, store {} + public struct UpgradeCapKey has copy, drop, store {} + + /// Capability created at `init`, which will be destroyed once + /// `create` is called. This ensures only the deployer can + /// create the shared `GovernanceState`. + public struct DeployerCap has key, store { id: UID } + + /// Shared object holding governance state. Capabilities (AdminCap, + /// UpgradeCap) are stored as dynamic object fields, preserving their + /// top-level identity in the object store. This allows caps to be + /// transferred out for governance handoff to a new contract. + public struct GovernanceState has key { + id: UID, + consumed_vaas: consumed_vaas::ConsumedVAAs, + } + + // ─── Initialization ─── + + /// Called automatically when module is first published. Transfers + /// `DeployerCap` to sender. The deployer must then call `create()` + /// to make the package immutable and create the `GovernanceState`. + fun init(ctx: &mut TxContext) { + transfer::transfer(DeployerCap { id: object::new(ctx) }, ctx.sender()); + } + + /// Consume the `DeployerCap`, make this package immutable by destroying + /// its `UpgradeCap`, and create the shared `GovernanceState`. + /// The existence of a `GovernanceState` proves this package is immutable. + #[allow(lint(share_owned))] + public fun create( + deployer: DeployerCap, + upgrade_cap: sui::package::UpgradeCap, + ctx: &mut TxContext, + ) { + let DeployerCap { id } = deployer; + object::delete(id); + + wormhole::package_utils::assert_package_upgrade_cap( + &upgrade_cap, + sui::package::compatible_policy(), + 1, + ); + sui::package::make_immutable(upgrade_cap); + + transfer::share_object(GovernanceState { + id: object::new(ctx), + consumed_vaas: consumed_vaas::new(ctx), + }); + } + + // ─── Capability Receiving ─── + + /// Receive an `AdminCap` that was transferred to this governance object. + /// Permissionless — the cap is already owned by this GovernanceState. + public fun receive_admin_cap( + gov: &mut GovernanceState, + cap: Receiving, + ) { + assert!(!ofield::exists_(&gov.id, AdminCapKey {}), EAdminCapAlreadySet); + let admin_cap = transfer::public_receive(&mut gov.id, cap); + ofield::add(&mut gov.id, AdminCapKey {}, admin_cap); + } + + /// Receive an `UpgradeCap` that was transferred to this governance object. + /// Permissionless — the cap is already owned by this GovernanceState. + /// + /// The type system guarantees the received cap is from the correct NTT + /// package: `ntt::upgrades::UpgradeCap` is bound to the NTT package at + /// compile time, and `public_receive` enforces exact type matching + /// (including package ID). Additionally, `ntt::upgrades::new_upgrade_cap` + /// validates the inner `sui::package::UpgradeCap` via + /// `assert_package_upgrade_cap` at creation. + public fun receive_upgrade_cap( + gov: &mut GovernanceState, + cap: Receiving, + ) { + assert!(!ofield::exists_(&gov.id, UpgradeCapKey {}), EUpgradeCapAlreadySet); + let upgrade_cap = transfer::public_receive(&mut gov.id, cap); + ofield::add(&mut gov.id, UpgradeCapKey {}, upgrade_cap); + } + + // ─── Verification ─── + + /// Verify a governance VAA, consume its digest for replay protection, + /// and return a hot-potato `GovernanceDecree` containing the verified + /// NTT action and action-specific payload. + /// + /// The Wormhole envelope uses ACTION_SUI_CALL (3) as the action byte. + /// The NTT-specific action is encoded in the payload after GOVERNANCE_ID. + /// + /// 1. Creates a `DecreeTicket` with GeneralPurposeGovernance module + SUI_CALL action + /// 2. Verifies the VAA via `governance_message::verify_vaa` + /// 3. Consumes the VAA digest into `ConsumedVAAs` (replay protection) + /// 4. Parses governance_id (verifies it targets this instance) and NTT action + /// 5. Returns a `GovernanceDecree` with the NTT action and remaining payload + public fun verify_and_consume( + gov: &mut GovernanceState, + wh_state: &wormhole::state::State, + vaa: VAA, + ): GovernanceDecree { + let vaa_digest = bytes32::to_address(vaa::digest(&vaa)); + + let ticket = governance_message::authorize_verify_local( + GovernanceWitness {}, + wormhole::state::governance_chain(wh_state), + wormhole::state::governance_contract(wh_state), + bytes32::from_bytes(MODULE_NAME), + ACTION_SUI_CALL, + ); + + let receipt = governance_message::verify_vaa(wh_state, vaa, ticket); + let payload = governance_message::take_payload( + &mut gov.consumed_vaas, + receipt, + ); + + let (action, action_payload) = parse_governance_payload(gov, payload); + + sui::event::emit(GovernanceActionExecuted { action, vaa_digest }); + + GovernanceDecree { action, payload: action_payload } + } + + /// Parse the governance payload: strip the 32-byte governance instance ID + /// (asserting it matches this `GovernanceState`), extract the NTT action + /// byte, and return both the action and remaining action-specific data. + fun parse_governance_payload( + gov: &GovernanceState, + payload: vector, + ): (u8, vector) { + let mut cur = cursor::new(payload); + let gov_id = object::id_from_address( + bytes32::to_address(bytes32::take_bytes(&mut cur)), + ); + assert!(gov_id == object::id(gov), EGovernanceIdMismatch); + let action = bytes::take_u8(&mut cur); + (action, cursor::take_rest(cur)) + } + + // ─── Action Handlers ─── + + /// Action 1: Set or update a peer on a remote chain. + /// Payload: chain_id (u16) | peer_address (32) | token_decimals (u8) | inbound_limit (u64) + public fun execute_set_peer( + gov: &mut GovernanceState, + ntt_state: &mut ntt::state::State, + decree: GovernanceDecree, + clock: &Clock, + ) { + let GovernanceDecree { action, payload } = decree; + assert!(action == ACTION_SET_PEER, EActionMismatch); + + let mut cur = cursor::new(payload); + let chain_id = bytes::take_u16_be(&mut cur); + let peer_address = external_address::take_bytes(&mut cur); + let token_decimals = bytes::take_u8(&mut cur); + let inbound_limit = bytes::take_u64_be(&mut cur); + cursor::destroy_empty(cur); + + let admin_cap: &AdminCap = ofield::borrow(&gov.id, AdminCapKey {}); + ntt::state::set_peer( + admin_cap, + ntt_state, + chain_id, + peer_address, + token_decimals, + inbound_limit, + clock, + ); + } + + /// Action 2: Set the attestation threshold. + /// Payload: threshold (u8) + public fun execute_set_threshold( + gov: &mut GovernanceState, + ntt_state: &mut ntt::state::State, + decree: GovernanceDecree, + ) { + let GovernanceDecree { action, payload } = decree; + assert!(action == ACTION_SET_THRESHOLD, EActionMismatch); + + let mut cur = cursor::new(payload); + let threshold = bytes::take_u8(&mut cur); + cursor::destroy_empty(cur); + + let admin_cap: &AdminCap = ofield::borrow(&gov.id, AdminCapKey {}); + ntt::state::set_threshold(admin_cap, ntt_state, threshold); + } + + /// Action 3: Set the outbound rate limit. + /// Payload: limit (u64) + public fun execute_set_outbound_limit( + gov: &mut GovernanceState, + ntt_state: &mut ntt::state::State, + decree: GovernanceDecree, + clock: &Clock, + ) { + let GovernanceDecree { action, payload } = decree; + assert!(action == ACTION_SET_OUTBOUND_LIMIT, EActionMismatch); + + let mut cur = cursor::new(payload); + let limit = bytes::take_u64_be(&mut cur); + cursor::destroy_empty(cur); + + let admin_cap: &AdminCap = ofield::borrow(&gov.id, AdminCapKey {}); + ntt::state::set_outbound_rate_limit(admin_cap, ntt_state, limit, clock); + } + + /// Action 4: Set the inbound rate limit for a specific chain. + /// Reads existing peer address and decimals, updates only the rate limit. + /// Payload: chain_id (u16) | limit (u64) + public fun execute_set_inbound_limit( + gov: &mut GovernanceState, + ntt_state: &mut ntt::state::State, + decree: GovernanceDecree, + clock: &Clock, + ) { + let GovernanceDecree { action, payload } = decree; + assert!(action == ACTION_SET_INBOUND_LIMIT, EActionMismatch); + + let mut cur = cursor::new(payload); + let chain_id = bytes::take_u16_be(&mut cur); + let limit = bytes::take_u64_be(&mut cur); + cursor::destroy_empty(cur); + + // Read existing peer data to preserve address and decimals + let existing = ntt_state.borrow_peer(chain_id); + let address = *peer::borrow_address(existing); + let decimals = peer::get_token_decimals(existing); + + let admin_cap: &AdminCap = ofield::borrow(&gov.id, AdminCapKey {}); + ntt::state::set_peer( + admin_cap, ntt_state, chain_id, address, decimals, limit, clock, + ); + } + + /// Action 5: Pause the NTT contract. + /// Payload: (empty) + public fun execute_pause( + gov: &mut GovernanceState, + ntt_state: &mut ntt::state::State, + decree: GovernanceDecree, + ) { + let GovernanceDecree { action, payload } = decree; + assert!(action == ACTION_PAUSE, EActionMismatch); + + let cur = cursor::new(payload); + cursor::destroy_empty(cur); + + let admin_cap: &AdminCap = ofield::borrow(&gov.id, AdminCapKey {}); + ntt::state::pause(admin_cap, ntt_state); + } + + /// Action 6: Unpause the NTT contract. + /// Payload: (empty) + public fun execute_unpause( + gov: &mut GovernanceState, + ntt_state: &mut ntt::state::State, + decree: GovernanceDecree, + ) { + let GovernanceDecree { action, payload } = decree; + assert!(action == ACTION_UNPAUSE, EActionMismatch); + + let cur = cursor::new(payload); + cursor::destroy_empty(cur); + + let admin_cap: &AdminCap = ofield::borrow(&gov.id, AdminCapKey {}); + ntt::state::unpause(admin_cap, ntt_state); + } + + /// Action 7: Register a new transceiver type. + /// The caller provides `Transceiver` as a type parameter in the PTB; + /// we verify it matches the fully qualified type name in the VAA payload. + /// Payload: state_object_id (32) | type_name_len (u16) | type_name (utf8) + public fun execute_register_transceiver( + gov: &mut GovernanceState, + ntt_state: &mut ntt::state::State, + decree: GovernanceDecree, + ) { + let GovernanceDecree { action, payload } = decree; + assert!(action == ACTION_REGISTER_TRANSCEIVER, EActionMismatch); + + let mut cur = cursor::new(payload); + let state_object_id = object::id_from_address( + bytes32::to_address(bytes32::take_bytes(&mut cur)), + ); + let type_name_len = bytes::take_u16_be(&mut cur); + let encoded_type_name = bytes::take_bytes(&mut cur, type_name_len as u64); + cursor::destroy_empty(cur); + + // Verify the caller's type parameter matches what the VAA authorised + let actual_type_name = type_name::with_defining_ids().into_string().into_bytes(); + assert!(encoded_type_name == actual_type_name, ETransceiverTypeMismatch); + + let admin_cap: &AdminCap = ofield::borrow(&gov.id, AdminCapKey {}); + ntt::state::register_transceiver( + ntt_state, + state_object_id, + admin_cap, + ); + } + + /// Action 8: Enable a transceiver by ID. + /// Payload: transceiver_id (u8) + public fun execute_enable_transceiver( + gov: &mut GovernanceState, + ntt_state: &mut ntt::state::State, + decree: GovernanceDecree, + ) { + let GovernanceDecree { action, payload } = decree; + assert!(action == ACTION_ENABLE_TRANSCEIVER, EActionMismatch); + + let mut cur = cursor::new(payload); + let transceiver_id = bytes::take_u8(&mut cur); + cursor::destroy_empty(cur); + + let admin_cap: &AdminCap = ofield::borrow(&gov.id, AdminCapKey {}); + ntt::state::enable_transceiver(ntt_state, admin_cap, transceiver_id); + } + + /// Action 9: Disable a transceiver by ID. + /// Payload: transceiver_id (u8) + public fun execute_disable_transceiver( + gov: &mut GovernanceState, + ntt_state: &mut ntt::state::State, + decree: GovernanceDecree, + ) { + let GovernanceDecree { action, payload } = decree; + assert!(action == ACTION_DISABLE_TRANSCEIVER, EActionMismatch); + + let mut cur = cursor::new(payload); + let transceiver_id = bytes::take_u8(&mut cur); + cursor::destroy_empty(cur); + + let admin_cap: &AdminCap = ofield::borrow(&gov.id, AdminCapKey {}); + ntt::state::disable_transceiver(ntt_state, admin_cap, transceiver_id); + } + + /// Action 10: Authorise a package upgrade. + /// Returns a hot-potato `UpgradeTicket` that must be consumed by + /// `sui::package::upgrade` in the same PTB. + /// Payload: digest (32 bytes) + public fun execute_authorize_upgrade( + gov: &mut GovernanceState, + decree: GovernanceDecree, + ): sui::package::UpgradeTicket { + let GovernanceDecree { action, payload } = decree; + assert!(action == ACTION_AUTHORIZE_UPGRADE, EActionMismatch); + + let mut cur = cursor::new(payload); + let digest = bytes::take_bytes(&mut cur, 32); + cursor::destroy_empty(cur); + + let upgrade_cap: &mut upgrades::UpgradeCap = ofield::borrow_mut(&mut gov.id, UpgradeCapKey {}); + upgrades::authorize_upgrade(upgrade_cap, digest) + } + + /// Commit a package upgrade after `sui::package::upgrade` completes. + /// No VAA needed — authorisation was already verified in + /// `execute_authorize_upgrade`. The `receipt` is a hot-potato from + /// `sui::package::upgrade`. + public fun execute_commit_upgrade( + gov: &mut GovernanceState, + ntt_state: &mut ntt::state::State, + receipt: sui::package::UpgradeReceipt, + ) { + let upgrade_cap: &mut upgrades::UpgradeCap = ofield::borrow_mut(&mut gov.id, UpgradeCapKey {}); + upgrades::commit_upgrade(upgrade_cap, ntt_state, receipt); + } + + // ─── Ownership Transfer ─── + + /// Action 11: Transfer ownership (AdminCap + UpgradeCap) to a new address. + /// Transfers both caps (if present) to `new_owner`. If only one cap is + /// stored, transfers just that one. + /// Payload: new_owner (32 bytes) + /// + /// WARNING: `new_owner` is used as-is from the VAA payload with no on-chain + /// validation that the address is a valid GovernanceState, EOA, or even + /// non-zero. A guardian-signed VAA with an incorrect `new_owner` will + /// permanently and irrecoverably lose admin/upgrade control. Operators must + /// verify the address in the governance proposal before signing. + public fun execute_transfer_ownership( + gov: &mut GovernanceState, + decree: GovernanceDecree, + ) { + let GovernanceDecree { action, payload } = decree; + assert!(action == ACTION_TRANSFER_OWNERSHIP, EActionMismatch); + + let mut cur = cursor::new(payload); + let new_owner = bytes32::to_address(bytes32::take_bytes(&mut cur)); + cursor::destroy_empty(cur); + + let has_admin = ofield::exists_(&gov.id, AdminCapKey {}); + let has_upgrade = ofield::exists_(&gov.id, UpgradeCapKey {}); + assert!(has_admin || has_upgrade, ENoCapToTransfer); + + if (has_admin) { + let cap: AdminCap = ofield::remove(&mut gov.id, AdminCapKey {}); + transfer::public_transfer(cap, new_owner); + }; + if (has_upgrade) { + let cap: upgrades::UpgradeCap = ofield::remove(&mut gov.id, UpgradeCapKey {}); + transfer::public_transfer(cap, new_owner); + }; + } + + // ─── Test Helpers ─── + + #[test_only] + public fun init_test_only(ctx: &mut TxContext) { + init(ctx); + transfer::public_transfer( + sui::package::test_publish(object::id_from_address(@ntt_governance), ctx), + ctx.sender(), + ); + } + + /// Create a `GovernanceState` directly for testing, skipping + /// the `DeployerCap` + `UpgradeCap` ceremony. + #[test_only] + #[allow(lint(share_owned))] + public fun create_test_only(ctx: &mut TxContext) { + transfer::share_object(GovernanceState { + id: object::new(ctx), + consumed_vaas: consumed_vaas::new(ctx), + }); + } + + #[test_only] + public fun test_add_caps( + gov: &mut GovernanceState, + admin_cap: AdminCap, + upgrade_cap: upgrades::UpgradeCap, + ) { + ofield::add(&mut gov.id, AdminCapKey {}, admin_cap); + ofield::add(&mut gov.id, UpgradeCapKey {}, upgrade_cap); + } + + #[test_only] + public fun new_decree(action: u8, payload: vector): GovernanceDecree { + GovernanceDecree { action, payload } + } + + /// Transfer ownership without VAA verification. Test-only equivalent + /// of `execute_transfer_ownership`. + #[test_only] + public fun test_transfer_ownership( + gov: &mut GovernanceState, + new_owner: address, + ) { + let has_admin = ofield::exists_(&gov.id, AdminCapKey {}); + let has_upgrade = ofield::exists_(&gov.id, UpgradeCapKey {}); + assert!(has_admin || has_upgrade, ENoCapToTransfer); + + if (has_admin) { + let cap: AdminCap = ofield::remove(&mut gov.id, AdminCapKey {}); + transfer::public_transfer(cap, new_owner); + }; + if (has_upgrade) { + let cap: upgrades::UpgradeCap = ofield::remove(&mut gov.id, UpgradeCapKey {}); + transfer::public_transfer(cap, new_owner); + }; + } + + /// Parse a governance payload (governance_id + ntt_action + action_data), + /// verify the governance_id matches this instance, and return the + /// (action, action_data). Uses the same code path as `verify_and_consume`. + #[test_only] + public fun test_parse_governance_payload( + gov: &GovernanceState, + payload: vector, + ): (u8, vector) { + parse_governance_payload(gov, payload) + } +} diff --git a/sui/packages/ntt_governance/tests/governance_scenario.move b/sui/packages/ntt_governance/tests/governance_scenario.move new file mode 100644 index 000000000..adf062f81 --- /dev/null +++ b/sui/packages/ntt_governance/tests/governance_scenario.move @@ -0,0 +1,183 @@ +#[test_only] +/// Test scenario helpers for the NTT governance package. +/// Sets up both NTT state and governance state for integration testing. +#[allow(deprecated_usage)] +module ntt_governance::governance_scenario { + use sui::test_scenario::{Self as ts, Scenario}; + use sui::coin::{Self, CoinMetadata}; + use sui::clock::{Self, Clock}; + use wormhole::bytes32; + use wormhole::external_address::{Self, ExternalAddress}; + use ntt::state::{State, AdminCap}; + use ntt::upgrades; + use ntt_governance::governance::{Self, GovernanceState}; + + const ADMIN: address = @0x1111; + const CHAIN_ID: u16 = 1; + const PEER_CHAIN_ID: u16 = 2; + const DECIMALS: u8 = 9; + const RATE_LIMIT: u64 = 5_000_000_000; // 5 tokens with 9 decimals + + public struct GOVERNANCE_SCENARIO has drop {} + + public fun admin(): address { ADMIN } + public fun chain_id(): u16 { CHAIN_ID } + public fun peer_chain_id(): u16 { PEER_CHAIN_ID } + public fun decimals(): u8 { DECIMALS } + public fun rate_limit(): u64 { RATE_LIMIT } + + public fun peer_address(): ExternalAddress { + external_address::new(bytes32::from_bytes( + x"0000000000000000000000000000000000000000000000000000000000000001", + )) + } + + /// Set up a complete governance test environment. + /// Builds on `setup_empty`, then moves caps from ADMIN into governance. + /// + /// After setup, the following shared objects are available: + /// - `State` (NTT state) + /// - `GovernanceState` (with AdminCap and UpgradeCap) + /// - `CoinMetadata` + public fun setup(scenario: &mut Scenario) { + setup_empty(scenario); + + // Move caps from ADMIN into governance + let admin_cap = scenario.take_from_sender(); + let upgrade_cap = scenario.take_from_sender(); + let mut gov: GovernanceState = ts::take_shared(scenario); + governance::test_add_caps(&mut gov, admin_cap, upgrade_cap); + ts::return_shared(gov); + + scenario.next_tx(ADMIN); + } + + /// Set up an empty governance state (for handoff testing). + /// Also sets up NTT state. The GovernanceState has no caps. + /// + /// After setup, the following shared objects are available: + /// - `State` (NTT state) + /// - `GovernanceState` (empty, no caps) + /// - `CoinMetadata` + /// And the following are owned by ADMIN: + /// - `AdminCap` + /// - `ntt::upgrades::UpgradeCap` + public fun setup_empty(scenario: &mut Scenario) { + // Transaction 1: init both packages + scenario.next_tx(ADMIN); + ntt::setup::init_test_only(ts::ctx(scenario)); + governance::create_test_only(ts::ctx(scenario)); + + // Transaction 2: complete NTT setup (gov already shared from create_test_only) + scenario.next_tx(ADMIN); + + let ntt_deployer = ts::take_from_sender(scenario); + let upgrade_cap = ts::take_from_sender(scenario); + + let (treasury_cap, metadata) = coin::create_currency( + GOVERNANCE_SCENARIO {}, + DECIMALS, + b"GTEST", + b"Gov Test Coin", + b"", + option::none(), + ts::ctx(scenario), + ); + + let (admin_cap, ntt_upgrade_cap) = ntt::setup::complete_burning( + ntt_deployer, + upgrade_cap, + CHAIN_ID, + treasury_cap, + ts::ctx(scenario), + ); + + // Transfer caps to sender (they'll be used in later transactions) + transfer::public_transfer(admin_cap, ADMIN); + transfer::public_transfer(ntt_upgrade_cap, ADMIN); + transfer::public_share_object(metadata); + + // Transaction 3: shared objects now available + scenario.next_tx(ADMIN); + } + + /// Create a second NTT deployment to produce an extra AdminCap. + /// The AdminCap and UpgradeCap are transferred to ADMIN. + /// Used for tests that need multiple AdminCaps (e.g. double-receive). + public fun setup_second_ntt(scenario: &mut Scenario) { + scenario.next_tx(ADMIN); + ntt::setup::init_test_only(ts::ctx(scenario)); + + scenario.next_tx(ADMIN); + let ntt_deployer = ts::take_from_sender(scenario); + let upgrade_cap = ts::take_from_sender(scenario); + + let (treasury_cap, metadata) = coin::create_currency( + GOVERNANCE_SCENARIO {}, + DECIMALS, + b"GT2", + b"Gov Test 2", + b"", + option::none(), + ts::ctx(scenario), + ); + + let (admin_cap, ntt_upgrade_cap) = ntt::setup::complete_burning( + ntt_deployer, + upgrade_cap, + CHAIN_ID, + treasury_cap, + ts::ctx(scenario), + ); + + transfer::public_transfer(admin_cap, ADMIN); + transfer::public_transfer(ntt_upgrade_cap, ADMIN); + transfer::public_share_object(metadata); + + scenario.next_tx(ADMIN); + } + + // ─── Object Take/Return Helpers ─── + + public fun take_gov(scenario: &Scenario): GovernanceState { + ts::take_shared(scenario) + } + + public fun return_gov(gov: GovernanceState) { + ts::return_shared(gov); + } + + public fun take_ntt_state(scenario: &Scenario): State { + ts::take_shared(scenario) + } + + public fun return_ntt_state(state: State) { + ts::return_shared(state); + } + + public fun take_coin_metadata(scenario: &Scenario): CoinMetadata { + ts::take_shared(scenario) + } + + public fun return_coin_metadata(metadata: CoinMetadata) { + ts::return_shared(metadata); + } + + public fun take_clock(scenario: &mut Scenario): Clock { + clock::create_for_testing(ts::ctx(scenario)) + } + + public fun return_clock(clock: Clock) { + clock::destroy_for_testing(clock); + } +} + +#[test_only] +module ntt_governance::test_transceiver_a { + public struct TransceiverAuth has drop {} +} + +#[test_only] +module ntt_governance::test_transceiver_b { + public struct TransceiverAuth has drop {} +} diff --git a/sui/packages/ntt_governance/tests/governance_tests.move b/sui/packages/ntt_governance/tests/governance_tests.move new file mode 100644 index 000000000..6d148e5ba --- /dev/null +++ b/sui/packages/ntt_governance/tests/governance_tests.move @@ -0,0 +1,782 @@ +#[test_only] +module ntt_governance::governance_tests { + use sui::test_scenario; + use std::type_name; + use wormhole::bytes; + use wormhole::bytes32; + use ntt::state::{Self, AdminCap}; + use ntt::upgrades; + use ntt_governance::governance::{Self, GovernanceState}; + use ntt_governance::governance_scenario::{Self as gs}; + use ntt_governance::test_transceiver_a; + use ntt_governance::test_transceiver_b; + + // ─── Deployment Tests ─── + + #[test] + fun test_init() { + let mut scenario = test_scenario::begin(gs::admin()); + gs::setup(&mut scenario); + + // Verify GovernanceState exists as shared object + let gov = gs::take_gov(&scenario); + gs::return_gov(gov); + + // Verify NTT State exists as shared object + let ntt_state = gs::take_ntt_state(&scenario); + gs::return_ntt_state(ntt_state); + + scenario.end(); + } + + // ─── Decree-Based Action Tests ─── + + #[test] + fun test_set_peer_via_governance() { + let mut scenario = test_scenario::begin(gs::admin()); + gs::setup(&mut scenario); + + let mut gov = gs::take_gov(&scenario); + let mut ntt_state = gs::take_ntt_state(&scenario); + let clock = gs::take_clock(&mut scenario); + + // Build set_peer payload: chain_id (u16) | peer_address (32) | decimals (u8) | limit (u64) + let mut payload = vector[]; + bytes::push_u16_be(&mut payload, gs::peer_chain_id()); + payload.append(x"0000000000000000000000000000000000000000000000000000000000000001"); + bytes::push_u8(&mut payload, gs::decimals()); + bytes::push_u64_be(&mut payload, gs::rate_limit()); + + // ACTION_SET_PEER = 1 + let decree = governance::new_decree(1, payload); + governance::execute_set_peer(&mut gov, &mut ntt_state, decree, &clock); + + // Verify peer was set + let peer = state::borrow_peer(&ntt_state, gs::peer_chain_id()); + assert!(*ntt::peer::borrow_address(peer) == gs::peer_address()); + assert!(ntt::peer::get_token_decimals(peer) == gs::decimals()); + + gs::return_gov(gov); + gs::return_ntt_state(ntt_state); + gs::return_clock(clock); + scenario.end(); + } + + #[test] + fun test_set_threshold_via_governance() { + let mut scenario = test_scenario::begin(gs::admin()); + gs::setup(&mut scenario); + + let mut gov = gs::take_gov(&scenario); + let mut ntt_state = gs::take_ntt_state(&scenario); + + // Register transceiver A (ACTION_REGISTER_TRANSCEIVER = 7) + let type_a = type_name::with_defining_ids() + .into_string().into_bytes(); + let mut reg_a = vector[]; + reg_a.append(x"0000000000000000000000000000000000000000000000000000000000000100"); + bytes::push_u16_be(&mut reg_a, type_a.length() as u16); + reg_a.append(type_a); + governance::execute_register_transceiver( + &mut gov, &mut ntt_state, governance::new_decree(7, reg_a), + ); + assert!(state::threshold(&ntt_state) == 1); + + // Register transceiver B + let type_b = type_name::with_defining_ids() + .into_string().into_bytes(); + let mut reg_b = vector[]; + reg_b.append(x"0000000000000000000000000000000000000000000000000000000000000101"); + bytes::push_u16_be(&mut reg_b, type_b.length() as u16); + reg_b.append(type_b); + governance::execute_register_transceiver( + &mut gov, &mut ntt_state, governance::new_decree(7, reg_b), + ); + + // Set threshold to 2 (ACTION_SET_THRESHOLD = 2) + let mut threshold_payload = vector[]; + bytes::push_u8(&mut threshold_payload, 2); + governance::execute_set_threshold( + &mut gov, &mut ntt_state, governance::new_decree(2, threshold_payload), + ); + assert!(state::threshold(&ntt_state) == 2); + + gs::return_gov(gov); + gs::return_ntt_state(ntt_state); + scenario.end(); + } + + #[test] + fun test_pause_unpause_via_governance() { + let mut scenario = test_scenario::begin(gs::admin()); + gs::setup(&mut scenario); + + let mut gov = gs::take_gov(&scenario); + let mut ntt_state = gs::take_ntt_state(&scenario); + + // Initially not paused + assert!(!state::is_paused(&ntt_state)); + + // Pause (ACTION_PAUSE = 5) + governance::execute_pause( + &mut gov, &mut ntt_state, governance::new_decree(5, vector[]), + ); + assert!(state::is_paused(&ntt_state)); + + // Unpause (ACTION_UNPAUSE = 6) + governance::execute_unpause( + &mut gov, &mut ntt_state, governance::new_decree(6, vector[]), + ); + assert!(!state::is_paused(&ntt_state)); + + gs::return_gov(gov); + gs::return_ntt_state(ntt_state); + scenario.end(); + } + + #[test] + fun test_set_outbound_limit_via_governance() { + let mut scenario = test_scenario::begin(gs::admin()); + gs::setup(&mut scenario); + + let mut gov = gs::take_gov(&scenario); + let mut ntt_state = gs::take_ntt_state(&scenario); + let clock = gs::take_clock(&mut scenario); + + // ACTION_SET_OUTBOUND_LIMIT = 3 + let new_limit: u64 = 1_000_000_000; + let mut payload = vector[]; + bytes::push_u64_be(&mut payload, new_limit); + governance::execute_set_outbound_limit( + &mut gov, &mut ntt_state, governance::new_decree(3, payload), &clock, + ); + + gs::return_gov(gov); + gs::return_ntt_state(ntt_state); + gs::return_clock(clock); + scenario.end(); + } + + #[test] + fun test_set_inbound_limit_via_governance() { + let mut scenario = test_scenario::begin(gs::admin()); + gs::setup(&mut scenario); + + let mut gov = gs::take_gov(&scenario); + let mut ntt_state = gs::take_ntt_state(&scenario); + let clock = gs::take_clock(&mut scenario); + + // Set up a peer first (ACTION_SET_PEER = 1) + let mut peer_payload = vector[]; + bytes::push_u16_be(&mut peer_payload, gs::peer_chain_id()); + peer_payload.append(x"0000000000000000000000000000000000000000000000000000000000000001"); + bytes::push_u8(&mut peer_payload, gs::decimals()); + bytes::push_u64_be(&mut peer_payload, gs::rate_limit()); + governance::execute_set_peer( + &mut gov, &mut ntt_state, governance::new_decree(1, peer_payload), &clock, + ); + + // Now update only the inbound limit (ACTION_SET_INBOUND_LIMIT = 4) + let new_limit: u64 = 2_000_000_000; + let mut limit_payload = vector[]; + bytes::push_u16_be(&mut limit_payload, gs::peer_chain_id()); + bytes::push_u64_be(&mut limit_payload, new_limit); + governance::execute_set_inbound_limit( + &mut gov, &mut ntt_state, governance::new_decree(4, limit_payload), &clock, + ); + + gs::return_gov(gov); + gs::return_ntt_state(ntt_state); + gs::return_clock(clock); + scenario.end(); + } + + #[test] + fun test_register_transceiver_via_governance() { + let mut scenario = test_scenario::begin(gs::admin()); + gs::setup(&mut scenario); + + let mut gov = gs::take_gov(&scenario); + let mut ntt_state = gs::take_ntt_state(&scenario); + + // No transceivers initially + assert!(state::get_enabled_transceivers(&ntt_state).count_ones() == 0); + + // Register transceiver A (ACTION_REGISTER_TRANSCEIVER = 7) + let type_a = type_name::with_defining_ids() + .into_string().into_bytes(); + let mut reg_a = vector[]; + reg_a.append(x"0000000000000000000000000000000000000000000000000000000000000100"); + bytes::push_u16_be(&mut reg_a, type_a.length() as u16); + reg_a.append(type_a); + governance::execute_register_transceiver( + &mut gov, &mut ntt_state, governance::new_decree(7, reg_a), + ); + assert!(state::get_enabled_transceivers(&ntt_state).count_ones() == 1); + assert!(state::threshold(&ntt_state) == 1); + + // Register transceiver B + let type_b = type_name::with_defining_ids() + .into_string().into_bytes(); + let mut reg_b = vector[]; + reg_b.append(x"0000000000000000000000000000000000000000000000000000000000000101"); + bytes::push_u16_be(&mut reg_b, type_b.length() as u16); + reg_b.append(type_b); + governance::execute_register_transceiver( + &mut gov, &mut ntt_state, governance::new_decree(7, reg_b), + ); + assert!(state::get_enabled_transceivers(&ntt_state).count_ones() == 2); + + gs::return_gov(gov); + gs::return_ntt_state(ntt_state); + scenario.end(); + } + + #[test] + fun test_enable_disable_transceiver_via_governance() { + let mut scenario = test_scenario::begin(gs::admin()); + gs::setup(&mut scenario); + + let mut gov = gs::take_gov(&scenario); + let mut ntt_state = gs::take_ntt_state(&scenario); + + // Register two transceivers (ACTION_REGISTER_TRANSCEIVER = 7) + let type_a = type_name::with_defining_ids() + .into_string().into_bytes(); + let mut reg_a = vector[]; + reg_a.append(x"0000000000000000000000000000000000000000000000000000000000000100"); + bytes::push_u16_be(&mut reg_a, type_a.length() as u16); + reg_a.append(type_a); + governance::execute_register_transceiver( + &mut gov, &mut ntt_state, governance::new_decree(7, reg_a), + ); + + let type_b = type_name::with_defining_ids() + .into_string().into_bytes(); + let mut reg_b = vector[]; + reg_b.append(x"0000000000000000000000000000000000000000000000000000000000000101"); + bytes::push_u16_be(&mut reg_b, type_b.length() as u16); + reg_b.append(type_b); + governance::execute_register_transceiver( + &mut gov, &mut ntt_state, governance::new_decree(7, reg_b), + ); + assert!(state::get_enabled_transceivers(&ntt_state).count_ones() == 2); + + // Set threshold to 2 (ACTION_SET_THRESHOLD = 2) + let mut threshold_payload = vector[]; + bytes::push_u8(&mut threshold_payload, 2); + governance::execute_set_threshold( + &mut gov, &mut ntt_state, governance::new_decree(2, threshold_payload), + ); + + // Disable transceiver B (id=1) — threshold auto-reduces to 1 + // ACTION_DISABLE_TRANSCEIVER = 9 + let mut disable_payload = vector[]; + bytes::push_u8(&mut disable_payload, 1); + governance::execute_disable_transceiver( + &mut gov, &mut ntt_state, governance::new_decree(9, disable_payload), + ); + assert!(state::get_enabled_transceivers(&ntt_state).count_ones() == 1); + assert!(state::threshold(&ntt_state) == 1); + + // Re-enable transceiver B (ACTION_ENABLE_TRANSCEIVER = 8) + let mut enable_payload = vector[]; + bytes::push_u8(&mut enable_payload, 1); + governance::execute_enable_transceiver( + &mut gov, &mut ntt_state, governance::new_decree(8, enable_payload), + ); + assert!(state::get_enabled_transceivers(&ntt_state).count_ones() == 2); + + gs::return_gov(gov); + gs::return_ntt_state(ntt_state); + scenario.end(); + } + + #[test] + fun test_authorize_upgrade_via_governance() { + let mut scenario = test_scenario::begin(gs::admin()); + gs::setup(&mut scenario); + + let mut gov = gs::take_gov(&scenario); + let mut ntt_state = gs::take_ntt_state(&scenario); + + // ACTION_AUTHORIZE_UPGRADE = 10 + let digest = x"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + let decree = governance::new_decree(10, digest); + let ticket = governance::execute_authorize_upgrade(&mut gov, decree); + let receipt = sui::package::test_upgrade(ticket); + governance::execute_commit_upgrade(&mut gov, &mut ntt_state, receipt); + + gs::return_gov(gov); + gs::return_ntt_state(ntt_state); + scenario.end(); + } + + // ─── Governance ID Validation Tests ─── + + #[test] + fun test_governance_id_check_correct() { + let mut scenario = test_scenario::begin(gs::admin()); + gs::setup(&mut scenario); + + let gov = gs::take_gov(&scenario); + + // Construct payload: governance_id (32) | ntt_action (1) | action_data + let gov_id_bytes = bytes32::from_address(object::id_address(&gov)); + let mut payload = bytes32::to_bytes(gov_id_bytes); + bytes::push_u8(&mut payload, 5); // NTT action (e.g. ACTION_PAUSE) + bytes::push_u8(&mut payload, 42); // Some action data + + // Should succeed — correct governance ID + let (action, action_data) = governance::test_parse_governance_payload(&gov, payload); + assert!(action == 5); + assert!(action_data.length() == 1); + + gs::return_gov(gov); + scenario.end(); + } + + #[test, expected_failure(abort_code = governance::EGovernanceIdMismatch)] + fun test_governance_id_check_wrong_id() { + let mut scenario = test_scenario::begin(gs::admin()); + gs::setup(&mut scenario); + + let gov = gs::take_gov(&scenario); + + // Construct payload with WRONG governance ID + let wrong_id = bytes32::from_bytes( + x"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + ); + let payload = bytes32::to_bytes(wrong_id); + + // Should abort — wrong governance ID + governance::test_parse_governance_payload(&gov, payload); + + gs::return_gov(gov); + scenario.end(); + } + + // ─── Capability Transfer Tests ─── + + #[test] + fun test_receive_caps() { + let mut scenario = test_scenario::begin(gs::admin()); + gs::setup_empty(&mut scenario); + + // Gov exists but has no caps + let gov = gs::take_gov(&scenario); + let gov_addr = object::id_address(&gov); + + // Transfer caps to governance object address + let admin_cap = scenario.take_from_sender(); + let upgrade_cap = scenario.take_from_sender(); + transfer::public_transfer(admin_cap, gov_addr); + transfer::public_transfer(upgrade_cap, gov_addr); + gs::return_gov(gov); + + // Next transaction: receive caps + scenario.next_tx(gs::admin()); + let mut gov = gs::take_gov(&scenario); + let admin_recv = test_scenario::most_recent_receiving_ticket( + &object::id(&gov), + ); + governance::receive_admin_cap(&mut gov, admin_recv); + let upgrade_recv = test_scenario::most_recent_receiving_ticket( + &object::id(&gov), + ); + governance::receive_upgrade_cap(&mut gov, upgrade_recv); + + // Verify governance can now use caps via decree + let mut ntt_state = gs::take_ntt_state(&scenario); + // ACTION_PAUSE = 5 + governance::execute_pause( + &mut gov, &mut ntt_state, governance::new_decree(5, vector[]), + ); + assert!(state::is_paused(&ntt_state)); + + gs::return_gov(gov); + gs::return_ntt_state(ntt_state); + scenario.end(); + } + + #[test] + fun test_handoff() { + let mut scenario = test_scenario::begin(gs::admin()); + + // 1. Setup gov_a with caps (fresh deploy) + gs::setup(&mut scenario); + let gov_a = gs::take_gov(&scenario); + let gov_a_id = object::id(&gov_a); + gs::return_gov(gov_a); + + // 2. Create gov_b (empty) + scenario.next_tx(gs::admin()); + governance::create_test_only(test_scenario::ctx(&mut scenario)); + + // 3. Find gov_b by taking both shared GovernanceStates + scenario.next_tx(gs::admin()); + let g1: GovernanceState = test_scenario::take_shared(&scenario); + let g2: GovernanceState = test_scenario::take_shared(&scenario); + let (gov_b_id, gov_b_address) = if (object::id(&g1) == gov_a_id) { + (object::id(&g2), object::id_address(&g2)) + } else { + (object::id(&g1), object::id_address(&g1)) + }; + test_scenario::return_shared(g1); + test_scenario::return_shared(g2); + + // 4. Transfer caps from gov_a to gov_b's address + scenario.next_tx(gs::admin()); + let mut gov_a = test_scenario::take_shared_by_id(&scenario, gov_a_id); + governance::test_transfer_ownership(&mut gov_a, gov_b_address); + test_scenario::return_shared(gov_a); + + // 5. Receive caps into gov_b + scenario.next_tx(gs::admin()); + let mut gov_b = test_scenario::take_shared_by_id( + &scenario, gov_b_id, + ); + let admin_recv = test_scenario::most_recent_receiving_ticket( + &object::id(&gov_b), + ); + governance::receive_admin_cap(&mut gov_b, admin_recv); + let upgrade_recv = test_scenario::most_recent_receiving_ticket( + &object::id(&gov_b), + ); + governance::receive_upgrade_cap(&mut gov_b, upgrade_recv); + + // 6. Verify gov_b can use the caps via decree + let mut ntt_state = gs::take_ntt_state(&scenario); + // ACTION_PAUSE = 5 + governance::execute_pause( + &mut gov_b, &mut ntt_state, governance::new_decree(5, vector[]), + ); + assert!(state::is_paused(&ntt_state)); + + gs::return_ntt_state(ntt_state); + test_scenario::return_shared(gov_b); + test_scenario::return_shared( + test_scenario::take_shared_by_id(&scenario, gov_a_id), + ); + scenario.end(); + } + + #[test, expected_failure] + fun test_action_fails_without_cap() { + let mut scenario = test_scenario::begin(gs::admin()); + gs::setup_empty(&mut scenario); + + let mut gov = gs::take_gov(&scenario); + let mut ntt_state = gs::take_ntt_state(&scenario); + + // Trying to execute on empty gov should abort + // (EFieldDoesNotExist from dynamic_object_field) + // ACTION_PAUSE = 5 + governance::execute_pause( + &mut gov, &mut ntt_state, governance::new_decree(5, vector[]), + ); + + gs::return_gov(gov); + gs::return_ntt_state(ntt_state); + scenario.end(); + } + + #[test, expected_failure(abort_code = governance::EAdminCapAlreadySet)] + fun test_double_receive_admin_cap_fails() { + let mut scenario = test_scenario::begin(gs::admin()); + + // Use setup_empty twice to get two sets of caps + gs::setup_empty(&mut scenario); + + let gov = gs::take_gov(&scenario); + let gov_addr = object::id_address(&gov); + + // Transfer first admin cap to gov + let admin_cap = scenario.take_from_sender(); + transfer::public_transfer(admin_cap, gov_addr); + // Keep upgrade cap and second admin cap aside + let upgrade_cap = scenario.take_from_sender(); + transfer::public_transfer(upgrade_cap, gs::admin()); + gs::return_gov(gov); + + // Receive first admin cap + scenario.next_tx(gs::admin()); + { + let mut gov = gs::take_gov(&scenario); + let admin_recv = test_scenario::most_recent_receiving_ticket( + &object::id(&gov), + ); + governance::receive_admin_cap(&mut gov, admin_recv); + gs::return_gov(gov); + }; + + // Now get a second AdminCap by creating another NTT deployment + // (done via the scenario module helper) + gs::setup_second_ntt(&mut scenario); + + // Transfer second admin cap to gov + scenario.next_tx(gs::admin()); + { + let gov = gs::take_gov(&scenario); + let gov_addr = object::id_address(&gov); + let admin_cap2 = scenario.take_from_sender(); + transfer::public_transfer(admin_cap2, gov_addr); + gs::return_gov(gov); + }; + + // Try to receive second admin cap — should fail with EAdminCapAlreadySet + scenario.next_tx(gs::admin()); + let mut gov = gs::take_gov(&scenario); + let admin_recv2 = test_scenario::most_recent_receiving_ticket( + &object::id(&gov), + ); + governance::receive_admin_cap(&mut gov, admin_recv2); + + gs::return_gov(gov); + scenario.end(); + } + + #[test] + fun test_transfer_with_only_admin_cap() { + let mut scenario = test_scenario::begin(gs::admin()); + gs::setup_empty(&mut scenario); + + let gov = gs::take_gov(&scenario); + let gov_addr = object::id_address(&gov); + + // Only transfer admin cap (not upgrade cap) + let admin_cap = scenario.take_from_sender(); + transfer::public_transfer(admin_cap, gov_addr); + // Keep upgrade cap with admin + let upgrade_cap = scenario.take_from_sender(); + transfer::public_transfer(upgrade_cap, gs::admin()); + gs::return_gov(gov); + + // Receive admin cap only + scenario.next_tx(gs::admin()); + let mut gov = gs::take_gov(&scenario); + let admin_recv = test_scenario::most_recent_receiving_ticket( + &object::id(&gov), + ); + governance::receive_admin_cap(&mut gov, admin_recv); + + // Transfer ownership with only admin cap + let target_addr = @0xBEEF; + governance::test_transfer_ownership(&mut gov, target_addr); + + gs::return_gov(gov); + scenario.end(); + } + + #[test, expected_failure(abort_code = governance::ENoCapToTransfer)] + fun test_transfer_empty_fails() { + let mut scenario = test_scenario::begin(gs::admin()); + gs::setup_empty(&mut scenario); + + let mut gov = gs::take_gov(&scenario); + + // Keep caps with admin, don't send to gov + let admin_cap = scenario.take_from_sender(); + let upgrade_cap = scenario.take_from_sender(); + transfer::public_transfer(admin_cap, gs::admin()); + transfer::public_transfer(upgrade_cap, gs::admin()); + + // Try to transfer ownership from empty gov — should fail + governance::test_transfer_ownership(&mut gov, @0xBEEF); + + gs::return_gov(gov); + scenario.end(); + } + + // ─── Action Mismatch Tests ─── + + #[test, expected_failure(abort_code = governance::EActionMismatch)] + fun test_set_peer_action_mismatch() { + let mut scenario = test_scenario::begin(gs::admin()); + gs::setup(&mut scenario); + + let mut gov = gs::take_gov(&scenario); + let mut ntt_state = gs::take_ntt_state(&scenario); + let clock = gs::take_clock(&mut scenario); + + // Build valid set_peer payload but with wrong action (2 instead of 1) + let mut payload = vector[]; + bytes::push_u16_be(&mut payload, gs::peer_chain_id()); + payload.append(x"0000000000000000000000000000000000000000000000000000000000000001"); + bytes::push_u8(&mut payload, gs::decimals()); + bytes::push_u64_be(&mut payload, gs::rate_limit()); + + let decree = governance::new_decree(2, payload); // ACTION_SET_THRESHOLD, not SET_PEER + governance::execute_set_peer(&mut gov, &mut ntt_state, decree, &clock); + + gs::return_gov(gov); + gs::return_ntt_state(ntt_state); + gs::return_clock(clock); + scenario.end(); + } + + #[test, expected_failure(abort_code = governance::EActionMismatch)] + fun test_pause_action_mismatch() { + let mut scenario = test_scenario::begin(gs::admin()); + gs::setup(&mut scenario); + + let mut gov = gs::take_gov(&scenario); + let mut ntt_state = gs::take_ntt_state(&scenario); + + // Send ACTION_UNPAUSE (6) to execute_pause (expects 5) + governance::execute_pause( + &mut gov, &mut ntt_state, governance::new_decree(6, vector[]), + ); + + gs::return_gov(gov); + gs::return_ntt_state(ntt_state); + scenario.end(); + } + + #[test, expected_failure(abort_code = governance::EActionMismatch)] + fun test_authorize_upgrade_action_mismatch() { + let mut scenario = test_scenario::begin(gs::admin()); + gs::setup(&mut scenario); + + let mut gov = gs::take_gov(&scenario); + + // Send ACTION_SET_PEER (1) to execute_authorize_upgrade (expects 10) + let digest = x"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + let decree = governance::new_decree(1, digest); + let _ticket = governance::execute_authorize_upgrade(&mut gov, decree); + + abort 0 // unreachable + } + + #[test, expected_failure(abort_code = governance::EActionMismatch)] + fun test_transfer_ownership_action_mismatch() { + let mut scenario = test_scenario::begin(gs::admin()); + gs::setup(&mut scenario); + + let mut gov = gs::take_gov(&scenario); + + // Send ACTION_PAUSE (5) to execute_transfer_ownership (expects 11) + let new_owner = x"0000000000000000000000000000000000000000000000000000000000001234"; + governance::execute_transfer_ownership( + &mut gov, governance::new_decree(5, new_owner), + ); + + gs::return_gov(gov); + scenario.end(); + } + + // ─── Payload Truncation Tests ─── + + #[test, expected_failure] + fun test_set_peer_truncated_payload() { + let mut scenario = test_scenario::begin(gs::admin()); + gs::setup(&mut scenario); + + let mut gov = gs::take_gov(&scenario); + let mut ntt_state = gs::take_ntt_state(&scenario); + let clock = gs::take_clock(&mut scenario); + + // Truncated payload: only chain_id (2 bytes), missing peer_address/decimals/limit + let mut payload = vector[]; + bytes::push_u16_be(&mut payload, gs::peer_chain_id()); + + let decree = governance::new_decree(1, payload); + governance::execute_set_peer(&mut gov, &mut ntt_state, decree, &clock); + + gs::return_gov(gov); + gs::return_ntt_state(ntt_state); + gs::return_clock(clock); + scenario.end(); + } + + #[test, expected_failure] + fun test_set_threshold_trailing_bytes() { + let mut scenario = test_scenario::begin(gs::admin()); + gs::setup(&mut scenario); + + let mut gov = gs::take_gov(&scenario); + let mut ntt_state = gs::take_ntt_state(&scenario); + + // Register a transceiver first so threshold=1 is valid + let type_a = type_name::with_defining_ids() + .into_string().into_bytes(); + let mut reg_a = vector[]; + reg_a.append(x"0000000000000000000000000000000000000000000000000000000000000100"); + bytes::push_u16_be(&mut reg_a, type_a.length() as u16); + reg_a.append(type_a); + governance::execute_register_transceiver( + &mut gov, &mut ntt_state, governance::new_decree(7, reg_a), + ); + + // Threshold payload with trailing garbage — cursor::destroy_empty should abort + let mut payload = vector[]; + bytes::push_u8(&mut payload, 1); + bytes::push_u8(&mut payload, 0xFF); // trailing byte + + let decree = governance::new_decree(2, payload); + governance::execute_set_threshold(&mut gov, &mut ntt_state, decree); + + gs::return_gov(gov); + gs::return_ntt_state(ntt_state); + scenario.end(); + } + + #[test, expected_failure] + fun test_authorize_upgrade_truncated_digest() { + let mut scenario = test_scenario::begin(gs::admin()); + gs::setup(&mut scenario); + + let mut gov = gs::take_gov(&scenario); + + // Truncated digest — only 16 bytes instead of 32 + let short_digest = x"deadbeefdeadbeefdeadbeefdeadbeef"; + let decree = governance::new_decree(10, short_digest); + let _ticket = governance::execute_authorize_upgrade(&mut gov, decree); + + abort 0 // unreachable + } + + #[test, expected_failure] + fun test_transfer_ownership_empty_payload() { + let mut scenario = test_scenario::begin(gs::admin()); + gs::setup(&mut scenario); + + let mut gov = gs::take_gov(&scenario); + + // Empty payload — should abort when trying to read 32-byte address + governance::execute_transfer_ownership( + &mut gov, governance::new_decree(11, vector[]), + ); + + gs::return_gov(gov); + scenario.end(); + } + + // ─── Type Mismatch Test ─── + + #[test, expected_failure(abort_code = governance::ETransceiverTypeMismatch)] + fun test_register_transceiver_type_mismatch() { + let mut scenario = test_scenario::begin(gs::admin()); + gs::setup(&mut scenario); + + let mut gov = gs::take_gov(&scenario); + let mut ntt_state = gs::take_ntt_state(&scenario); + + // Build payload with type_name of TransceiverAuth A... + let type_a = type_name::with_defining_ids() + .into_string().into_bytes(); + let mut payload = vector[]; + payload.append(x"0000000000000000000000000000000000000000000000000000000000000100"); + bytes::push_u16_be(&mut payload, type_a.length() as u16); + payload.append(type_a); + + // ...but provide TransceiverAuth B as the type parameter + governance::execute_register_transceiver( + &mut gov, &mut ntt_state, governance::new_decree(7, payload), + ); + + gs::return_gov(gov); + gs::return_ntt_state(ntt_state); + scenario.end(); + } +} diff --git a/sui/packages/wormhole_transceiver/Move.toml b/sui/packages/wormhole_transceiver/Move.toml index 23576c1ff..e213a02dd 100644 --- a/sui/packages/wormhole_transceiver/Move.toml +++ b/sui/packages/wormhole_transceiver/Move.toml @@ -1,22 +1,10 @@ [package] -name = "WormholeTransceiver" -edition = "2024.beta" # edition = "legacy" to use legacy (pre-2024) Move -license = "Apache 2.0" - -[dependencies.Wormhole] -git = "https://github.com/wormhole-foundation/wormhole.git" -rev = "sui/testnet" -subdir = "sui/wormhole" - -[dependencies.NttCommon] -local = "../ntt_common" - -[dependencies.Ntt] -local = "../ntt" - -[addresses] -wormhole_transceiver = "0x0" - -[dev-dependencies] - -[dev-addresses] +name = "wormhole_transceiver" +version = "1.0.0" +edition = "2024.beta" + +[dependencies] +# After wormhole PR #4639 merges, switch to: rev = "main" +wormhole = { git = "https://github.com/wormholelabs-xyz/wormhole.git", subdir = "sui/wormhole", rev = "sui-package-update" } +ntt_common = { local = "../ntt_common" } +ntt = { local = "../ntt" } diff --git a/sui/ts/src/ntt.ts b/sui/ts/src/ntt.ts index cd6fd87c5..c9c94f91c 100644 --- a/sui/ts/src/ntt.ts +++ b/sui/ts/src/ntt.ts @@ -261,6 +261,11 @@ export class SuiNtt "AddressOwner" in adminCap.data.owner ) { ownerAddress = adminCap.data.owner.AddressOwner; + } else if ( + typeof adminCap.data.owner === "object" && + "ObjectOwner" in adminCap.data.owner + ) { + ownerAddress = adminCap.data.owner.ObjectOwner; } else if (typeof adminCap.data.owner === "string") { ownerAddress = adminCap.data.owner; } else {