From b3bda7dcc637e96940be90c354a04569c410b3b8 Mon Sep 17 00:00:00 2001 From: FlowMemory HQ Agent Date: Thu, 14 May 2026 13:42:42 -0500 Subject: [PATCH 1/2] Add production L1 wallet implementation snapshot --- crypto/.gitignore | 1 + crypto/package.json | 19 + crypto/src/index.d.ts | 71 +++ crypto/src/index.js | 2 + crypto/src/operator-bridge-cli.js | 279 ++++++++++ crypto/src/wallet-cli.js | 513 +++++++++++++++--- crypto/src/wallet-documents.js | 341 ++++++++++++ crypto/src/wallet-e2e.js | 359 ++++++++++++ crypto/src/wallet-envelope.js | 211 +++++++ crypto/src/wallet.js | 190 ++++++- crypto/test/crypto.test.js | 162 ++++++ .../BRIDGE_FUNDED_WALLET_PROOF.md | 40 ++ .../BRIDGE_OPERATOR_PROOF.md | 64 +++ .../CHAIN_SAFETY_PROOF.md | 44 ++ .../production-l1-wallet/CHECKLIST.md | 61 +++ .../production-l1-wallet/COMPLETION_AUDIT.md | 64 +++ .../ENVELOPE_FILE_PROOF.md | 39 ++ .../production-l1-wallet/ENVELOPE_SCHEMA.md | 46 ++ .../production-l1-wallet/EXPERIMENTS.md | 17 + .../production-l1-wallet/HANDOFF.md | 59 ++ .../HUMAN_WALLET_RUNBOOK.md | 73 +++ docs/agent-runs/production-l1-wallet/NOTES.md | 13 + .../production-l1-wallet/NO_SECRET_PROOF.md | 36 ++ docs/agent-runs/production-l1-wallet/PLAN.md | 25 + .../PRODUCT_DEX_SIGNING_PROOF.md | 47 ++ .../TWO_WALLET_TRANSFER_PROOF.md | 38 ++ .../VAULT_BOUNDARY_PROOF.md | 31 ++ .../production-l1-wallet/WALLET_COMMANDS.md | 78 +++ infra/scripts/flowchain-wallet-operator.ps1 | 38 ++ package.json | 3 +- schemas/flowmemory/README.md | 13 + .../local-wallet-public-metadata.schema.json | 35 +- .../wallet-signed-envelope.schema.json | 136 +++++ 33 files changed, 3072 insertions(+), 76 deletions(-) create mode 100644 crypto/src/operator-bridge-cli.js create mode 100644 crypto/src/wallet-documents.js create mode 100644 crypto/src/wallet-e2e.js create mode 100644 crypto/src/wallet-envelope.js create mode 100644 docs/agent-runs/production-l1-wallet/BRIDGE_FUNDED_WALLET_PROOF.md create mode 100644 docs/agent-runs/production-l1-wallet/BRIDGE_OPERATOR_PROOF.md create mode 100644 docs/agent-runs/production-l1-wallet/CHAIN_SAFETY_PROOF.md create mode 100644 docs/agent-runs/production-l1-wallet/CHECKLIST.md create mode 100644 docs/agent-runs/production-l1-wallet/COMPLETION_AUDIT.md create mode 100644 docs/agent-runs/production-l1-wallet/ENVELOPE_FILE_PROOF.md create mode 100644 docs/agent-runs/production-l1-wallet/ENVELOPE_SCHEMA.md create mode 100644 docs/agent-runs/production-l1-wallet/EXPERIMENTS.md create mode 100644 docs/agent-runs/production-l1-wallet/HANDOFF.md create mode 100644 docs/agent-runs/production-l1-wallet/HUMAN_WALLET_RUNBOOK.md create mode 100644 docs/agent-runs/production-l1-wallet/NOTES.md create mode 100644 docs/agent-runs/production-l1-wallet/NO_SECRET_PROOF.md create mode 100644 docs/agent-runs/production-l1-wallet/PLAN.md create mode 100644 docs/agent-runs/production-l1-wallet/PRODUCT_DEX_SIGNING_PROOF.md create mode 100644 docs/agent-runs/production-l1-wallet/TWO_WALLET_TRANSFER_PROOF.md create mode 100644 docs/agent-runs/production-l1-wallet/VAULT_BOUNDARY_PROOF.md create mode 100644 docs/agent-runs/production-l1-wallet/WALLET_COMMANDS.md create mode 100644 infra/scripts/flowchain-wallet-operator.ps1 create mode 100644 schemas/flowmemory/wallet-signed-envelope.schema.json diff --git a/crypto/.gitignore b/crypto/.gitignore index 4e322039..fb1168d2 100644 --- a/crypto/.gitignore +++ b/crypto/.gitignore @@ -2,5 +2,6 @@ node_modules/ coverage/ .nyc_output/ .wallet/ +devnet/local/ *.vault.local.json *.wallet.local.json diff --git a/crypto/package.json b/crypto/package.json index 2076af9c..1b43b4b4 100644 --- a/crypto/package.json +++ b/crypto/package.json @@ -24,13 +24,32 @@ "validate:product-transactions": "node src/validate-product-testnet-fixtures.js", "wallet:product-smoke": "node src/validate-product-testnet-fixtures.js", "wallet:create": "node src/wallet-cli.js create", + "wallet:import": "node src/wallet-cli.js import", "wallet:check": "node src/wallet-cli.js check", + "wallet:unlock": "node src/wallet-cli.js unlock", + "wallet:lock": "node src/wallet-cli.js lock", "wallet:list": "node src/wallet-cli.js list", "wallet:metadata": "node src/wallet-cli.js metadata", + "wallet:export-metadata": "node src/wallet-cli.js export-metadata", + "wallet:verify-metadata": "node src/wallet-cli.js verify-metadata", "wallet:add-account": "node src/wallet-cli.js add-account", "wallet:rotate": "node src/wallet-cli.js rotate", "wallet:sign": "node src/wallet-cli.js sign", + "wallet:sign-transfer": "node src/wallet-cli.js sign-transfer", + "wallet:sign-token-launch": "node src/wallet-cli.js sign-token-launch", + "wallet:sign-token-transfer": "node src/wallet-cli.js sign-token-transfer", + "wallet:sign-pool-create": "node src/wallet-cli.js sign-pool-create", + "wallet:sign-add-liquidity": "node src/wallet-cli.js sign-add-liquidity", + "wallet:sign-remove-liquidity": "node src/wallet-cli.js sign-remove-liquidity", + "wallet:sign-swap": "node src/wallet-cli.js sign-swap", + "wallet:sign-withdrawal-intent": "node src/wallet-cli.js sign-withdrawal-intent", + "wallet:sign-finality": "node src/wallet-cli.js sign-finality", "wallet:verify": "node src/wallet-cli.js verify", + "wallet:submit": "node src/wallet-cli.js submit", + "wallet:query": "node src/wallet-cli.js query", + "wallet:e2e": "node src/wallet-e2e.js", + "wallet:transfer:e2e": "node src/wallet-e2e.js --transfer-only", + "wallet:operator-bridge": "node src/operator-bridge-cli.js", "wallet:pilot-config": "node src/pilot-wallet-cli.js config-from-env", "wallet:pilot-metadata": "node src/pilot-wallet-cli.js metadata", "wallet:pilot-sign": "node src/pilot-wallet-cli.js sign", diff --git a/crypto/src/index.d.ts b/crypto/src/index.d.ts index b1ef8c36..2ac4d23a 100644 --- a/crypto/src/index.d.ts +++ b/crypto/src/index.d.ts @@ -556,6 +556,19 @@ export interface LocalAlphaEnvelopeValidationResult { errors: string[]; } +export interface WalletEnvelopeVerificationResult { + schema: "flowchain.wallet_envelope_verification.v0"; + valid: boolean; + signatureValid: boolean; + chainIdMatch: boolean; + signerDerivedAddress: Bytes32 | null; + payloadHash: Bytes32 | null; + transactionId: Bytes32 | null; + replayKey: string | null; + rejectionReason: string | null; + errors: string[]; +} + export const ZERO_BYTES32: Bytes32; export const FLOWPULSE_SCHEMA_ID_PREIMAGE: string; export const FLOWPULSE_EVENT_SIGNATURE: string; @@ -570,6 +583,12 @@ export const LOCAL_ALPHA_FINALITY_STATES: Readonly>; export const LOCAL_ALPHA_HARDWARE_TRANSPORTS: Readonly>; export const LOCAL_ALPHA_SIGNER_ROLES: Readonly>; export const LOCAL_ALPHA_BRIDGE_STATUSES: Readonly>; +export const LOCAL_WALLET_PUBLIC_METADATA_SCHEMA: string; +export const LOCAL_WALLET_KEY_SCHEME: string; +export const DEFAULT_LOCAL_WALLET_CHAIN_ID: string; +export const LOCAL_TEST_UNIT_ASSET_ID: Bytes32; +export const WALLET_SIGNED_ENVELOPE_SCHEMA: string; +export const WALLET_ENVELOPE_VERIFICATION_SCHEMA: string; export function strip0x(value: string): string; export function bytesToHex(bytes: Uint8Array): Hex; @@ -721,6 +740,8 @@ export function createEncryptedTestVault(input?: { signerRole?: string; createdAtUnixMs?: number | bigint | string; privateKey?: Hex; + chainId?: number | bigint | string; + lastKnownNonce?: number | bigint | string; }): Record; export function unlockEncryptedTestVault(input: { vault: Record; @@ -728,6 +749,21 @@ export function unlockEncryptedTestVault(input: { }): Record; export function listVaultPublicAccounts(vaultOrSession: Record): Array>; export function exportVaultPublicMetadata(vaultOrSession: Record): Record; +export function exportLocalWalletPublicMetadata( + vaultOrSession: Record, + input?: { updatedAtUnixMs?: number | bigint | string } +): Record; +export function validateLocalWalletPublicMetadata( + metadata: Record, + context?: { expectedChainId?: number | bigint | string } +): { + schema: string; + valid: boolean; + secretFree: boolean; + chainIdMatch: boolean; + accountCount: number; + errors: string[]; +}; export function addEncryptedTestVaultAccount(input: { vault: Record; password: string; @@ -736,6 +772,8 @@ export function addEncryptedTestVaultAccount(input: { createdAtUnixMs?: number | bigint | string; privateKey?: Hex; signerId?: Bytes32; + chainId?: number | bigint | string; + lastKnownNonce?: number | bigint | string; }): Record; export function rotateEncryptedTestVaultAccount(input: { vault: Record; @@ -744,6 +782,7 @@ export function rotateEncryptedTestVaultAccount(input: { label?: string; createdAtUnixMs?: number | bigint | string; privateKey?: Hex; + chainId?: number | bigint | string; }): Record; export function signLocalTransactionWithVault(input: { vault: Record; @@ -760,6 +799,38 @@ export function verifyLocalTransactionSignature(input: { context?: Record; }): LocalAlphaEnvelopeValidationResult; +export function buildProductTransferDocument(input: Record): Record; +export function buildProductTokenLaunchDocument(input: Record): Record; +export function buildProductPoolCreateDocument(input: Record): Record; +export function buildProductAddLiquidityDocument(input: Record): Record; +export function buildProductRemoveLiquidityDocument(input: Record): Record; +export function buildProductSwapDocument(input: Record): Record; +export function buildBridgeWithdrawalIntentDocument(input: Record): Record; +export function buildFinalityActionDocument(input: Record): Record; +export function signWalletDocumentWithVault(input: { + vault: Record; + password: string; + signerKeyId: Bytes32; + document: Record; + chainId: number | bigint | string; + nonce: number | bigint | string; + issuedAtUnixMs?: number | bigint | string; + fee?: Record | null; + expiresAtUnixMs?: number | bigint | string | null; +}): Promise>; +export function verifyWalletSignedEnvelope(input: { + envelope: Record; + context?: { + document?: Record; + chainId?: number | bigint | string; + expectedNonce?: number | bigint | string; + expectedSignerId?: Bytes32; + expectedSignerAddress?: Bytes32; + expectedPayloadType?: string; + seenNonces?: Set; + }; +}): WalletEnvelopeVerificationResult; + export const PILOT_MESSAGE_SCHEMAS: readonly string[]; export function validatePilotOperatorEnvelope(input: { document: Record; diff --git a/crypto/src/index.js b/crypto/src/index.js index fca98f63..ca54ba09 100644 --- a/crypto/src/index.js +++ b/crypto/src/index.js @@ -10,3 +10,5 @@ export * from "./pilot-envelope-validation.js"; export * from "./pilot-operator.js"; export * from "./transactions.js"; export * from "./wallet.js"; +export * from "./wallet-documents.js"; +export * from "./wallet-envelope.js"; diff --git a/crypto/src/operator-bridge-cli.js b/crypto/src/operator-bridge-cli.js new file mode 100644 index 00000000..f3a30611 --- /dev/null +++ b/crypto/src/operator-bridge-cli.js @@ -0,0 +1,279 @@ +#!/usr/bin/env node + +const BASE_CHAIN_ID = 8453; +const OPERATOR_ACK_VALUE = "I_UNDERSTAND_THIS_IS_CAPPED_BASE8453_OWNER_PILOT"; +const MAX_SINGLE_DEPOSIT_WEI = 100000000000000n; +const MAX_TOTAL_CAP_WEI = 1000000000000000n; +const REQUIRED_ENV_NAMES = Object.freeze([ + "FLOWCHAIN_PILOT_OPERATOR_ACK", + "FLOWCHAIN_BASE8453_RPC_URL", + "FLOWCHAIN_BASE8453_LOCKBOX_ADDRESS", + "FLOWCHAIN_BASE8453_FROM_BLOCK", + "FLOWCHAIN_BASE8453_TO_BLOCK", + "FLOWCHAIN_PILOT_MAX_DEPOSIT_WEI", + "FLOWCHAIN_PILOT_TOTAL_CAP_WEI", + "FLOWCHAIN_PILOT_WITHDRAWAL_RECIPIENT", + "FLOWCHAIN_PILOT_MAX_USD" +]); + +const command = process.argv[2] ?? "env"; +const args = parseArgs(process.argv.slice(3)); + +try { + if (command === "env") { + printJson({ + schema: "flowchain.wallet_operator.base8453_env_names.v0", + baseChainId: BASE_CHAIN_ID, + operatorAckRequiredValue: OPERATOR_ACK_VALUE, + requiredEnvNames: REQUIRED_ENV_NAMES, + dryRunCommand: "npm run wallet:operator-bridge --prefix crypto -- env", + liveValidationCommand: "npm run wallet:operator-bridge --prefix crypto -- validate --live", + boundary: "Env names only. Secret values are read from the local shell and are never printed." + }); + } else if (command === "validate") { + const result = await validateOperatorEnv({ live: args.live === true || args.live === "true" }); + printJson(result); + process.exitCode = result.valid ? 0 : 1; + } else if (command === "prepare-deposit-evidence") { + printJson(prepareDepositEvidence()); + } else if (command === "prepare-release-evidence") { + printJson(prepareReleaseEvidence()); + } else { + usage(); + process.exitCode = 1; + } +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +} + +async function validateOperatorEnv({ live }) { + const errors = []; + const envPresence = Object.fromEntries(REQUIRED_ENV_NAMES.map((name) => [name, hasEnv(name)])); + const lockbox = envValue("FLOWCHAIN_BASE8453_LOCKBOX_ADDRESS"); + const lockboxAddressValid = !lockbox || isEthAddress(lockbox); + if (!lockboxAddressValid) { + errors.push("bad-lockbox-address"); + } + const withdrawalRecipient = envValue("FLOWCHAIN_PILOT_WITHDRAWAL_RECIPIENT"); + const withdrawalRecipientValid = !withdrawalRecipient || isEthAddress(withdrawalRecipient); + if (!withdrawalRecipientValid) { + errors.push("bad-withdrawal-recipient"); + } + const capResult = validateCaps(); + errors.push(...capResult.errors); + const operatorAckPresent = envValue("FLOWCHAIN_PILOT_OPERATOR_ACK") === OPERATOR_ACK_VALUE; + if (!operatorAckPresent && live) { + errors.push("missing-operator-ack"); + } + + let chainIdValid = live ? false : null; + if (live) { + if (!hasEnv("FLOWCHAIN_BASE8453_RPC_URL")) { + errors.push("missing-rpc-url"); + } else { + chainIdValid = await validateBaseChainId(envValue("FLOWCHAIN_BASE8453_RPC_URL")); + if (!chainIdValid) { + errors.push("wrong-chain-id"); + } + } + } + + return { + schema: "flowchain.wallet_operator.base8453_validation.v0", + valid: errors.length === 0, + live, + baseChainId: BASE_CHAIN_ID, + envPresence, + rpcConfigured: hasEnv("FLOWCHAIN_BASE8453_RPC_URL"), + rpcValuePrinted: false, + chainIdValid, + lockboxAddressValid, + withdrawalRecipientValid, + capCheck: capResult.check, + operatorAckPresent, + dryRunCommands: dryRunCommands(), + liveCommands: liveCommands(), + errors: [...new Set(errors)] + }; +} + +function prepareDepositEvidence() { + const lockbox = args["lockbox-address"] || envValue("FLOWCHAIN_BASE8453_LOCKBOX_ADDRESS") || ""; + if (!lockbox.startsWith("<") && !isEthAddress(lockbox)) { + throw new Error("lockbox address must be a 20-byte hex address"); + } + return { + schema: "flowchain.wallet_operator.bridge_deposit_evidence_commands.v0", + baseChainId: BASE_CHAIN_ID, + lockboxAddressFormat: lockbox.startsWith("<") ? "placeholder" : "valid", + requiredEnvNames: REQUIRED_ENV_NAMES, + dryRunCommands: [ + "npm run wallet:operator-bridge --prefix crypto -- env", + "npm run wallet:operator-bridge --prefix crypto -- validate" + ], + liveCommands: [ + "npm run wallet:operator-bridge --prefix crypto -- validate --live", + "npm run flowchain:real-value-pilot -- --Mode Live --Action Observe" + ], + evidenceOutputs: [ + "devnet/local/real-value-pilot/evidence/base8453-observation.json", + "devnet/local/real-value-pilot/evidence/base8453-credit-pending.json", + "devnet/local/real-value-pilot/evidence/base8453-handoff-pending.json" + ], + rpcValuePrinted: false, + broadcast: false + }; +} + +function prepareReleaseEvidence() { + const recipient = args["base-recipient"] || envValue("FLOWCHAIN_PILOT_WITHDRAWAL_RECIPIENT") || ""; + if (!recipient.startsWith("<") && !isEthAddress(recipient)) { + throw new Error("withdrawal recipient must be a 20-byte Base address"); + } + return { + schema: "flowchain.wallet_operator.bridge_release_evidence_commands.v0", + baseChainId: BASE_CHAIN_ID, + recipientAddressFormat: recipient.startsWith("<") ? "placeholder" : "valid", + requiredEnvNames: REQUIRED_ENV_NAMES, + dryRunCommands: [ + "npm run wallet:operator-bridge --prefix crypto -- validate", + "npm run wallet:e2e --prefix crypto" + ], + liveCommands: [ + "npm run wallet:operator-bridge --prefix crypto -- validate --live", + "npm run flowchain:real-value-pilot -- --Mode Live --Action Withdraw", + "npm run flowchain:real-value-pilot:export" + ], + evidenceOutputs: [ + "devnet/local/real-value-pilot/evidence/base8453-withdrawal-intent.json", + "devnet/local/real-value-pilot/evidence/base8453-handoff-with-withdrawal.json" + ], + rpcValuePrinted: false, + broadcast: false + }; +} + +async function validateBaseChainId(rpcUrl) { + let url; + try { + url = new URL(rpcUrl); + } catch { + throw new Error("FLOWCHAIN_BASE8453_RPC_URL must be an absolute HTTP(S) URL"); + } + if (!["http:", "https:"].includes(url.protocol)) { + throw new Error("FLOWCHAIN_BASE8453_RPC_URL must use HTTP(S)"); + } + let response; + try { + response = await fetch(rpcUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_chainId", params: [] }) + }); + } catch { + throw new Error("could not read eth_chainId from FLOWCHAIN_BASE8453_RPC_URL; endpoint value was not printed"); + } + const body = await response.json(); + if (body.error || typeof body.result !== "string" || !/^0x[0-9a-fA-F]+$/.test(body.result)) { + throw new Error("eth_chainId returned an invalid response; endpoint value was not printed"); + } + return Number.parseInt(body.result.slice(2), 16) === BASE_CHAIN_ID; +} + +function validateCaps() { + const errors = []; + const maxDeposit = parseOptionalUint("FLOWCHAIN_PILOT_MAX_DEPOSIT_WEI"); + const totalCap = parseOptionalUint("FLOWCHAIN_PILOT_TOTAL_CAP_WEI"); + if (maxDeposit === null || totalCap === null) { + errors.push("missing-cap-values"); + } else { + if (maxDeposit <= 0n || maxDeposit > MAX_SINGLE_DEPOSIT_WEI) { + errors.push("bad-max-deposit-cap"); + } + if (totalCap <= 0n || totalCap > MAX_TOTAL_CAP_WEI || totalCap < maxDeposit) { + errors.push("bad-total-cap"); + } + } + return { + errors, + check: { + configured: maxDeposit !== null && totalCap !== null, + maxDepositWei: maxDeposit?.toString() ?? null, + totalCapWei: totalCap?.toString() ?? null, + maxSingleDepositLimitWei: MAX_SINGLE_DEPOSIT_WEI.toString(), + totalCapLimitWei: MAX_TOTAL_CAP_WEI.toString() + } + }; +} + +function parseOptionalUint(name) { + const value = envValue(name); + if (!value) { + return null; + } + if (!/^[0-9]+$/.test(value)) { + return -1n; + } + return BigInt(value); +} + +function dryRunCommands() { + return [ + "npm run wallet:operator-bridge --prefix crypto -- env", + "npm run wallet:operator-bridge --prefix crypto -- validate", + "npm run wallet:operator-bridge --prefix crypto -- prepare-deposit-evidence", + "npm run wallet:operator-bridge --prefix crypto -- prepare-release-evidence" + ]; +} + +function liveCommands() { + return [ + "npm run wallet:operator-bridge --prefix crypto -- validate --live", + "npm run flowchain:real-value-pilot -- --Mode Live --Action Observe", + "npm run flowchain:real-value-pilot -- --Mode Live --Action Withdraw" + ]; +} + +function hasEnv(name) { + return envValue(name) !== ""; +} + +function envValue(name) { + return process.env[name] ?? ""; +} + +function isEthAddress(value) { + return /^0x[0-9a-fA-F]{40}$/.test(value); +} + +function parseArgs(argv) { + const parsed = {}; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg.startsWith("--")) { + continue; + } + const key = arg.slice(2); + const next = argv[i + 1]; + if (!next || next.startsWith("--")) { + parsed[key] = true; + } else { + parsed[key] = next; + i += 1; + } + } + return parsed; +} + +function printJson(value) { + console.log(JSON.stringify(value, null, 2)); +} + +function usage() { + console.error(`Usage: + node src/operator-bridge-cli.js env + node src/operator-bridge-cli.js validate [--live] + node src/operator-bridge-cli.js prepare-deposit-evidence [--lockbox-address <0x...>] + node src/operator-bridge-cli.js prepare-release-evidence [--base-recipient <0x...>]`); +} diff --git a/crypto/src/wallet-cli.js b/crypto/src/wallet-cli.js index 41809c48..d885bbcf 100644 --- a/crypto/src/wallet-cli.js +++ b/crypto/src/wallet-cli.js @@ -1,56 +1,136 @@ #!/usr/bin/env node -import { readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + import { addEncryptedTestVaultAccount, + buildBridgeWithdrawalIntentDocument, + buildFinalityActionDocument, + buildProductAddLiquidityDocument, + buildProductPoolCreateDocument, + buildProductRemoveLiquidityDocument, + buildProductSwapDocument, + buildProductTokenLaunchDocument, + buildProductTransferDocument, createEncryptedTestVault, + exportLocalWalletPublicMetadata, exportVaultPublicMetadata, listVaultPublicAccounts, + LOCAL_TEST_UNIT_ASSET_ID, rotateEncryptedTestVaultAccount, - signLocalTransactionWithVault, + signWalletDocumentWithVault, unlockEncryptedTestVault, - validateLocalTransactionEnvelope + validateLocalTransactionEnvelope, + validateLocalWalletPublicMetadata, + verifyWalletSignedEnvelope } from "./index.js"; -const command = process.argv[2]; -const args = parseArgs(process.argv.slice(3)); +const { command, args } = parseCommand(process.argv.slice(2)); try { if (command === "create") { const vault = createEncryptedTestVault({ password: password(), label: args.label ?? "local-operator", - signerRole: args.role ?? "operator" + signerRole: args.role ?? "operator", + chainId: args["chain-id"] ?? "31337", + createdAtUnixMs: args["created-at-unix-ms"] }); - writeOutput(args.vault, vault); - console.log(JSON.stringify(exportVaultPublicMetadata(vault), null, 2)); - } else if (command === "check") { + writeJson(requiredOrDefault("vault", "devnet/local/wallet/operator-vault.local.json"), vault); + maybeWriteMetadata(vault); + printAccountSummary("flowchain.wallet.account_created.v0", vault.publicAccounts[0], { includePublicKey: true }); + } else if (command === "import") { + const vaultPath = requiredOrDefault("vault", "devnet/local/wallet/imported-vault.local.json"); + const privateKey = importedPrivateKey(); + const vault = existsSync(vaultPath) + ? addEncryptedTestVaultAccount({ + vault: readJson(vaultPath), + password: password(), + label: args.label ?? "imported-account", + signerRole: args.role ?? "agent", + privateKey, + chainId: args["chain-id"] ?? "31337", + createdAtUnixMs: args["created-at-unix-ms"] + }) + : createEncryptedTestVault({ + password: password(), + label: args.label ?? "imported-account", + signerRole: args.role ?? "agent", + privateKey, + chainId: args["chain-id"] ?? "31337", + createdAtUnixMs: args["created-at-unix-ms"] + }); + writeJson(vaultPath, vault); + maybeWriteMetadata(vault); + printAccountSummary("flowchain.wallet.account_imported.v0", vault.publicAccounts.at(-1), { includePublicKey: false }); + } else if (command === "unlock" || command === "check") { const vault = readJson(required("vault")); - const session = unlockEncryptedTestVault({ vault, password: password() }); + const session = unlockVaultSafely(vault); + const markerPath = sessionPath(); + writeJson(markerPath, { + schema: "flowchain.wallet.unlock_marker.v0", + vaultId: session.vaultId, + unlockedAtUnixMs: Date.now().toString(), + accountCount: session.accounts.length, + containsSecrets: false + }); console.log(JSON.stringify({ - schema: "flowmemory.crypto.local-test-vault-check.v0", + schema: "flowchain.wallet.unlock_result.v0", vaultId: session.vaultId, + unlocked: true, accountCount: session.accounts.length, - publicAccountCount: session.publicAccounts.length, - unlocked: true + sessionPath: markerPath, + containsSecrets: false }, null, 2)); - } else if (command === "list") { + } else if (command === "lock") { + const markerPath = sessionPath(); + if (existsSync(markerPath)) { + rmSync(markerPath, { force: true }); + } + console.log(JSON.stringify({ + schema: "flowchain.wallet.lock_result.v0", + locked: true, + sessionPath: markerPath + }, null, 2)); + } else if (command === "list" || command === "list-accounts") { const vault = readJson(required("vault")); - unlockEncryptedTestVault({ vault, password: password() }); - console.log(JSON.stringify(listVaultPublicAccounts(vault), null, 2)); + if (!args.public) { + unlockVaultSafely(vault); + } + console.log(JSON.stringify({ + schema: "flowchain.wallet.account_list.v0", + vaultId: vault.vaultId, + locked: !existsSync(sessionPath()), + chainIds: [...new Set((vault.publicAccounts ?? []).map((account) => String(account.chainId ?? "31337")))], + accounts: listVaultPublicAccounts(vault) + }, null, 2)); } else if (command === "metadata") { const vault = readJson(required("vault")); console.log(JSON.stringify(exportVaultPublicMetadata(vault), null, 2)); - } else if (command === "add-account") { + } else if (command === "export-metadata") { + const vault = readJson(required("vault")); + const metadata = exportLocalWalletPublicMetadata(vault, { updatedAtUnixMs: args["updated-at-unix-ms"] }); + writeOutput(args.out, metadata); + console.log(JSON.stringify(metadata, null, 2)); + } else if (command === "verify-metadata") { + const metadata = readJson(required("metadata")); + const result = validateLocalWalletPublicMetadata(metadata, { expectedChainId: args["chain-id"] }); + console.log(JSON.stringify(result, null, 2)); + process.exitCode = result.valid ? 0 : 1; + } else if (command === "add-account" || command === "rotate-account") { const vaultPath = required("vault"); const vault = readJson(vaultPath); const updatedVault = addEncryptedTestVaultAccount({ vault, password: password(), label: args.label ?? "local-account", - signerRole: args.role ?? "agent" + signerRole: args.role ?? "agent", + chainId: args["chain-id"] ?? vault.publicAccounts?.[0]?.chainId ?? "31337", + createdAtUnixMs: args["created-at-unix-ms"] }); - writeOutput(vaultPath, updatedVault); - console.log(JSON.stringify(exportVaultPublicMetadata(updatedVault), null, 2)); + writeJson(vaultPath, updatedVault); + maybeWriteMetadata(updatedVault); + printAccountSummary("flowchain.wallet.account_added.v0", updatedVault.publicAccounts.at(-1), { includePublicKey: true }); } else if (command === "rotate") { const vaultPath = required("vault"); const vault = readJson(vaultPath); @@ -58,45 +138,140 @@ try { vault, password: password(), signerKeyId: required("signer-key-id"), - label: args.label + label: args.label, + chainId: args["chain-id"], + createdAtUnixMs: args["created-at-unix-ms"] }); - writeOutput(vaultPath, updatedVault); - console.log(JSON.stringify(exportVaultPublicMetadata(updatedVault), null, 2)); + writeJson(vaultPath, updatedVault); + maybeWriteMetadata(updatedVault); + printAccountSummary("flowchain.wallet.account_rotated.v0", updatedVault.publicAccounts.at(-1), { includePublicKey: true }); } else if (command === "sign") { - const vault = readJson(required("vault")); const document = readJson(required("document")); - const signerKeyId = args["signer-key-id"] ?? vault.publicAccounts[0]?.signerKeyId; - const envelope = await signLocalTransactionWithVault({ - vault, - password: password(), - signerKeyId, - document, - chainId: required("chain-id"), - nonce: required("nonce"), - issuedAtUnixMs: args["issued-at-unix-ms"] - }); - writeOutput(args.out, envelope); - console.log(JSON.stringify(envelope, null, 2)); + await signAndPrint(document); + } else if (command === "sign-transfer") { + await signAndPrint(buildProductTransferDocument({ + fromAccountId: required("from"), + toAccountId: required("to"), + assetId: args["asset-id"] ?? LOCAL_TEST_UNIT_ASSET_ID, + amount: required("amount"), + accountNonce: args["account-nonce"] ?? required("nonce"), + deadlineBlock: args["deadline-block"] ?? "0", + memo: args.memo, + memoHash: args["memo-hash"] + })); + } else if (command === "sign-token-launch") { + await signAndPrint(buildProductTokenLaunchDocument({ + issuerAccountId: required("owner"), + ownerAccountId: required("owner"), + symbol: required("symbol"), + name: required("name"), + supply: required("supply"), + decimals: args.decimals ?? 18, + accountNonce: args["account-nonce"] ?? required("nonce"), + tokenId: args["token-id"], + metadataHash: args["metadata-hash"], + launchPolicyHash: args["launch-policy-hash"] + })); + } else if (command === "sign-token-transfer") { + await signAndPrint(buildProductTransferDocument({ + fromAccountId: required("from"), + toAccountId: required("to"), + assetId: required("token-id"), + amount: required("amount"), + accountNonce: args["account-nonce"] ?? required("nonce"), + deadlineBlock: args["deadline-block"] ?? "0", + memo: args.memo, + memoHash: args["memo-hash"] + })); + } else if (command === "sign-pool-create") { + await signAndPrint(buildProductPoolCreateDocument({ + creatorAccountId: required("owner"), + baseAssetId: required("base-asset-id"), + quoteAssetId: required("quote-asset-id"), + baseReserve: required("base-reserve"), + quoteReserve: required("quote-reserve"), + feeBps: args["fee-bps"] ?? 30, + tickSpacing: args["tick-spacing"] ?? 1, + poolId: args["pool-id"], + metadataHash: args["metadata-hash"], + accountNonce: args["account-nonce"] ?? required("nonce") + })); + } else if (command === "sign-add-liquidity") { + await signAndPrint(buildProductAddLiquidityDocument({ + providerAccountId: required("owner"), + poolId: required("pool-id"), + baseAmount: required("base-amount"), + quoteAmount: required("quote-amount"), + minLiquidityTokens: required("min-liquidity-tokens"), + deadlineBlock: required("deadline-block"), + accountNonce: args["account-nonce"] ?? required("nonce") + })); + } else if (command === "sign-remove-liquidity") { + await signAndPrint(buildProductRemoveLiquidityDocument({ + providerAccountId: required("owner"), + poolId: required("pool-id"), + liquidityTokens: required("liquidity-tokens"), + minBaseAmount: required("min-base-amount"), + minQuoteAmount: required("min-quote-amount"), + deadlineBlock: required("deadline-block"), + accountNonce: args["account-nonce"] ?? required("nonce") + })); + } else if (command === "sign-swap") { + await signAndPrint(buildProductSwapDocument({ + traderAccountId: required("owner"), + poolId: required("pool-id"), + assetInId: required("input-token-id"), + assetOutId: required("output-token-id"), + amountIn: required("input-amount"), + minAmountOut: required("minimum-output"), + deadlineBlock: required("deadline-block"), + accountNonce: args["account-nonce"] ?? required("nonce") + })); + } else if (command === "sign-withdrawal-intent") { + await signAndPrint(buildBridgeWithdrawalIntentDocument({ + creditId: required("credit-id"), + depositId: required("deposit-id"), + sourceChainId: args["source-chain-id"] ?? required("chain-id"), + destinationChainId: args["destination-chain-id"] ?? 8453, + token: required("bridge-asset"), + amount: required("amount"), + flowchainAccount: required("account"), + baseRecipient: required("base-address"), + requestedAt: args["requested-at"] + })); + } else if (command === "sign-finality") { + await signAndPrint(buildFinalityActionDocument({ + receiptId: required("receipt-id"), + reportId: required("report-id"), + challengeRoot: args["challenge-root"], + finalityState: args["finality-state-code"] ?? 6, + finalizedAtUnixMs: required("finalized-at-unix-ms"), + finalizedBlockNumber: required("finalized-block-number"), + finalizedBlockHash: required("finalized-block-hash"), + policyHash: required("policy-hash") + })); } else if (command === "verify") { - const document = readJson(required("document")); - const envelope = readJson(required("envelope")); - const context = {}; - if (args["chain-id"]) { - context.chainId = args["chain-id"]; - } - if (args["expected-nonce"]) { - context.expectedNonce = args["expected-nonce"]; - } - if (args["expected-signer-id"]) { - context.expectedSignerId = args["expected-signer-id"]; + if (!args.envelope && !args.document) { + runVerificationSmoke(); + } else { + const envelope = readJson(required("envelope")); + const document = args.document ? readJson(args.document) : undefined; + const context = verificationContext(document); + const result = envelope.schema === "flowchain.wallet_signed_envelope.v0" + ? verifyWalletSignedEnvelope({ envelope, context }) + : validateLocalTransactionEnvelope({ document, envelope, context }); + console.log(JSON.stringify(result, null, 2)); + process.exitCode = result.valid ? 0 : 1; } - const result = validateLocalTransactionEnvelope({ - document, - envelope, - context - }); - console.log(JSON.stringify(result, null, 2)); - process.exitCode = result.valid ? 0 : 1; + } else if (command === "submit") { + const envelope = readJson(required("envelope")); + const response = await submitEnvelope(envelope); + console.log(JSON.stringify(response, null, 2)); + process.exitCode = response.error ? 1 : 0; + } else if (command === "query") { + const response = await queryControlPlane(required("method"), args.params ? parseJsonValue(args.params) : {}); + console.log(JSON.stringify(response, null, 2)); + process.exitCode = response.error ? 1 : 0; } else { usage(); process.exitCode = 1; @@ -106,6 +281,14 @@ try { process.exitCode = 1; } +function parseCommand(argv) { + const rawCommand = argv[0]; + if (rawCommand === "list" && argv[1] === "accounts") { + return { command: "list", args: parseArgs(argv.slice(2)) }; + } + return { command: rawCommand, args: parseArgs(argv.slice(1)) }; +} + function parseArgs(argv) { const parsed = {}; for (let i = 0; i < argv.length; i += 1) { @@ -125,14 +308,189 @@ function parseArgs(argv) { return parsed; } +async function signAndPrint(document) { + const vault = readJson(required("vault")); + const signerKeyId = args["signer-key-id"] ?? selectSignerKeyId(vault, args.from ?? args.owner ?? args.account); + const envelope = await signWalletDocumentWithVault({ + vault, + password: password(), + signerKeyId, + document, + chainId: required("chain-id"), + nonce: required("nonce"), + issuedAtUnixMs: args["issued-at-unix-ms"], + expiresAtUnixMs: args["expires-at-unix-ms"] ?? null + }); + const outPath = args.out ?? defaultEnvelopePath(envelope.txId); + writeJson(outPath, envelope); + console.log(JSON.stringify({ + schema: "flowchain.wallet.sign_result.v0", + txId: envelope.txId, + chainId: envelope.chainId, + payloadType: envelope.payloadType, + signerAddress: envelope.signerAddress, + nonce: envelope.nonce, + envelopePath: outPath, + verification: envelope.verification + }, null, 2)); +} + +function selectSignerKeyId(vault, preferredAddress) { + const accounts = vault.publicAccounts ?? []; + if (preferredAddress) { + const account = accounts.find((candidate) => + candidate.signerId === preferredAddress || + candidate.accountId === preferredAddress || + candidate.address === preferredAddress + ); + if (!account) { + throw new Error("vault does not contain an active signer for the requested account"); + } + return account.signerKeyId; + } + const account = accounts.find((candidate) => candidate.active !== false) ?? accounts[0]; + if (!account) { + throw new Error("vault does not contain any signer accounts"); + } + return account.signerKeyId; +} + +function maybeWriteMetadata(vault) { + if (args["metadata-out"]) { + writeJson(args["metadata-out"], exportLocalWalletPublicMetadata(vault, { updatedAtUnixMs: args["updated-at-unix-ms"] })); + } +} + +function printAccountSummary(schema, account, { includePublicKey }) { + const output = { + schema, + address: account.address ?? account.signerId + }; + if (includePublicKey) { + output.publicKey = account.publicKey; + } + console.log(JSON.stringify(output, null, 2)); +} + function password() { const value = args.password ?? process.env.FLOWMEMORY_TEST_WALLET_PASSWORD; if (!value) { - throw new Error("set --password or FLOWMEMORY_TEST_WALLET_PASSWORD for the local test vault"); + throw new Error("set --password or FLOWMEMORY_TEST_WALLET_PASSWORD for the local wallet vault"); } return value; } +function importedPrivateKey() { + if (args["private-key-env"]) { + const value = process.env[args["private-key-env"]]; + if (!value) { + throw new Error(`environment variable ${args["private-key-env"]} is empty`); + } + return normalizePrivateKey(value); + } + if (args["private-key-file"]) { + return normalizePrivateKey(readFileSync(args["private-key-file"], "utf8")); + } + if (args["private-key-stdin"]) { + return normalizePrivateKey(readFileSync(0, "utf8")); + } + throw new Error("import requires --private-key-env, --private-key-file, or --private-key-stdin"); +} + +function normalizePrivateKey(value) { + const trimmed = String(value).trim(); + if (!/^0x[0-9a-fA-F]{64}$/.test(trimmed)) { + throw new Error("imported private key must be a 32-byte hex value"); + } + return trimmed; +} + +function unlockVaultSafely(vault) { + try { + return unlockEncryptedTestVault({ vault, password: password() }); + } catch { + throw new Error("vault unlock failed"); + } +} + +function sessionPath() { + return args["session-path"] ?? `${requiredOrDefault("vault", "devnet/local/wallet/operator-vault.local.json")}.session.local.json`; +} + +function verificationContext(document) { + const context = {}; + if (document) { + context.document = document; + } + if (args["chain-id"]) { + context.chainId = args["chain-id"]; + } + if (args["expected-nonce"]) { + context.expectedNonce = args["expected-nonce"]; + } + if (args["expected-signer-id"]) { + context.expectedSignerId = args["expected-signer-id"]; + } + if (args["expected-signer-address"]) { + context.expectedSignerAddress = args["expected-signer-address"]; + } + return context; +} + +function runVerificationSmoke() { + const fixture = readJson(resolve(import.meta.dirname, "..", "fixtures", "product-testnet-transactions.json")); + const vector = fixture.transactions.positive[0]; + const document = fixture.documents.positive.find((entry) => entry.name === vector.objectName).document; + const result = validateLocalTransactionEnvelope({ + document, + envelope: vector.envelope, + context: { chainId: fixture.chainId, expectedNonce: vector.envelope.nonce } + }); + console.log(JSON.stringify({ + schema: "flowchain.wallet.verify_smoke.v0", + vector: vector.name, + txId: vector.envelope.envelopeId, + result + }, null, 2)); + process.exitCode = result.valid ? 0 : 1; +} + +async function submitEnvelope(envelope) { + const { dispatchJsonRpc, loadControlPlaneState } = await import("../../services/control-plane/src/index.ts"); + const state = loadControlPlaneState({ + txIntakePath: args["intake-path"] ?? "devnet/local/intake/transactions.ndjson" + }); + return dispatchJsonRpc({ + jsonrpc: "2.0", + id: 1, + method: "transaction_submit", + params: { + signedEnvelope: envelope, + submittedBy: args["submitted-by"] ?? "flowchain-wallet-cli" + } + }, { state }); +} + +async function queryControlPlane(method, params) { + const { dispatchJsonRpc, loadControlPlaneState } = await import("../../services/control-plane/src/index.ts"); + const state = loadControlPlaneState({ + txIntakePath: args["intake-path"] ?? "devnet/local/intake/transactions.ndjson" + }); + return dispatchJsonRpc({ + jsonrpc: "2.0", + id: 1, + method, + params + }, { state }); +} + +function parseJsonValue(value) { + if (existsSync(value)) { + return readJson(value); + } + return JSON.parse(value); +} + function required(name) { if (!args[name]) { throw new Error(`missing --${name}`); @@ -140,24 +498,51 @@ function required(name) { return args[name]; } +function requiredOrDefault(name, defaultValue) { + return args[name] ?? defaultValue; +} + function readJson(path) { return JSON.parse(readFileSync(path, "utf8")); } function writeOutput(path, value) { if (path) { - writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); + writeJson(path, value); } } +function writeJson(path, value) { + const fullPath = resolve(path); + mkdirSync(dirname(fullPath), { recursive: true }); + writeFileSync(fullPath, `${JSON.stringify(value, null, 2)}\n`); + return fullPath; +} + +function defaultEnvelopePath(txId) { + return `devnet/local/wallet/envelopes/${txId}.json`; +} + function usage() { console.error(`Usage: - node src/wallet-cli.js create --vault [--password ] [--label