Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 28 additions & 7 deletions cli/src/commands/deployment-new.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
ACE_CONTRACT_PACKAGES,
REPO_ROOT,
deployContracts,
deriveAceAddr,
ed25519PrivateKeyHex,
} from '../deploy-contracts.js';
import { CLI } from '../cli-name.js';
Expand Down Expand Up @@ -247,7 +248,21 @@ export async function deploymentNewCommand(): Promise<void> {
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.
Expand Down Expand Up @@ -287,7 +302,10 @@ export async function deploymentNewCommand(): Promise<void> {
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();
Expand Down Expand Up @@ -316,7 +334,7 @@ export async function deploymentNewCommand(): Promise<void> {

// 7. Operator onboarding blob.
const operatorBlob = JSON.stringify(
Object.assign({ rpcUrl, aceAddr: adminAddr },
Object.assign({ rpcUrl, aceAddr },
sharedNodeApiKey ? { rpcApiKey: sharedNodeApiKey } : {},
gasStationApiKey ? { gasStationKey: gasStationApiKey } : {},
),
Expand Down Expand Up @@ -379,12 +397,14 @@ export async function deploymentNewCommand(): Promise<void> {
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,
Expand All @@ -393,17 +413,18 @@ export async function deploymentNewCommand(): Promise<void> {
});
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.
const config = loadConfig();
const key = makeDeploymentKey(rpcUrl, adminAddr);
const dep: TrackedDeployment = {
rpcUrl,
aceAddr: adminAddr,
aceAddr,
adminAddress: adminAddr,
adminPrivateKey: `0x${adminPrivKeyHex}`,
bootstrapSeed,
sharedNodeApiKey,
gasStationApiKey,
alias: `${network}-${tag}`,
Expand Down
213 changes: 173 additions & 40 deletions cli/src/commands/update-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key>` (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 <pkg>.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 <session_addr>`. 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 {
Expand All @@ -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<bigint> {
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<string> {
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;
Expand All @@ -47,10 +143,6 @@ export async function updateContractsCommand(opts: {
}): Promise<void> {
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) {
Expand All @@ -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/, '');
Expand All @@ -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 <session_addr>\`) 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) {
Expand All @@ -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 <session_addr>\` 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('══════════════════════════════════════════════════════════════════════');
}
10 changes: 7 additions & 3 deletions cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading