diff --git a/cli/src/commands/deployment-new.ts b/cli/src/commands/deployment-new.ts index 8091d41f..591a938e 100644 --- a/cli/src/commands/deployment-new.ts +++ b/cli/src/commands/deployment-new.ts @@ -45,6 +45,7 @@ import { ACE_CONTRACT_PACKAGES, REPO_ROOT, deployContracts, + deriveAceAddr, ed25519PrivateKeyHex, } from '../deploy-contracts.js'; import { CLI } from '../cli-name.js'; @@ -247,7 +248,21 @@ export async function deploymentNewCommand(): Promise { const adminPrivKeyHex = ed25519PrivateKeyHex(adminAccount); console.log(); console.log(`Admin account address: ${adminAddr}`); - console.log(`(The contract package will be published AT this address; it doubles as the deployment's identity.)`); + console.log(`(EOA that creates the resource account. After bootstrap, the admin key has NO`); + console.log(` power to upgrade contracts — only to *propose* upgrades for committee approval.)`); + console.log(); + + // 3b. Resource-account seed — derives the on-chain @ace address. + console.log(`Bootstrap seed — used in createResourceAddress(admin, seed) to derive @ace.`); + console.log(`This makes the deployment address deterministic + reproducible.`); + const seedDefault = `ace-${network}-${tag}`; + const seedInput = (await input({ + message: `Bootstrap seed [default: "${seedDefault}"]:`, + })).trim(); + const bootstrapSeed = seedInput || seedDefault; + const aceAddr = deriveAceAddr(adminAddr, bootstrapSeed); + console.log(); + console.log(`Resource account address (= @ace where contracts will live): ${aceAddr}`); console.log(); // 4. Fund. @@ -287,7 +302,10 @@ export async function deploymentNewCommand(): Promise { console.log(` but slower and rate-limited. To speed up: Ctrl-C now, \`export NODE_API_KEY=aptoslabs_...\`, and re-run.)`); console.log(); } - await deployContracts(adminAccount, rpcUrl, ACE_CONTRACT_PACKAGES, version); + const deployedAceAddr = await deployContracts(adminAccount, rpcUrl, bootstrapSeed, ACE_CONTRACT_PACKAGES, version); + if (deployedAceAddr.toLowerCase() !== aceAddr.toLowerCase()) { + throw new Error(`Internal inconsistency: predicted aceAddr (${aceAddr}) != actual (${deployedAceAddr}).`); + } console.log(); console.log(` ✓ All ${ACE_CONTRACT_PACKAGES.length} packages published at version ${version}.`); console.log(); @@ -316,7 +334,7 @@ export async function deploymentNewCommand(): Promise { // 7. Operator onboarding blob. const operatorBlob = JSON.stringify( - Object.assign({ rpcUrl, aceAddr: adminAddr }, + Object.assign({ rpcUrl, aceAddr }, sharedNodeApiKey ? { rpcApiKey: sharedNodeApiKey } : {}, gasStationApiKey ? { gasStationKey: gasStationApiKey } : {}, ), @@ -379,12 +397,14 @@ export async function deploymentNewCommand(): Promise { console.log(); console.log(`Calling network::start_initial_epoch(nodes=${nodeAddresses.length}, threshold=${threshold}, epoch_duration=${epochDuration}s)...`); + console.log(`(Sealing step: admin's key signs as @ace, then loses signing power for @ace forever.)`); const aptos = makeAptos(rpcUrl, faucet, sharedNodeApiKey); const txn = await aptos.transaction.build.simple({ - sender: adminAccount.accountAddress, + sender: AccountAddress.fromString(aceAddr), data: { - function: `${adminAddr}::network::start_initial_epoch` as `${string}::${string}::${string}`, + function: `${aceAddr}::network::start_initial_epoch` as `${string}::${string}::${string}`, functionArguments: [ + adminAddr, nodeAddresses.map(a => a.toStringLong()), threshold, epochDuration, @@ -393,7 +413,7 @@ export async function deploymentNewCommand(): Promise { }); const resp = await aptos.signAndSubmitTransaction({ signer: adminAccount, transaction: txn }); await aptos.waitForTransaction({ transactionHash: resp.hash, options: { checkSuccess: true } }); - console.log(` ✓ Initial epoch (epoch 0) live. Txn: ${resp.hash}`); + console.log(` ✓ Initial epoch (epoch 0) live + sealing complete. Txn: ${resp.hash}`); console.log(); // 9. Persist profile. @@ -401,9 +421,10 @@ export async function deploymentNewCommand(): Promise { const key = makeDeploymentKey(rpcUrl, adminAddr); const dep: TrackedDeployment = { rpcUrl, - aceAddr: adminAddr, + aceAddr, adminAddress: adminAddr, adminPrivateKey: `0x${adminPrivKeyHex}`, + bootstrapSeed, sharedNodeApiKey, gasStationApiKey, alias: `${network}-${tag}`, diff --git a/cli/src/commands/update-contracts.ts b/cli/src/commands/update-contracts.ts index bfcea23f..887a41ae 100644 --- a/cli/src/commands/update-contracts.ts +++ b/cli/src/commands/update-contracts.ts @@ -2,30 +2,50 @@ // SPDX-License-Identifier: Apache-2.0 /** - * `ace deployment update-contracts` — republish all 11 ACE Move packages under the - * resolved deployment profile. + * `ace deployment update-contracts` — propose Move-package upgrades through the + * committee-controlled voting flow. * - * Version selection (in order): - * 1. `--version X.Y.Z` if passed - * 2. The vX.Y.Z tag at HEAD (stripped of leading `v`) - * 3. Otherwise: error. (NEXT_RELEASE is NOT used — at a release-tagged commit it has - * already been bumped past the current tag, so reading it stamps the wrong version.) + * The old behavior (`aptos move publish` 11 times signed by the admin key) no longer + * works after the sealed-bootstrap migration: the admin's key cannot sign for `@ace`. + * The new flow is: * - * If the profile has a `sharedNodeApiKey`, it's exported as `NODE_API_KEY` so each - * `aptos move publish` uses `Authorization: Bearer ` (avoids unauth rate limits). - * - * Like every admin operation, requires that you've already created the deployment - * profile via `ace deployment new`. The profile holds the admin private key. + * 1. For each package, compile + serialize a `code::publish_package_txn` payload via + * `aptos move build-publish-payload --json-output-file .json`. + * 2. Read each payload JSON; extract the `metadata` blob (args[0]) and `code` chunks + * (args[1]). + * 3. Submit `network::new_upgrade_proposal(package_name, metadata, code, description, + * target_epoch)` as the admin EOA — admin still occupies a non-voting "proposer" + * slot per the sealing design. + * 4. Print the voting-session address for each proposal. Committee members vote via + * `ace vote `. Once threshold is reached, `network::touch()` + * (worker `network-node` runs this periodically) executes the publish. */ import { confirm } from '@inquirer/prompts'; import { execFileSync } from 'child_process'; -import { Account, Ed25519PrivateKey } from '@aptos-labs/ts-sdk'; +import { readFileSync, mkdtempSync, rmSync } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { + Account, + AccountAddress, + Aptos, + AptosConfig, + Ed25519PrivateKey, + MoveVector, + Network, +} from '@aptos-labs/ts-sdk'; import { deriveRpcLabel, loadConfig, saveConfig } from '../config.js'; import { resolveDeployment } from '../resolve-profile.js'; import { CLI } from '../cli-name.js'; -import { ACE_CONTRACT_PACKAGES, REPO_ROOT, deployContracts } from '../deploy-contracts.js'; +import { + ACE_CONTRACT_PACKAGES, + REPO_ROOT, + prepareContractsPublishScratch, + rmContractsPublishScratch, +} from '../deploy-contracts.js'; /** Return the first vX.Y.Z tag pointing at HEAD, or null if none. */ function semverTagAtHead(): string | null { @@ -39,6 +59,82 @@ function semverTagAtHead(): string | null { return tags.find(t => /^v?\d+\.\d+\.\d+$/.test(t)) ?? null; } +function buildPublishPayload(packageDir: string, jsonOut: string): void { + execFileSync('aptos', [ + 'move', 'build-publish-payload', + '--package-dir', packageDir, + '--json-output-file', jsonOut, + '--assume-yes', + '--skip-fetch-latest-git-deps', + ], { stdio: 'inherit' }); +} + +interface PublishPayload { + metadata: Uint8Array; + code: Uint8Array[]; +} + +/** The `aptos move build-publish-payload` JSON shape: a single entry function call with + * args[0] = metadata bytes (hex) and args[1] = code chunks (array of hex). */ +function readPublishPayload(jsonPath: string): PublishPayload { + const raw = JSON.parse(readFileSync(jsonPath, 'utf8')) as { + args: { type: string; value: string | string[] }[]; + }; + if (!raw.args || raw.args.length < 2) { + throw new Error(`Malformed publish payload at ${jsonPath} — expected args[0]=metadata, args[1]=code`); + } + const hexToBytes = (hex: string): Uint8Array => { + const stripped = hex.startsWith('0x') ? hex.slice(2) : hex; + return new Uint8Array(Buffer.from(stripped, 'hex')); + }; + const metadataRaw = raw.args[0]!.value; + if (typeof metadataRaw !== 'string') throw new Error(`args[0] must be a hex string, got ${typeof metadataRaw}`); + const codeRaw = raw.args[1]!.value; + if (!Array.isArray(codeRaw)) throw new Error(`args[1] must be an array of hex strings, got ${typeof codeRaw}`); + return { + metadata: hexToBytes(metadataRaw), + code: codeRaw.map(hexToBytes), + }; +} + +async function fetchCurrentEpoch(aptos: Aptos, aceAddr: string): Promise { + const result = await aptos.view<[string]>({ + payload: { + function: `${aceAddr}::network::current_epoch` as `${string}::${string}::${string}`, + functionArguments: [], + }, + }); + return BigInt(result[0]); +} + +async function submitUpgradeProposal(args: { + aptos: Aptos; + admin: Account; + aceAddr: string; + packageName: string; + payload: PublishPayload; + description: string; + targetEpoch: bigint; +}): Promise { + const { aptos, admin, aceAddr, packageName, payload, description, targetEpoch } = args; + const txn = await aptos.transaction.build.simple({ + sender: admin.accountAddress, + data: { + function: `${aceAddr}::network::new_upgrade_proposal` as `${string}::${string}::${string}`, + functionArguments: [ + packageName, + payload.metadata, + new MoveVector(payload.code.map(c => MoveVector.U8(c))), + description, + targetEpoch, + ], + }, + }); + const resp = await aptos.signAndSubmitTransaction({ signer: admin, transaction: txn }); + await aptos.waitForTransaction({ transactionHash: resp.hash, options: { checkSuccess: true } }); + return resp.hash; +} + export async function updateContractsCommand(opts: { profile?: string; account?: string; @@ -47,10 +143,6 @@ export async function updateContractsCommand(opts: { }): Promise { const { deploymentKey, deployment } = resolveDeployment(opts.profile, opts.account); - // Version selection: --version overrides; otherwise read from the git tag at HEAD. - // NEXT_RELEASE is intentionally NOT used as a fallback — at any release-tagged commit - // NEXT_RELEASE has already been bumped past the current tag (per the release flow: - // bump NEXT_RELEASE in the same commit you tag), so reading it stamps the WRONG version. let version: string; let versionSource: string; if (opts.version) { @@ -63,7 +155,7 @@ export async function updateContractsCommand(opts: { `Cannot determine a version: HEAD is not at a vX.Y.Z tag, and no --version was given.\n` + `Either:\n` + ` • check out a release tag (e.g. \`git checkout v2.0.1\`), or\n` + - ` • pass --version X.Y.Z explicitly (you'll be stamping that into every Move.toml).\n`, + ` • pass --version X.Y.Z explicitly.\n`, ); } version = tag.replace(/^v/, ''); @@ -73,47 +165,81 @@ export async function updateContractsCommand(opts: { throw new Error(`Resolved version "${version}" is not in X.Y.Z form.`); } - if (deployment.sharedNodeApiKey) { - // Threaded through to `aptos move publish` so RPC calls authenticate via Bearer token - // and avoid testnet/mainnet rate limits during the 11-package republish. - process.env.NODE_API_KEY = deployment.sharedNodeApiKey; - } - const sk = new Ed25519PrivateKey(deployment.adminPrivateKey); const adminAccount = Account.fromPrivateKey({ privateKey: sk }); const adminAddr = adminAccount.accountAddress.toStringLong(); - if (deployment.adminAddress.toLowerCase() !== adminAddr.toLowerCase()) { throw new Error( `Profile inconsistency: stored adminAddress (${deployment.adminAddress}) does ` + `not match the address derived from the stored adminPrivateKey (${adminAddr}). ` + - `Refusing to republish — fix the profile via \`${CLI} deployment edit\` or recreate it.`, + `Refusing to propose — fix the profile via \`${CLI} deployment edit\`.`, ); } console.log(); - console.log('Republishing ACE contracts:'); + console.log('Submitting committee-voting upgrade proposals for ACE contracts:'); console.log(` profile : ${deployment.alias ?? deploymentKey}`); console.log(` network : ${deployment.network ?? deriveRpcLabel(deployment.rpcUrl)}`); console.log(` rpcUrl : ${deployment.rpcUrl}`); - console.log(` admin addr : ${adminAddr}`); - console.log(` rpc auth : ${deployment.sharedNodeApiKey ? 'Shared Node API Key from profile (Bearer token)' : 'none — using unauthenticated RPC (may rate-limit)'}`); + console.log(` ace addr : ${deployment.aceAddr}`); + console.log(` admin addr : ${adminAddr} (non-voting proposer slot)`); console.log(` version : ${version} (${versionSource})`); - console.log(` packages : all ${ACE_CONTRACT_PACKAGES.length} (${ACE_CONTRACT_PACKAGES.join(', ')})`); + console.log(` packages : all ${ACE_CONTRACT_PACKAGES.length}`); console.log(); - console.log(`This will run \`aptos move publish\` ${ACE_CONTRACT_PACKAGES.length} times in dependency order. Each publish is`); - console.log(`an on-chain transaction signed with the admin key. Estimated total: ~2-3 minutes`); - console.log(`+ ~0.5 APT in gas (real values vary by network and bytecode size).`); + console.log(`This will submit ${ACE_CONTRACT_PACKAGES.length} \`network::new_upgrade_proposal\` txns. Committee`); + console.log(`members must then vote (\`${CLI} vote \`) until each reaches threshold. \`network::touch()\``); + console.log(`(workers call this periodically) executes the publish once voting passes.`); console.log(); - if (!opts.yes) { const ok = await confirm({ message: 'Proceed?', default: false }); if (!ok) { console.log('Aborted.'); return; } } - await deployContracts(adminAccount, deployment.rpcUrl, ACE_CONTRACT_PACKAGES, version); + const aptosConfig = new AptosConfig({ + network: Network.CUSTOM, + fullnode: deployment.rpcUrl, + ...(deployment.sharedNodeApiKey ? { clientConfig: { HEADERS: { Authorization: `Bearer ${deployment.sharedNodeApiKey}` } } } : {}), + }); + const aptos = new Aptos(aptosConfig); + + const targetEpoch = await fetchCurrentEpoch(aptos, deployment.aceAddr); + console.log(` Current on-chain epoch: ${targetEpoch} (target_epoch for all proposals).`); + console.log(); + + const scratch = prepareContractsPublishScratch( + path.join(REPO_ROOT, 'contracts'), + deployment.aceAddr, + version, + ); + const payloadTmp = mkdtempSync(path.join(os.tmpdir(), 'ace-upgrade-payloads-')); + const submittedSessions: { pkg: string; txHash: string }[] = []; + try { + for (const folder of ACE_CONTRACT_PACKAGES) { + const packageDir = path.join(scratch.contractsDir, folder); + const jsonOut = path.join(payloadTmp, `${folder}.json`); + console.log(`Compiling ${folder} → publish payload...`); + buildPublishPayload(packageDir, jsonOut); + const payload = readPublishPayload(jsonOut); + console.log(` metadata: ${payload.metadata.length} B, code: ${payload.code.length} chunks (total ${payload.code.reduce((s, c) => s + c.length, 0)} B)`); + console.log(` Submitting new_upgrade_proposal(${folder})...`); + const txHash = await submitUpgradeProposal({ + aptos, + admin: adminAccount, + aceAddr: deployment.aceAddr, + packageName: folder, + payload, + description: `${folder} @ v${version}`, + targetEpoch, + }); + submittedSessions.push({ pkg: folder, txHash }); + console.log(` ✓ Submitted. Tx: ${txHash}`); + console.log(); + } + } finally { + rmSync(payloadTmp, { recursive: true, force: true }); + rmContractsPublishScratch(scratch); + } - // Persist the published version into the profile so `deployment ls` shows it. const config = loadConfig(); const dep = config.deployments[deploymentKey]; if (dep) { @@ -122,8 +248,15 @@ export async function updateContractsCommand(opts: { saveConfig(config); } + console.log('══════════════════════════════════════════════════════════════════════'); + console.log(` ${submittedSessions.length} upgrade proposals submitted at v${version}.`); console.log(); - console.log(`✓ All ${ACE_CONTRACT_PACKAGES.length} packages republished at version ${version}.`); - console.log(` Profile updated: deployedAtTag = v${version}, deployedAt = now.`); - console.log(` Confirm via \`${CLI} network-status\` — the contract version line should reflect v${version}.`); + console.log(' Next steps:'); + console.log(` 1. Committee members vote: \`${CLI} proposal ls\` shows the open sessions;`); + console.log(` \`${CLI} vote \` casts a vote.`); + console.log(` 2. Once threshold votes accumulate, the next worker-triggered`); + console.log(` \`network::touch()\` invokes \`code::publish_package_txn\` and the new`); + console.log(' bytecode lands on chain.'); + console.log(` 3. Verify with \`${CLI} network-status\` — version line reflects v${version}.`); + console.log('══════════════════════════════════════════════════════════════════════'); } diff --git a/cli/src/config.ts b/cli/src/config.ts index 69324f4d..7105689d 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -64,15 +64,19 @@ export interface LocalConfig { * An ACE deployment you administer. Persists the admin private key + RPC config. * Created by `ace deployment new`; consumed by `ace deployment {update-contracts,edit,...}`. * - * `aceAddr` and `adminAddress` are the same Aptos account in this codebase (the package - * is published *at* the admin address). They're stored as separate fields for clarity - * and so a future "delegated admin" deployment shape doesn't need a migration. + * `aceAddr` is the **resource account address** where the Move packages live (committee + * voting governs upgrades; admin's key cannot sign for it after `start_initial_epoch`). + * `adminAddress` is the EOA that created the resource account during sealed bootstrap; + * its only post-bootstrap power is submitting upgrade proposals (no voting power). + * `bootstrapSeed` is the seed used in `createResourceAddress(admin, seed)` to derive + * `aceAddr`; persisted for reproducibility / debugging. */ export interface TrackedDeployment { rpcUrl: string; aceAddr: string; adminAddress: string; adminPrivateKey: string; // 0x-prefixed hex + bootstrapSeed: string; // utf-8 seed used to derive aceAddr from adminAddress sharedNodeApiKey?: string; gasStationApiKey?: string; alias?: string; diff --git a/cli/src/deploy-contracts.ts b/cli/src/deploy-contracts.ts index 831f6561..6ace2658 100644 --- a/cli/src/deploy-contracts.ts +++ b/cli/src/deploy-contracts.ts @@ -25,7 +25,14 @@ import { import * as os from 'os'; import * as path from 'path'; -import type { Account } from '@aptos-labs/ts-sdk'; +import { + Account, + Aptos, + AptosConfig, + AuthenticationKey, + Network, + createResourceAddress, +} from '@aptos-labs/ts-sdk'; /** `` — `cli/src` is two levels deep (cli is CommonJS, so `__dirname` is available). */ export const REPO_ROOT = path.resolve(__dirname, '../..'); @@ -122,7 +129,12 @@ export function rmContractsPublishScratch(scratch: ContractsPublishScratch): voi rmSync(scratch.tmpRoot, { recursive: true, force: true }); } -export async function publishMovePackage(packageDir: string, privateKeyHex: string, rpcUrl: string): Promise { +export async function publishMovePackage( + packageDir: string, + privateKeyHex: string, + rpcUrl: string, + senderAddr?: string, +): Promise { const args = [ 'move', 'publish', '--package-dir', packageDir, @@ -131,6 +143,7 @@ export async function publishMovePackage(packageDir: string, privateKeyHex: stri '--assume-yes', '--skip-fetch-latest-git-deps', ]; + if (senderAddr) args.push('--sender-account', senderAddr); // Print the command with the private key redacted; the real value is still passed // to the spawned process via `args`. const redactedArgs = args.map((a, i) => (args[i - 1] === '--private-key' ? '' : a)); @@ -138,6 +151,35 @@ export async function publishMovePackage(packageDir: string, privateKeyHex: stri await spawnExitZero('aptos', args, 'aptos move publish'); } +/** Derive the `@ace` resource account address from (admin, seed). Pure function — no chain calls. */ +export function deriveAceAddr(adminAddrStr: string, seed: string): string { + const { AccountAddress } = require('@aptos-labs/ts-sdk') as typeof import('@aptos-labs/ts-sdk'); + return createResourceAddress(AccountAddress.fromString(adminAddrStr), seed).toStringLong(); +} + +/** Phase A of sealed bootstrap: submit `0x1::resource_account::create_resource_account` with + * `optional_auth_key` = admin's auth_key, so admin's SK signs Phase-B publishes as the resource + * account. Returns the resource account address. */ +export async function createAceResourceAccount( + admin: Account, + seed: string, + rpcUrl: string, +): Promise { + const aptos = new Aptos(new AptosConfig({ network: Network.CUSTOM, fullnode: rpcUrl })); + const seedBytes = new Uint8Array(Buffer.from(seed, 'utf8')); + const adminAuthKey = AuthenticationKey.fromPublicKey({ publicKey: admin.publicKey }); + const txn = await aptos.transaction.build.simple({ + sender: admin.accountAddress, + data: { + function: '0x1::resource_account::create_resource_account', + functionArguments: [seedBytes, adminAuthKey.toUint8Array()], + }, + }); + const resp = await aptos.signAndSubmitTransaction({ signer: admin, transaction: txn }); + await aptos.waitForTransaction({ transactionHash: resp.hash, options: { checkSuccess: true } }); + return deriveAceAddr(admin.accountAddress.toStringLong(), seed); +} + /** Hex (no `0x`) for an Ed25519-backed `Account`. */ export function ed25519PrivateKeyHex(account: Account): string { if (!('privateKey' in account)) { @@ -148,29 +190,38 @@ export function ed25519PrivateKeyHex(account: Account): string { } /** - * Publish the canonical 11 ACE packages (or any subset) under `REPO_ROOT/contracts/` in order. + * Sealed bootstrap (Phase A + B): create the `@ace` resource account, then publish the + * canonical 11 ACE packages (or any subset) to that resource account. * - * If `versionStr` is provided, every `Move.toml`'s `version = "..."` line is rewritten to that value - * before publishing (the shipped values are placeholders). + * Phase C (`network::start_initial_epoch`) must be invoked separately by the caller to + * burn admin's signing path and finish the sealing. After Phase C, contract upgrades + * are only possible via committee voting (`network::new_upgrade_proposal` → `touch`). + * + * If `versionStr` is provided, every `Move.toml`'s `version = "..."` line is rewritten + * before publishing. + * + * Returns the resource account address that became `@ace`. */ export async function deployContracts( adminAccount: Account, rpcUrl: string, + seed: string, packageFolders: readonly string[] = ACE_CONTRACT_PACKAGES, versionStr?: string, -): Promise { - const adminAddr = adminAccount.accountAddress.toStringLong(); +): Promise { + const aceAddr = await createAceResourceAccount(adminAccount, seed, rpcUrl); const adminKeyHex = ed25519PrivateKeyHex(adminAccount); - const scratch = prepareContractsPublishScratch(path.join(REPO_ROOT, 'contracts'), adminAddr, versionStr); + const scratch = prepareContractsPublishScratch(path.join(REPO_ROOT, 'contracts'), aceAddr, versionStr); try { for (const folder of packageFolders) { const packageDir = path.join(scratch.contractsDir, folder); if (!existsSync(path.join(packageDir, 'Move.toml'))) { throw new Error(`missing Move package at ${packageDir}`); } - await publishMovePackage(packageDir, adminKeyHex, rpcUrl); + await publishMovePackage(packageDir, adminKeyHex, rpcUrl, aceAddr); } } finally { rmContractsPublishScratch(scratch); } + return aceAddr; } diff --git a/contracts/network/sources/network.move b/contracts/network/sources/network.move index 60825220..dc23c8c9 100644 --- a/contracts/network/sources/network.move +++ b/contracts/network/sources/network.move @@ -8,7 +8,10 @@ module ace::network { use ace::group; use ace::epoch_change; use std::bcs; - use aptos_framework::object::{Self, ExtendRef}; + use aptos_framework::account::{Self, SignerCapability}; + use aptos_framework::code; + use aptos_framework::object; + use aptos_framework::resource_account; use aptos_std::bcs_stream; use std::vector::range; use ace::voting; @@ -33,6 +36,7 @@ module ace::network { const E_INVALID_RESHARING_INTERVAL: u64 = 17; const E_PROPOSAL_IS_NOT_CURRENT: u64 = 18; const E_YOU_ALREADY_PROPOSED_IN_THIS_EPOCH: u64 = 19; + const E_ALREADY_BOOTSTRAPPED: u64 = 20; struct ProposalState has store, drop { proposal: ProposedEpochConfig, @@ -44,6 +48,12 @@ module ace::network { session_addr: address, } + /// Pointer (in `State.upgrade_proposals`) to a separately-stored `UpgradeBlob` object. + /// The payload itself (compiled bytecode) is too large to live inside `State` directly. + struct UpgradeProposalRef has store, drop { + blob_addr: address, + } + struct State has key { epoch: u64, epoch_start_time_micros: u64, @@ -54,10 +64,36 @@ module ace::network { /// Stores proposals from nodes and admin, therefore having length n+1. indices 0..>, epoch_change_info: Option, + /// Parallel to `proposals` (n+1 slots; last slot = admin). Each slot points at a separate + /// `UpgradeBlob` object holding the compiled bytecode being voted on. + upgrade_proposals: vector>, } + /// SignerCapability for @ace, installed during sealed bootstrap by `start_initial_epoch`. + /// Before that call, @ace's auth_key matches admin's pubkey (admin can sign as @ace to + /// publish packages). `resource_account::retrieve_resource_account_cap` (used by + /// `start_initial_epoch`) atomically (a) extracts the cap from admin's `Container`, and + /// (b) rotates @ace's auth_key to zero — so after bootstrap completes, the only path + /// to produce a signer for @ace is through this module's `ace_signer()` helper. struct SignerStore has key { - extend_ref: ExtendRef, + signer_cap: SignerCapability, + } + + /// Compiled-package payload for an in-flight upgrade proposal. Lives at its own sticky-object + /// address (not at @ace) because the bytecode `code` field is large and would bloat the + /// `State` BCS view that every node fetches. + struct UpgradeBlob has key { + /// Human-readable package name (e.g. "Network", "PKE"). Informational only — the actual + /// package identity is encoded in `metadata_serialized`. + package_name: String, + metadata_serialized: vector, + code: vector>, + proposer: address, + voting_session: address, + /// Must equal `State.epoch` at submission; prevents stale upgrade payloads from firing + /// after a committee rotation has invalidated their assumptions. + target_epoch: u64, + description: String, } struct ProposedEpochConfig has store, drop, copy { @@ -85,6 +121,14 @@ module ace::network { bcs::to_bytes(&State[@ace]) } + #[view] + /// Convenience view for clients that only need the current epoch (e.g. CLI sets + /// `target_epoch` when submitting an upgrade proposal). Cheaper than fetching the + /// full `state_view_*_bcs`. + public fun current_epoch(): u64 { + State[@ace].epoch + } + struct ProposalView has drop { proposal: ProposedEpochConfig, voting_session: address, @@ -101,6 +145,20 @@ module ace::network { nxt_threshold: u64, } + /// Used by `StateViewV1` only. The actual payload (`metadata_serialized` / `code`) is + /// intentionally omitted to keep this view small — fetch the blob at `blob_addr` directly + /// if you need the bytecode. + struct UpgradeProposalView has drop { + package_name: String, + description: String, + proposer: address, + target_epoch: u64, + blob_addr: address, + voting_session: address, + votes: vector, + voting_passed: bool, + } + struct StateViewV0 has drop { epoch: u64, epoch_start_time_micros: u64, @@ -113,6 +171,18 @@ module ace::network { epoch_change_info: Option, } + struct StateViewV1 has drop { + epoch: u64, + epoch_start_time_micros: u64, + epoch_duration_micros: u64, + cur_nodes: vector
, + cur_threshold: u64, + secrets: vector, + proposals: vector>, + epoch_change_info: Option, + upgrade_proposals: vector>, + } + // Single BCS-encoded snapshot covering network::State plus all sub-protocol data nodes // need to make local decisions (touch, epoch-change-nxt membership, proposal vote status). // Versioned so new fields can be added in StateViewV1, V2, etc. @@ -187,32 +257,147 @@ module ace::network { }) } - entry fun start_initial_epoch(ace: &signer, nodes: vector
, threshold: u64, resharing_interval_secs: u64) { - assert!(@ace == ace.address_of(), error::invalid_argument(E_ONLY_ADMIN_CAN_DO_THIS)); - let n = nodes.length(); - let t = threshold; - assert!(t >= 2 && 2*t > n && t <= n, error::invalid_argument(E_INVALID_SECRET_SHARING_PARAMETERS)); - assert!(resharing_interval_secs >= MIN_RESHARING_INTERVAL_SECS, error::invalid_argument(E_INVALID_RESHARING_INTERVAL)); - nodes.for_each(|node| { - assert!(worker_config::has_pke_enc_key(node), error::invalid_argument(E_INVALID_NODE)); + #[view] + /// V1 = V0 + `upgrade_proposals` (committee-controlled contract upgrades). + public fun state_view_v1_bcs(): vector { + let state = &State[@ace]; + bcs::to_bytes(&StateViewV1 { + epoch: state.epoch, + epoch_start_time_micros: state.epoch_start_time_micros, + epoch_duration_micros: state.epoch_duration_micros, + cur_nodes: state.cur_nodes, + cur_threshold: state.cur_threshold, + secrets: build_secrets_view(state), + proposals: build_proposal_views(state), + epoch_change_info: build_epoch_change_info_view(state), + upgrade_proposals: build_upgrade_proposal_views(state), + }) + } + + fun build_secrets_view(state: &State): vector { + let result = vector[]; + state.secrets.for_each_ref(|addr: &address| { + let (keypair_id, scheme) = if (dkg::is_session(*addr)) { + dkg::keypair_id_and_scheme(*addr) + } else { + dkr::keypair_id_and_scheme(*addr) + }; + result.push_back(SecretInfo { current_session: *addr, keypair_id, scheme }); + }); + result + } + + fun build_proposal_views(state: &State): vector> { + let result = vector[]; + state.proposals.for_each_ref(|slot: &Option| { + if (slot.is_none()) { + result.push_back(option::none()); + } else { + let ps = slot.borrow(); + let (votes, threshold) = voting::session_votes_and_threshold(ps.voting_session); + let voting_passed = count_yes(&votes) >= threshold; + result.push_back(option::some(ProposalView { + proposal: ps.proposal, + voting_session: ps.voting_session, + votes, + voting_passed, + })); + }; }); + result + } - let object_ref = object::create_sticky_object(@ace); - let extend_ref = object_ref.generate_extend_ref(); - move_to(ace, SignerStore { - extend_ref, + fun build_upgrade_proposal_views(state: &State): vector> { + let result = vector[]; + state.upgrade_proposals.for_each_ref(|slot: &Option| { + if (slot.is_none()) { + result.push_back(option::none()); + } else { + let blob_addr = slot.borrow().blob_addr; + let blob = &UpgradeBlob[blob_addr]; + let (votes, threshold) = voting::session_votes_and_threshold(blob.voting_session); + let voting_passed = count_yes(&votes) >= threshold; + result.push_back(option::some(UpgradeProposalView { + package_name: blob.package_name, + description: blob.description, + proposer: blob.proposer, + target_epoch: blob.target_epoch, + blob_addr, + voting_session: blob.voting_session, + votes, + voting_passed, + })); + }; }); + result + } + + fun build_epoch_change_info_view(state: &State): Option { + if (state.epoch_change_info.is_none()) return option::none(); + let info = state.epoch_change_info.borrow(); + let (nxt_nodes, nxt_threshold) = epoch_change::nxt_nodes_and_threshold(info.session_addr); + option::some(EpochChangeView { + triggering_proposal_idx: info.triggering_proposal_idx, + session_addr: info.session_addr, + nxt_nodes, + nxt_threshold, + }) + } + + fun count_yes(votes: &vector): u64 { + let n = 0u64; + votes.for_each_ref(|v: &bool| { if (*v) n += 1; }); + n + } - let epoch_start_time_micros = timestamp::now_microseconds(); + /// Mints a signer for `@ace` using the SignerCapability installed during `start_initial_epoch`. + /// This is the **only** path to producing a signer for `@ace` after bootstrap completes — + /// the resource account's auth_key was burnt to zero by `retrieve_resource_account_cap`. + fun ace_signer(): signer { + account::create_signer_with_capability(&SignerStore[@ace].signer_cap) + } + + entry fun start_initial_epoch( + ace: &signer, + admin_addr: address, + nodes: vector
, + threshold: u64, + resharing_interval_secs: u64, + ) { + // `ace` is the resource account @ace itself — admin must have signed this tx as @ace + // using the auth_key set during `0x1::resource_account::create_resource_account`. + assert!(@ace == ace.address_of(), error::invalid_argument(E_ONLY_ADMIN_CAN_DO_THIS)); + assert!(!exists(@ace), error::invalid_state(E_ALREADY_BOOTSTRAPPED)); + validate_initial_committee(&nodes, threshold, resharing_interval_secs); + + // The "sealing" step. `retrieve_resource_account_cap` atomically: + // 1. removes the SignerCapability from `Container[admin_addr]` + // 2. rotates @ace's auth_key to zero, killing admin's private-key signing path + // After this call returns, the only way to mint a signer for @ace is via `ace_signer()`. + let cap = resource_account::retrieve_resource_account_cap(ace, admin_addr); + move_to(ace, SignerStore { signer_cap: cap }); + + let n = nodes.length(); move_to(ace, State { epoch: 0, - epoch_start_time_micros, + epoch_start_time_micros: timestamp::now_microseconds(), epoch_duration_micros: resharing_interval_secs * 1_000_000, cur_nodes: nodes, cur_threshold: threshold, secrets: vector[], proposals: range(0, n+1).map(|_| option::none()), epoch_change_info: option::none(), + upgrade_proposals: range(0, n+1).map(|_| option::none()), + }); + } + + fun validate_initial_committee(nodes: &vector
, threshold: u64, resharing_interval_secs: u64) { + let n = nodes.length(); + let t = threshold; + assert!(t >= 2 && 2*t > n && t <= n, error::invalid_argument(E_INVALID_SECRET_SHARING_PARAMETERS)); + assert!(resharing_interval_secs >= MIN_RESHARING_INTERVAL_SECS, error::invalid_argument(E_INVALID_RESHARING_INTERVAL)); + nodes.for_each_ref(|node: &address| { + assert!(worker_config::has_pke_enc_key(*node), error::invalid_argument(E_INVALID_NODE)); }); } @@ -241,9 +426,11 @@ module ace::network { state.secrets = secrets; state.epoch_duration_micros = epoch_duration_micros; state.proposals = range(0, nodes.length()+1).map(|_| option::none()); + state.upgrade_proposals = range(0, nodes.length()+1).map(|_| option::none()); state.epoch_change_info = option::none(); } } else { + try_execute_upgrade(state); // Touch all voting sessions. state.proposals.for_each_ref(|proposal: &Option|{ if (proposal.is_some()) { @@ -266,10 +453,9 @@ module ace::network { i += 1; }; if (approved_proposal_found) { - let proposed_epoch_config = state.proposals[approved_proposal_idx].borrow().proposal; + let proposed_epoch_config = state.proposals[approved_proposal_idx].borrow().proposal; // Create a new epoch change session. - let signer_store = &SignerStore[@ace]; - let service_account = signer_store.extend_ref.generate_signer_for_extending(); + let service_account = ace_signer(); let session = epoch_change::new_session( &service_account, state.cur_nodes, @@ -287,8 +473,7 @@ module ace::network { } else if (now_micros - state.epoch_start_time_micros >= state.epoch_duration_micros) { - let signer_store = &SignerStore[@ace]; - let service_account = signer_store.extend_ref.generate_signer_for_extending(); + let service_account = ace_signer(); let epoch_change_session = epoch_change::new_session( &service_account, state.cur_nodes, @@ -307,6 +492,47 @@ module ace::network { } } + /// Touches every active upgrade-proposal voting session, then — if one passed — pulls the + /// blob out, clears every upgrade slot, and executes `code::publish_package_txn` as @ace. + /// New bytecode takes effect from the NEXT tx; the current tx finishes running the OLD code. + /// Losing proposals get their slots cleared so their proposers can re-submit; their blobs + /// remain on chain at sticky object addresses (orphan storage, not at @ace). + fun try_execute_upgrade(state: &mut State) { + state.upgrade_proposals.for_each_ref(|slot: &Option| { + if (slot.is_some()) voting::touch(UpgradeBlob[slot.borrow().blob_addr].voting_session); + }); + let approved_idx_opt = find_approved_upgrade_slot(state); + if (approved_idx_opt.is_none()) return; + let blob_addr = state.upgrade_proposals[approved_idx_opt.destroy_some()].borrow().blob_addr; + let n = state.upgrade_proposals.length(); + state.upgrade_proposals = range(0, n).map(|_| option::none()); + let UpgradeBlob { + package_name: _, + metadata_serialized, + code, + proposer: _, + voting_session: _, + target_epoch: _, + description: _, + } = move_from(blob_addr); + let s = ace_signer(); + code::publish_package_txn(&s, metadata_serialized, code); + } + + fun find_approved_upgrade_slot(state: &State): Option { + let i = 0; + let n = state.upgrade_proposals.length(); + while (i < n) { + let slot = &state.upgrade_proposals[i]; + if (slot.is_some()) { + let blob = &UpgradeBlob[slot.borrow().blob_addr]; + if (voting::completed(blob.voting_session)) return option::some(i); + }; + i += 1; + }; + option::none() + } + #[randomness] entry fun new_proposal(proposer: &signer, proposal_bcs: vector) { let state = &mut State[@ace]; @@ -317,8 +543,7 @@ module ace::network { let proposal = proposal_from_bcs(proposal_bcs); validate_proposal(state, &proposal); - let signer_store = &SignerStore[@ace]; - let service_account = signer_store.extend_ref.generate_signer_for_extending(); + let service_account = ace_signer(); let voting_session = voting::new_session(&service_account, state.cur_nodes, state.cur_threshold); let proposal_state = ProposalState { proposal, @@ -334,6 +559,47 @@ module ace::network { } } + #[randomness] + /// Submit an upgrade proposal for a single Move package. Permissioned identically to + /// `new_proposal` (current committee node OR admin EOA — admin remains a non-voting + /// proposer slot to keep release operations ergonomic). + entry fun new_upgrade_proposal( + proposer: &signer, + package_name: String, + metadata_serialized: vector, + code: vector>, + description: String, + target_epoch: u64, + ) { + let state = &mut State[@ace]; + let proposer_addr = proposer.address_of(); + let (proposed_by_node, node_idx) = state.cur_nodes.find(|node| *node == proposer_addr); + assert!(@ace == proposer_addr || proposed_by_node, error::permission_denied(E_ONLY_ADMIN_OR_CURRENT_NODE_CAN_PROPOSE)); + assert!(target_epoch == state.epoch, error::invalid_argument(E_PROPOSAL_IS_NOT_CURRENT)); + let proposer_idx = if (@ace == proposer_addr) { state.cur_nodes.length() } else { node_idx }; + assert!(state.upgrade_proposals[proposer_idx].is_none(), error::invalid_state(E_YOU_ALREADY_PROPOSED_IN_THIS_EPOCH)); + + let service_account = ace_signer(); + let voting_session = voting::new_session(&service_account, state.cur_nodes, state.cur_threshold); + let blob_ref = object::create_sticky_object(@ace); + let blob_signer = object::generate_signer(&blob_ref); + let blob_addr = object::address_from_constructor_ref(&blob_ref); + move_to(&blob_signer, UpgradeBlob { + package_name, + metadata_serialized, + code, + proposer: proposer_addr, + voting_session, + target_epoch, + description, + }); + state.upgrade_proposals[proposer_idx] = option::some(UpgradeProposalRef { blob_addr }); + + if (proposed_by_node) { + voting::vote(proposer, voting_session); + } + } + fun proposal_from_bcs(proposal_bcs: vector): ProposedEpochConfig { let stream = bcs_stream::new(proposal_bcs); let proposal = ProposedEpochConfig { diff --git a/scenarios/common/ace-network.ts b/scenarios/common/ace-network.ts index 8767679d..686be453 100644 --- a/scenarios/common/ace-network.ts +++ b/scenarios/common/ace-network.ts @@ -43,6 +43,9 @@ export const ACE_CONTRACTS: readonly string[] = [ export interface AceNetworkOptions { adminAccount: Account; + /** Resource account address where the ACE Move packages were published; required for + * worker config / start_initial_epoch calls. Returned by `deployContracts`. */ + aceAddr: string; /** Total worker accounts to mint. Indices 0..totalWorkers-1. */ totalWorkers: number; /** Indices of the workers in the initial committee (subset of 0..totalWorkers-1). */ @@ -67,6 +70,8 @@ export interface AceNetworkState { epoch0WorkerAccounts: Account[]; aceDeployment: ACE.AceDeployment; adminAccountAddress: AccountAddress; + /** Resource account address where ACE was published. */ + aceAddr: string; } /** @@ -85,16 +90,16 @@ export interface AceNetworkState { * access-failure scenarios want exactly this composition. */ export async function deployAndBringUpAceNetwork( - opts: AceNetworkOptions, + opts: Omit, ): Promise { - await deployContracts(opts.adminAccount, [...ACE_CONTRACTS]); - return setupAceNetworkAndWorkers(opts); + const aceAddr = await deployContracts(opts.adminAccount, [...ACE_CONTRACTS]); + return setupAceNetworkAndWorkers({ ...opts, aceAddr }); } export async function setupAceNetworkAndWorkers( opts: AceNetworkOptions, ): Promise { - const { adminAccount, totalWorkers, epoch0WorkerIndices, epoch0Threshold } = opts; + const { adminAccount, aceAddr, totalWorkers, epoch0WorkerIndices, epoch0Threshold } = opts; const reshareIntervalSecs = opts.reshareIntervalSecs ?? 600; const adminAddr = adminAccount.accountAddress.toStringLong(); const adminAccountAddress = adminAccount.accountAddress; @@ -120,7 +125,7 @@ export async function setupAceNetworkAndWorkers( assertTxnSuccess( await submitTxn({ signer: workerAccounts[i]!, - entryFunction: `${adminAddr}::worker_config::register_pke_enc_key`, + entryFunction: `${aceAddr}::worker_config::register_pke_enc_key`, args: [Array.from(encKeypairs[i]!.encryptionKey.toBytes())], }), `register_pke_enc_key worker ${i}`, @@ -128,22 +133,28 @@ export async function setupAceNetworkAndWorkers( assertTxnSuccess( await submitTxn({ signer: workerAccounts[i]!, - entryFunction: `${adminAddr}::worker_config::register_endpoint`, + entryFunction: `${aceAddr}::worker_config::register_endpoint`, args: [endpoint], }), `register_endpoint worker ${i}`, ); } - // ── Kick off the initial epoch ────────────────────────────────────────── + // ── Kick off the initial epoch (sealed bootstrap Phase C) ─────────────── + // Admin signs the tx but the sender is the resource account (aceAddr); auth_key on + // aceAddr still matches admin's pubkey, so this passes admission. Inside the call, + // `retrieve_resource_account_cap` extracts the SignerCapability from + // `Container[admin_addr]` AND burns aceAddr's auth_key to zero — after this tx, + // admin's key can no longer sign as @ace. const epoch0Addrs = epoch0WorkerIndices.map( (i) => workerAccounts[i]!.accountAddress.toStringLong(), ); assertTxnSuccess( await submitTxn({ signer: adminAccount, - entryFunction: `${adminAddr}::network::start_initial_epoch`, - args: [epoch0Addrs, epoch0Threshold, reshareIntervalSecs], + sender: aceAddr, + entryFunction: `${aceAddr}::network::start_initial_epoch`, + args: [adminAddr, epoch0Addrs, epoch0Threshold, reshareIntervalSecs], }), 'network::start_initial_epoch', ); @@ -158,7 +169,7 @@ export async function setupAceNetworkAndWorkers( total: totalWorkers, runAs: workerAccounts[i]!, pkeDkHex, - aceDeploymentAddr: adminAddr, + aceDeploymentAddr: aceAddr, aceDeploymentApi: LOCALNET_URL, workerBasePort: WORKER_BASE_PORT, })); @@ -167,7 +178,7 @@ export async function setupAceNetworkAndWorkers( const aceDeployment = new ACE.AceDeployment({ apiEndpoint: LOCALNET_URL, - contractAddr: adminAccountAddress, + contractAddr: AccountAddress.fromString(aceAddr), }); return { @@ -177,6 +188,7 @@ export async function setupAceNetworkAndWorkers( epoch0WorkerAccounts: epoch0WorkerIndices.map((i) => workerAccounts[i]!), aceDeployment, adminAccountAddress, + aceAddr, }; } diff --git a/scenarios/common/helpers.ts b/scenarios/common/helpers.ts index 8602a6cf..80156c82 100644 --- a/scenarios/common/helpers.ts +++ b/scenarios/common/helpers.ts @@ -3,7 +3,7 @@ import * as ace from '@aptos-labs/ace-sdk'; import { Result } from '@aptos-labs/ace-sdk'; -import { Account, AccountAddress, Aptos, AptosConfig, Ed25519PrivateKey, Network, Serializer } from '@aptos-labs/ts-sdk'; +import { Account, AccountAddress, Aptos, AptosConfig, AuthenticationKey, Ed25519PrivateKey, Network, Serializer, createResourceAddress } from '@aptos-labs/ts-sdk'; import { execFile, spawn, type ChildProcess } from 'child_process'; import * as readline from 'readline'; import { @@ -165,7 +165,12 @@ export function rmContractsPublishScratch(scratch: ContractsPublishScratch): voi rmSync(scratch.tmpRoot, { recursive: true, force: true }); } -export async function publishMovePackage(packageDir: string, privateKeyHex: string, rpcUrl = LOCALNET_URL): Promise { +export async function publishMovePackage( + packageDir: string, + privateKeyHex: string, + rpcUrl = LOCALNET_URL, + senderAddr?: string, +): Promise { const args = [ 'move', 'publish', @@ -178,10 +183,45 @@ export async function publishMovePackage(packageDir: string, privateKeyHex: stri '--assume-yes', '--skip-fetch-latest-git-deps', ]; + if (senderAddr) args.push('--sender-account', senderAddr); console.log(` $ aptos ${args.join(' ')}`); await spawnExitZero('aptos', args, 'aptos move publish'); } +/** Default seed for `deployContracts` resource-account derivation. Scenarios that need a + * distinct ace address (e.g. multiple deployments on the same chain) pass `opts.seed`. */ +export const DEFAULT_BOOTSTRAP_SEED = 'ace-scenario-bootstrap'; + +/** Compute the resource account address `network` will live at, given admin + seed. */ +export function deriveAceAddr(adminAddr: AccountAddress, seed: string): string { + return createResourceAddress(adminAddr, seed).toStringLong(); +} + +/** Phase A of the sealed bootstrap: admin creates a resource account at the deterministic + * derived address. `optional_auth_key` is set to admin's own auth_key so admin's private key + * can sign `aptos move publish` tx's with `--sender-account ` during Phase B. Phase C + * (`start_initial_epoch`) burns this auth_key to zero via `retrieve_resource_account_cap`. + * Returns the resource account address. */ +export async function createAceResourceAccount( + admin: Account, + seed: string, + rpcUrl = LOCALNET_URL, +): Promise { + const aptos = new Aptos(new AptosConfig({ network: Network.CUSTOM, fullnode: rpcUrl })); + const seedBytes = new Uint8Array(Buffer.from(seed, 'utf8')); + const adminAuthKey = AuthenticationKey.fromPublicKey({ publicKey: admin.publicKey }); + const txn = await aptos.transaction.build.simple({ + sender: admin.accountAddress, + data: { + function: '0x1::resource_account::create_resource_account', + functionArguments: [seedBytes, adminAuthKey.toUint8Array()], + }, + }); + const resp = await aptos.signAndSubmitTransaction({ signer: admin, transaction: txn }); + await aptos.waitForTransaction({ transactionHash: resp.hash, options: { checkSuccess: true } }); + return deriveAceAddr(admin.accountAddress, seed); +} + /** Hex (no `0x`) for an Ed25519-backed `Account` (e.g. `generate()` / `fromPrivateKey`). */ export function ed25519PrivateKeyHex(account: Account): string { if (!('privateKey' in account)) { @@ -192,24 +232,35 @@ export function ed25519PrivateKeyHex(account: Account): string { } /** - * Publish Move packages under `REPO_ROOT/contracts/` in order (one `aptos move publish` per folder). - * The `network` package depends on `epoch-change`; publish `epoch-change` before `network`. + * Sealed bootstrap (Phase A + B): create a resource account, then publish each Move package + * under `REPO_ROOT/contracts/` (one `aptos move publish` per folder) to that resource + * account. Phase C — `start_initial_epoch` — must be invoked separately by the caller to burn + * admin's signing path and lock upgrades behind committee voting. + * + * Returns the resource account address that became `@ace`. */ -export async function deployContracts(adminAccount: Account, packageFolders: string[], rpcUrl = LOCALNET_URL): Promise { - const adminAddr = adminAccount.accountAddress.toStringLong(); +export async function deployContracts( + adminAccount: Account, + packageFolders: string[], + opts: { rpcUrl?: string; seed?: string } = {}, +): Promise { + const rpcUrl = opts.rpcUrl ?? LOCALNET_URL; + const seed = opts.seed ?? DEFAULT_BOOTSTRAP_SEED; + const aceAddr = await createAceResourceAccount(adminAccount, seed, rpcUrl); const adminKeyHex = ed25519PrivateKeyHex(adminAccount); - const scratch = prepareContractsPublishScratch(path.join(REPO_ROOT, 'contracts'), adminAddr); + const scratch = prepareContractsPublishScratch(path.join(REPO_ROOT, 'contracts'), aceAddr); try { for (const folder of packageFolders) { const packageDir = path.join(scratch.contractsDir, folder); if (!existsSync(path.join(packageDir, 'Move.toml'))) { throw new Error(`missing Move package at ${packageDir}`); } - await publishMovePackage(packageDir, adminKeyHex, rpcUrl); + await publishMovePackage(packageDir, adminKeyHex, rpcUrl, aceAddr); } } finally { rmContractsPublishScratch(scratch); } + return aceAddr; } export async function fundAccount(address: AccountAddress): Promise { @@ -350,12 +401,18 @@ function parseMoveAbortCode(vmStatus: string): number | undefined { export async function submitTxn( { signer, + sender, entryFunction, args, rpcUrl = LOCALNET_URL, awaitEventType, }: { signer: Account, + /** Override the tx sender (default: signer's address). Use when the signing key's + * auth_key matches a different account — e.g. publishing/initializing the resource + * account during sealed bootstrap, where admin signs but sender is the resource + * account address. */ + sender?: AccountAddress | string, entryFunction: `${string}::${string}::${string}`, args: any[], rpcUrl?: string, @@ -374,8 +431,11 @@ export async function submitTxn( recordsExecutionTimeMs: false, task: async () => { const aptos = new Aptos(new AptosConfig({ network: Network.CUSTOM, fullnode: rpcUrl })); + const senderAddr = sender !== undefined + ? (typeof sender === 'string' ? AccountAddress.fromString(sender) : sender) + : signer.accountAddress; const txn = await aptos.transaction.build.simple({ - sender: signer.accountAddress, + sender: senderAddr, data: { function: entryFunction, typeArguments: [], diff --git a/scenarios/run-local-network-forever.ts b/scenarios/run-local-network-forever.ts index cb01bced..ac2651d5 100644 --- a/scenarios/run-local-network-forever.ts +++ b/scenarios/run-local-network-forever.ts @@ -25,7 +25,7 @@ * pnpm run-local-network-forever */ -import { Account } from '@aptos-labs/ts-sdk'; +import { Account, AccountAddress } from '@aptos-labs/ts-sdk'; import * as ace from '@aptos-labs/ace-sdk'; import { spawn, type ChildProcess } from 'child_process'; import { mkdtempSync, openSync, writeFileSync } from 'fs'; @@ -74,13 +74,17 @@ async function main() { } const adminAccount = accounts[numWorkers]!; + const adminAddr = adminAccount.accountAddress.toStringLong(); const workerAccounts = accounts.slice(0, numWorkers); - const aceContract = adminAccount.accountAddress.toStringLong(); const threshold = 2; - // ── Deploy contracts ───────────────────────────────────────────────────── - log('Deploying contracts...'); - await deployContracts(adminAccount, ['pke', 'worker_config', 'group', 'fiat-shamir-transform', 'sigma-dlog-eq', 'vss', 'dkg', 'dkr', 'epoch-change', 'voting', 'network']); + // ── Deploy contracts (sealed bootstrap Phase A + B) ────────────────────── + log('Deploying contracts via resource-account bootstrap...'); + const aceContract = await deployContracts( + adminAccount, + ['pke', 'worker_config', 'group', 'fiat-shamir-transform', 'sigma-dlog-eq', 'vss', 'dkg', 'dkr', 'epoch-change', 'voting', 'network'], + ); + log(`@ace resource account: ${aceContract}`); // ── Register PKE enc keys + HTTP endpoints ─────────────────────────────── const WORKER_BASE_PORT = 19000; @@ -136,12 +140,14 @@ async function main() { // ── Keep all accounts funded ───────────────────────────────────────────── keepFunded(accounts.map(a => a.accountAddress)); - // ── Start initial epoch ────────────────────────────────────────────────── + // ── Start initial epoch (sealed bootstrap Phase C) ─────────────────────── log('Admin: start_initial_epoch([A,B,C], threshold=2, resharing_interval_secs=120)...'); (await submitTxn({ signer: adminAccount, + sender: aceContract, entryFunction: `${aceContract}::network::start_initial_epoch`, args: [ + adminAddr, workerAccounts.map(w => w.accountAddress), threshold, 120, @@ -160,7 +166,7 @@ async function main() { const dkgDeadlineMs = Date.now() + 300_000; // 5-minute timeout let networkState: ace.network.State | undefined; while (Date.now() < dkgDeadlineMs) { - const maybe = await getNetworkState(adminAccount.accountAddress); + const maybe = await getNetworkState(AccountAddress.fromString(aceContract)); if (maybe.isOk) { networkState = maybe.okValue!; if (networkState.epochChangeInfo === null && networkState.secrets.length >= 1) break; @@ -205,7 +211,7 @@ async function main() { // ── Heartbeat loop (run forever) ───────────────────────────────────────── while (true) { await sleep(30_000); - const maybeState = await getNetworkState(adminAccount.accountAddress); + const maybeState = await getNetworkState(AccountAddress.fromString(aceContract)); if (maybeState.isOk) { const s = maybeState.okValue!; log(`epoch=${s.epoch} secrets=${s.secrets.length} epoch_change=${s.epochChangeInfo !== null ? 'in_progress' : 'none'}`);