diff --git a/cli/src/api/cvms.ts b/cli/src/api/cvms.ts index 85b6bd82..97230536 100644 --- a/cli/src/api/cvms.ts +++ b/cli/src/api/cvms.ts @@ -1,7 +1,6 @@ import { safeGetCvmList, safeGetCvmInfo, - safeGetCvmComposeFile, type Client, type CvmInfoDetailV20260121, } from "@phala/cloud"; @@ -20,7 +19,6 @@ import type { GetCvmNetworkResponse, TeepodResponse, PubkeyResponse, - CvmComposeConfigResponse, UpgradeResponse, } from "./types"; import inquirer from "inquirer"; @@ -45,22 +43,6 @@ export async function getCvmByAppId( return result.data; } -/** - * Get CVM compose configuration - */ -export async function getCvmComposeConfig( - cvmId: string, -): Promise { - const client = await getClient(); - const result = await safeGetCvmComposeFile(client, { id: cvmId }); - - if (!result.success) { - throw new Error(result.error.message); - } - - return result.data as CvmComposeConfigResponse; -} - /** * Get CVM network information * @param appId App ID (with or without app_ prefix) @@ -168,11 +150,13 @@ export interface ResizeCvmPayload { } /** - * Replicate a CVM + * Replicate a CVM instance * @param appId App ID (with or without app_ prefix) + * @param vmUuid Source CVM UUID (with or without dashes) */ export async function replicateCvm( appId: string, + vmUuid: string, payload: { teepod_id?: number; encrypted_env?: string; @@ -181,7 +165,7 @@ export async function replicateCvm( const client = await getClient(); const cleanAppId = appId.replace(/^app_/, ""); const response = await client.post( - `cvms/app_${cleanAppId}/replicas`, + `apps/${cleanAppId}/cvms/${vmUuid}/replicas`, payload, ); return replicateCvmResponseSchema.parse(response); diff --git a/cli/src/api/types.ts b/cli/src/api/types.ts index 7dc0f192..a247cc1d 100644 --- a/cli/src/api/types.ts +++ b/cli/src/api/types.ts @@ -148,22 +148,22 @@ export const replicateCvmResponseSchema = z.object({ id: z.number(), name: z.string(), }), - user_id: z.number().nullable(), + user_id: z.number().nullable().optional(), app_id: z.string(), - vm_uuid: z.string().nullable(), - instance_id: z.string().nullable(), - app_url: z.string().nullable(), - base_image: z.string().nullable(), + vm_uuid: z.string().nullable().optional(), + instance_id: z.string().nullable().optional(), + app_url: z.string().nullable().optional(), + base_image: z.string().nullable().optional(), vcpu: z.number(), memory: z.number(), disk_size: z.number(), - manifest_version: z.number().nullable(), - version: z.string().nullable(), - runner: z.string().nullable(), - docker_compose_file: z.string().nullable(), - features: z.array(z.string()).nullable(), + manifest_version: z.number().nullable().optional(), + version: z.string().nullable().optional(), + runner: z.string().nullable().optional(), + docker_compose_file: z.string().nullable().optional(), + features: z.array(z.string()).nullable().optional(), created_at: z.string(), - encrypted_env_pubkey: z.string().nullable(), + encrypted_env_pubkey: z.string().nullable().optional(), }); export type ReplicateCvmResponse = z.infer; @@ -309,9 +309,3 @@ export interface AvailableNodesResponse { nodes: TEEPod[]; kms_list?: KmsListItem[]; } - -// CVM Compose Config Response -export interface CvmComposeConfigResponse { - env_pubkey: string; - [key: string]: unknown; -} diff --git a/cli/src/commands/allow-devices/index.ts b/cli/src/commands/allow-devices/index.ts index d66a28cf..e26b4d52 100644 --- a/cli/src/commands/allow-devices/index.ts +++ b/cli/src/commands/allow-devices/index.ts @@ -1,14 +1,5 @@ import chalk from "chalk"; import inquirer from "inquirer"; -import { - type Chain, - type PublicClient, - type WalletClient, - createPublicClient, - createWalletClient, - http, -} from "viem"; -import { privateKeyToAccount, nonceManager } from "viem/accounts"; import { safeGetCvmInfo, safeGetAppDeviceAllowlist, @@ -163,24 +154,6 @@ function resolvePrivateKey(input: { privateKey?: string }): `0x${string}` { return (key.startsWith("0x") ? key : `0x${key}`) as `0x${string}`; } -function createSharedClients( - chain: Chain, - privateKey: `0x${string}`, - rpcUrl?: string, -) { - const account = privateKeyToAccount(privateKey, { nonceManager }); - const publicClient = createPublicClient({ - chain, - transport: http(rpcUrl), - }) as unknown as PublicClient; - const walletClient = createWalletClient({ - account, - chain, - transport: http(rpcUrl), - }) as unknown as WalletClient; - return { publicClient, walletClient }; -} - async function resolveDeviceIdOrNodeName( deviceInput: string, ): Promise<`0x${string}`> { @@ -429,12 +402,6 @@ async function runAdd( return 1; } - const { publicClient, walletClient } = createSharedClients( - chain, - privateKey, - input.rpcUrl, - ); - const results: { deviceId: string; txHash: string; @@ -444,10 +411,10 @@ async function runAdd( for (const deviceId of deviceIds) { const result = await safeAddDevice({ chain, + rpcUrl: input.rpcUrl, appAddress: appContractAddress, deviceId, - walletClient, - publicClient, + privateKey, skipPrerequisiteChecks: true, }); @@ -574,12 +541,6 @@ async function runRemove( return 1; } - const { publicClient, walletClient } = createSharedClients( - chain, - privateKey, - input.rpcUrl, - ); - const results: { deviceId: string; txHash: string; @@ -589,10 +550,10 @@ async function runRemove( for (const deviceId of deviceIds) { const result = await safeRemoveDevice({ chain, + rpcUrl: input.rpcUrl, appAddress: appContractAddress, deviceId, - walletClient, - publicClient, + privateKey, skipPrerequisiteChecks: true, }); diff --git a/cli/src/commands/cvms/replicate/command.ts b/cli/src/commands/cvms/replicate/command.ts index 4c41dba5..2836979d 100644 --- a/cli/src/commands/cvms/replicate/command.ts +++ b/cli/src/commands/cvms/replicate/command.ts @@ -9,10 +9,17 @@ export const cvmsReplicateCommandMeta: CommandMeta = { arguments: [cvmIdArgument], options: [ { - name: "teepod-id", - description: "TEEPod ID for replica", + name: "node-id", + description: "Node ID for replica", type: "string", - target: "teepodId", + target: "nodeId", + }, + { + name: "compose-hash", + description: + "Explicit compose hash to replicate. Required when the source app has multiple live instances", + type: "string", + target: "composeHash", }, { name: "env-file", @@ -21,20 +28,82 @@ export const cvmsReplicateCommandMeta: CommandMeta = { type: "string", target: "envFile", }, + { + name: "private-key", + description: "Private key for signing transactions.", + type: "string", + target: "privateKey", + group: "advanced", + }, + { + name: "rpc-url", + description: "RPC URL for the blockchain.", + type: "string", + target: "rpcUrl", + group: "advanced", + }, + { + name: "prepare-only", + description: + "Only prepare the replica (generate commit token) without performing on-chain operations.", + type: "boolean", + target: "prepareOnly", + group: "advanced", + }, + { + name: "commit", + description: + "Commit a previously prepared replica using a commit token. Requires --token and --compose-hash.", + type: "boolean", + target: "commit", + group: "advanced", + }, + { + name: "token", + description: "Commit token from a prepare-only replica request", + type: "string", + target: "token", + group: "advanced", + }, + { + name: "transaction-hash", + description: + "Transaction hash proving on-chain compose hash registration. Use already-registered for state-only verification.", + type: "string", + target: "transactionHash", + group: "advanced", + }, interactiveOption, ], examples: [ { name: "Replicate a CVM", - value: "phala cvms replicate 1234 --teepod-id 5", + value: "phala cvms replicate 1234 --node-id 5", + }, + { + name: "Prepare a replica for multisig approval", + value: + "phala cvms replicate 1234 --node-id 5 --compose-hash --prepare-only", + }, + { + name: "Commit a prepared replica", + value: + "phala cvms replicate 1234 --commit --token --compose-hash --transaction-hash ", }, ], }; export const cvmsReplicateCommandSchema = z.object({ cvmId: z.string().optional(), - teepodId: z.string().optional(), + nodeId: z.string().optional(), + composeHash: z.string().optional(), envFile: z.string().optional(), + privateKey: z.string().optional(), + rpcUrl: z.string().optional(), + prepareOnly: z.boolean().default(false), + commit: z.boolean().default(false), + token: z.string().optional(), + transactionHash: z.string().optional(), interactive: z.boolean().default(false), }); diff --git a/cli/src/commands/cvms/replicate/index.ts b/cli/src/commands/cvms/replicate/index.ts index f87e4e30..a60d67c3 100644 --- a/cli/src/commands/cvms/replicate/index.ts +++ b/cli/src/commands/cvms/replicate/index.ts @@ -1,10 +1,27 @@ import fs from "node:fs"; import path from "node:path"; -import { CvmIdSchema, encryptEnvVars } from "@phala/cloud"; -import { getCvmComposeConfig, replicateCvm } from "@/src/api/cvms"; +import { + type PhalaCloudError, + ResourceError, + type Client, + type ErrorLink, + type EnvVar, + safeAddComposeHash, + safeAddDevice, + safeCheckOnChainPrerequisites, + safeGetAvailableNodes, + safeGetAppCvms, + safeGetCvmInfo, + encryptEnvVars, + formatErrorMessage, + formatStructuredError, +} from "@phala/cloud"; +import { getClient } from "@/src/lib/client"; +import { getEncryptPubkey } from "@/src/commands/envs/get-encrypt-pubkey"; import { defineCommand } from "@/src/core/define-command"; +import { isInJsonMode } from "@/src/core/json-mode"; import type { CommandContext } from "@/src/core/types"; - +import { CLOUD_URL } from "@/src/utils/constants"; import { logger } from "@/src/utils/logger"; import { cvmsReplicateCommandMeta, @@ -12,7 +29,22 @@ import { type CvmsReplicateCommandInput, } from "./command"; -function parseEnvFile(filePath: string): { key: string; value: string }[] { +interface ReplicaPreparePayload { + composeHash: string; + appId: string; + deviceId: string; + kmsInfo?: unknown; + commitToken?: string; + commitUrl?: string; + apiCommitUrl?: string; + onchainStatus?: { + compose_hash_allowed: boolean; + device_id_allowed: boolean; + is_allowed: boolean; + }; +} + +function parseEnvFile(filePath: string): EnvVar[] { const envContent = fs.readFileSync(filePath, "utf-8"); return envContent .split("\n") @@ -26,6 +58,229 @@ function parseEnvFile(filePath: string): { key: string; value: string }[] { }); } +function extractDetailsMap(error: ResourceError): Record { + const map: Record = {}; + const details = (error.structuredDetails ?? []) as Array<{ + field?: string; + value?: unknown; + }>; + for (const item of details) { + if (item.field) { + map[item.field] = item.value; + } + } + return map; +} + +function getPreparePayload(error: ResourceError): ReplicaPreparePayload | null { + const status = (error as unknown as { status?: number }).status; + if (status !== 465) { + return null; + } + const details = extractDetailsMap(error); + return { + composeHash: String(details.compose_hash ?? ""), + appId: String(details.app_id ?? ""), + deviceId: String(details.device_id ?? ""), + kmsInfo: details.kms_info, + commitToken: details.commit_token as string | undefined, + commitUrl: details.commit_url as string | undefined, + apiCommitUrl: details.api_commit_url as string | undefined, + onchainStatus: details.onchain_status as + | ReplicaPreparePayload["onchainStatus"] + | undefined, + }; +} + +function readPrivateKey(input: CvmsReplicateCommandInput): `0x${string}` { + const privateKey = input.privateKey || process.env.PRIVATE_KEY; + if (!privateKey) { + throw new Error( + "Private key is required for on-chain replica approval. Pass --private-key or set PRIVATE_KEY.", + ); + } + return privateKey as `0x${string}`; +} + +async function loadEncryptedEnv( + client: Client<"2026-01-21">, + sourceCvm: Parameters[1], + envFile?: string, +): Promise { + if (!envFile) { + return undefined; + } + const envPath = path.resolve(process.cwd(), envFile); + if (!fs.existsSync(envPath)) { + throw new Error(`Environment file not found: ${envPath}`); + } + const envVars = parseEnvFile(envPath); + const pubkey = await getEncryptPubkey(client, sourceCvm); + return encryptEnvVars(envVars, pubkey); +} + +async function resolveNodeId( + client: Client<"2026-01-21">, + nodeInput?: string, +): Promise { + if (!nodeInput) { + return undefined; + } + + const trimmed = nodeInput.trim(); + if (/^\d+$/.test(trimmed)) { + return Number.parseInt(trimmed, 10); + } + + const nodesResult = await safeGetAvailableNodes(client); + if (!nodesResult.success) { + const nodesError = "error" in nodesResult ? nodesResult.error : undefined; + throw new Error( + nodesError?.message || `Failed to resolve node name \"${trimmed}\"`, + ); + } + + const matches = nodesResult.data.nodes.filter( + (node) => node.name.toLowerCase() === trimmed.toLowerCase(), + ); + if (matches.length === 0) { + throw new Error(`Node \"${trimmed}\" not found.`); + } + if (matches.length > 1) { + throw new Error( + `Node name \"${trimmed}\" is ambiguous (${matches.length} matches). Use an explicit node ID.`, + ); + } + return matches[0].teepod_id; +} + +function formatReplicaOutput( + replica: Record, + context: CommandContext, + sourceCvm?: { + workspace?: { slug?: string | null; name?: string } | null; + kms_type?: string | null; + }, +): void { + if (isInJsonMode()) { + context.success(replica); + return; + } + + const vmUuid = typeof replica.vm_uuid === "string" ? replica.vm_uuid : ""; + const workspaceSlug = + sourceCvm?.workspace?.slug || context.projectConfig.slug || ""; + const workspaceName = sourceCvm?.workspace?.name || workspaceSlug || "-"; + const teamLabel = + workspaceSlug && workspaceSlug !== workspaceName + ? `${workspaceName} (${workspaceSlug})` + : workspaceSlug || workspaceName; + const vmUuidCompact = vmUuid.replace(/-/g, ""); + const appUrl = + workspaceSlug && vmUuidCompact && typeof replica.app_id === "string" + ? `${CLOUD_URL}/${workspaceSlug}/apps/${replica.app_id}/instances/${vmUuidCompact}` + : typeof replica.app_url === "string" + ? replica.app_url + : "-"; + const teepodName = + typeof replica.teepod === "object" && + replica.teepod !== null && + "name" in replica.teepod && + typeof replica.teepod.name === "string" + ? replica.teepod.name + : typeof replica.teepod_name === "string" + ? replica.teepod_name + : "-"; + const kmsType = sourceCvm?.kms_type || "-"; + const lines = [ + `Source CVM ID: ${context.cvmId?.id || replica.source_cvm_id || "-"}`, + `Team: ${teamLabel}`, + `CVM UUID: ${vmUuid || "-"}`, + `App ID: ${replica.app_id}`, + `Name: ${replica.name}`, + `Status: ${replica.status}`, + `KMS: ${kmsType}`, + `Node: ${teepodName} (ID: ${replica.teepod_id})`, + `vCPUs: ${replica.vcpu}`, + `Memory: ${replica.memory} MB`, + `Disk Size: ${replica.disk_size} GB`, + `App URL: ${appUrl}`, + ]; + context.stdout.write(`${lines.join("\n")}\n`); +} + +function formatPrepareOutput( + payload: ReplicaPreparePayload, + context: CommandContext, +): void { + if (isInJsonMode()) { + context.success({ success: true, prepare_only: true, ...payload }); + return; + } + + const ensureHex = (v: string) => (v && !v.startsWith("0x") ? `0x${v}` : v); + const lines = [ + "CVM replica prepared successfully (pending on-chain approval).", + "", + `Compose Hash: ${ensureHex(payload.composeHash)}`, + `App ID: ${ensureHex(payload.appId)}`, + `Device ID: ${ensureHex(payload.deviceId)}`, + ]; + if (payload.commitToken) { + lines.push(`Commit Token: ${payload.commitToken}`); + } + if (payload.commitUrl) { + lines.push(`Commit URL: ${payload.commitUrl}`); + } + if (payload.apiCommitUrl) { + lines.push(`API Commit URL: ${payload.apiCommitUrl}`); + } + if (payload.onchainStatus) { + lines.push( + "", + "On-chain Status:", + ` Compose Hash: ${payload.onchainStatus.compose_hash_allowed ? "registered" : "NOT registered"}`, + ` Device ID: ${payload.onchainStatus.device_id_allowed ? "registered" : "NOT registered"}`, + ); + if (payload.onchainStatus.is_allowed) { + lines.push( + " All prerequisites met. You can commit with --transaction-hash already-registered.", + ); + } + } + if (payload.commitToken) { + const composeHashHex = payload.composeHash.startsWith("0x") + ? payload.composeHash + : `0x${payload.composeHash}`; + lines.push( + "", + "To complete the replica after on-chain approval:", + " phala cvms replicate \\", + " --commit \\", + ` --token ${payload.commitToken} \\`, + ` --compose-hash ${composeHashHex} \\`, + " --transaction-hash ", + ); + } + context.stdout.write(`${lines.join("\n")}\n`); +} + +async function commitReplica( + client: Client<"2026-01-21">, + sourceVmUuid: string, + payload: { + token: string; + composeHash: string; + transactionHash: string; + }, +): Promise> { + return client.post(`/cvms/${sourceVmUuid}/commit-replica`, { + token: payload.token, + compose_hash: payload.composeHash, + transaction_hash: payload.transactionHash, + }); +} + async function runCvmsReplicateCommand( input: CvmsReplicateCommandInput, context: CommandContext, @@ -38,61 +293,235 @@ async function runCvmsReplicateCommand( return 1; } - const { cvmId: normalizedCvmId } = CvmIdSchema.parse(context.cvmId); - let encryptedEnv: string | undefined; - - if (input.envFile) { - const envPath = path.resolve(process.cwd(), input.envFile); - if (!fs.existsSync(envPath)) { - throw new Error(`Environment file not found: ${envPath}`); + const client = await getClient(context); + let sourceCvm: Awaited< + ReturnType> + >["data"] extends infer T + ? NonNullable + : never; + const cvmResult = await safeGetCvmInfo(client, context.cvmId); + if (!cvmResult.success) { + // When identifier matches multiple CVMs and --compose-hash is given, + // resolve by listing CVMs and picking the one with matching compose_hash. + const isMultiple = + cvmResult.error && + typeof cvmResult.error === "object" && + "errorCode" in cvmResult.error && + cvmResult.error.errorCode === "ERR-03-010"; + if (isMultiple) { + const rawId = (context.cvmId?.id ?? "") + .replace(/^app_/, "") + .replace(/^0x/, "") + .toLowerCase(); + const appCvmsResult = await safeGetAppCvms(client, { appId: rawId }); + if (!appCvmsResult.success || appCvmsResult.data.length === 0) { + throw cvmResult.error; + } + // Pick first instance as source; compose_hash is passed to the + // replicate API and the backend resolves the revision. + const first = appCvmsResult.data[0]; + if (!first.vm_uuid) { + throw cvmResult.error; + } + const resolved = await safeGetCvmInfo(client, { + id: first.vm_uuid, + }); + if (!resolved.success) { + throw resolved.error; + } + sourceCvm = resolved.data; + } else { + throw cvmResult.error; } + } else { + sourceCvm = cvmResult.data; + } - const envVars = parseEnvFile(envPath); - const cvmConfig = await getCvmComposeConfig(normalizedCvmId); - - logger.info("Encrypting environment variables..."); - encryptedEnv = await encryptEnvVars(envVars, cvmConfig.env_pubkey); + if (!sourceCvm.vm_uuid) { + throw new Error("Source CVM has no vm_uuid"); } - const requestBody: { teepod_id?: number; encrypted_env?: string } = {}; + if (input.commit) { + if (!input.token) { + throw new Error("--token is required for --commit mode"); + } + if (!input.composeHash) { + throw new Error("--compose-hash is required for --commit mode"); + } + const replica = (await commitReplica(client, sourceCvm.vm_uuid, { + token: input.token, + composeHash: input.composeHash, + transactionHash: input.transactionHash || "already-registered", + })) as Record; + formatReplicaOutput(replica, context, sourceCvm); + return 0; + } - if (input.teepodId) { - requestBody.teepod_id = Number.parseInt(input.teepodId, 10); + const encryptedEnv = await loadEncryptedEnv( + client, + sourceCvm, + input.envFile, + ); + const resolvedNodeId = await resolveNodeId(client, input.nodeId); + const requestBody: Record = {}; + if (resolvedNodeId !== undefined) { + requestBody.node_id = resolvedNodeId; } if (encryptedEnv) { requestBody.encrypted_env = encryptedEnv; } + if (input.composeHash) { + requestBody.compose_hash = input.composeHash; + } - const replica = await replicateCvm(normalizedCvmId, requestBody); + try { + const replica = (await client.post( + `/cvms/${sourceCvm.vm_uuid}/replicas`, + requestBody, + { + headers: input.prepareOnly ? { "X-Prepare-Only": "true" } : undefined, + }, + )) as Record; + formatReplicaOutput(replica, context, sourceCvm); + return 0; + } catch (error) { + if (!(error instanceof ResourceError)) { + throw error; + } - logger.success( - `Successfully created replica of CVM UUID: ${normalizedCvmId} with App ID: ${replica.app_id}`, - ); + const preparePayload = getPreparePayload(error); + if (!preparePayload) { + throw error; + } - logger.keyValueTable( - { - "CVM UUID": replica.vm_uuid.replace(/-/g, ""), - "App ID": replica.app_id, - Name: replica.name, - Status: replica.status, - TEEPod: `${replica.teepod.name} (ID: ${replica.teepod_id})`, - vCPUs: replica.vcpu, - Memory: `${replica.memory} MB`, - "Disk Size": `${replica.disk_size} GB`, - "App URL": - replica.app_url || - `${process.env.CLOUD_URL || "https://cloud.phala.com"}/dashboard/cvms/${replica.vm_uuid.replace(/-/g, "")}`, - }, - { borderStyle: "rounded" }, - ); + if (input.prepareOnly) { + formatPrepareOutput(preparePayload, context); + return 0; + } - logger.success( - `Your CVM replica is being created. You can check its status with: -phala cvms get ${replica.app_id}`, - ); - return 0; + if (!preparePayload.commitToken) { + throw new Error( + "Replica prepare response did not include a commit token", + ); + } + const chain = ( + sourceCvm as { + kms_info?: { + chain?: Parameters< + typeof safeCheckOnChainPrerequisites + >[0]["chain"]; + }; + } + ).kms_info?.chain; + if (!chain) { + throw new Error("Source CVM kms_info is missing chain configuration"); + } + if (!sourceCvm.app_id) { + throw new Error( + "Source CVM is missing app_id required for on-chain approval", + ); + } + + const prereqs = await safeCheckOnChainPrerequisites({ + chain: chain, + rpcUrl: input.rpcUrl, + appAddress: sourceCvm.app_id as `0x${string}`, + deviceId: preparePayload.deviceId, + composeHash: preparePayload.composeHash, + }); + if (!prereqs.success) { + const prereqError = "error" in prereqs ? prereqs.error : undefined; + throw new Error( + prereqError?.message || "Failed to check on-chain prerequisites", + ); + } + + let transactionHash = input.transactionHash || "already-registered"; + const needsDevice = !prereqs.data.deviceAllowed; + const needsCompose = !prereqs.data.composeHashAllowed; + + if (needsDevice || needsCompose) { + const missing: string[] = []; + if (needsCompose) { + missing.push("compose hash"); + } + if (needsDevice) { + missing.push("device"); + } + + const privateKey = input.privateKey || process.env.PRIVATE_KEY; + if (!privateKey) { + formatPrepareOutput(preparePayload, context); + throw new Error( + `On-chain registration required: ${missing.join(", ")} not registered. Pass --private-key or set PRIVATE_KEY to register automatically, or use --prepare-only to handle registration separately.`, + ); + } + const typedKey = privateKey as `0x${string}`; + + if (needsDevice) { + const deviceResult = await safeAddDevice({ + chain: chain, + rpcUrl: input.rpcUrl, + appAddress: sourceCvm.app_id as `0x${string}`, + deviceId: preparePayload.deviceId, + privateKey: typedKey, + }); + if (!deviceResult.success) { + const deviceError = + "error" in deviceResult ? deviceResult.error : undefined; + throw new Error( + deviceError?.message || "Failed to register device on-chain", + ); + } + } + + if (needsCompose) { + const receiptResult = await safeAddComposeHash({ + chain: chain, + rpcUrl: input.rpcUrl, + appId: sourceCvm.app_id as `0x${string}`, + composeHash: preparePayload.composeHash, + privateKey: typedKey, + }); + if (!receiptResult.success) { + const receiptError = + "error" in receiptResult ? receiptResult.error : undefined; + throw new Error( + receiptError?.message || + "Failed to register compose hash on-chain", + ); + } + transactionHash = String( + (receiptResult.data as { transactionHash?: string }) + .transactionHash || "already-registered", + ); + } + } + + const replica = (await commitReplica(client, sourceCvm.vm_uuid, { + token: preparePayload.commitToken, + composeHash: preparePayload.composeHash, + transactionHash, + })) as Record; + formatReplicaOutput(replica, context, sourceCvm); + return 0; + } } catch (error) { logger.error("Failed to create CVM replica"); + if (error instanceof ResourceError) { + process.stderr.write(`${formatStructuredError(error)}\n`); + const links = error.links as ErrorLink[] | undefined; + if (links && links.length > 0) { + for (const link of links) { + process.stderr.write(` ${link.label}: ${link.url}\n`); + } + } + return 1; + } + if (error instanceof Error) { + process.stderr.write(`${formatErrorMessage(error as PhalaCloudError)}\n`); + return 1; + } logger.logDetailedError(error); return 1; } diff --git a/js/src/actions/blockchains/abi/dstack_app.ts b/js/src/actions/blockchains/abi/dstack_app.ts index 14d49fe6..7214ae71 100644 --- a/js/src/actions/blockchains/abi/dstack_app.ts +++ b/js/src/actions/blockchains/abi/dstack_app.ts @@ -60,6 +60,18 @@ export const dstackAppAbi = [ type: "function", }, + // ── Errors ─────────────────────────────────────────────────────── + { + inputs: [{ internalType: "address", name: "owner", type: "address" }], + name: "OwnableInvalidOwner", + type: "error", + }, + { + inputs: [{ internalType: "address", name: "account", type: "address" }], + name: "OwnableUnauthorizedAccount", + type: "error", + }, + // ── Events ─────────────────────────────────────────────────────── { anonymous: false, diff --git a/js/src/actions/blockchains/add_compose_hash.ts b/js/src/actions/blockchains/add_compose_hash.ts index 88061b44..7acadf77 100644 --- a/js/src/actions/blockchains/add_compose_hash.ts +++ b/js/src/actions/blockchains/add_compose_hash.ts @@ -498,6 +498,27 @@ export async function addComposeHash => { const hash = await clients.walletClient.writeContract({ diff --git a/js/src/actions/blockchains/add_device.ts b/js/src/actions/blockchains/add_device.ts index 0b9055af..94c7b9bf 100644 --- a/js/src/actions/blockchains/add_device.ts +++ b/js/src/actions/blockchains/add_device.ts @@ -229,6 +229,19 @@ export async function addDevice => { return clients.walletClient.writeContract({ address: contractAddress, diff --git a/js/src/types/cvm_info_v20260121.ts b/js/src/types/cvm_info_v20260121.ts index b2067ac4..48989be3 100644 --- a/js/src/types/cvm_info_v20260121.ts +++ b/js/src/types/cvm_info_v20260121.ts @@ -1,5 +1,7 @@ +import type { Chain } from "viem"; import { z } from "zod"; import { CvmNetworkUrlsV20251028Schema } from "./cvm_info_v20251028"; +import { SUPPORTED_CHAINS } from "./supported_chains"; export const BillingPeriodSchema = z.enum(["skip", "hourly", "monthly"]); export type BillingPeriod = z.infer; @@ -43,7 +45,7 @@ export const CvmOsInfoV20260121Schema = z.object({ }); export type CvmOsInfoV20260121 = z.infer; -export const CvmKmsInfoV20260121Schema = z.object({ +const CvmKmsInfoV20260121BaseSchema = z.object({ chain_id: z.number().int().nullable().optional(), dstack_kms_address: z.string().nullable().optional(), dstack_app_address: z.string().nullable().optional(), @@ -51,6 +53,16 @@ export const CvmKmsInfoV20260121Schema = z.object({ rpc_endpoint: z.string().nullable().optional(), encrypted_env_pubkey: z.string().nullable().optional(), }); + +export const CvmKmsInfoV20260121Schema = CvmKmsInfoV20260121BaseSchema.transform((data) => { + if (data.chain_id != null) { + const chain: Chain | undefined = SUPPORTED_CHAINS[data.chain_id]; + if (chain) { + return { ...data, chain } as typeof data & { chain: Chain }; + } + } + return data as typeof data; +}); export type CvmKmsInfoV20260121 = z.infer; export const CvmProgressInfoV20260121Schema = z.object({