diff --git a/bun.lock b/bun.lock index 8ad906aa..d611ee67 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,7 @@ }, "cli": { "name": "phala", - "version": "1.1.12", + "version": "1.1.13", "bin": { "phala": "./dist/index.js", "pha": "./dist/index.js", @@ -29,6 +29,7 @@ "open": "^10.0.0", "ora": "^6.3.1", "prompts": "^2.4.2", + "punycode": "^2.3.1", "semver": "^7.6.3", "viem": "^2.7.0", "zod": "^3.24.1", @@ -46,10 +47,14 @@ "type-fest": "^3.8.0", "typescript": "^5.9.2", }, + "optionalDependencies": { + "@safe-global/api-kit": "^4.0.0", + "@safe-global/protocol-kit": "^6.0.0", + }, }, "js": { "name": "@phala/cloud", - "version": "0.2.4", + "version": "0.2.5", "dependencies": { "@phala/dstack-sdk": "0.5.7", "debug": "^4.4.1", @@ -220,6 +225,8 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.6.0", "", { "dependencies": { "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg=="], + "@phala/cloud": ["@phala/cloud@workspace:js"], "@phala/dstack-sdk": ["@phala/dstack-sdk@0.5.7", "", { "dependencies": { "crypto-browserify": "^3.12.0" }, "optionalDependencies": { "@noble/curves": "^1.8.1", "@solana/web3.js": "^1.98.0", "viem": "^2.21.0 <3.0.0" }, "peerDependencies": { "@noble/hashes": "^1.6.1" } }, "sha512-yhdH1dIYCeyn/3jp9tIT4aCfOaVtO1cwFcTHKjeLzKeL/XTVWzbyTX1SU6NCN7tKpHWJ9y6Vdht/vcffZYEZnw=="], @@ -270,6 +277,16 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.53.2", "", { "os": "win32", "cpu": "x64" }, "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA=="], + "@safe-global/api-kit": ["@safe-global/api-kit@4.0.1", "", { "dependencies": { "@safe-global/protocol-kit": "^6.1.2", "@safe-global/types-kit": "^3.0.0", "node-fetch": "^2.7.0", "viem": "^2.21.8" } }, "sha512-pNtDLgMHlCSr4Hwwe6jsnvMheAu2SZCTqjYlnNe4cKH2pSKINVRTiILoeJ0wOpixrMCH4NlgJ+9N3QruRNcCpQ=="], + + "@safe-global/protocol-kit": ["@safe-global/protocol-kit@6.1.2", "", { "dependencies": { "@safe-global/safe-deployments": "^1.37.49", "@safe-global/safe-modules-deployments": "^2.2.21", "@safe-global/types-kit": "^3.0.0", "abitype": "^1.0.2", "semver": "^7.7.2", "viem": "^2.21.8" }, "optionalDependencies": { "@noble/curves": "^1.6.0", "@peculiar/asn1-schema": "^2.3.13" } }, "sha512-cTpPdUAS2AMfGCkD1T601rQNjT0rtMQLA2TH7L/C+iFPAC6WrrDFop2B9lzeHjczlnVzrRpfFe4cL1bLrJ9NZw=="], + + "@safe-global/safe-deployments": ["@safe-global/safe-deployments@1.37.53", "", { "dependencies": { "semver": "^7.6.2" } }, "sha512-3XihirwKqcCi6jsipCiW3lYXra9i4pC9nlhHTdUyi7Yx38nBYIkXeLZN2Nmf2UPcQBeHGnW1T3DgzY4VnuF/FQ=="], + + "@safe-global/safe-modules-deployments": ["@safe-global/safe-modules-deployments@2.2.25", "", {}, "sha512-KjgenKhBRyFHEfo8xlBgNzKAy25vrmGyCGZwTjIuA81yOSRJRe85GE5Yfg/FBKeeyHqR2dD1WPZr6c2Uqd6C/g=="], + + "@safe-global/types-kit": ["@safe-global/types-kit@3.0.0", "", { "dependencies": { "abitype": "^1.0.2" } }, "sha512-AZWIlR5MguDPdGiOj7BB4JQPY2afqmWQww1mu8m8Oi16HHBW99G01kFOu4NEHBwEU1cgwWOMY19hsI5KyL4W2w=="], + "@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="], "@scure/bip32": ["@scure/bip32@1.7.0", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw=="], @@ -346,6 +363,8 @@ "asn1.js": ["asn1.js@4.10.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0" } }, "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw=="], + "asn1js": ["asn1js@3.0.7", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ=="], + "assertion-error": ["assertion-error@1.1.0", "", {}, "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw=="], "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], @@ -728,6 +747,12 @@ "public-encrypt": ["public-encrypt@4.0.3", "", { "dependencies": { "bn.js": "^4.1.0", "browserify-rsa": "^4.0.0", "create-hash": "^1.1.0", "parse-asn1": "^5.0.0", "randombytes": "^2.0.1", "safe-buffer": "^5.1.2" } }, "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], + + "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="], @@ -944,7 +969,7 @@ "ox/@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="], - "phala/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], + "phala/@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], "phala/dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], @@ -990,7 +1015,7 @@ "bl/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], - "phala/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + "phala/@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], "restore-cursor/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], diff --git a/cli/package.json b/cli/package.json index d81d83ca..6a66af1f 100644 --- a/cli/package.json +++ b/cli/package.json @@ -55,10 +55,15 @@ "open": "^10.0.0", "ora": "^6.3.1", "prompts": "^2.4.2", + "punycode": "^2.3.1", "semver": "^7.6.3", "viem": "^2.7.0", "zod": "^3.24.1" }, + "optionalDependencies": { + "@safe-global/api-kit": "^4.0.0", + "@safe-global/protocol-kit": "^6.0.0" + }, "devDependencies": { "@biomejs/biome": "1.9.4", "@types/bun": "latest", diff --git a/cli/src/commands/allow-devices/index.ts b/cli/src/commands/allow-devices/index.ts index d66a28cf..d8e43dee 100644 --- a/cli/src/commands/allow-devices/index.ts +++ b/cli/src/commands/allow-devices/index.ts @@ -1,17 +1,6 @@ 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, safeGetAvailableNodes, safeAddDevice, safeRemoveDevice, @@ -27,6 +16,12 @@ import type { CommandContext } from "@/src/core/types"; import { getClient } from "@/src/lib/client"; import { printTable } from "@/src/lib/table"; import { logger } from "@/src/utils/logger"; +import { + resolveAppContract, + resolvePrivateKey, + createSharedClients, + txExplorerUrl, +} from "@/src/utils/onchain"; import { allowDevicesGroup, allowDevicesListMeta, @@ -62,15 +57,7 @@ export function isValidDeviceId(deviceId: string): boolean { return DEVICE_ID_REGEX.test(deviceId); } -export function txExplorerUrl( - chain: (typeof SUPPORTED_CHAINS)[keyof typeof SUPPORTED_CHAINS], - txHash: string | undefined, -): string | null { - if (!txHash) return null; - const baseUrl = chain.blockExplorers?.default?.url; - if (!baseUrl) return null; - return `${baseUrl}/tx/${txHash}`; -} +export { txExplorerUrl } from "@/src/utils/onchain"; /** * Determine the allow-any flag value for the `allow-any` command. @@ -153,33 +140,6 @@ function isExitPromptError(error: unknown): boolean { ); } -function resolvePrivateKey(input: { privateKey?: string }): `0x${string}` { - const key = input.privateKey || process.env.PRIVATE_KEY; - if (!key) { - throw new Error( - "Private key required. Use --private-key or set PRIVATE_KEY env var.", - ); - } - 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, @@ -225,64 +185,6 @@ async function resolveDeviceIdOrNodeName( return normalizeDeviceId(resolvedDeviceId); } -async function resolveAppContract( - cvmIdentifier: string, - context: CommandContext, -) { - const client = await getClient(); - - const infoResult = await safeGetCvmInfo(client, { id: cvmIdentifier }); - if (!infoResult.success) { - context.fail(infoResult.error.message); - return null; - } - - const cvm = infoResult.data; - if (!cvm) { - context.fail("CVM not found"); - return null; - } - - const appId = cvm.app_id; - if (!appId) { - context.fail("CVM has no app_id assigned yet."); - return null; - } - - const allowlistResult = await safeGetAppDeviceAllowlist(client, { appId }); - if (!allowlistResult.success) { - context.fail(allowlistResult.error.message); - return null; - } - - const allowlist = allowlistResult.data; - if (!allowlist.is_onchain_kms) { - context.fail( - "This app does not use on-chain KMS. Device management requires an on-chain KMS.", - ); - return null; - } - - if (!allowlist.chain_id || !allowlist.app_contract_address) { - context.fail( - "Missing chain_id or app_contract_address in allowlist response.", - ); - return null; - } - - const chain = SUPPORTED_CHAINS[allowlist.chain_id]; - if (!chain) { - context.fail(`Unsupported chain_id: ${allowlist.chain_id}`); - return null; - } - - return { - chain, - chainId: allowlist.chain_id, - appContractAddress: allowlist.app_contract_address as `0x${string}`, - allowlist, - }; -} // ── list ──────────────────────────────────────────────────────────── diff --git a/cli/src/commands/compose-hash/command.ts b/cli/src/commands/compose-hash/command.ts new file mode 100644 index 00000000..2eb69be3 --- /dev/null +++ b/cli/src/commands/compose-hash/command.ts @@ -0,0 +1,66 @@ +import { z } from "zod"; +import type { CommandMeta } from "@/src/core/types"; +import { jsonOption } from "@/src/core/common-flags"; + +const cvmArgument = { + name: "cvm", + description: "CVM identifier (UUID, app_id, instance_id, or name)", + required: true, + target: "cvm", +}; + +export const composeHashMeta: CommandMeta = { + name: "compose-hash", + description: + "Compute the compose hash for a CVM update", + stability: "unstable", + arguments: [cvmArgument], + options: [ + { + name: "compose", + shorthand: "c", + description: + "Path to new Docker Compose file (default: docker-compose.yml)", + type: "string", + target: "compose", + group: "basic", + }, + { + name: "pre-launch-script", + description: "Path to pre-launch script", + type: "string", + target: "preLaunchScript", + group: "advanced", + }, + { + name: "env", + shorthand: "e", + description: + "Environment variable (KEY=VALUE) or env file path (repeatable)", + type: "string[]", + target: "env", + group: "basic", + }, + jsonOption, + ], + examples: [ + { + name: "Get compose hash for a new compose file", + value: "phala compose-hash app_abc123 -c new-docker-compose.yml", + }, + { + name: "Get compose hash with environment variables", + value: "phala compose-hash app_abc123 -c docker-compose.yml -e .env", + }, + ], +}; + +export const composeHashSchema = z.object({ + cvm: z.string(), + compose: z.string().optional(), + preLaunchScript: z.string().optional(), + env: z.array(z.string()).optional(), + json: z.boolean().default(false), +}); + +export type ComposeHashInput = z.infer; diff --git a/cli/src/commands/compose-hash/index.ts b/cli/src/commands/compose-hash/index.ts new file mode 100644 index 00000000..2f5d7dbe --- /dev/null +++ b/cli/src/commands/compose-hash/index.ts @@ -0,0 +1,165 @@ +import path from "node:path"; +import fs from "fs-extra"; +import { + type ProvisionCvmComposeFileUpdateRequest, + safeGetCvmComposeFile, + safeProvisionCvmComposeFileUpdate, + safeGetCvmInfo, + parseEnvVars, +} from "@phala/cloud"; +import { defineCommand } from "@/src/core/define-command"; +import type { CommandContext } from "@/src/core/types"; +import { getClient } from "@/src/lib/client"; +import { logger } from "@/src/utils/logger"; +import { parseEnvInputs } from "@/src/utils/env-parsing"; +import { + composeHashMeta, + composeHashSchema, + type ComposeHashInput, +} from "./command"; + +export async function computeComposeHash( + input: { cvm?: string; compose?: string; preLaunchScript?: string; env?: string[] }, + context: CommandContext, +): Promise<{ compose_hash: string; app_id: string; cvm_id: string } | null> { + if (!input.cvm) { + context.fail("CVM identifier is required."); + return null; + } + const cvmId = input.cvm; + const client = await getClient(); + + // Resolve compose file path + const composePath = input.compose || "docker-compose.yml"; + if (!fs.existsSync(composePath)) { + context.fail(`Docker compose file not found: ${composePath}`); + return null; + } + const dockerComposeYml = fs.readFileSync(composePath, "utf8"); + + // Read pre-launch script if specified + let preLaunchScriptContent: string | undefined; + if (input.preLaunchScript) { + if (!fs.existsSync(input.preLaunchScript)) { + context.fail( + `Pre-launch script file not found: ${input.preLaunchScript}`, + ); + return null; + } + preLaunchScriptContent = fs.readFileSync(input.preLaunchScript, "utf8"); + } + + // Parse env vars if provided + let envKeys: string[] | undefined; + if (input.env && input.env.length > 0) { + const parsed = parseEnvInputs(input.env); + const allEnvs: { key: string; value: string }[] = []; + + for (const filePath of parsed.files) { + const resolvedPath = path.resolve(process.cwd(), filePath); + if (!fs.existsSync(resolvedPath)) { + context.fail(`Environment file not found: ${filePath}`); + return null; + } + const envContent = fs.readFileSync(resolvedPath, { encoding: "utf8" }); + allEnvs.push(...parseEnvVars(envContent)); + } + allEnvs.push(...parsed.keyValues); + + if (allEnvs.length > 0) { + envKeys = allEnvs.map((e) => e.key); + } + } + + // Fetch existing CVM compose + const composeResult = await safeGetCvmComposeFile(client, { + id: cvmId, + }); + if (!composeResult.success) { + context.fail(composeResult.error.message); + return null; + } + + // Fetch CVM info for app_id + const infoResult = await safeGetCvmInfo(client, { id: cvmId }); + if (!infoResult.success) { + context.fail(infoResult.error.message); + return null; + } + + // biome-ignore lint/suspicious/noExplicitAny: type inference issue with @phala/cloud library + const appCompose = composeResult.data as any; + + // Patch the compose (same as updateCvm in deploy handler) + appCompose.docker_compose_file = dockerComposeYml; + if (preLaunchScriptContent) { + appCompose.pre_launch_script = preLaunchScriptContent; + } + if (envKeys && envKeys.length > 0) { + appCompose.allowed_envs = envKeys; + } + + // Provision to get the server-computed compose_hash + const provisionResult = await safeProvisionCvmComposeFileUpdate(client, { + id: cvmId, + app_compose: + appCompose as ProvisionCvmComposeFileUpdateRequest["app_compose"], + update_env_vars: !!(envKeys && envKeys.length > 0), + }); + if (!provisionResult.success) { + context.fail(provisionResult.error.message); + return null; + } + + // biome-ignore lint/suspicious/noExplicitAny: type inference issue with @phala/cloud library + const provision = provisionResult.data as any; + // biome-ignore lint/suspicious/noExplicitAny: type inference issue with @phala/cloud library + const cvm = infoResult.data as any; + + return { + compose_hash: provision.compose_hash, + app_id: cvm.app_id, + cvm_id: cvmId, + }; +} + +async function handler( + input: ComposeHashInput, + context: CommandContext, +): Promise { + try { + const result = await computeComposeHash(input, context); + if (!result) return 1; + + if (input.json) { + context.success(result); + return 0; + } + + logger.info(`CVM: ${result.cvm_id}`); + logger.info(`App ID: ${result.app_id}`); + logger.info(`Compose Hash: ${result.compose_hash}`); + logger.info(""); + logger.info( + "Submit addComposeHash() to the DstackApp contract via your Safe or external signer.", + ); + logger.info( + "Then run: phala deploy --cvm-id -c --skip-onchain-tx", + ); + + return 0; + } catch (error) { + logger.logDetailedError(error); + context.fail( + `Failed: ${error instanceof Error ? error.message : String(error)}`, + ); + return 1; + } +} + +export const composeHashCommand = defineCommand({ + path: ["compose-hash"], + meta: composeHashMeta, + schema: composeHashSchema, + handler, +}); diff --git a/cli/src/commands/deploy/command.ts b/cli/src/commands/deploy/command.ts index 57640203..a8a0c36d 100644 --- a/cli/src/commands/deploy/command.ts +++ b/cli/src/commands/deploy/command.ts @@ -172,6 +172,14 @@ export const deployCommandMeta: CommandMeta = { target: "rpcUrl", group: "advanced", }, + { + name: "skip-onchain-tx", + description: + "Skip the on-chain transaction (use when tx was submitted externally)", + type: "boolean", + target: "skipOnchainTx", + group: "advanced", + }, { name: "wait", description: "Wait for deployment/update completion", @@ -301,6 +309,7 @@ export const deployCommandSchema = z.object({ preLaunchScript: z.string().optional(), privateKey: z.string().optional(), rpcUrl: z.string().optional(), + skipOnchainTx: z.boolean().default(false), wait: z.boolean().default(false), sshPubkey: z.string().optional(), devOs: z.boolean().optional(), diff --git a/cli/src/commands/deploy/handler.ts b/cli/src/commands/deploy/handler.ts index eb0b24cd..61b3f82b 100644 --- a/cli/src/commands/deploy/handler.ts +++ b/cli/src/commands/deploy/handler.ts @@ -971,42 +971,46 @@ const updateCvm = async ( let encrypted_env: string | undefined; if (cvm.kms_info?.chain_id) { - // Update with decentralized KMS. - if (!validatedOptions.privateKey) { - throw new Error("Private key is required for contract DstackApp"); - } + if (!validatedOptions.skipOnchainTx) { + // Update with decentralized KMS. + if (!validatedOptions.privateKey) { + throw new Error("Private key is required for contract DstackApp"); + } - if (validatedOptions.debug) { - console.log("[DEBUG] provision.compose_hash:", provision.compose_hash); - console.log("[DEBUG] cvm.app_id:", cvm.app_id); - } + if (validatedOptions.debug) { + console.log("[DEBUG] provision.compose_hash:", provision.compose_hash); + console.log("[DEBUG] cvm.app_id:", cvm.app_id); + } - const receipt_result = await safeAddComposeHash({ - chain: cvm.kms_info?.chain, - rpcUrl: validatedOptions.rpcUrl, - appId: cvm.app_id as `0x${string}`, - composeHash: provision.compose_hash, - privateKey: validatedOptions.privateKey as `0x${string}`, - }); - if (!receipt_result.success) { - logger.logDetailedError(receipt_result, "Add Compose Hash"); - const errorMsg = - typeof receipt_result === "object" && receipt_result !== null - ? JSON.stringify(receipt_result) - : String(receipt_result); - throw new Error(`Failed to add compose hash: ${errorMsg}`); - } + const receipt_result = await safeAddComposeHash({ + chain: cvm.kms_info?.chain, + rpcUrl: validatedOptions.rpcUrl, + appId: cvm.app_id as `0x${string}`, + composeHash: provision.compose_hash, + privateKey: validatedOptions.privateKey as `0x${string}`, + }); + if (!receipt_result.success) { + logger.logDetailedError(receipt_result, "Add Compose Hash"); + const errorMsg = + typeof receipt_result === "object" && receipt_result !== null + ? JSON.stringify(receipt_result) + : String(receipt_result); + throw new Error(`Failed to add compose hash: ${errorMsg}`); + } - if (validatedOptions.debug) { - const txResult = receipt_result.data as { - transactionHash?: string; - composeHash?: string; - }; - console.log( - "[DEBUG] addComposeHash.transactionHash:", - txResult.transactionHash, - ); - console.log("[DEBUG] addComposeHash.composeHash:", txResult.composeHash); + if (validatedOptions.debug) { + const txResult = receipt_result.data as { + transactionHash?: string; + composeHash?: string; + }; + console.log( + "[DEBUG] addComposeHash.transactionHash:", + txResult.transactionHash, + ); + console.log("[DEBUG] addComposeHash.composeHash:", txResult.composeHash); + } + } else { + logger.info("Skipping on-chain transaction (--skip-onchain-tx)"); } // Encrypt environment variables for decentralized KMS diff --git a/cli/src/commands/safe/command.ts b/cli/src/commands/safe/command.ts new file mode 100644 index 00000000..e3c11382 --- /dev/null +++ b/cli/src/commands/safe/command.ts @@ -0,0 +1,106 @@ +import { z } from "zod"; +import type { CommandMeta, CommandGroup } from "@/src/core/types"; +import { jsonOption } from "@/src/core/common-flags"; + +const cvmArgument = { + name: "cvm", + description: "CVM identifier (UUID, app_id, instance_id, or name)", + required: true, + target: "cvm", +}; + +export const safeGroup: CommandGroup = { + path: ["safe"], + meta: { + name: "safe", + description: "Propose transactions to a Safe multisig", + stability: "unstable", + }, +}; + +export const safeProposeUpdateMeta: CommandMeta = { + name: "propose-update", + description: + "Compute compose hash and propose addComposeHash to a Safe multisig", + stability: "unstable", + arguments: [cvmArgument], + options: [ + { + name: "compose", + shorthand: "c", + description: + "Path to new Docker Compose file (default: docker-compose.yml)", + type: "string", + target: "compose", + group: "basic", + }, + { + name: "safe-address", + description: "Safe multisig address", + type: "string", + target: "safeAddress", + group: "basic", + }, + { + name: "private-key", + description: + "Private key of a Safe signer (proposer). Also reads PRIVATE_KEY env var.", + type: "string", + target: "privateKey", + group: "advanced", + }, + { + name: "rpc-url", + description: "Custom RPC URL for the blockchain", + type: "string", + target: "rpcUrl", + group: "advanced", + }, + { + name: "env", + shorthand: "e", + description: + "Environment variable (KEY=VALUE) or env file path (repeatable)", + type: "string[]", + target: "env", + group: "basic", + }, + { + name: "pre-launch-script", + description: "Path to pre-launch script", + type: "string", + target: "preLaunchScript", + group: "advanced", + }, + { + name: "safe-api-key", + description: + "Safe Transaction Service API key. Also reads SAFE_API_KEY env var.", + type: "string", + target: "safeApiKey", + group: "advanced", + }, + jsonOption, + ], + examples: [ + { + name: "Propose compose update to Safe", + value: + "phala safe propose-update app_abc123 -c new-docker-compose.yml --safe-address 0xSafe --private-key 0xKey", + }, + ], +}; + +export const safeProposeUpdateSchema = z.object({ + cvm: z.string(), + compose: z.string().optional(), + safeAddress: z.string(), + privateKey: z.string().optional(), + rpcUrl: z.string().optional(), + env: z.array(z.string()).optional(), + preLaunchScript: z.string().optional(), + safeApiKey: z.string().optional(), + json: z.boolean().default(false), +}); + +export type SafeProposeUpdateInput = z.infer; diff --git a/cli/src/commands/safe/index.ts b/cli/src/commands/safe/index.ts new file mode 100644 index 00000000..0d7a345b --- /dev/null +++ b/cli/src/commands/safe/index.ts @@ -0,0 +1,9 @@ +import { safeGroup } from "./command"; +import { safeProposeUpdateCommand } from "./propose-update"; + +export const safeCommands = { + group: safeGroup, + commands: [safeProposeUpdateCommand], +}; + +export default safeCommands; diff --git a/cli/src/commands/safe/propose-update.ts b/cli/src/commands/safe/propose-update.ts new file mode 100644 index 00000000..a033c1ac --- /dev/null +++ b/cli/src/commands/safe/propose-update.ts @@ -0,0 +1,216 @@ +import { encodeFunctionData, isAddress } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { defineCommand } from "@/src/core/define-command"; +import type { CommandContext } from "@/src/core/types"; +import { logger } from "@/src/utils/logger"; +import { resolveAppContract, resolvePrivateKey } from "@/src/utils/onchain"; +import { computeComposeHash } from "@/src/commands/compose-hash"; +import { + safeProposeUpdateMeta, + safeProposeUpdateSchema, + type SafeProposeUpdateInput, +} from "./command"; + +const addComposeHashAbi = [ + { + inputs: [{ name: "composeHash", type: "bytes32" }], + name: "addComposeHash", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, +] as const; + +const SAFE_CHAIN_PREFIXES: Record = { + 1: "eth", + 8453: "base", +}; + +function safeQueueUrl(chainId: number, safeAddress: string): string | null { + const prefix = SAFE_CHAIN_PREFIXES[chainId]; + if (!prefix) return null; + return `https://app.safe.global/transactions/queue?safe=${prefix}:${safeAddress}`; +} + +async function handler( + input: SafeProposeUpdateInput, + context: CommandContext, +): Promise { + try { + // Dynamic import of Safe SDK — handle CJS/ESM double-default wrapping + // biome-ignore lint/suspicious/noExplicitAny: dynamic import shape varies by bundler + let Safe: any; + // biome-ignore lint/suspicious/noExplicitAny: dynamic import shape varies by bundler + let ApiKit: any; + try { + // biome-ignore lint/suspicious/noExplicitAny: dynamic import shape varies by bundler + const safeModule: any = await import("@safe-global/protocol-kit"); + // biome-ignore lint/suspicious/noExplicitAny: dynamic import shape varies by bundler + const apiModule: any = await import("@safe-global/api-kit"); + // Resolve double-default: bundled CJS → m.default.default, ESM → m.default + Safe = safeModule.default?.init + ? safeModule.default + : safeModule.default?.default ?? safeModule.default; + ApiKit = apiModule.default?.prototype?.constructor + ? apiModule.default + : apiModule.default?.default ?? apiModule.default; + } catch { + context.fail( + "Safe SDK not installed. Run: bun add @safe-global/protocol-kit @safe-global/api-kit", + ); + return 1; + } + + // 1. Compute compose hash + const hashResult = await computeComposeHash(input, context); + if (!hashResult) return 1; + + // 2. Resolve app contract + const resolved = await resolveAppContract(hashResult.cvm_id, context); + if (!resolved) return 1; + + const { chain, chainId, appContractAddress } = resolved; + + // 3. Resolve private key + const privateKey = resolvePrivateKey(input); + + // 4. Validate chainId is supported by Safe Transaction Service + if (!SAFE_CHAIN_PREFIXES[chainId]) { + context.fail( + `Chain ${chainId} (${chain.name}) is not supported by Safe Transaction Service. Supported: Ethereum (1), Base (8453).`, + ); + return 1; + } + + // 5. Validate safe address + if (!isAddress(input.safeAddress)) { + context.fail(`Invalid Safe address: ${input.safeAddress}`); + return 1; + } + const safeAddress = input.safeAddress as `0x${string}`; + + // 6. Encode calldata + const composeHashHex = hashResult.compose_hash.startsWith("0x") + ? hashResult.compose_hash + : `0x${hashResult.compose_hash}`; + const calldata = encodeFunctionData({ + abi: addComposeHashAbi, + functionName: "addComposeHash", + args: [composeHashHex as `0x${string}`], + }); + + // 7. Derive sender address + const account = privateKeyToAccount(privateKey); + const senderAddress = account.address; + + // 8. Init Safe Protocol Kit + const rpcUrl = + input.rpcUrl || chain.rpcUrls?.default?.http?.[0]; + if (!rpcUrl) { + context.fail( + "No RPC URL available. Use --rpc-url to specify one.", + ); + return 1; + } + + const protocolKit = await Safe.init({ + provider: rpcUrl, + signer: privateKey, + safeAddress, + }); + + // 9. Create Safe transaction + const safeTx = await protocolKit.createTransaction({ + transactions: [ + { + to: appContractAddress, + data: calldata, + value: "0", + }, + ], + }); + + // 10. Sign + const safeTxHash = await protocolKit.getTransactionHash(safeTx); + const signature = await protocolKit.signHash(safeTxHash); + + // 11. Propose via API Kit + const safeApiKey = input.safeApiKey || process.env.SAFE_API_KEY; + const apiKit = new ApiKit({ + chainId: BigInt(chainId), + ...(safeApiKey ? { apiKey: safeApiKey } : {}), + }); + + try { + await apiKit.proposeTransaction({ + safeAddress, + safeTransactionData: safeTx.data, + safeTxHash, + senderAddress, + senderSignature: signature.data, + }); + } catch (proposeError: unknown) { + const msg = proposeError instanceof Error ? proposeError.message : String(proposeError); + if (msg.includes("Too Many Requests") || msg.includes("429")) { + context.fail( + "Safe Transaction Service rate limit exceeded. Please wait a minute and try again.", + ); + } else { + context.fail(`Failed to propose transaction: ${msg}`); + } + return 1; + } + + // 12. Output + const queueUrl = safeQueueUrl(chainId, safeAddress); + + if (input.json) { + context.success({ + safeTxHash, + safeAddress, + to: appContractAddress, + composeHash: hashResult.compose_hash, + appId: hashResult.app_id, + cvmId: hashResult.cvm_id, + chainId, + queueUrl, + }); + return 0; + } + + logger.success("Safe transaction proposed successfully!"); + logger.info(""); + logger.info(`Safe Tx Hash: ${safeTxHash}`); + logger.info(`Safe Address: ${safeAddress}`); + logger.info(`Target: ${appContractAddress}`); + logger.info(`Compose Hash: ${hashResult.compose_hash}`); + logger.info(`Chain: ${chain.name} (${chainId})`); + if (queueUrl) { + logger.info(""); + logger.info(`View in Safe UI: ${queueUrl}`); + } + logger.info(""); + logger.info("Next steps:"); + logger.info( + " 1. Other signers approve + execute the transaction in the Safe UI", + ); + logger.info( + ` 2. After execution, run: phala deploy --cvm-id ${hashResult.cvm_id} -c ${input.compose || "docker-compose.yml"} --skip-onchain-tx`, + ); + + return 0; + } catch (error) { + logger.logDetailedError(error); + context.fail( + `Failed: ${error instanceof Error ? error.message : String(error)}`, + ); + return 1; + } +} + +export const safeProposeUpdateCommand = defineCommand({ + path: ["safe", "propose-update"], + meta: safeProposeUpdateMeta, + schema: safeProposeUpdateSchema, + handler, +}); diff --git a/cli/src/commands/transfer-ownership/command.ts b/cli/src/commands/transfer-ownership/command.ts new file mode 100644 index 00000000..76b56c1a --- /dev/null +++ b/cli/src/commands/transfer-ownership/command.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; +import type { CommandMeta } from "@/src/core/types"; +import { jsonOption } from "@/src/core/common-flags"; + +const cvmArgument = { + name: "cvm", + description: "CVM identifier (UUID, app_id, instance_id, or name)", + required: true, + target: "cvm", +}; + +export const transferOwnershipMeta: CommandMeta = { + name: "transfer-ownership", + description: + "Transfer ownership of a CVM's on-chain DstackApp contract to a new address", + stability: "unstable", + arguments: [cvmArgument], + options: [ + { + name: "new-owner", + description: "New owner address", + type: "string", + target: "newOwner", + }, + { + name: "private-key", + description: "Current owner's private key for signing the transaction", + type: "string", + target: "privateKey", + group: "advanced", + }, + { + name: "rpc-url", + description: "Custom RPC URL for the blockchain", + type: "string", + target: "rpcUrl", + group: "advanced", + }, + jsonOption, + ], + examples: [ + { + name: "Transfer ownership to a new address", + value: + "phala transfer-ownership app_abc123 --new-owner 0xNewAddress --private-key 0x...", + }, + ], +}; + +export const transferOwnershipSchema = z.object({ + cvm: z.string(), + newOwner: z.string(), + privateKey: z.string().optional(), + rpcUrl: z.string().optional(), + json: z.boolean().default(false), +}); + +export type TransferOwnershipInput = z.infer; diff --git a/cli/src/commands/transfer-ownership/index.ts b/cli/src/commands/transfer-ownership/index.ts new file mode 100644 index 00000000..53956b79 --- /dev/null +++ b/cli/src/commands/transfer-ownership/index.ts @@ -0,0 +1,91 @@ +import { + safeTransferOwnership, + type TransferOwnership, +} from "@phala/cloud"; +import { defineCommand } from "@/src/core/define-command"; +import type { CommandContext } from "@/src/core/types"; +import { logger } from "@/src/utils/logger"; +import { + resolveAppContract, + resolvePrivateKey, + txExplorerUrl, +} from "@/src/utils/onchain"; +import { + transferOwnershipMeta, + transferOwnershipSchema, + type TransferOwnershipInput, +} from "./command"; + +async function handler( + input: TransferOwnershipInput, + context: CommandContext, +): Promise { + try { + if (!input.newOwner) { + context.fail("--new-owner is required."); + return 1; + } + + const resolved = await resolveAppContract(input.cvm, context); + if (!resolved) return 1; + + const { chain, appContractAddress } = resolved; + const privateKey = resolvePrivateKey(input); + + logger.info( + `Transferring ownership of ${appContractAddress} on ${chain.name}...`, + ); + + const result = await safeTransferOwnership({ + chain, + rpcUrl: input.rpcUrl, + appAddress: appContractAddress, + newOwner: input.newOwner as `0x${string}`, + privateKey, + skipPrerequisiteChecks: true, + }); + + if (!result.success) { + const err = result as { success: false; error: { message: string } }; + context.fail(err.error.message); + return 1; + } + + const data = result.data as TransferOwnership; + const explorerUrl = txExplorerUrl(chain, data.transactionHash); + + if (input.json) { + context.success({ + previousOwner: data.previousOwner, + newOwner: data.newOwner, + transactionHash: data.transactionHash, + blockNumber: data.blockNumber?.toString(), + explorer: explorerUrl ?? undefined, + }); + return 0; + } + + logger.success("Ownership transferred successfully!"); + logger.info(`Previous owner: ${data.previousOwner}`); + logger.info(`New owner: ${data.newOwner}`); + logger.info(`Transaction: ${data.transactionHash}`); + if (explorerUrl) { + logger.info(`Explorer: ${explorerUrl}`); + } + + return 0; + } catch (error) { + logger.logDetailedError(error); + context.fail( + `Failed: ${error instanceof Error ? error.message : String(error)}`, + ); + return 1; + } +} + +export const transferOwnershipCommand = defineCommand({ + path: ["transfer-ownership"], + meta: transferOwnershipMeta, + schema: transferOwnershipSchema, + handler, +}); diff --git a/cli/src/index.ts b/cli/src/index.ts index 8104cd6f..6ac60629 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -34,6 +34,9 @@ import { cvmsRuntimeConfigCommand } from "./commands/cvms/runtime-config"; import { sshKeysCommands } from "./commands/ssh-keys"; import { whoamiCommand } from "./commands/whoami"; import { allowDevicesCommands } from "./commands/allow-devices"; +import { transferOwnershipCommand } from "./commands/transfer-ownership"; +import { composeHashCommand } from "./commands/compose-hash"; +import { safeCommands } from "./commands/safe"; import { detectRuntimeFromProcess } from "./core/package-manager"; const __filename = fileURLToPath(import.meta.url); @@ -69,6 +72,8 @@ registry.registerCommand(statusCommand); registry.registerCommand(whoamiCommand); registry.registerCommand(completionCommand); registry.registerCommand(osImagesCommand); +registry.registerCommand(transferOwnershipCommand); +registry.registerCommand(composeHashCommand); // Command groups + subcommands registry.registerGroup(selfCommands.group); @@ -132,6 +137,11 @@ for (const command of allowDevicesCommands.commands) { registry.registerCommand(command); } +registry.registerGroup(safeCommands.group); +for (const command of safeCommands.commands) { + registry.registerCommand(command); +} + process.on("SIGINT", () => process.exit(0)); process.on("SIGTERM", () => process.exit(0)); diff --git a/cli/src/shims/node-fetch.ts b/cli/src/shims/node-fetch.ts new file mode 100644 index 00000000..e692cfbf --- /dev/null +++ b/cli/src/shims/node-fetch.ts @@ -0,0 +1,3 @@ +// Shim: use native fetch instead of node-fetch to avoid deprecation warnings +// (punycode via tr46/whatwg-url, and url.parse) +export default fetch; diff --git a/cli/src/utils/onchain.ts b/cli/src/utils/onchain.ts new file mode 100644 index 00000000..0d34f468 --- /dev/null +++ b/cli/src/utils/onchain.ts @@ -0,0 +1,115 @@ +import { + type Chain, + type PublicClient, + type WalletClient, + createPublicClient, + createWalletClient, + http, +} from "viem"; +import { privateKeyToAccount, nonceManager } from "viem/accounts"; +import { + safeGetCvmInfo, + safeGetAppDeviceAllowlist, + SUPPORTED_CHAINS, +} from "@phala/cloud"; +import type { CommandContext } from "@/src/core/types"; +import { getClient } from "@/src/lib/client"; + +export function txExplorerUrl( + chain: (typeof SUPPORTED_CHAINS)[keyof typeof SUPPORTED_CHAINS], + txHash: string | undefined, +): string | null { + if (!txHash) return null; + const baseUrl = chain.blockExplorers?.default?.url; + if (!baseUrl) return null; + return `${baseUrl}/tx/${txHash}`; +} + +export function resolvePrivateKey(input: { + privateKey?: string; +}): `0x${string}` { + const key = input.privateKey || process.env.PRIVATE_KEY; + if (!key) { + throw new Error( + "Private key required. Use --private-key or set PRIVATE_KEY env var.", + ); + } + return (key.startsWith("0x") ? key : `0x${key}`) as `0x${string}`; +} + +export 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 }; +} + +export async function resolveAppContract( + cvmIdentifier: string, + context: CommandContext, +) { + const client = await getClient(); + + const infoResult = await safeGetCvmInfo(client, { id: cvmIdentifier }); + if (!infoResult.success) { + context.fail(infoResult.error.message); + return null; + } + + const cvm = infoResult.data; + if (!cvm) { + context.fail("CVM not found"); + return null; + } + + const appId = cvm.app_id; + if (!appId) { + context.fail("CVM has no app_id assigned yet."); + return null; + } + + const allowlistResult = await safeGetAppDeviceAllowlist(client, { appId }); + if (!allowlistResult.success) { + context.fail(allowlistResult.error.message); + return null; + } + + const allowlist = allowlistResult.data; + if (!allowlist.is_onchain_kms) { + context.fail( + "This app does not use on-chain KMS. This operation requires an on-chain KMS.", + ); + return null; + } + + if (!allowlist.chain_id || !allowlist.app_contract_address) { + context.fail( + "Missing chain_id or app_contract_address in allowlist response.", + ); + return null; + } + + const chain = SUPPORTED_CHAINS[allowlist.chain_id]; + if (!chain) { + context.fail(`Unsupported chain_id: ${allowlist.chain_id}`); + return null; + } + + return { + chain, + chainId: allowlist.chain_id, + appContractAddress: allowlist.app_contract_address as `0x${string}`, + allowlist, + }; +} diff --git a/cli/tsconfig.json b/cli/tsconfig.json index 93820d58..7d3bfea4 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -4,7 +4,8 @@ "isolatedModules": false, "baseUrl": ".", "paths": { - "@/*": ["./*"] + "@/*": ["./*"], + "punycode": ["punycode/"] }, "rootDir": "src", "moduleResolution": "bundler", diff --git a/cli/tsup.config.ts b/cli/tsup.config.ts index a1804e21..01bfa911 100644 --- a/cli/tsup.config.ts +++ b/cli/tsup.config.ts @@ -36,7 +36,7 @@ export default defineConfig({ platform: "node", bundle: true, splitting: false, - noExternal: ["@phala/cloud"], + noExternal: ["@phala/cloud", "punycode"], esbuildOptions(options) { options.banner = { js: "import { createRequire } from 'module';const require = createRequire(import.meta.url);", @@ -45,5 +45,9 @@ export default defineConfig({ ...options.define, __GIT_INFO__: JSON.stringify(getGitInfo()), }; + options.alias = { + ...options.alias, + "node-fetch": "./src/shims/node-fetch.ts", + }; }, }); diff --git a/js/src/actions/blockchains/abi/dstack_app.ts b/js/src/actions/blockchains/abi/dstack_app.ts index 9e4c2d70..4d99e31a 100644 --- a/js/src/actions/blockchains/abi/dstack_app.ts +++ b/js/src/actions/blockchains/abi/dstack_app.ts @@ -52,7 +52,24 @@ export const dstackAppAbi = [ type: "function", }, + { + inputs: [{ internalType: "address", name: "newOwner", type: "address" }], + name: "transferOwnership", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + // ── Events ─────────────────────────────────────────────────────── + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "address", name: "previousOwner", type: "address" }, + { indexed: true, internalType: "address", name: "newOwner", type: "address" }, + ], + name: "OwnershipTransferred", + type: "event", + }, { anonymous: false, inputs: [{ indexed: false, internalType: "bytes32", name: "deviceId", type: "bytes32" }], diff --git a/js/src/actions/blockchains/transfer_ownership.ts b/js/src/actions/blockchains/transfer_ownership.ts new file mode 100644 index 00000000..7d79c309 --- /dev/null +++ b/js/src/actions/blockchains/transfer_ownership.ts @@ -0,0 +1,344 @@ +import { z } from "zod"; +import { + type Chain, + type Address, + type Hash, + type Hex, + type TransactionReceipt, + type PublicClient, + type WalletClient, + createPublicClient, + createWalletClient, + http, + parseEventLogs, + parseEther, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { + type NetworkClients, + validateNetworkPrerequisites, + type TransactionTracker, + createTransactionTracker, + type RetryOptions, + executeTransactionWithRetry, + type TransactionOptions, +} from "../../utils"; +import { dstackAppAbi } from "./abi/dstack_app"; + +const TransferOwnershipRequestSchema = z + .object({ + chain: z.unknown().optional(), + rpcUrl: z.string().optional(), + appAddress: z.string(), + newOwner: z.string(), + privateKey: z.string().optional(), + walletClient: z.unknown().optional(), + publicClient: z.unknown().optional(), + skipPrerequisiteChecks: z.boolean().optional().default(false), + minBalance: z.string().optional(), + timeout: z.number().optional().default(120000), + retryOptions: z.unknown().optional(), + signal: z.unknown().optional(), + onTransactionStateChange: z.function().optional(), + onTransactionSubmitted: z.function().optional(), + onTransactionConfirmed: z.function().optional(), + }) + .passthrough() + .refine( + (data) => { + const hasPrivateKey = !!data.privateKey; + const hasWalletClient = !!data.walletClient; + return hasPrivateKey !== hasWalletClient; + }, + { + message: "Either 'privateKey' or 'walletClient' must be provided, but not both", + path: ["privateKey", "walletClient"], + }, + ) + .refine( + (data) => { + const hasPublicClient = !!data.publicClient; + const hasWalletClient = !!data.walletClient; + const hasChain = !!data.chain; + if (hasPublicClient && hasWalletClient) return true; + return hasChain; + }, + { + message: "Chain is required when publicClient or walletClient is not provided", + path: ["chain"], + }, + ); + +export type TransferOwnershipRequest = { + chain?: Chain; + rpcUrl?: string; + appAddress: Address; + newOwner: Address; + privateKey?: Hex; + walletClient?: WalletClient; + publicClient?: PublicClient; + skipPrerequisiteChecks?: boolean; + minBalance?: string; + timeout?: number; + retryOptions?: RetryOptions; + signal?: AbortSignal; + onTransactionStateChange?: (state: TransactionTracker["status"]) => void; + onTransactionSubmitted?: (hash: Hash) => void; + onTransactionConfirmed?: (receipt: TransactionReceipt) => void; +}; + +export const TransferOwnershipSchema = z + .object({ + previousOwner: z.string(), + newOwner: z.string(), + transactionHash: z.string(), + blockNumber: z.bigint().optional(), + gasUsed: z.bigint().optional(), + }) + .passthrough(); + +export type TransferOwnership = z.infer; + +export type TransferOwnershipParameters = T extends z.ZodTypeAny + ? { schema: T } + : T extends false + ? { schema: false } + : { schema?: z.ZodTypeAny | false }; + +export type TransferOwnershipReturnType = T extends z.ZodTypeAny + ? z.infer + : T extends false + ? unknown + : TransferOwnership; + +function parseTransferOwnershipResult( + receipt: TransactionReceipt, + appAddress: Address, + expectedNewOwner: Address, +): TransferOwnership { + try { + const logs = parseEventLogs({ + abi: dstackAppAbi, + eventName: "OwnershipTransferred", + logs: receipt.logs, + strict: false, + }); + + if (logs.length > 0) { + const event = logs[0]; + return { + previousOwner: (event?.args as { previousOwner?: string })?.previousOwner ?? "", + newOwner: (event?.args as { newOwner?: string })?.newOwner ?? expectedNewOwner, + transactionHash: receipt.transactionHash, + blockNumber: receipt.blockNumber, + gasUsed: receipt.gasUsed, + }; + } + + return { + previousOwner: "", + newOwner: expectedNewOwner, + transactionHash: receipt.transactionHash, + blockNumber: receipt.blockNumber, + gasUsed: receipt.gasUsed, + }; + } catch { + return { + previousOwner: "", + newOwner: expectedNewOwner, + transactionHash: receipt.transactionHash, + blockNumber: receipt.blockNumber, + gasUsed: receipt.gasUsed, + }; + } +} + +export async function transferOwnership( + request: TransferOwnershipRequest, + parameters?: TransferOwnershipParameters, +): Promise> { + const validatedRequest = TransferOwnershipRequestSchema.parse(request); + + const { + chain, + rpcUrl, + appAddress: rawAppAddress, + newOwner: rawNewOwner, + privateKey, + walletClient: providedWalletClient, + publicClient: providedPublicClient, + timeout = 120000, + retryOptions, + signal, + onTransactionStateChange, + onTransactionSubmitted, + onTransactionConfirmed, + skipPrerequisiteChecks = false, + minBalance, + } = validatedRequest; + + const appAddress = ( + rawAppAddress.startsWith("0x") ? rawAppAddress : `0x${rawAppAddress}` + ) as Address; + const newOwner = (rawNewOwner.startsWith("0x") ? rawNewOwner : `0x${rawNewOwner}`) as Address; + + let publicClient: PublicClient; + let walletClient: WalletClient; + let address: Address; + let chainId: number; + + if (privateKey) { + const account = privateKeyToAccount(privateKey as Hex); + + if (providedPublicClient) { + publicClient = providedPublicClient as PublicClient; + } else { + if (!chain) throw new Error("Chain required when creating publicClient"); + publicClient = createPublicClient({ chain: chain as Chain, transport: http(rpcUrl) }); + } + + if (!chain) throw new Error("Chain required when creating walletClient"); + walletClient = createWalletClient({ + account, + chain: chain as Chain, + transport: http(rpcUrl), + }); + + address = account.address; + chainId = (chain as Chain).id; + } else if (providedWalletClient) { + walletClient = providedWalletClient as WalletClient; + + if (providedPublicClient) { + publicClient = providedPublicClient as PublicClient; + } else { + if (!chain) throw new Error("Chain required when creating publicClient"); + publicClient = createPublicClient({ chain: chain as Chain, transport: http(rpcUrl) }); + } + + if (!walletClient.account?.address) { + throw new Error("WalletClient must have an account with address"); + } + address = walletClient.account.address; + chainId = chain ? (chain as Chain).id : await walletClient.getChainId(); + } else { + throw new Error("Either privateKey or walletClient must be provided"); + } + + const networkClients: NetworkClients = { + publicClient, + walletClient, + address, + chainId, + }; + + const transactionTracker = createTransactionTracker(); + + if (onTransactionStateChange && typeof onTransactionStateChange === "function") { + const pollStatus = (): void => { + onTransactionStateChange(transactionTracker.status); + if (!transactionTracker.isComplete) { + setTimeout(pollStatus, 100); + } + }; + setTimeout(pollStatus, 10); + } + + if (!skipPrerequisiteChecks) { + const requirements = { + targetChainId: chainId, + minBalance: minBalance ? parseEther(minBalance) : parseEther("0.001"), + }; + + const validation = await validateNetworkPrerequisites(networkClients, requirements); + + if (!validation.networkValid) { + throw new Error( + `Network mismatch: Expected chain ${requirements.targetChainId}, but wallet is on chain ${validation.details.currentChainId}`, + ); + } + + if (!validation.balanceValid) { + const requiredEth = Number(requirements.minBalance) / 1e18; + const currentEth = Number(validation.details.balance) / 1e18; + throw new Error( + `Insufficient balance: Required ${requiredEth} ETH, but account has ${currentEth.toFixed(6)} ETH`, + ); + } + } + + const transferOwnershipOperation = async (clients: NetworkClients): Promise => { + const hash = await clients.walletClient.writeContract({ + address: appAddress, + abi: dstackAppAbi, + functionName: "transferOwnership", + args: [newOwner], + account: clients.walletClient.account || clients.address, + chain: (chain as Chain) || null, + }); + + return hash; + }; + + const transactionResult = retryOptions + ? await executeTransactionWithRetry( + transferOwnershipOperation, + networkClients, + [], + { + timeout: timeout as number, + confirmations: 1, + onSubmitted: onTransactionSubmitted, + onConfirmed: onTransactionConfirmed, + signal: signal, + } as TransactionOptions & { signal?: AbortSignal }, + retryOptions, + ) + : await transactionTracker.execute(transferOwnershipOperation, networkClients, [], { + timeout: timeout as number, + confirmations: 1, + onSubmitted: onTransactionSubmitted, + onConfirmed: onTransactionConfirmed, + signal: signal, + } as TransactionOptions & { signal?: AbortSignal }); + + const result = parseTransferOwnershipResult(transactionResult.receipt, appAddress, newOwner); + + if (parameters?.schema === false) { + return result as TransferOwnershipReturnType; + } + + const schema = (parameters?.schema || TransferOwnershipSchema) as z.ZodTypeAny; + return schema.parse(result) as TransferOwnershipReturnType; +} + +export type SafeTransferOwnershipResult = + | { + success: true; + data: TransferOwnershipReturnType; + } + | { + success: false; + error: { isRequestError: true; message: string; status: number; detail: string }; + }; + +export async function safeTransferOwnership( + request: TransferOwnershipRequest, + parameters?: TransferOwnershipParameters, +): Promise> { + try { + const result = await transferOwnership(request, parameters); + return { success: true, data: result }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown blockchain error"; + return { + success: false, + error: { + isRequestError: true, + message: errorMessage, + status: 500, + detail: errorMessage, + }, + }; + } +} diff --git a/js/src/actions/index.ts b/js/src/actions/index.ts index 064cece1..e984400c 100644 --- a/js/src/actions/index.ts +++ b/js/src/actions/index.ts @@ -104,6 +104,17 @@ export { type CheckDeviceAllowedRequest, } from "./blockchains/check_device_allowed"; +export { + transferOwnership, + safeTransferOwnership, + type TransferOwnershipParameters, + type TransferOwnershipReturnType, + TransferOwnershipSchema, + type TransferOwnership, + type TransferOwnershipRequest, + type SafeTransferOwnershipResult, +} from "./blockchains/transfer_ownership"; + export { dstackAppAbi } from "./blockchains/abi/dstack_app"; export {