diff --git a/packages/cli/src/commands/auth/generate.ts b/packages/cli/src/commands/auth/generate.ts index 430211ad..d729c9cf 100644 --- a/packages/cli/src/commands/auth/generate.ts +++ b/packages/cli/src/commands/auth/generate.ts @@ -1,19 +1,27 @@ /** * Auth Generate Command * - * Generate a new private key and optionally store it in OS keyring + * Generate a new private key and optionally store it in OS keyring. + * This only manages the signing key — use `auth identity new` to create identities. */ import { Command, Flags } from "@oclif/core"; import { confirm } from "@inquirer/prompts"; -import { generateNewPrivateKey, storePrivateKey, keyExists } from "@layr-labs/ecloud-sdk"; +import { + generateNewPrivateKey, + storePrivateKey, + keyExists, + getPrivateKeyWithSource, + getAddressFromPrivateKey, +} from "@layr-labs/ecloud-sdk"; import { showPrivateKey, displayWarning } from "../../utils/security"; import { withTelemetry } from "../../telemetry"; +import { replaceAllIdentities, setActiveIdentity } from "../../utils/globalConfig"; export default class AuthGenerate extends Command { - static description = "Generate a new private key"; + static description = "Generate a new private key and store in OS keyring"; - static aliases = ["auth:gen", "auth:new"]; + static aliases = ["auth:gen"]; static examples = [ "<%= config.bin %> <%= command.id %>", @@ -22,7 +30,7 @@ export default class AuthGenerate extends Command { static flags = { store: Flags.boolean({ - description: "Automatically store in OS keyring", + description: "Automatically store in OS keyring (skip prompt)", default: false, }), }; @@ -31,11 +39,36 @@ export default class AuthGenerate extends Command { return withTelemetry(this, async () => { const { flags } = await this.parse(AuthGenerate); - // Generate new key - this.log("Generating new private key...\n"); + let shouldStore = flags.store; + if (!shouldStore) { + shouldStore = await confirm({ message: "Store this key in your OS keyring?", default: true }); + } + + // Check for existing key BEFORE generating a new one + if (shouldStore) { + const exists = await keyExists(); + if (exists) { + const existing = await getPrivateKeyWithSource({ privateKey: undefined }); + if (existing) { + const existingAddress = getAddressFromPrivateKey(existing.key); + displayWarning([ + "A signing key already exists.", + `Address: ${existingAddress}`, + "", + "Replacing it will clear all current identities.", + ]); + } + const confirmReplace = await confirm({ message: "Replace existing key?", default: false }); + if (!confirmReplace) { + this.log("\nCancelled."); + return; + } + } + } + + // Generate the new key const { privateKey, address } = generateNewPrivateKey(); - // Display key securely const content = ` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ A new private key was generated for you. @@ -56,54 +89,23 @@ Press 'q' to exit and continue... `; const displayed = await showPrivateKey(content); - if (!displayed) { this.log("Key generation cancelled."); return; } - // Ask about storing - let shouldStore = flags.store; - - if (!shouldStore && displayed) { - shouldStore = await confirm({ - message: "Store this key in your OS keyring?", - default: true, - }); - } - if (shouldStore) { - // Check if key already exists - const exists = await keyExists(); - - if (exists) { - displayWarning([ - `WARNING: A private key for ecloud already exists!`, - "If you continue, the existing key will be PERMANENTLY REPLACED.", - "This cannot be undone!", - "", - "The previous key will be lost forever if you haven't backed it up.", - ]); - - const confirmReplace = await confirm({ - message: `Replace existing key for ecloud?`, - default: false, - }); - - if (!confirmReplace) { - this.log( - "\nKey not stored. If you did not save your new key when it was displayed, it is now lost and cannot be recovered.", - ); - return; - } - } - - // Store the key try { await storePrivateKey(privateKey); + // New signing key — wipe all identities (they belonged to the previous key) + replaceAllIdentities([{ type: "eoa", address }]); + for (const env of ["sepolia", "sepolia-dev", "mainnet-alpha"]) { + setActiveIdentity(env, address); + } this.log(`\n✓ Private key stored in OS keyring`); this.log(`✓ Address: ${address}`); this.log("\nYou can now use ecloud commands without --private-key flag."); + this.log("Run 'ecloud auth identity new' to create a Safe or Timelock identity."); } catch (err: any) { this.error(`Failed to store key: ${err.message}`); } diff --git a/packages/cli/src/commands/auth/identity/list.ts b/packages/cli/src/commands/auth/identity/list.ts new file mode 100644 index 00000000..0a4552e5 --- /dev/null +++ b/packages/cli/src/commands/auth/identity/list.ts @@ -0,0 +1,53 @@ +/** + * Auth Identity List Command + * + * Show all stored identities and which is active. + */ + +import { Command } from "@oclif/core"; +import { withTelemetry } from "../../../telemetry"; +import { commonFlags } from "../../../flags"; +import { + getIdentities, + getActiveIdentityAddress, + formatIdentity, +} from "../../../utils/globalConfig"; + +export default class AuthIdentityList extends Command { + static description = "Show all stored identities"; + + static aliases = ["auth:identity:ls"]; + + static examples = ["<%= config.bin %> <%= command.id %>"]; + + static flags = { + environment: commonFlags.environment, + }; + + async run(): Promise { + return withTelemetry(this, async () => { + const { flags } = await this.parse(AuthIdentityList); + const environment = flags.environment as string; + + const identities = getIdentities(); + const activeAddress = getActiveIdentityAddress(environment); + + if (identities.length === 0) { + this.log("No identities."); + this.log("\nRun 'ecloud auth identity new' to create one."); + return; + } + + this.log(`Identities (${environment}):\n`); + for (const id of identities) { + const isActive = id.address.toLowerCase() === activeAddress?.toLowerCase(); + const marker = isActive ? "●" : "○"; + const active = isActive ? " ← active" : ""; + this.log(` ${marker} ${formatIdentity(id)}${active}`); + } + + this.log(""); + this.log("Run 'ecloud auth identity select' to switch active identity."); + }); + } +} diff --git a/packages/cli/src/commands/auth/identity/new.ts b/packages/cli/src/commands/auth/identity/new.ts new file mode 100644 index 00000000..2c1c7889 --- /dev/null +++ b/packages/cli/src/commands/auth/identity/new.ts @@ -0,0 +1,349 @@ +/** + * Auth Identity New Command + * + * Create a new identity: Gnosis Safe or Timelock. + * Requires a signing key in the keyring (run `auth generate` or `auth login` first). + */ + +import { Command } from "@oclif/core"; +import { confirm, select, input } from "@inquirer/prompts"; +import { + keyExists, + getPrivateKeyWithSource, + getAddressFromPrivateKey, + getEnvironmentConfig, + deploySafe, + deployTimelock, + getTimelocksByDeployer, + getSafesByDeployer, + getSafeTimelockFactoryAddress, + CANONICAL_SALT, + type DeploySafeOptions, + type DeployTimelockOptions, +} from "@layr-labs/ecloud-sdk"; +import { withTelemetry } from "../../../telemetry"; +import { commonFlags, validateCommonFlags } from "../../../flags"; +import { createViemClients } from "../../../utils/viemClients"; +import { addIdentity, setActiveIdentity, getIdentities } from "../../../utils/globalConfig"; +import { SAFE_ABI, TIMELOCK_ABI } from "../../../utils/contractAbis"; +import { keccak256, encodePacked } from "viem"; +import type { Address } from "viem"; + +/** Parse human delay strings like "24h", "7d", "30m" into seconds */ +function parseDelay(s: string): bigint { + const match = s.trim().match(/^(\d+)(s|m|h|d)$/i); + if (!match) throw new Error(`Invalid delay format: "${s}". Use e.g. "24h", "7d", "3600s" (unit required).`); + const n = parseInt(match[1], 10); + const unit = match[2].toLowerCase(); + const multipliers: Record = { s: 1, m: 60, h: 3600, d: 86400 }; + return BigInt(n * multipliers[unit]); +} + +function makeLogger(log: (m: string) => void, warn: (m: string) => void, verbose: boolean) { + return { + debug: (msg: string) => { if (verbose) log(msg); }, + info: (msg: string) => log(msg), + warn: (msg: string) => warn(msg), + error: (msg: string) => warn(msg), + }; +} + +export default class AuthIdentityNew extends Command { + static description = "Create a new identity: Gnosis Safe or Timelock"; + + static examples = [ + "<%= config.bin %> <%= command.id %>", + ]; + + static flags = { + ...commonFlags, + }; + + async run(): Promise { + return withTelemetry(this, async () => { + const { flags } = await this.parse(AuthIdentityNew); + + // Require a signing key + const exists = await keyExists(); + if (!exists) { + this.error("No signing key found. Run 'ecloud auth generate' or 'ecloud auth login' first."); + } + + const kind = await select({ + message: "What type of identity?", + choices: [ + { name: "Gnosis Safe (multi-sig)", value: "safe" }, + { name: "Timelock (for existing EOA or Safe)", value: "timelock" }, + ], + }); + + this.log(""); + + if (kind === "safe") { + await this._runSafe(flags); + } else { + await this._runTimelock(flags); + } + }); + } + + private async _runSafe(flags: any): Promise { + const existing = await getPrivateKeyWithSource({ privateKey: flags["private-key"] }); + if (!existing) { + this.error("No signing key available."); + } + + const signingKey = existing.key; + const environmentConfig = getEnvironmentConfig(flags.environment); + const { walletClient, publicClient, address: signerAddress } = createViemClients({ + privateKey: signingKey, + rpcUrl: flags["rpc-url"], + environment: flags.environment, + }); + + this.log(`Signing key ${signerAddress} will be included as an owner and cannot be removed.\n`); + + const extraOwnersRaw = await input({ + message: "Additional owner addresses (comma-separated, leave blank for none):", + default: "", + }); + const extraOwners = extraOwnersRaw + .split(",") + .map((a) => a.trim()) + .filter((a) => a.length > 0) as Address[]; + const owners: Address[] = [signerAddress, ...extraOwners]; + + const thresholdRaw = await input({ + message: `Threshold (e.g., ${Math.ceil(owners.length / 2)} of ${owners.length}):`, + default: String(Math.ceil(owners.length / 2)), + validate: (v) => { + const n = parseInt(v, 10); + return n >= 1 && n <= owners.length ? true : `Must be between 1 and ${owners.length}`; + }, + }); + const threshold = parseInt(thresholdRaw, 10); + + const addTimelock = await confirm({ message: "Add timelock delay?", default: false }); + let delayStr = ""; + if (addTimelock) { + delayStr = await input({ + message: 'Minimum delay (e.g., "24h", "7d"):', + default: "24h", + validate: (v) => /^\d+(s|m|h|d)$/i.test(v.trim()) ? true : 'Invalid format. Use a number followed by a unit: s, m, h, or d (e.g., "24h", "7d").', + }); + } + const logger = makeLogger(this.log.bind(this), this.warn.bind(this), flags.verbose); + + this.log(""); + if (addTimelock) { + this.log(`Deploying Safe (${thresholdRaw} of ${owners.length}) + Timelock via factory...`); + } else { + this.log(`Deploying Safe (${thresholdRaw} of ${owners.length}) via factory...`); + } + + const { tx: safeTx, safe, alreadyExisted } = await deploySafe( + { walletClient, publicClient, environmentConfig, owners, threshold } as DeploySafeOptions, + logger, + ); + if (alreadyExisted) { + this.log(`\n✓ Safe already exists at ${safe} (${thresholdRaw}/${owners.length}) — reusing`); + } else { + this.log(`\n✓ Safe deployed: ${safe} (${thresholdRaw}/${owners.length})`); + this.log(` Tx: ${safeTx}`); + } + + if (addTimelock) { + const minDelay = parseDelay(delayStr); + const { tx: tlTx, timelock } = await deployTimelock( + { + walletClient, + publicClient, + environmentConfig, + minDelay, + proposers: [safe], + executors: [safe], + } as DeployTimelockOptions, + logger, + ); + addIdentity({ type: "timelock", address: timelock, delay: delayStr, safeAddress: safe, environment: flags.environment }); + setActiveIdentity(flags.environment, timelock); + this.log(`✓ Timelock deployed: ${timelock} (${delayStr} delay, wraps Safe)`); + this.log(` Tx: ${tlTx}`); + this.log(`\n✓ Active identity set to: Timelock(Safe) ${timelock}`); + } else { + addIdentity({ type: "safe", address: safe, environment: flags.environment, threshold, owners: owners.map(String) }); + setActiveIdentity(flags.environment, safe); + this.log(`\n✓ Active identity set to: Safe ${safe}`); + } + } + + private async _runTimelock(flags: any): Promise { + await validateCommonFlags(flags, { requirePrivateKey: true }); + + const environmentConfig = getEnvironmentConfig(flags.environment); + const { walletClient, publicClient, address: signerAddress } = createViemClients({ + privateKey: flags["private-key"] as string, + rpcUrl: flags["rpc-url"], + environment: flags.environment, + }); + + const balance = await publicClient.getBalance({ address: signerAddress }); + if (balance === BigInt(0)) { + this.error(`Account ${signerAddress} has no ETH. Fund it before deploying.`); + } + + // Build proposer choices: EOA + any Safes deployed by this EOA. + // Also check the predicted canonical Safe address — it may exist on-chain but be + // registered with an older factory (not visible via getSafesByDeployer on the new one). + const factoryAddress = await getSafeTimelockFactoryAddress(publicClient, environmentConfig); + const knownSafesFromFactory = await getSafesByDeployer(publicClient, environmentConfig, signerAddress); + const predictedSafe = await publicClient.readContract({ + address: factoryAddress, + abi: [{ name: "calculateSafeAddress", type: "function", inputs: [{ type: "address" }, { type: "tuple", components: [{ name: "owners", type: "address[]" }, { name: "threshold", type: "uint256" }] }, { type: "bytes32" }], outputs: [{ type: "address" }], stateMutability: "view" }], + functionName: "calculateSafeAddress", + args: [signerAddress, { owners: [signerAddress], threshold: BigInt(1) }, CANONICAL_SALT], + }) as Address; + const predictedCode = await publicClient.getCode({ address: predictedSafe }); + const knownSafes = knownSafesFromFactory.includes(predictedSafe) || !(predictedCode && predictedCode !== "0x") + ? knownSafesFromFactory + : [...knownSafesFromFactory, predictedSafe]; + + const safeInfos = await Promise.all( + knownSafes.map(async (safe) => { + try { + const [threshold, owners] = await Promise.all([ + publicClient.readContract({ address: safe, abi: SAFE_ABI, functionName: "getThreshold" }) as Promise, + publicClient.readContract({ address: safe, abi: SAFE_ABI, functionName: "getOwners" }) as Promise, + ]); + const ownerSummary = owners.length <= 3 + ? owners.map((o) => `${o.slice(0, 6)}…${o.slice(-4)}`).join(", ") + : `${owners.slice(0, 2).map((o) => `${o.slice(0, 6)}…${o.slice(-4)}`).join(", ")} +${owners.length - 2} more`; + return { safe, label: `Safe ${safe} (${threshold}/${owners.length}: ${ownerSummary})` }; + } catch { + return { safe, label: `Safe ${safe}` }; + } + }), + ); + + const proposerChoices: { name: string; value: string }[] = [ + { name: `EOA ${signerAddress}`, value: `eoa:${signerAddress}` }, + ...safeInfos.map(({ safe, label }) => ({ name: label, value: `safe:${safe}` })), + ]; + + const proposerChoice = await select({ message: "Select proposer/executor:", choices: proposerChoices }); + const proposerKind = proposerChoice.startsWith("safe:") ? "safe" : "eoa"; + const proposer = proposerChoice.split(":")[1] as Address; + + // Timelocks are indexed by msg.sender (always the signing EOA), not by proposer/executor. + // Filter to those where the selected proposer actually holds PROPOSER_ROLE. + const allDeployedTimelocks = await getTimelocksByDeployer(publicClient, environmentConfig, signerAddress); + const PROPOSER_ROLE = "0xb09aa5aeb3702cfd50b6b62bc4532604938f21248a27a1d5ca736082b6819cc1" as const; + const proposerFlags = await Promise.all( + allDeployedTimelocks.map((tl) => + publicClient.readContract({ address: tl, abi: TIMELOCK_ABI, functionName: "hasRole", args: [PROPOSER_ROLE, proposer] }) as Promise, + ), + ); + const existingTimelocks = allDeployedTimelocks.filter((_, i) => proposerFlags[i]); + if (existingTimelocks.length > 0) { + const proposerLabel = proposerKind === "eoa" ? "EOA" : "Safe"; + const storedAddresses = new Set(getIdentities().map((id) => id.address.toLowerCase())); + + // Separate into already-stored and new + const newTimelocks = existingTimelocks.filter((a) => !storedAddresses.has(a.toLowerCase())); + const knownTimelocks = existingTimelocks.filter((a) => storedAddresses.has(a.toLowerCase())); + + if (newTimelocks.length === 0) { + // All already in config — offer to switch active or deploy a new one + this.log(`\nAll Timelocks for this ${proposerLabel} are already in your identities:`); + const identityMap = new Map(getIdentities().map((id) => [id.address.toLowerCase(), id])); + for (const addr of knownTimelocks) { + const delay = identityMap.get(addr.toLowerCase())?.delay; + this.log(` ${addr}${delay ? ` (delay: ${delay})` : ""}`); + } + const action = await select({ + message: "What would you like to do?", + choices: [ + { name: "Set one as active identity", value: "activate" }, + { name: "Deploy a new Timelock with a different delay", value: "deploy" }, + { name: "Nothing", value: "nothing" }, + ], + }); + if (action === "activate") { + const chosen = existingTimelocks.length === 1 + ? existingTimelocks[0] + : (await select({ + message: "Which Timelock?", + choices: existingTimelocks.map((a) => { + const delay = identityMap.get(a.toLowerCase())?.delay; + return { name: delay ? `${a} (delay: ${delay})` : a, value: a }; + }), + })); + setActiveIdentity(flags.environment, chosen); + this.log(`✓ Active identity set to Timelock ${chosen}`); + return; + } else if (action === "nothing") { + return; + } + // action === "deploy": fall through to deploy flow below + } else { + this.log(`\nFound ${newTimelocks.length} Timelock${newTimelocks.length > 1 ? "s" : ""} deployed by this ${proposerLabel}:`); + for (const addr of newTimelocks) this.log(` ${addr}`); + const addIt = await confirm({ message: "Add them to your identities?", default: true }); + if (addIt) { + const isSafe = proposerKind === "safe"; + for (const addr of newTimelocks) { + addIdentity({ type: "timelock", address: addr as Address, delay: "unknown", safeAddress: isSafe ? proposer : undefined, environment: flags.environment }); + } + const chosen = newTimelocks.length === 1 + ? newTimelocks[0] + : (await select({ + message: "Set which one as active?", + choices: newTimelocks.map((a) => ({ name: a, value: a })), + })); + setActiveIdentity(flags.environment, chosen as Address); + this.log(`✓ Timelock${newTimelocks.length > 1 ? "s" : ""} added and active set to ${chosen}`); + } + const deployAnother = await confirm({ message: "Deploy an additional Timelock with a different delay?", default: false }); + if (!deployAnother) return; + } + } + + const delayStr = await input({ + message: 'Minimum delay (e.g., "24h", "7d"):', + default: "24h", + validate: (v) => /^\d+(s|m|h|d)$/i.test(v.trim()) ? true : 'Invalid format. Use a number followed by a unit: s, m, h, or d (e.g., "24h", "7d").', + }); + const minDelay = parseDelay(delayStr); + const logger = makeLogger(this.log.bind(this), this.warn.bind(this), flags.verbose); + + this.log("\nDeploying Timelock via factory..."); + const { tx, timelock } = await deployTimelock( + { + walletClient, + publicClient, + environmentConfig, + minDelay, + proposers: [proposer], + executors: [proposer], + salt: keccak256(encodePacked(["address", "uint256"], [proposer, minDelay])), + } as DeployTimelockOptions, + logger, + ); + + const isSafe = proposerKind === "safe"; + addIdentity({ + type: "timelock", + address: timelock, + delay: delayStr, + safeAddress: isSafe ? proposer : undefined, + environment: flags.environment, + }); + setActiveIdentity(flags.environment, timelock); + + this.log(`\n✓ Timelock deployed: ${timelock}`); + this.log(` Minimum delay: ${delayStr}`); + this.log(` Proposer/Executor: ${proposer}${isSafe ? " (Safe)" : ""}`); + this.log(` Tx: ${tx}`); + this.log(`\n✓ Active identity set to: Timelock(${isSafe ? "Safe" : "EOA"}) ${timelock}`); + } +} diff --git a/packages/cli/src/commands/auth/identity/select.ts b/packages/cli/src/commands/auth/identity/select.ts new file mode 100644 index 00000000..c815fcae --- /dev/null +++ b/packages/cli/src/commands/auth/identity/select.ts @@ -0,0 +1,59 @@ +/** + * Auth Identity Select Command + * + * Switch active identity for an environment. + */ + +import { Command } from "@oclif/core"; +import { select } from "@inquirer/prompts"; +import { withTelemetry } from "../../../telemetry"; +import { commonFlags } from "../../../flags"; +import { + getIdentities, + getActiveIdentityAddress, + setActiveIdentity, + formatIdentity, +} from "../../../utils/globalConfig"; + +export default class AuthIdentitySelect extends Command { + static description = "Switch active identity for an environment"; + + static aliases = ["auth:identity:switch"]; + + static examples = ["<%= config.bin %> <%= command.id %>"]; + + static flags = { + environment: commonFlags.environment, + }; + + async run(): Promise { + return withTelemetry(this, async () => { + const { flags } = await this.parse(AuthIdentitySelect); + const environment = flags.environment as string; + + const identities = getIdentities(); + + if (identities.length === 0) { + this.log("No identities."); + this.log("\nRun 'ecloud auth identity new' to create one."); + return; + } + + const activeAddress = getActiveIdentityAddress(environment); + + const choices = identities.map((id) => ({ + name: formatIdentity(id) + (id.address.toLowerCase() === activeAddress?.toLowerCase() ? " ✓ active" : ""), + value: id.address, + })); + + const selected = await select({ + message: `Select active identity for ${environment}:`, + choices, + }); + + setActiveIdentity(environment, selected); + const id = identities.find((i) => i.address.toLowerCase() === selected.toLowerCase())!; + this.log(`\n✓ Active identity: ${formatIdentity(id)}`); + }); + } +} diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 62fc1060..bbee669c 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -1,196 +1,252 @@ /** * Auth Login Command * - * Store an existing private key in OS keyring + * Import an existing private key into OS keyring. + * Automatically discovers associated Timelocks and Safes on-chain. */ -import { Command, Flags } from "@oclif/core"; +import { Command } from "@oclif/core"; import { confirm, select } from "@inquirer/prompts"; import { storePrivateKey, keyExists, validatePrivateKey, getAddressFromPrivateKey, + getPrivateKeyWithSource, getLegacyKeys, getLegacyPrivateKey, deleteLegacyPrivateKey, + getEnvironmentConfig, + getTimelocksByDeployer, + getSafesByDeployer, type LegacyKey, } from "@layr-labs/ecloud-sdk"; import { getHiddenInput, displayWarning } from "../../utils/security"; import { withTelemetry } from "../../telemetry"; +import { commonFlags } from "../../flags"; +import { fetchSafeInfo, fetchTimelockDelay } from "../../utils/contractAbis"; +import { + getIdentities, + addIdentity, + replaceAllIdentities, + setActiveIdentity, +} from "../../utils/globalConfig"; +import { createPublicClientOnly } from "../../utils/viemClients"; +import type { Address } from "viem"; export default class AuthLogin extends Command { - static description = "Store your private key in OS keyring"; + static description = "Import an existing private key into OS keyring"; - static examples = [ - "<%= config.bin %> auth login", - "<%= config.bin %> auth login --private-key 0x...", - "<%= config.bin %> auth login --private-key 0x... --force", - ]; + static examples = ["<%= config.bin %> <%= command.id %>"]; static flags = { - "private-key": Flags.string({ - description: "Private key to store (skips interactive prompt)", - env: "ECLOUD_PRIVATE_KEY", - }), - force: Flags.boolean({ - description: "Skip all confirmation prompts", - default: false, - }), + environment: commonFlags.environment, + "rpc-url": commonFlags["rpc-url"], }; async run(): Promise { return withTelemetry(this, async () => { const { flags } = await this.parse(AuthLogin); - const isNonInteractive = !!flags["private-key"]; + const environment = flags.environment as string; - // Check if key already exists + // Check for existing key const exists = await keyExists(); - if (exists) { - if (isNonInteractive) { - if (!flags.force) { - this.error( - "A private key already exists. Use --force to replace it.", - ); - } - } else { + const existing = await getPrivateKeyWithSource({ privateKey: undefined }); + if (existing) { + const existingAddress = getAddressFromPrivateKey(existing.key); displayWarning([ - "WARNING: A private key for ecloud already exists!", - "Replacing it will cause PERMANENT DATA LOSS if not backed up.", - "The previous key will be lost forever.", + "A signing key already exists.", + `Address: ${existingAddress}`, + "", + "Replacing it will clear all current identities.", ]); - - const confirmReplace = await confirm({ - message: "Replace existing key?", - default: false, - }); - - if (!confirmReplace) { - this.log("\nLogin cancelled."); - return; - } + } + const confirmReplace = await confirm({ message: "Replace current signing key?", default: false }); + if (!confirmReplace) { + this.log("\nCancelled."); + return; } } + // Check for legacy keys from eigenx-cli + const legacyKeys = await getLegacyKeys(); let privateKey: string | null = null; let selectedKey: LegacyKey | null = null; - if (isNonInteractive) { - // Use flag value directly - privateKey = flags["private-key"]!; - } else { - // Check for legacy keys from eigenx-cli - const legacyKeys = await getLegacyKeys(); + if (legacyKeys.length > 0) { + this.log("\nFound legacy keys from eigenx-cli:"); + this.log(""); - if (legacyKeys.length > 0) { - this.log("\nFound legacy keys from eigenx-cli:"); + for (const key of legacyKeys) { + this.log(` Address: ${key.address}`); + this.log(` Environment: ${key.environment}`); + this.log(` Source: ${key.source}`); this.log(""); + } - // Display legacy keys - for (const key of legacyKeys) { - this.log(` Address: ${key.address}`); - this.log(` Environment: ${key.environment}`); - this.log(` Source: ${key.source}`); - this.log(""); - } - - const importLegacy = await confirm({ - message: "Would you like to import one of these legacy keys?", - default: false, - }); - - if (importLegacy) { - // Create choices for selection - const choices = legacyKeys.map((key) => ({ - name: `${key.address} (${key.environment} - ${key.source})`, - value: key, - })); + const importLegacy = await confirm({ + message: "Would you like to import one of these legacy keys?", + default: false, + }); - selectedKey = await select({ - message: "Select a key to import:", - choices, - }); + if (importLegacy) { + const choices = legacyKeys.map((key) => ({ + name: `${key.address} (${key.environment} - ${key.source})`, + value: key, + })); - // Retrieve the actual private key - privateKey = await getLegacyPrivateKey(selectedKey.environment, selectedKey.source); + selectedKey = await select({ + message: "Select a key to import:", + choices, + }); - if (!privateKey) { - this.error(`Failed to retrieve legacy key for ${selectedKey.environment}`); - } + privateKey = await getLegacyPrivateKey(selectedKey.environment, selectedKey.source); - this.log(`\nImporting key from ${selectedKey.source}:${selectedKey.environment}`); + if (!privateKey) { + this.error(`Failed to retrieve legacy key for ${selectedKey.environment}`); } - } - // If no legacy key was selected, prompt for private key input - if (!privateKey) { - privateKey = await getHiddenInput("Enter your private key:"); - privateKey = privateKey.trim(); + this.log(`\nImporting key from ${selectedKey.source}:${selectedKey.environment}`); } } + // If no legacy key was selected, prompt for private key input + if (!privateKey) { + privateKey = await getHiddenInput("Enter your private key:"); + privateKey = privateKey.trim(); + } + if (!validatePrivateKey(privateKey)) { this.error("Invalid private key format. Please check and try again."); } - // Derive address for confirmation const address = getAddressFromPrivateKey(privateKey); - this.log(`\nAddress: ${address}`); - if (!isNonInteractive) { - const confirmStore = await confirm({ - message: "Store this key in OS keyring?", - default: true, - }); + const confirmStore = await confirm({ + message: "Store this key in OS keyring?", + default: true, + }); - if (!confirmStore) { - this.log("\nLogin cancelled."); - return; - } + if (!confirmStore) { + this.log("\nLogin cancelled."); + return; } - // Store in keyring try { await storePrivateKey(privateKey); this.log("\n✓ Private key stored in OS keyring"); this.log(`✓ Address: ${address}`); - this.log("\nNote: This key will be used for all environments (mainnet, sepolia, etc.)"); - this.log("You can now use ecloud commands without --private-key flag."); - // Ask if user wants to delete the legacy key (only if save was successful) + // New signing key — wipe old identities, set EOA as default + replaceAllIdentities([{ type: "eoa", address }]); + setActiveIdentity(environment, address); + + // Discover all Safes and Timelocks deployed by this EOA via SafeTimelockFactory. + // Discovery order: + // 1. Safes deployed by EOA + // 2. Timelocks deployed by EOA directly (EOA → Timelock) + // 3. Timelocks deployed by each Safe (Safe → Timelock) + this.log(`\nScanning chain for associated identities...`); + try { + const publicClient = createPublicClientOnly({ environment, rpcUrl: flags["rpc-url"] }); + const environmentConfig = getEnvironmentConfig(environment); + + // Step 1 + 2: fetch Safes and direct Timelocks in parallel + const [safes, directTimelocks] = await Promise.all([ + getSafesByDeployer(publicClient, environmentConfig, address as Address), + getTimelocksByDeployer(publicClient, environmentConfig, address as Address), + ]); + + // Step 3: for each Safe, fetch Timelocks it deployed + const safeTimelockArrays = await Promise.all( + safes.map((safe) => getTimelocksByDeployer(publicClient, environmentConfig, safe as Address)), + ); + const safeTimelocks = safeTimelockArrays.flat(); + + if (safes.length === 0 && directTimelocks.length === 0) { + this.log(`No factory-deployed identities found for this EOA on ${environment}`); + } + + for (const safe of safes) { + const alreadyKnown = getIdentities().some( + (id) => id.address.toLowerCase() === safe.toLowerCase(), + ); + if (alreadyKnown) { + this.log(`Safe ${safe} (already in identities)`); + } else { + this.log(`Found Safe: ${safe}`); + const addIt = await confirm({ message: `Add this Safe to your identities?`, default: true }); + if (addIt) { + const { threshold, owners } = await fetchSafeInfo(publicClient, safe as Address); + addIdentity({ type: "safe", address: safe, environment, threshold, owners }); + this.log(`✓ Safe added to identities`); + } + } + } + + // Timelocks: direct (EOA → Timelock) first, then Safe-deployed (Safe → Timelock) + for (const timelock of directTimelocks) { + const alreadyKnown = getIdentities().some( + (id) => id.address.toLowerCase() === timelock.toLowerCase(), + ); + if (alreadyKnown) { + this.log(`Timelock ${timelock} (already in identities)`); + } else { + this.log(`Found Timelock: ${timelock}`); + const addIt = await confirm({ message: `Add this Timelock to your identities?`, default: true }); + if (addIt) { + const delay = await fetchTimelockDelay(publicClient, timelock as Address); + addIdentity({ type: "timelock", address: timelock, delay, environment }); + this.log(`✓ Timelock added to identities (delay: ${delay})`); + } + } + } + + for (const timelock of safeTimelocks) { + const safe = safes.find((s) => + safeTimelockArrays[safes.indexOf(s)]?.some((t) => t.toLowerCase() === timelock.toLowerCase()), + ); + const alreadyKnown = getIdentities().some( + (id) => id.address.toLowerCase() === timelock.toLowerCase(), + ); + if (alreadyKnown) { + this.log(`Timelock ${timelock} (already in identities)`); + } else { + this.log(`Found Timelock: ${timelock}${safe ? ` (deployed by Safe ${safe})` : ""}`); + const addIt = await confirm({ message: `Add this Timelock to your identities?`, default: true }); + if (addIt) { + const delay = await fetchTimelockDelay(publicClient, timelock as Address); + addIdentity({ type: "timelock", address: timelock, delay, safeAddress: safe, environment }); + this.log(`✓ Timelock added to identities (delay: ${delay})`); + } + } + } + } catch { + this.log(`(Identity scan skipped — chain not reachable)`); + } + + // Clean up legacy key if imported if (selectedKey) { this.log(""); - - const shouldDelete = flags.force || await confirm({ + const confirmDelete = await confirm({ message: `Delete the legacy key from ${selectedKey.source}:${selectedKey.environment}?`, default: false, }); - if (shouldDelete) { - const deleted = await deleteLegacyPrivateKey( - selectedKey.environment, - selectedKey.source, - ); - + if (confirmDelete) { + const deleted = await deleteLegacyPrivateKey(selectedKey.environment, selectedKey.source); if (deleted) { - this.log( - `\n✓ Legacy key deleted from ${selectedKey.source}:${selectedKey.environment}`, - ); - this.log("\nNote: The key is now only stored in ecloud. You can still use it with"); - this.log("eigenx-cli by providing --private-key flag or EIGENX_PRIVATE_KEY env var."); + this.log(`\n✓ Legacy key deleted from ${selectedKey.source}:${selectedKey.environment}`); } else { - this.log( - `\n⚠️ Failed to delete legacy key from ${selectedKey.source}:${selectedKey.environment}`, - ); - this.log("The key may have already been removed."); + this.log(`\n⚠️ Failed to delete legacy key`); } - } else { - this.log(`\nLegacy key kept in ${selectedKey.source}:${selectedKey.environment}`); - this.log("You can delete it later using 'eigenx auth logout' if needed."); } } + + this.log("\nRun 'ecloud auth identity new' to create a Safe or Timelock identity."); + this.log("Run 'ecloud auth identity select' to switch active identity."); } catch (err: any) { this.error(`Failed to store key: ${err.message}`); } diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts index 367f7f50..e29ea922 100644 --- a/packages/cli/src/commands/auth/logout.ts +++ b/packages/cli/src/commands/auth/logout.ts @@ -8,6 +8,7 @@ import { Command, Flags } from "@oclif/core"; import { confirm } from "@inquirer/prompts"; import { deletePrivateKey, getPrivateKey, getAddressFromPrivateKey } from "@layr-labs/ecloud-sdk"; import { withTelemetry } from "../../telemetry"; +import { replaceAllIdentities } from "../../utils/globalConfig"; export default class AuthLogout extends Command { static description = "Remove private key from OS keyring"; @@ -61,9 +62,10 @@ export default class AuthLogout extends Command { const deleted = await deletePrivateKey(); if (deleted) { - this.log("\n✓ Successfully removed key from keyring"); - this.log("\nYou will need to provide --private-key flag for future commands,"); - this.log("or run 'ecloud auth login' to store a key again."); + replaceAllIdentities([]); + this.log("\n✓ Signing key removed from keyring"); + this.log("✓ All identities cleared"); + this.log("\nRun 'ecloud auth generate' or 'ecloud auth login' to set up again."); } else { this.log("\nFailed to remove key (it may have already been removed)"); } diff --git a/packages/cli/src/commands/auth/sync.ts b/packages/cli/src/commands/auth/sync.ts new file mode 100644 index 00000000..4fb3c3f6 --- /dev/null +++ b/packages/cli/src/commands/auth/sync.ts @@ -0,0 +1,115 @@ +/** + * Auth Sync Command + * + * Rescans the chain for Safes and Timelocks deployed by the current signing key + * and rebuilds the identities list in config. + */ + +import { Command } from "@oclif/core"; +import { + keyExists, + getPrivateKeyWithSource, + getAddressFromPrivateKey, + getEnvironmentConfig, + getTimelocksByDeployer, + getSafesByDeployer, +} from "@layr-labs/ecloud-sdk"; +import { withTelemetry } from "../../telemetry"; +import { commonFlags } from "../../flags"; +import { + replaceAllIdentities, + setActiveIdentity, + addIdentity, +} from "../../utils/globalConfig"; +import { createPublicClientOnly } from "../../utils/viemClients"; +import { fetchSafeInfo, fetchTimelockDelay, isTimelockProposer } from "../../utils/contractAbis"; +import type { Address } from "viem"; + +export default class AuthSync extends Command { + static description = "Rescan chain and rebuild identities for the current signing key"; + + static examples = ["<%= config.bin %> <%= command.id %>"]; + + static flags = { + environment: commonFlags.environment, + "rpc-url": commonFlags["rpc-url"], + }; + + async run(): Promise { + return withTelemetry(this, async () => { + const { flags } = await this.parse(AuthSync); + const environment = flags.environment as string; + + const existing = await keyExists(); + if (!existing) { + this.error("No signing key found. Run 'ecloud auth generate' or 'ecloud auth login' first."); + } + + const result = await getPrivateKeyWithSource({ privateKey: undefined }); + if (!result) { + this.error("Failed to read signing key."); + } + + const address = getAddressFromPrivateKey(result.key) as Address; + this.log(`Signing key: ${address}`); + this.log(`Scanning ${environment} for associated identities...\n`); + + const publicClient = createPublicClientOnly({ environment, rpcUrl: flags["rpc-url"] }); + const environmentConfig = getEnvironmentConfig(environment); + + const [safes, directTimelocks] = await Promise.all([ + getSafesByDeployer(publicClient, environmentConfig, address), + getTimelocksByDeployer(publicClient, environmentConfig, address), + ]); + + const safeTimelockArrays = await Promise.all( + safes.map((safe) => getTimelocksByDeployer(publicClient, environmentConfig, safe as Address)), + ); + const safeTimelocks = safeTimelockArrays.flat(); + + // Rebuild identities from scratch + replaceAllIdentities([{ type: "eoa", address }]); + setActiveIdentity(environment, address); + + for (const safe of safes) { + const { threshold, owners } = await fetchSafeInfo(publicClient, safe as Address); + addIdentity({ type: "safe", address: safe, environment, threshold, owners }); + this.log(`✓ Safe: ${safe}`); + } + + // Combine all timelocks and resolve their actual proposer by checking hasRole + const allTimelocks = [ + ...directTimelocks, + ...safeTimelocks.filter((t) => !directTimelocks.some((d) => d.toLowerCase() === t.toLowerCase())), + ]; + + for (const timelock of allTimelocks) { + const delay = await fetchTimelockDelay(publicClient, timelock as Address); + // Check if any known Safe is a proposer on this Timelock + const safeProposer = safes.length > 0 + ? await (async () => { + for (const safe of safes) { + if (await isTimelockProposer(publicClient, timelock as Address, safe as Address)) return safe; + } + return undefined; + })() + : undefined; + addIdentity({ type: "timelock", address: timelock, delay, safeAddress: safeProposer, environment }); + if (safeProposer) { + this.log(`✓ Timelock: ${timelock} (via Safe ${safeProposer}, delay: ${delay})`); + } else { + this.log(`✓ Timelock: ${timelock} (via EOA, delay: ${delay})`); + } + } + + const total = safes.length + allTimelocks.length; + if (total === 0) { + this.log(`No factory-deployed identities found on ${environment}.`); + } else { + this.log(`\n✓ Synced ${total} identit${total === 1 ? "y" : "ies"}.`); + } + + this.log(`\nRun 'ecloud auth identity select' to set an active identity.`); + }); + } +} diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts index e7ed885d..ad9d7f8f 100644 --- a/packages/cli/src/commands/auth/whoami.ts +++ b/packages/cli/src/commands/auth/whoami.ts @@ -1,53 +1,132 @@ /** * Auth Whoami Command * - * Show current authentication status and address + * Show stored identities, active identity, and signing key status */ import { Command } from "@oclif/core"; -import { getPrivateKeyWithSource, getAddressFromPrivateKey } from "@layr-labs/ecloud-sdk"; +import { + getPrivateKeyWithSource, + getAddressFromPrivateKey, + getPendingTimelockOps, + getEnvironmentConfig, + type PendingTimelockOp, +} from "@layr-labs/ecloud-sdk"; import { commonFlags } from "../../flags"; import { withTelemetry } from "../../telemetry"; +import { createViemClients } from "../../utils/viemClients"; +import { + getIdentities, + getActiveIdentityAddress, + formatIdentity, + type StoredIdentity, +} from "../../utils/globalConfig"; +import { formatCountdown } from "../../utils/format"; +import chalk from "chalk"; +import type { Address } from "viem"; export default class AuthWhoami extends Command { - static description = "Show current authentication status and address"; + static description = "Show stored identities and current authentication status"; static examples = ["<%= config.bin %> <%= command.id %>"]; static flags = { - "private-key": { - ...commonFlags["private-key"], - required: false, // Make optional for whoami - }, + environment: commonFlags.environment, + "rpc-url": commonFlags["rpc-url"], + verbose: commonFlags.verbose, }; async run(): Promise { return withTelemetry(this, async () => { const { flags } = await this.parse(AuthWhoami); + const environment = flags.environment as string; + const verbose = flags.verbose ?? false; - // Try to get private key from any source - const result = await getPrivateKeyWithSource({ - privateKey: flags["private-key"], - }); + // Signing key status + const result = await getPrivateKeyWithSource({ privateKey: undefined }); + if (result) { + const signingAddress = getAddressFromPrivateKey(result.key); + this.log(`Signing key: ${signingAddress} (${result.source})`); + } else { + this.log(`Signing key: none (run: ecloud auth login)`); + } + + this.log(""); - if (!result) { - this.log("Not authenticated"); + // Identities + const identities = getIdentities(); + const activeAddress = getActiveIdentityAddress(environment); + + if (identities.length === 0) { + this.log("Identities: none"); this.log(""); - this.log("To authenticate, use one of:"); - this.log(" ecloud auth login # Store key in keyring"); - this.log(" export ECLOUD_PRIVATE_KEY=0x... # Use environment variable"); - this.log(" ecloud --private-key 0x... # Use flag"); + this.log("Run 'ecloud auth gen' to generate a new key, or 'ecloud auth login' to import an existing one."); return; } - // Get address from private key - const address = getAddressFromPrivateKey(result.key); + // Fetch pending ops for all Timelock identities in this environment + const timelocks = identities.filter( + (id) => id.type === "timelock" && id.environment === environment, + ); + const pendingOpsMap = new Map(); + + if (timelocks.length > 0 && result) { + const environmentConfig = getEnvironmentConfig(environment); + const rpcUrl = flags["rpc-url"] || environmentConfig.defaultRPCURL; + try { + const { publicClient } = createViemClients({ + privateKey: result.key, + rpcUrl, + environment, + }); + await Promise.all( + timelocks.map(async (id) => { + try { + const ops = await getPendingTimelockOps(publicClient, id.address as Address); + if (ops.length > 0) pendingOpsMap.set(id.address.toLowerCase(), ops); + } catch (e: any) { + this.warn(`Could not fetch pending ops for ${id.address}: ${e?.message ?? e}`); + } + }), + ); + } catch { + // silently skip pending ops if RPC unavailable + } + } + + this.log(`Identities (${environment}):`); + for (const id of identities) { + const isActive = id.address.toLowerCase() === activeAddress?.toLowerCase(); + const marker = isActive ? "●" : "○"; + const active = isActive ? " ← active" : ""; + this.log(` ${marker} ${formatIdentity(id, verbose)}${active}`); + + if (id.type === "timelock") { + const ops = pendingOpsMap.get(id.address.toLowerCase()) ?? []; + if (ops.length > 0) { + for (const op of ops) { + const now = BigInt(Math.floor(Date.now() / 1000)); + const status = op.ready + ? chalk.green("ready to execute") + : `executable in ${formatCountdown(op.executableAt - now)}`; + this.log(` ⏳ ${op.description} [${status}] id: ${verbose ? op.id : `${op.id.slice(0, 10)}…`}`); + } + } + } + } + + // If active identity is the EOA signing key itself (no contract identity active) + if (result && activeAddress?.toLowerCase() === getAddressFromPrivateKey(result.key).toLowerCase()) { + this.log(`\n Active: signing key (EOA)`); + } - // Display authentication info - this.log(`Address: ${address}`); - this.log(`Source: ${result.source}`); this.log(""); - this.log("Note: This key is used for all environments (mainnet, sepolia, etc.)"); + if (!activeAddress) { + this.log("No active identity. Run 'ecloud auth login' to select one."); + } else { + this.log("Run 'ecloud auth identity new' to create a Safe or Timelock identity."); + this.log("Run 'ecloud auth identity select' to switch active identity."); + } }); } } diff --git a/packages/cli/src/commands/compute/app/info.ts b/packages/cli/src/commands/compute/app/info.ts index 511867a5..90676b9b 100644 --- a/packages/cli/src/commands/compute/app/info.ts +++ b/packages/cli/src/commands/compute/app/info.ts @@ -4,13 +4,17 @@ import { getAppLatestReleaseBlockNumbers, getBlockTimestamps, UserApiClient, + getPendingTimelockOps, + type PendingTimelockOp, } from "@layr-labs/ecloud-sdk"; import { commonFlags, validateCommonFlags } from "../../../flags"; import { getOrPromptAppID } from "../../../utils/prompts"; -import { formatAppDisplay, printAppDisplay } from "../../../utils/format"; +import { formatAppDisplay, printAppDisplay, formatCountdown } from "../../../utils/format"; import { getClientId } from "../../../utils/version"; import { getDashboardUrl } from "../../../utils/dashboard"; import { createViemClients } from "../../../utils/viemClients"; +import { getIdentities } from "../../../utils/globalConfig"; +import { identityForActiveContext } from "../../../utils/apiIdentity"; import { Address, type PublicClient } from "viem"; import chalk from "chalk"; @@ -66,6 +70,7 @@ export default class AppInfo extends Command { }); const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { clientId: getClientId(), + identities: identityForActiveContext(environment), }); if (flags.watch) { @@ -175,6 +180,33 @@ export default class AppInfo extends Command { const dashboardUrl = getDashboardUrl(environmentConfig.name, appID); this.log(` Dashboard: ${chalk.blue.underline(dashboardUrl)}`); + // Show pending Timelock ops for this app + const identities = getIdentities(); + const timelockIdentities = identities.filter((id) => id.type === "timelock"); + for (const tlId of timelockIdentities) { + try { + const ops = await getPendingTimelockOps(publicClient, tlId.address as Address); + const appOps = ops.filter((op) => { + const match = op.description.match(/\((0x[0-9a-fA-F]{40})\)/); + return match && match[1].toLowerCase() === appID.toLowerCase(); + }); + if (appOps.length > 0) { + this.log(""); + this.log(` ${chalk.bold("Pending operations:")}`); + for (const op of appOps) { + const now = BigInt(Math.floor(Date.now() / 1000)); + const status = op.ready + ? chalk.green("ready to execute") + : `executable in ${formatCountdown(op.executableAt - now)}`; + this.log(` ⏳ ${op.description} [${status}]`); + this.log(` id: ${op.id}`); + } + } + } catch { + // skip + } + } + console.log(); } diff --git a/packages/cli/src/commands/compute/app/list.ts b/packages/cli/src/commands/compute/app/list.ts index 7c55160e..85c2e307 100644 --- a/packages/cli/src/commands/compute/app/list.ts +++ b/packages/cli/src/commands/compute/app/list.ts @@ -5,6 +5,8 @@ import { getAppLatestReleaseBlockNumbers, getBlockTimestamps, UserApiClient, + getPendingTimelockOps, + type PendingTimelockOp, } from "@layr-labs/ecloud-sdk"; import { commonFlags, validateCommonFlags } from "../../../flags"; import { privateKeyToAccount } from "viem/accounts"; @@ -16,10 +18,11 @@ import { getStatusSortPriority, } from "../../../utils/prompts"; import { getAppInfosChunked } from "../../../utils/appResolver"; -import { formatAppDisplay, printAppDisplay } from "../../../utils/format"; +import { formatAppDisplay, printAppDisplay, formatCountdown } from "../../../utils/format"; import { createViemClients } from "../../../utils/viemClients"; import { getDashboardUrl } from "../../../utils/dashboard"; import { getClientId } from "../../../utils/version"; +import { getIdentities, getActiveIdentityAddress, formatIdentity } from "../../../utils/globalConfig"; import chalk from "chalk"; import { withTelemetry } from "../../../telemetry"; @@ -33,199 +36,227 @@ export default class AppList extends Command { char: "a", default: false, }), - "address-count": Flags.integer({ - description: "Number of addresses to fetch", - default: 1, - }), }; async run() { return withTelemetry(this, async () => { const { flags } = await this.parse(AppList); - // Validate flags and prompt for missing values + // Validate flags — private key is required for API authentication const validatedFlags = await validateCommonFlags(flags); - // Get validated values from flags const environment = validatedFlags.environment; const environmentConfig = getEnvironmentConfig(environment); const rpcUrl = validatedFlags["rpc-url"] || environmentConfig.defaultRPCURL; const privateKey = validatedFlags["private-key"]!; - // Get developer address from private key const account = privateKeyToAccount(privateKey as Hex); - const developerAddr = account.address; + const eoaAddress = account.address; - // Create viem clients and UserAPI client const { publicClient, walletClient } = createViemClients({ privateKey, rpcUrl, environment, }); - if (flags.verbose) { - this.log(`Fetching apps for developer: ${developerAddr}`); - } - - // List apps from contract - const result = await getAllAppsByDeveloper(publicClient, environmentConfig, developerAddr); - - if (result.apps.length === 0) { - this.log(`\nNo apps found for developer ${developerAddr}`); - return; - } - - // Filter out terminated apps unless --all flag is used - const filteredApps: Address[] = []; - const filteredConfigs: { status: number }[] = []; + const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { + clientId: getClientId(), + }); - for (let i = 0; i < result.apps.length; i++) { - const config = result.appConfigs[i]; - if (!flags.all && config.status === ContractAppStatusTerminated) { - continue; - } - filteredApps.push(result.apps[i]); - filteredConfigs.push(config); - } + // Collect addresses to query — EOA + all identity addresses + const identities = getIdentities(); + const addressesToQuery: { address: Address; label: string }[] = []; - if (filteredApps.length === 0) { - if (flags.all) { - this.log(`\nNo apps found for developer ${developerAddr}`); - } else { - this.log( - `\nNo active apps found for developer ${developerAddr} (use --all to show terminated apps)`, - ); + if (identities.length > 0) { + for (const id of identities) { + addressesToQuery.push({ + address: id.address as Address, + label: formatIdentity(id), + }); } - return; + } else { + // No identities configured — query just the EOA + addressesToQuery.push({ + address: eoaAddress, + label: `${eoaAddress.slice(0, 6)}...${eoaAddress.slice(-4)} (EOA)`, + }); } - // Create UserAPI client - const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { - clientId: getClientId(), - }); - - // Fetch all data in parallel - const [appInfos, releaseBlockNumbers] = await Promise.all([ - getAppInfosChunked(userApiClient, filteredApps, 1).catch((err) => { - if (flags.verbose) { - this.warn(`Could not fetch app info from UserAPI: ${err}`); - } - return []; - }), - getAppLatestReleaseBlockNumbers(publicClient, environmentConfig, filteredApps).catch( - (err) => { - if (flags.verbose) { - this.warn(`Could not fetch release block numbers: ${err}`); + const activeAddress = getActiveIdentityAddress(environment); + let totalApps = 0; + + // Fetch pending Timelock ops for any Timelock identities + const pendingOpsMap = new Map(); // app address → ops + const timelockIdentities = identities.filter((id) => id.type === "timelock" && id.environment === environment); + for (const tlId of timelockIdentities) { + try { + const ops = await getPendingTimelockOps(publicClient, tlId.address as Address); + for (const op of ops) { + // Extract app address from description (format: "functionName(0x...)") + const match = op.description.match(/\((0x[0-9a-fA-F]{40})\)/); + if (match) { + const appAddr = match[1].toLowerCase(); + const existing = pendingOpsMap.get(appAddr) ?? []; + existing.push(op); + pendingOpsMap.set(appAddr, existing); } - return new Map(); - }, - ) as Promise>, - ]); - - // Get unique block numbers and fetch their timestamps - const blockNumbers = Array.from(releaseBlockNumbers.values()).filter((n) => n > 0); - const blockTimestamps = - blockNumbers.length > 0 - ? await getBlockTimestamps(publicClient, blockNumbers).catch((err) => { - if (flags.verbose) { - this.warn(`Could not fetch block timestamps: ${err}`); - } - return new Map(); - }) - : new Map(); - - // Build app items with all data for sorting - interface AppDisplayItem { - appAddr: Address; - apiInfo: (typeof appInfos)[0] | undefined; - appName: string; - status: string; - releaseTimestamp: number | undefined; + } + } catch { + // skip if Timelock doesn't support getPendingOperations + } } - const appItems: AppDisplayItem[] = []; - for (let i = 0; i < filteredApps.length; i++) { - const appAddr = filteredApps[i]; - const config = filteredConfigs[i]; + console.log(); - const apiInfo = appInfos.find( - (info) => info.address && String(info.address).toLowerCase() === appAddr.toLowerCase(), + for (const { address, label } of addressesToQuery) { + // Declare the identity we're querying under so the platform evaluates + // permissions against this address rather than the signing EOA. + // EOA queries keep the header empty — server falls back to signer. + userApiClient.setIdentities( + address.toLowerCase() === eoaAddress.toLowerCase() ? [] : [address], ); - const profileName = apiInfo?.profile?.name; - const localName = getAppName(environment, appAddr); - const appName = profileName || localName; + // Query apps owned by this address from blockchain + const result = await getAllAppsByDeveloper(publicClient, environmentConfig, address); - const status = apiInfo?.status || getContractStatusString(config.status); + // Filter out terminated unless --all + const filteredApps: Address[] = []; + const filteredConfigs: { status: number }[] = []; - const releaseBlockNumber = releaseBlockNumbers.get(appAddr); - const releaseTimestamp = releaseBlockNumber - ? blockTimestamps.get(releaseBlockNumber) - : undefined; + for (let i = 0; i < result.apps.length; i++) { + const config = result.appConfigs[i]; + if (!flags.all && config.status === ContractAppStatusTerminated) { + continue; + } + filteredApps.push(result.apps[i]); + filteredConfigs.push(config); + } - appItems.push({ appAddr, apiInfo, appName, status, releaseTimestamp }); - } + if (filteredApps.length === 0) continue; - // Sort apps: Running first, then by status priority, then by release time (newest first) - appItems.sort((a, b) => { - const aPriority = getStatusSortPriority(a.status); - const bPriority = getStatusSortPriority(b.status); + totalApps += filteredApps.length; - if (aPriority !== bPriority) { - return aPriority - bPriority; + // Print identity header + const isActive = address.toLowerCase() === activeAddress?.toLowerCase(); + const activeMarker = isActive ? chalk.green(" ← active") : ""; + this.log(chalk.bold(`${label}${activeMarker}`)); + console.log(); + + // Fetch app info from UserAPI (authenticated with EOA signature — backend + // resolves Safe/Timelock ownership) and release data from blockchain + const [appInfos, releaseBlockNumbers] = await Promise.all([ + getAppInfosChunked(userApiClient, filteredApps, 1).catch((err) => { + if (flags.verbose) { + this.warn(`Could not fetch app info from UserAPI: ${err}`); + } + return []; + }), + getAppLatestReleaseBlockNumbers(publicClient, environmentConfig, filteredApps).catch( + (err) => { + if (flags.verbose) { + this.warn(`Could not fetch release block numbers: ${err}`); + } + return new Map(); + }, + ) as Promise>, + ]); + + // Get unique block numbers and fetch their timestamps + const blockNumbers = Array.from(releaseBlockNumbers.values()).filter((n) => n > 0); + const blockTimestamps = + blockNumbers.length > 0 + ? await getBlockTimestamps(publicClient, blockNumbers).catch((err) => { + if (flags.verbose) { + this.warn(`Could not fetch block timestamps: ${err}`); + } + return new Map(); + }) + : new Map(); + + // Build and sort app items + interface AppDisplayItem { + appAddr: Address; + apiInfo: (typeof appInfos)[0] | undefined; + appName: string; + status: string; + releaseTimestamp: number | undefined; } - // Within same status, sort by release time (newest first) - const aTime = a.releaseTimestamp || 0; - const bTime = b.releaseTimestamp || 0; - return bTime - aTime; - }); + const appItems: AppDisplayItem[] = []; + for (let i = 0; i < filteredApps.length; i++) { + const appAddr = filteredApps[i]; + const config = filteredConfigs[i]; - // Print header - console.log(); - this.log(chalk.bold(`Apps for ${developerAddr} (${environment}):`)); - console.log(); + const apiInfo = appInfos.find( + (info) => info.address && String(info.address).toLowerCase() === appAddr.toLowerCase(), + ); + + const profileName = apiInfo?.profile?.name; + const localName = getAppName(environment, appAddr); + const appName = profileName || localName; + const status = apiInfo?.status || getContractStatusString(config.status); - // Print each app - for (let i = 0; i < appItems.length; i++) { - const { apiInfo, appName, status, releaseTimestamp } = appItems[i]; + const releaseBlockNumber = releaseBlockNumbers.get(appAddr); + const releaseTimestamp = releaseBlockNumber + ? blockTimestamps.get(releaseBlockNumber) + : undefined; - // Skip if no API info (shouldn't happen, but be safe) - if (!apiInfo) { - continue; + appItems.push({ appAddr, apiInfo, appName, status, releaseTimestamp }); } - // Format app display using shared utility - const display = formatAppDisplay({ - appInfo: apiInfo, - appName, - status, - releaseTimestamp, + appItems.sort((a, b) => { + const aPriority = getStatusSortPriority(a.status); + const bPriority = getStatusSortPriority(b.status); + if (aPriority !== bPriority) return aPriority - bPriority; + return (b.releaseTimestamp || 0) - (a.releaseTimestamp || 0); }); - // Print app name header - this.log(` ${display.name}`); + // Print each app + for (let i = 0; i < appItems.length; i++) { + const { apiInfo, appName, status, releaseTimestamp } = appItems[i]; - // Print app details using shared utility - printAppDisplay(display, this.log.bind(this), " ", { - singleAddress: true, - showProfile: false, - }); + if (!apiInfo) continue; - // Show dashboard link - const dashboardUrl = getDashboardUrl(environment, appItems[i].appAddr); - this.log(` Dashboard: ${chalk.blue.underline(dashboardUrl)}`); + const display = formatAppDisplay({ appInfo: apiInfo, appName, status, releaseTimestamp }); - // Add separator between apps - if (i < appItems.length - 1) { - this.log( - chalk.gray(" ────────────────────────────────────────────────────────────────────"), - ); + this.log(` ${display.name}`); + printAppDisplay(display, this.log.bind(this), " ", { + singleAddress: true, + showProfile: false, + }); + + const dashboardUrl = getDashboardUrl(environment, appItems[i].appAddr); + this.log(` Dashboard: ${chalk.blue.underline(dashboardUrl)}`); + + // Show pending Timelock ops for this app + const appOps = pendingOpsMap.get(appItems[i].appAddr.toLowerCase()); + if (appOps && appOps.length > 0) { + for (const op of appOps) { + const now = BigInt(Math.floor(Date.now() / 1000)); + const status = op.ready + ? chalk.green("ready to execute") + : `executable in ${formatCountdown(op.executableAt - now)}`; + this.log(` ⏳ ${op.description} [${status}]`); + } + } + + if (i < appItems.length - 1) { + this.log(chalk.gray(" ──────────────────────────────────────────────────────────────")); + } } + + console.log(); } - console.log(); - this.log(chalk.gray(`Total: ${appItems.length} app(s)`)); + if (totalApps === 0) { + if (flags.all) { + this.log("No apps found."); + } else { + this.log("No active apps found (use --all to show terminated apps)."); + } + } else { + this.log(chalk.gray(`Total: ${totalApps} app(s) across ${addressesToQuery.length} identity(ies)`)); + } }); } } diff --git a/packages/cli/src/commands/compute/app/ownership/transfer.ts b/packages/cli/src/commands/compute/app/ownership/transfer.ts new file mode 100644 index 00000000..0ee03f75 --- /dev/null +++ b/packages/cli/src/commands/compute/app/ownership/transfer.ts @@ -0,0 +1,150 @@ +import { Command, Args, Flags } from "@oclif/core"; +import { + getEnvironmentConfig, + isMainnet, + estimateTransactionGas, + encodeTransferOwnershipData, +} from "@layr-labs/ecloud-sdk"; +import { withTelemetry } from "../../../../telemetry"; +import { commonFlags, timelockFlags, applyTxOverrides } from "../../../../flags"; +import { createComputeClient } from "../../../../client"; +import { getOrPromptAppID, getPrivateKeyInteractive, confirm } from "../../../../utils/prompts"; +import { createViemClients } from "../../../../utils/viemClients"; +import { printIdentityContext, executeWithIdentity, printTransactionResult } from "../../../../utils/identityTransaction"; +import { handleTimelockExecute, handleTimelockCancel } from "../../../../utils/timelockExecute"; +import { isAddress } from "viem"; +import type { Address } from "viem"; +import chalk from "chalk"; + +export default class AppOwnershipTransfer extends Command { + static description = "Transfer ownership of an app to a new address (Safe or Timelock enables governance mode)"; + + static args = { + "app-id": Args.string({ + description: "App ID or name", + required: false, + }), + }; + + static flags = { + ...commonFlags, + to: Flags.string({ + required: false, + description: "New owner address (Safe or Timelock address enables governance mode)", + env: "ECLOUD_NEW_OWNER", + }), + force: Flags.boolean({ + description: "Skip all confirmation prompts", + default: false, + }), + ...timelockFlags, + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(AppOwnershipTransfer); + const compute = await createComputeClient(flags); + + const environment = flags.environment; + const environmentConfig = getEnvironmentConfig(environment); + const rpcUrl = flags["rpc-url"] || environmentConfig.defaultRPCURL; + const privateKey = await getPrivateKeyInteractive(flags["private-key"]); + + if (flags.execute) { + await handleTimelockExecute({ opId: flags.execute, environment, privateKey, rpcUrl, log: this.log.bind(this), error: this.error.bind(this) }); + return; + } + if (flags.cancel) { + await handleTimelockCancel({ opId: flags.cancel, environment, privateKey, rpcUrl, log: this.log.bind(this), error: this.error.bind(this) }); + return; + } + + if (!flags.to) { + this.error("--to is required when not using --execute"); + } + + const appId = await getOrPromptAppID({ + appID: args["app-id"], + environment, + privateKey, + rpcUrl, + action: "transfer ownership", + }); + + const newOwner = flags.to; + if (!isAddress(newOwner)) { + this.error(`Invalid address: ${newOwner}`); + } + + const { publicClient, walletClient, address } = createViemClients({ + privateKey, + rpcUrl, + environment, + }); + + const identity = printIdentityContext(environment, address, this.log.bind(this)); + + this.log(`\nApp: ${chalk.bold(appId)}`); + this.log(`New owner: ${chalk.bold(newOwner)}`); + + const callData = encodeTransferOwnershipData(appId, newOwner as Address); + const estimate = identity.type === "eoa" + ? await estimateTransactionGas({ + publicClient, + from: address, + to: environmentConfig.appControllerAddress, + data: callData, + }) + : undefined; + + const finalTx = estimate ? await applyTxOverrides(estimate, flags, { publicClient, address }) : undefined; + if (finalTx) { + if (flags["max-fee-per-gas"] || flags["max-priority-fee"]) { + this.log(chalk.yellow(`\nGas override active — max fee: ${flags["max-fee-per-gas"] || "estimated"} gwei, priority fee: ${flags["max-priority-fee"] || "estimated"} gwei`)); + } + if (finalTx.nonce != null) { + this.log(chalk.yellow(`Nonce override active — nonce: ${finalTx.nonce}`)); + } + } + + if ((isMainnet(environmentConfig) || identity.type !== "eoa") && !flags.force) { + const confirmed = await confirm("Continue with ownership transfer?"); + if (!confirmed) { + this.log(`\n${chalk.gray("Transfer cancelled")}`); + return; + } + } + + if (identity.type === "eoa") { + const res = await compute.app.transferOwnership(appId, newOwner, { gas: finalTx }); + this.log(`\n✅ ${chalk.green(`Ownership transferred successfully (tx: ${res.tx})`)}`); + + // Check whether timelocked mode was enabled as a result + const nowTimelocked = await compute.app.isTimelocked(appId); + if (nowTimelocked) { + this.log(chalk.cyan("\nTimelocked mode enabled. Sensitive ops (upgrade, terminate, grant ADMIN) now go through Timelock.schedule → execute uniformly.")); + } + } else { + const result = await executeWithIdentity({ + environment, + eoaAddress: address, + walletClient, + publicClient, + environmentConfig, + to: environmentConfig.appControllerAddress as Address, + data: callData, + pendingMessage: `Transferring ownership of app ${appId} to ${newOwner}...`, + txDescription: "TransferOwnership", + gas: finalTx, + delayOverride: flags.delay, + }); + + this.log(""); + printTransactionResult(result, this.log.bind(this)); + if (result.type === "direct") { + this.log(`\n✅ ${chalk.green(`Ownership transferred successfully`)}`); + } + } + }); + } +} diff --git a/packages/cli/src/commands/compute/app/releases.ts b/packages/cli/src/commands/compute/app/releases.ts index 7355ca0f..314e5a5d 100644 --- a/packages/cli/src/commands/compute/app/releases.ts +++ b/packages/cli/src/commands/compute/app/releases.ts @@ -5,6 +5,7 @@ import { getOrPromptAppID } from "../../../utils/prompts"; import { withTelemetry } from "../../../telemetry"; import { getClientId } from "../../../utils/version"; import { createViemClients } from "../../../utils/viemClients"; +import { identityForActiveContext } from "../../../utils/apiIdentity"; import chalk from "chalk"; import { formatAppRelease } from "../../../utils/releases"; import { Address } from "viem"; @@ -106,6 +107,7 @@ export default class AppReleases extends Command { }); const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { clientId: getClientId(), + identities: identityForActiveContext(environment), }); const data = await userApiClient.getApp(appID as Address); diff --git a/packages/cli/src/commands/compute/app/start.ts b/packages/cli/src/commands/compute/app/start.ts index 78d0ddf8..ec9ccab6 100644 --- a/packages/cli/src/commands/compute/app/start.ts +++ b/packages/cli/src/commands/compute/app/start.ts @@ -1,6 +1,6 @@ import { Command, Args, Flags } from "@oclif/core"; import { createComputeClient } from "../../../client"; -import { commonFlags, applyTxOverrides } from "../../../flags"; +import { commonFlags, timelockFlags, applyTxOverrides } from "../../../flags"; import { getEnvironmentConfig, estimateTransactionGas, @@ -10,8 +10,11 @@ import { import { getOrPromptAppID, confirm } from "../../../utils/prompts"; import { getPrivateKeyInteractive } from "../../../utils/prompts"; import { createViemClients } from "../../../utils/viemClients"; +import { printIdentityContext, executeWithIdentity, printTransactionResult } from "../../../utils/identityTransaction"; +import { handleTimelockExecute, handleTimelockCancel } from "../../../utils/timelockExecute"; import chalk from "chalk"; import { withTelemetry } from "../../../telemetry"; +import type { Address } from "viem"; export default class AppLifecycleStart extends Command { static description = "Start stopped app (start GCP instance)"; @@ -29,6 +32,7 @@ export default class AppLifecycleStart extends Command { description: "Skip all confirmation prompts", default: false, }), + ...timelockFlags, }; async run() { @@ -36,17 +40,20 @@ export default class AppLifecycleStart extends Command { const { args, flags } = await this.parse(AppLifecycleStart); const compute = await createComputeClient(flags); - // Get environment config (flags already validated by createComputeClient) const environment = flags.environment; const environmentConfig = getEnvironmentConfig(environment); - - // Get RPC URL (needed for contract queries and authentication) const rpcUrl = flags.rpcUrl || environmentConfig.defaultRPCURL; + const privateKey = await getPrivateKeyInteractive(flags["private-key"]); - // Get private key for gas estimation - const privateKey = flags["private-key"] || (await getPrivateKeyInteractive(environment)); + if (flags.execute) { + await handleTimelockExecute({ opId: flags.execute, environment, privateKey, rpcUrl, log: this.log.bind(this), error: this.error.bind(this) }); + return; + } + if (flags.cancel) { + await handleTimelockCancel({ opId: flags.cancel, environment, privateKey, rpcUrl, log: this.log.bind(this), error: this.error.bind(this) }); + return; + } - // Resolve app ID (prompt if not provided) const appId = await getOrPromptAppID({ appID: args["app-id"], environment: flags["environment"]!, @@ -55,50 +62,70 @@ export default class AppLifecycleStart extends Command { action: "start", }); - // Create viem clients for gas estimation - const { publicClient, address } = createViemClients({ + const { publicClient, walletClient, address } = createViemClients({ privateKey, rpcUrl, environment, }); - // Estimate gas cost + const identity = printIdentityContext(environment, address, this.log.bind(this)); + const callData = encodeStartAppData(appId); - const estimate = await estimateTransactionGas({ - publicClient, - from: address, - to: environmentConfig.appControllerAddress, - data: callData, - }); + const estimate = identity.type === "eoa" + ? await estimateTransactionGas({ + publicClient, + from: address, + to: environmentConfig.appControllerAddress, + data: callData, + }) + : undefined; - // Apply gas overrides if provided - const finalTx = await applyTxOverrides(estimate, flags, { publicClient, address }); - if (flags["max-fee-per-gas"] || flags["max-priority-fee"]) { - this.log(chalk.yellow(`Gas override active — max fee: ${flags["max-fee-per-gas"] || "estimated"} gwei, priority fee: ${flags["max-priority-fee"] || "estimated"} gwei`)); - } - if (finalTx.nonce != null) { - this.log(chalk.yellow(`Nonce override active — nonce: ${finalTx.nonce}`)); + const finalTx = estimate ? await applyTxOverrides(estimate, flags, { publicClient, address }) : undefined; + if (finalTx) { + if (flags["max-fee-per-gas"] || flags["max-priority-fee"]) { + this.log(chalk.yellow(`Gas override active — max fee: ${flags["max-fee-per-gas"] || "estimated"} gwei, priority fee: ${flags["max-priority-fee"] || "estimated"} gwei`)); + } + if (finalTx.nonce != null) { + this.log(chalk.yellow(`Nonce override active — nonce: ${finalTx.nonce}`)); + } } - // On mainnet, prompt for confirmation with cost if (isMainnet(environmentConfig) && !flags.force) { - const confirmed = await confirm( - `This will cost up to ${finalTx.maxCostEth} ETH. Continue?`, - ); + const costInfo = finalTx ? ` (cost: up to ${finalTx.maxCostEth} ETH)` : ""; + const confirmed = await confirm(`This will start app ${appId}${costInfo}. Continue?`); if (!confirmed) { this.log(`\n${chalk.gray(`Start cancelled`)}`); return; } } - const res = await compute.app.start(appId, { - gas: finalTx, - }); - - if (!res.tx) { - this.log(`\n${chalk.gray(`Start failed`)}`); + if (identity.type === "eoa") { + const res = await compute.app.start(appId, { gas: finalTx }); + if (!res.tx) { + this.log(`\n${chalk.gray(`Start failed`)}`); + } else { + this.log(`\n✅ ${chalk.green(`App started successfully`)}`); + } } else { - this.log(`\n✅ ${chalk.green(`App started successfully`)}`); + const result = await executeWithIdentity({ + environment, + eoaAddress: address, + walletClient, + publicClient, + environmentConfig, + to: environmentConfig.appControllerAddress as Address, + data: callData, + pendingMessage: `Starting app ${appId}...`, + txDescription: "StartApp", + gas: finalTx, + delayOverride: flags.delay, + }); + + this.log(""); + printTransactionResult(result, this.log.bind(this)); + if (result.type === "direct") { + this.log(`\n✅ ${chalk.green(`App started successfully`)}`); + } } }); } diff --git a/packages/cli/src/commands/compute/app/stop.ts b/packages/cli/src/commands/compute/app/stop.ts index b4e86e7c..d5cb983f 100644 --- a/packages/cli/src/commands/compute/app/stop.ts +++ b/packages/cli/src/commands/compute/app/stop.ts @@ -1,6 +1,6 @@ import { Command, Args, Flags } from "@oclif/core"; import { createComputeClient } from "../../../client"; -import { commonFlags, applyTxOverrides } from "../../../flags"; +import { commonFlags, timelockFlags, applyTxOverrides } from "../../../flags"; import { getEnvironmentConfig, estimateTransactionGas, @@ -10,8 +10,11 @@ import { import { getOrPromptAppID, confirm } from "../../../utils/prompts"; import { getPrivateKeyInteractive } from "../../../utils/prompts"; import { createViemClients } from "../../../utils/viemClients"; +import { printIdentityContext, executeWithIdentity, printTransactionResult } from "../../../utils/identityTransaction"; +import { handleTimelockExecute, handleTimelockCancel } from "../../../utils/timelockExecute"; import chalk from "chalk"; import { withTelemetry } from "../../../telemetry"; +import type { Address } from "viem"; export default class AppLifecycleStop extends Command { static description = "Stop running app (stop GCP instance)"; @@ -29,6 +32,7 @@ export default class AppLifecycleStop extends Command { description: "Skip all confirmation prompts", default: false, }), + ...timelockFlags, }; async run() { @@ -44,7 +48,16 @@ export default class AppLifecycleStop extends Command { const rpcUrl = flags.rpcUrl || environmentConfig.defaultRPCURL; // Get private key for gas estimation - const privateKey = flags["private-key"] || (await getPrivateKeyInteractive(environment)); + const privateKey = await getPrivateKeyInteractive(flags["private-key"]); + + if (flags.execute) { + await handleTimelockExecute({ opId: flags.execute, environment, privateKey, rpcUrl, log: this.log.bind(this), error: this.error.bind(this) }); + return; + } + if (flags.cancel) { + await handleTimelockCancel({ opId: flags.cancel, environment, privateKey, rpcUrl, log: this.log.bind(this), error: this.error.bind(this) }); + return; + } // Resolve app ID (prompt if not provided) const appId = await getOrPromptAppID({ @@ -55,50 +68,83 @@ export default class AppLifecycleStop extends Command { action: "stop", }); - // Create viem clients for gas estimation - const { publicClient, address } = createViemClients({ + // Create viem clients + const { publicClient, walletClient, address } = createViemClients({ privateKey, rpcUrl, environment, }); - // Estimate gas cost + // Show which identity will be used + const identity = printIdentityContext(environment, address, this.log.bind(this)); + + // Encode the calldata const callData = encodeStopAppData(appId); - const estimate = await estimateTransactionGas({ - publicClient, - from: address, - to: environmentConfig.appControllerAddress, - data: callData, - }); + + // Gas estimation only works when sending from EOA directly. + // For Safe/Timelock identities, msg.sender is the Safe/Timelock — not the EOA — + // so estimating from EOA would revert. Skip estimation for non-EOA identities. + const estimate = identity.type === "eoa" + ? await estimateTransactionGas({ + publicClient, + from: address, + to: environmentConfig.appControllerAddress, + data: callData, + }) + : undefined; // Apply gas overrides if provided - const finalTx = await applyTxOverrides(estimate, flags, { publicClient, address }); - if (flags["max-fee-per-gas"] || flags["max-priority-fee"]) { - this.log(chalk.yellow(`Gas override active — max fee: ${flags["max-fee-per-gas"] || "estimated"} gwei, priority fee: ${flags["max-priority-fee"] || "estimated"} gwei`)); - } - if (finalTx.nonce != null) { - this.log(chalk.yellow(`Nonce override active — nonce: ${finalTx.nonce}`)); + const finalTx = estimate ? await applyTxOverrides(estimate, flags, { publicClient, address }) : undefined; + if (finalTx) { + if (flags["max-fee-per-gas"] || flags["max-priority-fee"]) { + this.log(chalk.yellow(`Gas override active — max fee: ${flags["max-fee-per-gas"] || "estimated"} gwei, priority fee: ${flags["max-priority-fee"] || "estimated"} gwei`)); + } + if (finalTx.nonce != null) { + this.log(chalk.yellow(`Nonce override active — nonce: ${finalTx.nonce}`)); + } } // On mainnet, prompt for confirmation with cost if (isMainnet(environmentConfig) && !flags.force) { - const confirmed = await confirm( - `This will cost up to ${finalTx.maxCostEth} ETH. Continue?`, - ); + const costInfo = finalTx ? ` (cost: up to ${finalTx.maxCostEth} ETH)` : ""; + const confirmed = await confirm(`This will stop app ${appId}${costInfo}. Continue?`); if (!confirmed) { this.log(`\n${chalk.gray(`Stop cancelled`)}`); return; } } - const res = await compute.app.stop(appId, { - gas: finalTx, - }); - - if (!res.tx) { - this.log(`\n${chalk.gray(`Stop failed`)}`); + // Route based on active identity + if (identity.type === "eoa") { + // Direct transaction (existing behavior) + const res = await compute.app.stop(appId, { gas: finalTx }); + if (!res.tx) { + this.log(`\n${chalk.gray(`Stop failed`)}`); + } else { + this.log(`\n✅ ${chalk.green(`App stopped successfully`)}`); + } } else { - this.log(`\n✅ ${chalk.green(`App stopped successfully`)}`); + // Identity-aware routing (Safe propose / Timelock schedule) + const result = await executeWithIdentity({ + environment, + eoaAddress: address, + walletClient, + publicClient, + environmentConfig, + to: environmentConfig.appControllerAddress as Address, + data: callData, + pendingMessage: `Stopping app ${appId}...`, + txDescription: "StopApp", + gas: finalTx, + delayOverride: flags.delay, + }); + + this.log(""); + printTransactionResult(result, this.log.bind(this)); + + if (result.type === "direct") { + this.log(`\n✅ ${chalk.green(`App stopped successfully`)}`); + } } }); } diff --git a/packages/cli/src/commands/compute/app/terminate.ts b/packages/cli/src/commands/compute/app/terminate.ts index e489afb4..8baf4fa5 100644 --- a/packages/cli/src/commands/compute/app/terminate.ts +++ b/packages/cli/src/commands/compute/app/terminate.ts @@ -1,6 +1,6 @@ import { Command, Args, Flags } from "@oclif/core"; import { createComputeClient } from "../../../client"; -import { commonFlags, applyTxOverrides } from "../../../flags"; +import { commonFlags, timelockFlags, applyTxOverrides } from "../../../flags"; import { getEnvironmentConfig, estimateTransactionGas, @@ -10,8 +10,11 @@ import { import { getOrPromptAppID, confirm } from "../../../utils/prompts"; import { getPrivateKeyInteractive } from "../../../utils/prompts"; import { createViemClients } from "../../../utils/viemClients"; +import { printIdentityContext, executeWithIdentity, printTransactionResult } from "../../../utils/identityTransaction"; +import { handleTimelockExecute, handleTimelockCancel } from "../../../utils/timelockExecute"; import chalk from "chalk"; import { withTelemetry } from "../../../telemetry"; +import type { Address } from "viem"; export default class AppLifecycleTerminate extends Command { static description = "Terminate app (terminate GCP instance) permanently"; @@ -30,6 +33,7 @@ export default class AppLifecycleTerminate extends Command { description: "Force termination without confirmation", default: false, }), + ...timelockFlags, }; async run() { @@ -37,17 +41,20 @@ export default class AppLifecycleTerminate extends Command { const { args, flags } = await this.parse(AppLifecycleTerminate); const compute = await createComputeClient(flags); - // Get environment config (flags already validated by createComputeClient) const environment = flags.environment; const environmentConfig = getEnvironmentConfig(environment); - - // Get RPC URL (needed for contract queries and authentication) const rpcUrl = flags.rpcUrl || environmentConfig.defaultRPCURL; + const privateKey = await getPrivateKeyInteractive(flags["private-key"]); - // Get private key for gas estimation - const privateKey = flags["private-key"] || (await getPrivateKeyInteractive(environment)); + if (flags.execute) { + await handleTimelockExecute({ opId: flags.execute, environment, privateKey, rpcUrl, log: this.log.bind(this), error: this.error.bind(this) }); + return; + } + if (flags.cancel) { + await handleTimelockCancel({ opId: flags.cancel, environment, privateKey, rpcUrl, log: this.log.bind(this), error: this.error.bind(this) }); + return; + } - // Resolve app ID (prompt if not provided) const appId = await getOrPromptAppID({ appID: args["app-id"], environment: flags["environment"]!, @@ -56,34 +63,36 @@ export default class AppLifecycleTerminate extends Command { action: "terminate", }); - // Create viem clients for gas estimation - const { publicClient, address } = createViemClients({ + const { publicClient, walletClient, address } = createViemClients({ privateKey, rpcUrl, environment, }); - // Estimate gas cost + const identity = printIdentityContext(environment, address, this.log.bind(this)); + const callData = encodeTerminateAppData(appId); - const estimate = await estimateTransactionGas({ - publicClient, - from: address, - to: environmentConfig.appControllerAddress, - data: callData, - }); + const estimate = identity.type === "eoa" + ? await estimateTransactionGas({ + publicClient, + from: address, + to: environmentConfig.appControllerAddress, + data: callData, + }) + : undefined; - // Apply gas overrides if provided - const finalTx = await applyTxOverrides(estimate, flags, { publicClient, address }); - if (flags["max-fee-per-gas"] || flags["max-priority-fee"]) { - this.log(chalk.yellow(`Gas override active — max fee: ${flags["max-fee-per-gas"] || "estimated"} gwei, priority fee: ${flags["max-priority-fee"] || "estimated"} gwei`)); - } - if (finalTx.nonce != null) { - this.log(chalk.yellow(`Nonce override active — nonce: ${finalTx.nonce}`)); + const finalTx = estimate ? await applyTxOverrides(estimate, flags, { publicClient, address }) : undefined; + if (finalTx) { + if (flags["max-fee-per-gas"] || flags["max-priority-fee"]) { + this.log(chalk.yellow(`Gas override active — max fee: ${flags["max-fee-per-gas"] || "estimated"} gwei, priority fee: ${flags["max-priority-fee"] || "estimated"} gwei`)); + } + if (finalTx.nonce != null) { + this.log(chalk.yellow(`Nonce override active — nonce: ${finalTx.nonce}`)); + } } - // Ask for confirmation unless forced if (!flags.force) { - const costInfo = isMainnet(environmentConfig) + const costInfo = finalTx && isMainnet(environmentConfig) ? ` (cost: up to ${finalTx.maxCostEth} ETH)` : ""; const confirmed = await confirm(`⚠️ Permanently destroy app ${appId}${costInfo}?`); @@ -93,14 +102,33 @@ export default class AppLifecycleTerminate extends Command { } } - const res = await compute.app.terminate(appId, { - gas: finalTx, - }); - - if (!res.tx) { - this.log(`\n${chalk.gray(`Termination failed`)}`); + if (identity.type === "eoa") { + const res = await compute.app.terminate(appId, { gas: finalTx }); + if (!res.tx) { + this.log(`\n${chalk.gray(`Termination failed`)}`); + } else { + this.log(`\n✅ ${chalk.green(`App terminated successfully`)}`); + } } else { - this.log(`\n✅ ${chalk.green(`App terminated successfully`)}`); + const result = await executeWithIdentity({ + environment, + eoaAddress: address, + walletClient, + publicClient, + environmentConfig, + to: environmentConfig.appControllerAddress as Address, + data: callData, + pendingMessage: `Terminating app ${appId}...`, + txDescription: "TerminateApp", + gas: finalTx, + delayOverride: flags.delay, + }); + + this.log(""); + printTransactionResult(result, this.log.bind(this)); + if (result.type === "direct") { + this.log(`\n✅ ${chalk.green(`App terminated successfully`)}`); + } } }); } diff --git a/packages/cli/src/commands/compute/app/upgrade.ts b/packages/cli/src/commands/compute/app/upgrade.ts index 90a3ad04..c0d8cf09 100644 --- a/packages/cli/src/commands/compute/app/upgrade.ts +++ b/packages/cli/src/commands/compute/app/upgrade.ts @@ -1,9 +1,13 @@ import { Command, Args, Flags } from "@oclif/core"; import { getEnvironmentConfig, UserApiClient, isMainnet } from "@layr-labs/ecloud-sdk"; import { withTelemetry } from "../../../telemetry"; -import { commonFlags, applyTxOverrides } from "../../../flags"; +import { commonFlags, timelockFlags, applyTxOverrides } from "../../../flags"; import { createBuildClient, createComputeClient } from "../../../client"; import { createViemClients } from "../../../utils/viemClients"; +import { printIdentityContext, executeWithIdentity, printTransactionResult } from "../../../utils/identityTransaction"; +import { handleTimelockExecute, handleTimelockCancel } from "../../../utils/timelockExecute"; +import { identityForActiveContext } from "../../../utils/apiIdentity"; +import type { Address } from "viem"; import { getDockerfileInteractive, getImageReferenceInteractive, @@ -130,6 +134,7 @@ export default class AppUpgrade extends Command { description: "Skip all confirmation prompts", default: false, }), + ...timelockFlags, }; async run() { @@ -143,6 +148,16 @@ export default class AppUpgrade extends Command { const rpcUrl = flags["rpc-url"] || environmentConfig.defaultRPCURL; const privateKey = flags["private-key"]!; + // --execute / --cancel path: handle pending Timelock ops, skipping the build flow + if (flags.execute) { + await handleTimelockExecute({ opId: flags.execute, environment, privateKey, rpcUrl, log: this.log.bind(this), error: this.error.bind(this) }); + return; + } + if (flags.cancel) { + await handleTimelockCancel({ opId: flags.cancel, environment, privateKey, rpcUrl, log: this.log.bind(this), error: this.error.bind(this) }); + return; + } + // 1. Get app ID interactively if not provided const appID = await getOrPromptAppID({ appID: args["app-id"], @@ -152,6 +167,14 @@ export default class AppUpgrade extends Command { action: "upgrade", }); + // Determine active identity for routing + const { publicClient, walletClient, address } = createViemClients({ + privateKey, + rpcUrl, + environment, + }); + const identity = printIdentityContext(environment, address, this.log.bind(this)); + type VerifiableMode = "none" | "git" | "prebuilt"; let buildClient: Awaited> | undefined; const getBuildClient = async () => { @@ -309,15 +332,11 @@ export default class AppUpgrade extends Command { } // 5. Get current instance type (best-effort, used as default) - const { publicClient, walletClient, address } = createViemClients({ - privateKey, - rpcUrl, - environment, - }); let currentInstanceType = ""; try { const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { clientId: getClientId(), + identities: identityForActiveContext(environment), }); const infos = await userApiClient.getInfos([appID], 1); if (infos.length > 0) { @@ -381,7 +400,7 @@ export default class AppUpgrade extends Command { resourceUsageMonitoring, }); - // 10. Apply gas overrides if provided, show estimate, and prompt for confirmation on mainnet + // 10. Apply gas overrides if provided, show estimate, and prompt for confirmation const finalTx = await applyTxOverrides(gasEstimate, flags, { publicClient, address }); if (flags["max-fee-per-gas"] || flags["max-priority-fee"]) { this.log( @@ -395,7 +414,7 @@ export default class AppUpgrade extends Command { } this.log(`\nEstimated transaction cost: ${chalk.cyan(finalTx.maxCostEth)} ETH`); - if (isMainnet(environmentConfig) && !flags.force) { + if ((isMainnet(environmentConfig) || identity.type !== "eoa") && !flags.force) { const confirmed = await confirm(`Continue with upgrade?`); if (!confirmed) { this.log(`\n${chalk.gray(`Upgrade cancelled`)}`); @@ -403,60 +422,81 @@ export default class AppUpgrade extends Command { } } - // 11. Execute the upgrade - const res = await compute.app.executeUpgrade(prepared, finalTx); - - // 12. Watch until upgrade completes - await compute.app.watchUpgrade(res.appId); + if (identity.type === "eoa") { + // 11a. EOA: execute the EIP-7702 batch directly + const res = await compute.app.executeUpgrade(prepared, finalTx); - try { - const cwd = process.env.INIT_CWD || process.cwd(); - setLinkedAppForDirectory(environment, cwd, res.appId); - } catch (err: any) { - this.debug(`Failed to link directory to app: ${err.message}`); - } + // 12. Watch until upgrade completes + await compute.app.watchUpgrade(res.appId); - this.log( - `\n✅ ${chalk.green(`App upgraded successfully ${chalk.bold(`(id: ${res.appId}, image: ${res.imageRef})`)}`)}`, - ); - - // Update profile name if --name was provided (merge with existing profile to avoid wiping fields) - if (flags.name) { try { - const { publicClient, walletClient } = createViemClients({ - privateKey, - rpcUrl, - environment, - }); - const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { - clientId: getClientId(), - }); - const infos = await userApiClient.getInfos([res.appId], 1); - const existing = infos[0]?.profile; - - await compute.app.setProfile(res.appId, { - name: flags.name, - website: existing?.website, - description: existing?.description, - xURL: existing?.xURL, - }); - invalidateProfileCache(environment); - this.log(`✓ Profile name updated to "${flags.name}"`); + const cwd = process.env.INIT_CWD || process.cwd(); + setLinkedAppForDirectory(environment, cwd, res.appId); } catch (err: any) { - this.warn(`Upgrade succeeded but failed to update profile name: ${err.message}`); + this.debug(`Failed to link directory to app: ${err.message}`); } - } - // Show dashboard link - const dashboardUrl = getDashboardUrl(environment, res.appId); - this.log(`\n${chalk.gray("View your app:")} ${chalk.blue.underline(dashboardUrl)}`); + this.log( + `\n✅ ${chalk.green(`App upgraded successfully ${chalk.bold(`(id: ${res.appId}, image: ${res.imageRef})`)}`)}`, + ); - // Health verification hint — "Running" means container started, not serving traffic - this.log( - chalk.gray( - `\nNote: "Running" means the container started. Verify your app is serving traffic before considering the upgrade complete.`, - ), - ); + // Update profile name if --name was provided (merge with existing profile to avoid wiping fields) + if (flags.name) { + try { + const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { + clientId: getClientId(), + identities: identityForActiveContext(environment), + }); + const infos = await userApiClient.getInfos([res.appId], 1); + const existing = infos[0]?.profile; + + await compute.app.setProfile(res.appId, { + name: flags.name, + website: existing?.website, + description: existing?.description, + xURL: existing?.xURL, + }); + invalidateProfileCache(environment); + this.log(`✓ Profile name updated to "${flags.name}"`); + } catch (err: any) { + this.warn(`Upgrade succeeded but failed to update profile name: ${err.message}`); + } + } + + // Show dashboard link + const dashboardUrl = getDashboardUrl(environment, res.appId); + this.log(`\n${chalk.gray("View your app:")} ${chalk.blue.underline(dashboardUrl)}`); + + // Health verification hint — "Running" means container started, not serving traffic + this.log( + chalk.gray( + `\nNote: "Running" means the container started. Verify your app is serving traffic before considering the upgrade complete.`, + ), + ); + } else { + // 11b. Safe/Timelock: route the upgradeApp calldata through identity router. + // The first execution in the batch is always the upgradeApp call. + const upgradeCallData = prepared.data.executions[0]!.callData; + const result = await executeWithIdentity({ + environment, + eoaAddress: address, + walletClient, + publicClient, + environmentConfig, + to: environmentConfig.appControllerAddress as Address, + data: upgradeCallData, + pendingMessage: `Upgrading app ${appID}...`, + txDescription: "UpgradeApp", + gas: finalTx, + delayOverride: flags.delay, + }); + + this.log(""); + printTransactionResult(result, this.log.bind(this)); + if (result.type === "direct") { + this.log(`\n✅ ${chalk.green(`App upgrade submitted (image: ${prepared.imageRef})`)}`); + } + } }); } } diff --git a/packages/cli/src/commands/compute/team/grant.ts b/packages/cli/src/commands/compute/team/grant.ts new file mode 100644 index 00000000..92025514 --- /dev/null +++ b/packages/cli/src/commands/compute/team/grant.ts @@ -0,0 +1,151 @@ +import { Command, Args, Flags } from "@oclif/core"; +import { + getEnvironmentConfig, + isMainnet, + TeamRole, + estimateTransactionGas, + encodeGrantTeamRoleData, + getAppOwner, +} from "@layr-labs/ecloud-sdk"; +import { withTelemetry } from "../../../telemetry"; +import { commonFlags, timelockFlags, applyTxOverrides } from "../../../flags"; +import { createComputeClient } from "../../../client"; +import { getOrPromptAppID, getPrivateKeyInteractive, confirm } from "../../../utils/prompts"; +import { createViemClients } from "../../../utils/viemClients"; +import { printIdentityContext, executeWithIdentity, printTransactionResult } from "../../../utils/identityTransaction"; +import { handleTimelockExecute, handleTimelockCancel } from "../../../utils/timelockExecute"; +import { isAddress } from "viem"; +import type { Address } from "viem"; +import chalk from "chalk"; + +const ROLE_CHOICES = ["ADMIN", "PAUSER", "DEVELOPER"] as const; +type RoleChoice = (typeof ROLE_CHOICES)[number]; + +export default class TeamGrant extends Command { + static description = "Grant a team role (ADMIN, PAUSER, or DEVELOPER) to an address for an app's team"; + + static args = { + address: Args.string({ + description: "Address to grant the role to", + required: false, + }), + }; + + static flags = { + ...commonFlags, + app: Flags.string({ + required: false, + description: "App ID (used to look up the team owner)", + env: "ECLOUD_APP_ID", + }), + role: Flags.string({ + required: false, + description: "Role to grant: ADMIN, PAUSER, or DEVELOPER", + options: ROLE_CHOICES as unknown as string[], + env: "ECLOUD_TEAM_ROLE", + }), + force: Flags.boolean({ + description: "Skip all confirmation prompts", + default: false, + }), + ...timelockFlags, + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(TeamGrant); + + const environment = flags.environment; + const environmentConfig = getEnvironmentConfig(environment); + const rpcUrl = flags["rpc-url"] || environmentConfig.defaultRPCURL; + const privateKey = await getPrivateKeyInteractive(flags["private-key"]); + + if (flags.execute) { + await handleTimelockExecute({ opId: flags.execute, environment, privateKey, rpcUrl, log: this.log.bind(this), error: this.error.bind(this) }); + return; + } + if (flags.cancel) { + await handleTimelockCancel({ opId: flags.cancel, environment, privateKey, rpcUrl, log: this.log.bind(this), error: this.error.bind(this) }); + return; + } + + if (!flags.role) { + this.error("--role is required when not using --execute"); + } + + const account = args.address; + if (!account) { + this.error("ADDRESS argument is required when not using --execute or --cancel"); + } + if (!isAddress(account)) { + this.error(`Invalid address: ${account}`); + } + + const appID = await getOrPromptAppID({ + appID: flags.app, + environment, + privateKey, + rpcUrl, + action: "grant team role", + }); + + const role = TeamRole[flags.role as RoleChoice]; + this.log(`\nApp: ${chalk.bold(appID)}`); + this.log(`Grant: ${chalk.bold(flags.role)} → ${chalk.bold(account)}`); + + const { publicClient, walletClient, address } = createViemClients({ + privateKey, + rpcUrl, + environment, + }); + + const identity = printIdentityContext(environment, address, this.log.bind(this)); + + if ((isMainnet(environmentConfig) || identity.type !== "eoa") && !flags.force) { + const confirmed = await confirm(`Grant ${flags.role} role?`); + if (!confirmed) { + this.log(`\n${chalk.gray("Cancelled")}`); + return; + } + } + + if (identity.type === "eoa") { + const compute = await createComputeClient(flags); + const res = await compute.app.grantTeamRole(appID, role, account); + this.log(`\n✅ ${chalk.green(`${flags.role} role granted to ${account} (tx: ${res.tx})`)}`); + } else { + const team = await getAppOwner(publicClient, environmentConfig, appID as Address); + const callData = encodeGrantTeamRoleData(team, role, account as Address); + const estimate = identity.type === "eoa" + ? await estimateTransactionGas({ + publicClient, + from: address, + to: environmentConfig.appControllerAddress, + data: callData, + }) + : undefined; + const finalTx = estimate ? await applyTxOverrides(estimate, flags, { publicClient, address }) : undefined; + + const result = await executeWithIdentity({ + environment, + eoaAddress: address, + walletClient, + publicClient, + environmentConfig, + to: environmentConfig.appControllerAddress as Address, + data: callData, + pendingMessage: `Granting ${flags.role} role to ${account}...`, + txDescription: "GrantTeamRole", + gas: finalTx, + delayOverride: flags.delay, + }); + + this.log(""); + printTransactionResult(result, this.log.bind(this)); + if (result.type === "direct") { + this.log(`\n✅ ${chalk.green(`${flags.role} role granted to ${account}`)}`); + } + } + }); + } +} diff --git a/packages/cli/src/commands/compute/team/list.ts b/packages/cli/src/commands/compute/team/list.ts new file mode 100644 index 00000000..3b1aa38a --- /dev/null +++ b/packages/cli/src/commands/compute/team/list.ts @@ -0,0 +1,65 @@ +import { Command, Flags } from "@oclif/core"; +import { getEnvironmentConfig, TeamRole } from "@layr-labs/ecloud-sdk"; +import { withTelemetry } from "../../../telemetry"; +import { commonFlags } from "../../../flags"; +import { createComputeClient } from "../../../client"; +import { getOrPromptAppID } from "../../../utils/prompts"; +import chalk from "chalk"; + +export default class TeamList extends Command { + static description = "List team role members (ADMIN, PAUSER, DEVELOPER) for an app"; + + static flags = { + ...commonFlags, + app: Flags.string({ + required: false, + description: "App ID", + env: "ECLOUD_APP_ID", + }), + }; + + async run() { + return withTelemetry(this, async () => { + const { flags } = await this.parse(TeamList); + const compute = await createComputeClient(flags); + + const environment = flags.environment; + const environmentConfig = getEnvironmentConfig(environment); + const rpcUrl = flags["rpc-url"] || environmentConfig.defaultRPCURL; + const privateKey = flags["private-key"]!; + + const appID = await getOrPromptAppID({ + appID: flags.app, + environment, + privateKey, + rpcUrl, + action: "list team", + }); + + const [admins, pausers, developers] = await Promise.all([ + compute.app.getTeamRoleMembers(appID, TeamRole.ADMIN), + compute.app.getTeamRoleMembers(appID, TeamRole.PAUSER), + compute.app.getTeamRoleMembers(appID, TeamRole.DEVELOPER), + ]); + + this.log(`\nApp: ${chalk.bold(appID)}`); + this.log(""); + + const printRole = (label: string, members: string[]) => { + this.log(` ${chalk.bold(label)}`); + if (members.length === 0) { + this.log(` ${chalk.gray("(none)")}`); + } else { + for (const m of members) { + this.log(` ${m}`); + } + } + }; + + printRole("ADMIN", admins); + printRole("PAUSER", pausers); + printRole("DEVELOPER", developers); + this.log(""); + }); + } +} diff --git a/packages/cli/src/commands/compute/team/revoke.ts b/packages/cli/src/commands/compute/team/revoke.ts new file mode 100644 index 00000000..e160d626 --- /dev/null +++ b/packages/cli/src/commands/compute/team/revoke.ts @@ -0,0 +1,143 @@ +import { Command, Args, Flags } from "@oclif/core"; +import { + getEnvironmentConfig, + isMainnet, + TeamRole, + encodeRevokeTeamRoleData, + getAppOwner, +} from "@layr-labs/ecloud-sdk"; +import { withTelemetry } from "../../../telemetry"; +import { commonFlags, timelockFlags } from "../../../flags"; +import { createComputeClient } from "../../../client"; +import { getOrPromptAppID, getPrivateKeyInteractive, confirm } from "../../../utils/prompts"; +import { createViemClients } from "../../../utils/viemClients"; +import { printIdentityContext, executeWithIdentity, printTransactionResult } from "../../../utils/identityTransaction"; +import { handleTimelockExecute, handleTimelockCancel } from "../../../utils/timelockExecute"; +import { isAddress } from "viem"; +import type { Address } from "viem"; +import chalk from "chalk"; + +const ROLE_CHOICES = ["PAUSER", "DEVELOPER"] as const; +type RoleChoice = (typeof ROLE_CHOICES)[number]; + +export default class TeamRevoke extends Command { + static description = "Revoke a team role (PAUSER or DEVELOPER) from an address"; + + static args = { + address: Args.string({ + description: "Address to revoke the role from", + required: false, + }), + }; + + static flags = { + ...commonFlags, + app: Flags.string({ + required: false, + description: "App ID (used to look up the team owner)", + env: "ECLOUD_APP_ID", + }), + role: Flags.string({ + required: false, + description: "Role to revoke: PAUSER or DEVELOPER", + options: ROLE_CHOICES as unknown as string[], + env: "ECLOUD_TEAM_ROLE", + }), + force: Flags.boolean({ + description: "Skip all confirmation prompts", + default: false, + }), + ...timelockFlags, + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(TeamRevoke); + + const environment = flags.environment; + const environmentConfig = getEnvironmentConfig(environment); + const rpcUrl = flags["rpc-url"] || environmentConfig.defaultRPCURL; + const privateKey = await getPrivateKeyInteractive(flags["private-key"]); + + if (flags.execute) { + await handleTimelockExecute({ opId: flags.execute, environment, privateKey, rpcUrl, log: this.log.bind(this), error: this.error.bind(this) }); + return; + } + if (flags.cancel) { + await handleTimelockCancel({ opId: flags.cancel, environment, privateKey, rpcUrl, log: this.log.bind(this), error: this.error.bind(this) }); + return; + } + + if (!flags.role) { + this.error("--role is required when not using --execute"); + } + + const account = args.address; + if (!account) { + this.error("ADDRESS argument is required when not using --execute or --cancel"); + } + if (!isAddress(account)) { + this.error(`Invalid address: ${account}`); + } + + const appID = await getOrPromptAppID({ + appID: flags.app, + environment, + privateKey, + rpcUrl, + action: "revoke team role", + }); + + const role = TeamRole[flags.role as RoleChoice]; + + this.log(`\nApp: ${chalk.bold(appID)}`); + this.log(`Revoke: ${chalk.bold(flags.role)} from ${chalk.bold(account)}`); + + const { publicClient, walletClient, address } = createViemClients({ + privateKey, + rpcUrl, + environment, + }); + + const identity = printIdentityContext(environment, address, this.log.bind(this)); + + if ((isMainnet(environmentConfig) || identity.type !== "eoa") && !flags.force) { + const confirmed = await confirm(`Revoke ${flags.role} role?`); + if (!confirmed) { + this.log(`\n${chalk.gray("Cancelled")}`); + return; + } + } + + if (identity.type === "eoa") { + const compute = await createComputeClient(flags); + const res = await compute.app.revokeTeamRole(appID, role, account); + this.log(`\n✅ ${chalk.green(`${flags.role} role revoked from ${account} (tx: ${res.tx})`)}`); + } else { + const team = await getAppOwner(publicClient, environmentConfig, appID as Address); + const callData = encodeRevokeTeamRoleData(team, role, account as Address); + const finalTx = undefined; // skip gas estimation — msg.sender will be Safe/Timelock, not EOA + + const result = await executeWithIdentity({ + environment, + eoaAddress: address, + walletClient, + publicClient, + environmentConfig, + to: environmentConfig.appControllerAddress as Address, + data: callData, + pendingMessage: `Revoking ${flags.role} role from ${account}...`, + txDescription: "RevokeTeamRole", + gas: finalTx, + delayOverride: flags.delay, + }); + + this.log(""); + printTransactionResult(result, this.log.bind(this)); + if (result.type === "direct") { + this.log(`\n✅ ${chalk.green(`${flags.role} role revoked from ${account}`)}`); + } + } + }); + } +} diff --git a/packages/cli/src/flags.ts b/packages/cli/src/flags.ts index a64e7680..833acfef 100644 --- a/packages/cli/src/flags.ts +++ b/packages/cli/src/flags.ts @@ -53,6 +53,21 @@ export const commonFlags = { }), }; +export const timelockFlags = { + execute: Flags.string({ + description: "Execute a ready Timelock operation by its op ID", + required: false, + }), + cancel: Flags.string({ + description: "Cancel a pending Timelock operation by its op ID", + required: false, + }), + delay: Flags.string({ + description: "Override Timelock delay (e.g., 10m, 1h, 2d). Must be >= minDelay. Defaults to minDelay.", + required: false, + }), +}; + /** * Apply user-provided gas and nonce overrides to an estimated GasEstimate. * If the user passed --max-fee-per-gas or --max-priority-fee, those values diff --git a/packages/cli/src/utils/apiIdentity.ts b/packages/cli/src/utils/apiIdentity.ts new file mode 100644 index 00000000..7de9f652 --- /dev/null +++ b/packages/cli/src/utils/apiIdentity.ts @@ -0,0 +1,37 @@ +import type { Address } from "viem"; +import { getActiveIdentity, getIdentities } from "./globalConfig"; + +/** + * Figure out which on-chain identities the CLI should declare to UserAPI via + * X-eigenx-identity. Rules: + * + * - Only non-EOA identities (Safe, Timelock, Timelock(Safe)) are returned. + * The EOA has no independent identity claim — the server recovers it from + * the auth signature. + * - For commands that act on a single app the "active" identity is the one + * to declare; use `identityForActiveContext` and pass the result. + * - For commands that enumerate apps across every identity the caller owns + * (e.g., `app list`), use `identityForAllContexts` — all non-EOA identities + * for the current environment are returned so the server can authorize + * across them in one request. + */ + +/** + * The active identity for this environment, as a header-friendly list. + * Returns [] if the active identity is the EOA or if no identity is set. + */ +export function identityForActiveContext(environment: string): Address[] { + const active = getActiveIdentity(environment); + if (!active || active.type === "eoa") return []; + return [active.address as Address]; +} + +/** + * Every non-EOA identity the caller has stored for this environment. + * Returns [] if only EOA identities exist. + */ +export function identityForAllContexts(environment: string): Address[] { + return getIdentities() + .filter((id) => id.type !== "eoa" && id.environment === environment) + .map((id) => id.address as Address); +} diff --git a/packages/cli/src/utils/appResolver.ts b/packages/cli/src/utils/appResolver.ts index c92aaea2..992d9494 100644 --- a/packages/cli/src/utils/appResolver.ts +++ b/packages/cli/src/utils/appResolver.ts @@ -24,6 +24,7 @@ import { resolveAppIDFromRegistry, } from "./appNames"; import { getClientId } from "./version"; +import { identityForAllContexts } from "./apiIdentity"; const CHUNK_SIZE = 10; @@ -242,12 +243,14 @@ export class AppResolver { return; } - // Fetch info for all apps to get profile names + // Fetch info for all apps to get profile names. Apps may be owned by any + // of the user's identities, so declare all non-EOA identities so the + // platform evaluates permissions across them in one batched request. const userApiClient = new UserApiClient( this.environmentConfig, walletClient, publicClient, - { clientId: getClientId() }, + { clientId: getClientId(), identities: identityForAllContexts(this.environment) }, ); const appInfos = await getAppInfosChunked(userApiClient, apps); diff --git a/packages/cli/src/utils/contractAbis.ts b/packages/cli/src/utils/contractAbis.ts new file mode 100644 index 00000000..915230b2 --- /dev/null +++ b/packages/cli/src/utils/contractAbis.ts @@ -0,0 +1,65 @@ +/** + * Shared contract ABIs and on-chain read helpers for identity management. + */ + +import type { Address, PublicClient } from "viem"; +import { formatDelay } from "./format"; + +export const SAFE_ABI = [ + { name: "getThreshold", type: "function", inputs: [], outputs: [{ type: "uint256" }], stateMutability: "view" }, + { name: "getOwners", type: "function", inputs: [], outputs: [{ type: "address[]" }], stateMutability: "view" }, +] as const; + +export const TIMELOCK_ABI = [ + { name: "getMinDelay", type: "function", inputs: [], outputs: [{ type: "uint256" }], stateMutability: "view" }, + { name: "hasRole", type: "function", inputs: [{ type: "bytes32" }, { type: "address" }], outputs: [{ type: "bool" }], stateMutability: "view" }, + { name: "execute", type: "function", inputs: [{ name: "target", type: "address" }, { name: "value", type: "uint256" }, { name: "payload", type: "bytes" }, { name: "predecessor", type: "bytes32" }, { name: "salt", type: "bytes32" }], outputs: [], stateMutability: "payable" }, + { name: "cancel", type: "function", inputs: [{ name: "id", type: "bytes32" }], outputs: [], stateMutability: "nonpayable" }, +] as const; + +// keccak256("PROPOSER_ROLE") +const PROPOSER_ROLE = "0xb09aa5aeb3702cfd50b6b62bc4532604938f21248a27a1d5ca736082b6819cc1" as const; + +/** Check if a candidate address has PROPOSER_ROLE on a TimelockController. */ +export async function isTimelockProposer( + publicClient: PublicClient, + timelock: Address, + candidate: Address, +): Promise { + try { + return await publicClient.readContract({ + address: timelock, abi: TIMELOCK_ABI, functionName: "hasRole", args: [PROPOSER_ROLE, candidate], + }) as boolean; + } catch { + return false; + } +} + +/** Fetch threshold and owners for a Gnosis Safe. Returns undefined fields on failure. */ +export async function fetchSafeInfo( + publicClient: PublicClient, + safe: Address, +): Promise<{ threshold: number | undefined; owners: string[] | undefined }> { + try { + const [t, o] = await Promise.all([ + publicClient.readContract({ address: safe, abi: SAFE_ABI, functionName: "getThreshold" }) as Promise, + publicClient.readContract({ address: safe, abi: SAFE_ABI, functionName: "getOwners" }) as Promise, + ]); + return { threshold: Number(t), owners: o.map(String) }; + } catch { + return { threshold: undefined, owners: undefined }; + } +} + +/** Fetch getMinDelay() from a Timelock and return as human-readable string (e.g. "24h"). */ +export async function fetchTimelockDelay( + publicClient: PublicClient, + timelock: Address, +): Promise { + try { + const minDelay = await publicClient.readContract({ address: timelock, abi: TIMELOCK_ABI, functionName: "getMinDelay" }) as bigint; + return formatDelay(minDelay); + } catch { + return "unknown"; + } +} diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index 3b9164d6..01f3746e 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -175,6 +175,30 @@ export function formatAppDisplay(options: FormatAppDisplayOptions): FormattedApp }; } +/** + * Convert a timelock minimum delay (seconds as bigint) to a human-readable string. + * Uses the largest even unit without remainder, falling back to seconds. + * Examples: 3600n → "1h", 86400n → "1d", 90000n → "25h", 60n → "1m", 45n → "45s" + */ +export function formatDelay(seconds: bigint): string { + const s = Number(seconds); + if (s % 86400 === 0 && s >= 86400) return `${s / 86400}d`; + if (s % 3600 === 0 && s >= 3600) return `${s / 3600}h`; + if (s % 60 === 0 && s >= 60) return `${s / 60}m`; + return `${s}s`; +} + +export function formatCountdown(seconds: bigint): string { + const s = Number(seconds); + if (s <= 0) return "now"; + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const rem = s % 60; + if (h > 0) return `${h}h ${m}m`; + if (m > 0) return `${m}m ${rem}s`; + return `${rem}s`; +} + /** * Print formatted app display with given indent * @param display - Formatted app display data diff --git a/packages/cli/src/utils/globalConfig.ts b/packages/cli/src/utils/globalConfig.ts index 9618d310..9f9f65f7 100644 --- a/packages/cli/src/utils/globalConfig.ts +++ b/packages/cli/src/utils/globalConfig.ts @@ -23,6 +23,21 @@ export interface ProfileCacheEntry { profiles: { [appId: string]: string }; // appId -> profile name } +export interface StoredIdentity { + type: "eoa" | "safe" | "timelock"; + address: string; + /** Present for safe/timelock — the chain they were deployed on */ + environment?: string; + /** Timelock minimum delay in human-readable form, e.g. "24h" */ + delay?: string; + /** For Timelock(Safe): the underlying Safe address */ + safeAddress?: string; + /** For Safe: signing threshold, e.g. 2 */ + threshold?: number; + /** For Safe: owner addresses */ + owners?: string[]; +} + export interface GlobalConfig { first_run?: boolean; telemetry_enabled?: boolean; @@ -38,6 +53,12 @@ export interface GlobalConfig { [directoryPath: string]: string; }; }; + /** All known identities (EOA, Safe, Timelock) */ + identities?: StoredIdentity[]; + /** Active identity address per environment. EOA address means EOA flow. */ + active_identity?: { + [environment: string]: string; + }; } // Profile cache TTL: 24 hours in milliseconds @@ -371,3 +392,114 @@ export function saveUserUUID(userUUID: string): void { saveGlobalConfig(config); } } + +// ==================== Identity Functions ==================== + +/** + * Get all stored identities + */ +export function getIdentities(): StoredIdentity[] { + const config = loadGlobalConfig(); + return config.identities || []; +} + +/** + * Add an identity to the list (no-op if address already exists) + */ +export function addIdentity(identity: StoredIdentity): void { + const config = loadGlobalConfig(); + if (!config.identities) config.identities = []; + const exists = config.identities.some( + (id) => id.address.toLowerCase() === identity.address.toLowerCase(), + ); + if (!exists) { + config.identities.push(identity); + } + saveGlobalConfig(config); +} + +/** + * Get the active identity address for an environment, or null if none set + */ +export function getActiveIdentityAddress(environment: string): string | null { + const config = loadGlobalConfig(); + return config.active_identity?.[environment] ?? null; +} + +/** + * Get the full active identity object for an environment, or null + */ +export function getActiveIdentity(environment: string): StoredIdentity | null { + const address = getActiveIdentityAddress(environment); + if (!address) return null; + const config = loadGlobalConfig(); + return ( + config.identities?.find((id) => id.address.toLowerCase() === address.toLowerCase()) ?? null + ); +} + +/** + * Set the active identity for an environment + */ +export function setActiveIdentity(environment: string, address: string): void { + const config = loadGlobalConfig(); + if (!config.active_identity) config.active_identity = {}; + config.active_identity[environment] = address; + saveGlobalConfig(config); +} + +/** + * Replace all stored identities with a new list (used when switching signing key) + */ +export function replaceAllIdentities(identities: StoredIdentity[]): void { + const config = loadGlobalConfig(); + config.identities = identities; + saveGlobalConfig(config); +} + +/** + * Remove a single identity by address + */ +export function removeIdentity(address: string): void { + const config = loadGlobalConfig(); + config.identities = (config.identities || []).filter( + (id) => id.address.toLowerCase() !== address.toLowerCase(), + ); + saveGlobalConfig(config); +} + +/** + * Clear the active identity for an environment (logout) + */ +export function clearActiveIdentity(environment: string): void { + const config = loadGlobalConfig(); + if (config.active_identity) { + delete config.active_identity[environment]; + saveGlobalConfig(config); + } +} + +/** + * Format a stored identity for display + */ +export function formatIdentity(id: StoredIdentity, verbose = false): string { + const short = verbose ? id.address : id.address.slice(0, 6) + "..." + id.address.slice(-4); + if (id.type === "eoa") return `${short} (EOA)`; + if (id.type === "safe") { + let safeInfo = "Safe"; + if (id.threshold != null && id.owners != null) { + const ownerSummary = id.owners.length <= 3 + ? id.owners.map((o) => verbose ? o : `${o.slice(0, 6)}…${o.slice(-4)}`).join(", ") + : `${id.owners.slice(0, 2).map((o) => verbose ? o : `${o.slice(0, 6)}…${o.slice(-4)}`).join(", ")} +${id.owners.length - 2} more`; + safeInfo = `Safe ${id.threshold}/${id.owners.length} · ${ownerSummary}`; + } + return `${short} (${safeInfo}${id.environment ? ` · ${id.environment}` : ""})`; + } + if (id.type === "timelock") { + const via = id.safeAddress + ? `via Safe ${verbose ? id.safeAddress : id.safeAddress.slice(0, 6) + "..." + id.safeAddress.slice(-4)}` + : "via EOA"; + return `${short} (Timelock ${id.delay ?? ""} · ${via}${id.environment ? ` · ${id.environment}` : ""})`; + } + return short; +} diff --git a/packages/cli/src/utils/identityTransaction.ts b/packages/cli/src/utils/identityTransaction.ts new file mode 100644 index 00000000..8ace6b79 --- /dev/null +++ b/packages/cli/src/utils/identityTransaction.ts @@ -0,0 +1,115 @@ +/** + * Identity-aware transaction utilities for CLI commands + * + * Shared logic for reading the active identity and formatting results. + */ + +import { + sendWithIdentity, + formatTransactionResult, + type TransactionResult, + type IdentityRouterOptions, +} from "@layr-labs/ecloud-sdk"; +import { + getActiveIdentity, + getIdentities, + type StoredIdentity, +} from "./globalConfig"; +import type { Address, Hex, PublicClient, WalletClient } from "viem"; +import type { EnvironmentConfig } from "@layr-labs/ecloud-sdk"; +import chalk from "chalk"; + +/** + * Get the active identity for the current environment. + * Falls back to EOA (signing key address) if no identity is configured. + */ +export function getActiveIdentityOrEOA(environment: string, eoaAddress: string): StoredIdentity { + const active = getActiveIdentity(environment); + if (active) return active; + + // No active identity — fall back to EOA + return { type: "eoa", address: eoaAddress }; +} + +/** + * Execute a transaction using the active identity. + * Routes to direct send, Safe proposal, or Timelock schedule based on identity type. + */ +export async function executeWithIdentity(options: { + environment: string; + eoaAddress: string; + walletClient: WalletClient; + publicClient: PublicClient; + environmentConfig: any; + to: Address; + data: Hex; + value?: bigint; + pendingMessage?: string; + txDescription?: string; + gas?: any; + delayOverride?: string; +}): Promise { + const identity = getActiveIdentityOrEOA(options.environment, options.eoaAddress); + + return sendWithIdentity({ + identity: { + type: identity.type, + address: identity.address, + delay: identity.delay, + safeAddress: identity.safeAddress, + }, + walletClient: options.walletClient, + publicClient: options.publicClient, + environmentConfig: options.environmentConfig, + to: options.to, + data: options.data, + value: options.value, + environment: options.environment, + pendingMessage: options.pendingMessage, + txDescription: options.txDescription, + gas: options.gas, + delayOverride: options.delayOverride, + }); +} + +/** + * Print the result of an identity-aware transaction + */ +export function printTransactionResult( + result: TransactionResult, + log: (msg: string) => void, +): void { + const lines = formatTransactionResult(result); + for (const line of lines) { + log(line); + } +} + +/** + * Print a warning about which identity will be used for this transaction + */ +export function printIdentityContext( + environment: string, + eoaAddress: string, + log: (msg: string) => void, +): StoredIdentity { + const identity = getActiveIdentityOrEOA(environment, eoaAddress); + + switch (identity.type) { + case "eoa": + log(chalk.gray(`Identity: EOA ${identity.address.slice(0, 6)}...${identity.address.slice(-4)} (direct transaction)`)); + break; + case "safe": + log(chalk.gray(`Identity: Safe ${identity.address.slice(0, 6)}...${identity.address.slice(-4)} (will propose to Safe)`)); + break; + case "timelock": + if (identity.safeAddress) { + log(chalk.gray(`Identity: Timelock(Safe) ${identity.address.slice(0, 6)}...${identity.address.slice(-4)} (will propose schedule to Safe)`)); + } else { + log(chalk.gray(`Identity: Timelock ${identity.address.slice(0, 6)}...${identity.address.slice(-4)} (will schedule with ${identity.delay || "24h"} delay)`)); + } + break; + } + + return identity; +} diff --git a/packages/cli/src/utils/prompts.ts b/packages/cli/src/utils/prompts.ts index 1c16c7cc..4813781a 100644 --- a/packages/cli/src/utils/prompts.ts +++ b/packages/cli/src/utils/prompts.ts @@ -40,6 +40,7 @@ import { import { listApps, isAppNameAvailable, findAvailableName } from "./appNames"; import { execSync } from "child_process"; import { getClientId } from "./version"; +import { identityForAllContexts } from "./apiIdentity"; // Helper to add hex prefix function addHexPrefix(value: string): Hex { @@ -1179,6 +1180,7 @@ async function getAppIDInteractive(options: GetAppIDOptions): Promise
{ try { const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { clientId: getClientId(), + identities: identityForAllContexts(environment), }); const appInfos = await getAppInfosChunked(userApiClient, apps); diff --git a/packages/cli/src/utils/timelockExecute.ts b/packages/cli/src/utils/timelockExecute.ts new file mode 100644 index 00000000..85288d37 --- /dev/null +++ b/packages/cli/src/utils/timelockExecute.ts @@ -0,0 +1,134 @@ +import { + getPendingTimelockOps, + executeTimelockOp, + proposeSafeTransaction, + getEnvironmentConfig, +} from "@layr-labs/ecloud-sdk"; +import { encodeFunctionData } from "viem"; +import type { Address, Hex, PublicClient, WalletClient } from "viem"; +import { createViemClients } from "./viemClients"; +import { getActiveIdentityOrEOA } from "./identityTransaction"; +import { TIMELOCK_ABI } from "./contractAbis"; +import { formatCountdown } from "./format"; +import chalk from "chalk"; + +export interface TimelockExecuteOptions { + opId: string; + environment: string; + privateKey: string; + rpcUrl: string; + log: (msg: string) => void; + error: (msg: string) => never; +} + +export async function handleTimelockExecute(options: TimelockExecuteOptions): Promise { + const { opId, environment, privateKey, rpcUrl, log, error } = options; + const environmentConfig = getEnvironmentConfig(environment); + const { publicClient, walletClient, address } = createViemClients({ privateKey, rpcUrl, environment }); + const identity = getActiveIdentityOrEOA(environment, address); + + if (identity.type !== "timelock") { + error("--execute requires a Timelock identity to be active. Run 'ecloud auth identity select'."); + } + + const timelockAddress = identity.address as Address; + const ops = await getPendingTimelockOps(publicClient, timelockAddress); + const op = ops.find((o) => o.id.toLowerCase() === opId.toLowerCase()); + + if (!op) { + error(`No pending operation found with ID ${opId} on Timelock ${timelockAddress}`); + } + if (!op.ready) { + const now = BigInt(Math.floor(Date.now() / 1000)); + const remaining = op.executableAt - now; + error(`Operation is not yet ready. Executable in ${formatCountdown(remaining)}.`); + } + + log(chalk.gray(`Executing Timelock op: ${op.description}`)); + log(chalk.gray(`Timelock: ${timelockAddress}`)); + log(""); + + const ZERO_BYTES32 = "0x0000000000000000000000000000000000000000000000000000000000000000" as Hex; + const executeData = encodeFunctionData({ + abi: TIMELOCK_ABI, + functionName: "execute", + args: [ + environmentConfig.appControllerAddress as Address, + 0n, + op.calldata, + ZERO_BYTES32, + ZERO_BYTES32, + ], + }); + + if (identity.safeAddress) { + const proposal = await proposeSafeTransaction({ + walletClient, + publicClient, + safeAddress: identity.safeAddress as Address, + to: timelockAddress, + data: executeData, + environment, + }); + log(`✓ Proposed execute to Safe ${identity.safeAddress}`); + log(` Safe tx hash: ${proposal.safeTxHash}`); + log(`\n Approve at: ${proposal.safeUrl}`); + } else { + const txHash = await executeTimelockOp( + { walletClient, publicClient, environmentConfig, timelockAddress, calldata: op.calldata }, + ); + log(`\n✅ ${chalk.green(`Timelock operation executed`)} tx: ${txHash}`); + } +} + +export async function handleTimelockCancel(options: TimelockExecuteOptions): Promise { + const { opId, environment, privateKey, rpcUrl, log, error } = options; + const environmentConfig = getEnvironmentConfig(environment); + const { publicClient, walletClient, address } = createViemClients({ privateKey, rpcUrl, environment }); + const identity = getActiveIdentityOrEOA(environment, address); + + if (identity.type !== "timelock") { + error("--cancel requires a Timelock identity to be active. Run 'ecloud auth identity select'."); + } + + const timelockAddress = identity.address as Address; + const ops = await getPendingTimelockOps(publicClient, timelockAddress); + const op = ops.find((o) => o.id.toLowerCase() === opId.toLowerCase()); + + if (!op) { + error(`No pending operation found with ID ${opId} on Timelock ${timelockAddress}`); + } + + log(chalk.gray(`Cancelling Timelock op: ${op.description}`)); + log(chalk.gray(`Timelock: ${timelockAddress}`)); + log(""); + + const cancelData = encodeFunctionData({ + abi: TIMELOCK_ABI, + functionName: "cancel", + args: [opId as Hex], + }); + + if (identity.safeAddress) { + const proposal = await proposeSafeTransaction({ + walletClient, + publicClient, + safeAddress: identity.safeAddress as Address, + to: timelockAddress, + data: cancelData, + environment, + }); + log(`✓ Proposed cancel to Safe ${identity.safeAddress}`); + log(` Safe tx hash: ${proposal.safeTxHash}`); + log(`\n Approve at: ${proposal.safeUrl}`); + } else { + const txHash = await walletClient.sendTransaction({ + to: timelockAddress, + data: cancelData, + chain: walletClient.chain, + account: walletClient.account!, + }); + await publicClient.waitForTransactionReceipt({ hash: txHash }); + log(`\n✅ ${chalk.green(`Timelock operation cancelled`)} tx: ${txHash}`); + } +} diff --git a/packages/sdk/package.json b/packages/sdk/package.json index b922f1bc..5cb6de1e 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -45,6 +45,7 @@ "lint": "eslint .", "format": "prettier --check .", "format:fix": "prettier --write .", + "test": "vitest run", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/packages/sdk/src/client/common/abis/AppController.json b/packages/sdk/src/client/common/abis/AppController.json index 4608a2ed..8bb61548 100644 --- a/packages/sdk/src/client/common/abis/AppController.json +++ b/packages/sdk/src/client/common/abis/AppController.json @@ -10,27 +10,32 @@ { "name": "_permissionController", "type": "address", - "internalType": "contractIPermissionController" + "internalType": "contract IPermissionController" }, { "name": "_releaseManager", "type": "address", - "internalType": "contractIReleaseManager" + "internalType": "contract IReleaseManager" }, { "name": "_computeAVSRegistrar", "type": "address", - "internalType": "contractIComputeAVSRegistrar" + "internalType": "contract IComputeAVSRegistrar" }, { "name": "_computeOperator", "type": "address", - "internalType": "contractIComputeOperator" + "internalType": "contract IComputeOperator" }, { "name": "_appBeacon", "type": "address", - "internalType": "contractIBeacon" + "internalType": "contract IBeacon" + }, + { + "name": "_safeTimelockFactory", + "type": "address", + "internalType": "contract ISafeTimelockFactory" } ], "stateMutability": "nonpayable" @@ -48,6 +53,19 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "DEFAULT_ADMIN_ROLE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "appBeacon", @@ -56,7 +74,7 @@ { "name": "", "type": "address", - "internalType": "contractIBeacon" + "internalType": "contract IBeacon" } ], "stateMutability": "view" @@ -104,7 +122,7 @@ { "name": "", "type": "address", - "internalType": "contractIApp" + "internalType": "contract IApp" } ], "stateMutability": "view" @@ -117,7 +135,7 @@ { "name": "", "type": "address", - "internalType": "contractIComputeAVSRegistrar" + "internalType": "contract IComputeAVSRegistrar" } ], "stateMutability": "view" @@ -130,7 +148,7 @@ { "name": "", "type": "address", - "internalType": "contractIComputeOperator" + "internalType": "contract IComputeOperator" } ], "stateMutability": "view" @@ -147,17 +165,17 @@ { "name": "release", "type": "tuple", - "internalType": "structIAppController.Release", + "internalType": "struct IAppController.Release", "components": [ { "name": "rmsRelease", "type": "tuple", - "internalType": "structIReleaseManagerTypes.Release", + "internalType": "struct IReleaseManagerTypes.Release", "components": [ { "name": "artifacts", "type": "tuple[]", - "internalType": "structIReleaseManagerTypes.Artifact[]", + "internalType": "struct IReleaseManagerTypes.Artifact[]", "components": [ { "name": "digest", @@ -195,15 +213,20 @@ { "name": "app", "type": "address", - "internalType": "contractIApp" + "internalType": "contract IApp" } ], "stateMutability": "nonpayable" }, { "type": "function", - "name": "createAppWithIsolatedBilling", + "name": "createAppForTeam", "inputs": [ + { + "name": "team", + "type": "address", + "internalType": "address" + }, { "name": "salt", "type": "bytes32", @@ -212,17 +235,17 @@ { "name": "release", "type": "tuple", - "internalType": "structIAppController.Release", + "internalType": "struct IAppController.Release", "components": [ { "name": "rmsRelease", "type": "tuple", - "internalType": "structIReleaseManagerTypes.Release", + "internalType": "struct IReleaseManagerTypes.Release", "components": [ { "name": "artifacts", "type": "tuple[]", - "internalType": "structIReleaseManagerTypes.Artifact[]", + "internalType": "struct IReleaseManagerTypes.Artifact[]", "components": [ { "name": "digest", @@ -260,7 +283,7 @@ { "name": "app", "type": "address", - "internalType": "contractIApp" + "internalType": "contract IApp" } ], "stateMutability": "nonpayable" @@ -299,95 +322,95 @@ }, { "type": "function", - "name": "getBillingType", + "name": "getAppLatestReleaseBlockNumber", "inputs": [ { "name": "app", "type": "address", - "internalType": "address" + "internalType": "contract IApp" } ], "outputs": [ { "name": "", - "type": "uint8", - "internalType": "uint8" + "type": "uint32", + "internalType": "uint32" } ], "stateMutability": "view" }, { "type": "function", - "name": "getAppCreator", + "name": "getAppOperatorSetId", "inputs": [ { "name": "app", "type": "address", - "internalType": "contractIApp" + "internalType": "contract IApp" } ], "outputs": [ { "name": "", - "type": "address", - "internalType": "address" + "type": "uint32", + "internalType": "uint32" } ], "stateMutability": "view" }, { "type": "function", - "name": "getAppLatestReleaseBlockNumber", + "name": "getAppOwner", "inputs": [ { "name": "app", "type": "address", - "internalType": "contractIApp" + "internalType": "contract IApp" } ], "outputs": [ { "name": "", - "type": "uint32", - "internalType": "uint32" + "type": "address", + "internalType": "address" } ], "stateMutability": "view" }, { "type": "function", - "name": "getAppOperatorSetId", + "name": "getAppStatus", "inputs": [ { "name": "app", "type": "address", - "internalType": "contractIApp" + "internalType": "contract IApp" } ], "outputs": [ { "name": "", - "type": "uint32", - "internalType": "uint32" + "type": "uint8", + "internalType": "enum IAppController.AppStatus" } ], "stateMutability": "view" }, { "type": "function", - "name": "getAppStatus", + "name": "getAppTimelocked", "inputs": [ { "name": "app", "type": "address", - "internalType": "contractIApp" + "internalType": "contract IApp" } ], "outputs": [ { "name": "", - "type": "uint8", - "internalType": "enumIAppController.AppStatus" + "type": "bool", + "internalType": "bool" } ], "stateMutability": "view" @@ -411,15 +434,15 @@ { "name": "apps", "type": "address[]", - "internalType": "contractIApp[]" + "internalType": "contract IApp[]" }, { "name": "appConfigsMem", "type": "tuple[]", - "internalType": "structIAppController.AppConfig[]", + "internalType": "struct IAppController.AppConfig[]", "components": [ { - "name": "creator", + "name": "owner", "type": "address", "internalType": "address" }, @@ -436,7 +459,12 @@ { "name": "status", "type": "uint8", - "internalType": "enumIAppController.AppStatus" + "internalType": "enum IAppController.AppStatus" + }, + { + "name": "timelocked", + "type": "bool", + "internalType": "bool" } ] } @@ -445,10 +473,10 @@ }, { "type": "function", - "name": "getAppsByBillingAccount", + "name": "getAppsByCreator", "inputs": [ { - "name": "account", + "name": "creator", "type": "address", "internalType": "address" }, @@ -467,15 +495,15 @@ { "name": "apps", "type": "address[]", - "internalType": "contractIApp[]" + "internalType": "contract IApp[]" }, { "name": "appConfigsMem", "type": "tuple[]", - "internalType": "structIAppController.AppConfig[]", + "internalType": "struct IAppController.AppConfig[]", "components": [ { - "name": "creator", + "name": "owner", "type": "address", "internalType": "address" }, @@ -492,7 +520,12 @@ { "name": "status", "type": "uint8", - "internalType": "enumIAppController.AppStatus" + "internalType": "enum IAppController.AppStatus" + }, + { + "name": "timelocked", + "type": "bool", + "internalType": "bool" } ] } @@ -501,10 +534,10 @@ }, { "type": "function", - "name": "getAppsByCreator", + "name": "getAppsByDeveloper", "inputs": [ { - "name": "creator", + "name": "developer", "type": "address", "internalType": "address" }, @@ -523,15 +556,15 @@ { "name": "apps", "type": "address[]", - "internalType": "contractIApp[]" + "internalType": "contract IApp[]" }, { "name": "appConfigsMem", "type": "tuple[]", - "internalType": "structIAppController.AppConfig[]", + "internalType": "struct IAppController.AppConfig[]", "components": [ { - "name": "creator", + "name": "owner", "type": "address", "internalType": "address" }, @@ -548,7 +581,12 @@ { "name": "status", "type": "uint8", - "internalType": "enumIAppController.AppStatus" + "internalType": "enum IAppController.AppStatus" + }, + { + "name": "timelocked", + "type": "bool", + "internalType": "bool" } ] } @@ -557,10 +595,10 @@ }, { "type": "function", - "name": "getAppsByDeveloper", + "name": "getAppsForAccount", "inputs": [ { - "name": "developer", + "name": "account", "type": "address", "internalType": "address" }, @@ -577,34 +615,24 @@ ], "outputs": [ { - "name": "apps", - "type": "address[]", - "internalType": "contractIApp[]" - }, - { - "name": "appConfigsMem", + "name": "appRoles", "type": "tuple[]", - "internalType": "structIAppController.AppConfig[]", + "internalType": "struct IAppController.AppRoles[]", "components": [ { - "name": "creator", + "name": "app", "type": "address", - "internalType": "address" + "internalType": "contract IApp" }, { - "name": "operatorSetId", - "type": "uint32", - "internalType": "uint32" + "name": "isOwner", + "type": "bool", + "internalType": "bool" }, { - "name": "latestReleaseBlockNumber", - "type": "uint32", - "internalType": "uint32" - }, - { - "name": "status", - "type": "uint8", - "internalType": "enumIAppController.AppStatus" + "name": "roles", + "type": "uint8[]", + "internalType": "enum IAppController.TeamRole[]" } ] } @@ -630,6 +658,145 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "getRoleAdmin", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getRoleMember", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "index", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getRoleMemberCount", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getTeamRoleMember", + "inputs": [ + { + "name": "team", + "type": "address", + "internalType": "address" + }, + { + "name": "role", + "type": "uint8", + "internalType": "enum IAppController.TeamRole" + }, + { + "name": "index", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getTeamRoleMemberCount", + "inputs": [ + { + "name": "team", + "type": "address", + "internalType": "address" + }, + { + "name": "role", + "type": "uint8", + "internalType": "enum IAppController.TeamRole" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getTeamRoleMembers", + "inputs": [ + { + "name": "team", + "type": "address", + "internalType": "address" + }, + { + "name": "role", + "type": "uint8", + "internalType": "enum IAppController.TeamRole" + } + ], + "outputs": [ + { + "name": "", + "type": "address[]", + "internalType": "address[]" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "globalActiveAppCount", @@ -643,6 +810,100 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "grantRole", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "grantTeamRole", + "inputs": [ + { + "name": "team", + "type": "address", + "internalType": "address" + }, + { + "name": "role", + "type": "uint8", + "internalType": "enum IAppController.TeamRole" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "hasRole", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "hasTeamRole", + "inputs": [ + { + "name": "team", + "type": "address", + "internalType": "address" + }, + { + "name": "role", + "type": "uint8", + "internalType": "enum IAppController.TeamRole" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "initialize", @@ -669,6 +930,19 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "migrateAdmins", + "inputs": [ + { + "name": "apps", + "type": "address[]", + "internalType": "contract IApp[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "permissionController", @@ -677,7 +951,7 @@ { "name": "", "type": "address", - "internalType": "contractIPermissionController" + "internalType": "contract IPermissionController" } ], "stateMutability": "view" @@ -690,7 +964,97 @@ { "name": "", "type": "address", - "internalType": "contractIReleaseManager" + "internalType": "contract IReleaseManager" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "renounceRole", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "renounceTeamRole", + "inputs": [ + { + "name": "team", + "type": "address", + "internalType": "address" + }, + { + "name": "role", + "type": "uint8", + "internalType": "enum IAppController.TeamRole" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "revokeRole", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "revokeTeamRole", + "inputs": [ + { + "name": "team", + "type": "address", + "internalType": "address" + }, + { + "name": "role", + "type": "uint8", + "internalType": "enum IAppController.TeamRole" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "safeTimelockFactory", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract ISafeTimelockFactory" } ], "stateMutability": "view" @@ -733,7 +1097,7 @@ { "name": "app", "type": "address", - "internalType": "contractIApp" + "internalType": "contract IApp" } ], "outputs": [], @@ -746,12 +1110,31 @@ { "name": "app", "type": "address", - "internalType": "contractIApp" + "internalType": "contract IApp" } ], "outputs": [], "stateMutability": "nonpayable" }, + { + "type": "function", + "name": "supportsInterface", + "inputs": [ + { + "name": "interfaceId", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "suspend", @@ -764,7 +1147,7 @@ { "name": "apps", "type": "address[]", - "internalType": "contractIApp[]" + "internalType": "contract IApp[]" } ], "outputs": [], @@ -777,7 +1160,7 @@ { "name": "app", "type": "address", - "internalType": "contractIApp" + "internalType": "contract IApp" } ], "outputs": [], @@ -790,7 +1173,25 @@ { "name": "app", "type": "address", - "internalType": "contractIApp" + "internalType": "contract IApp" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "transferOwnership", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contract IApp" + }, + { + "name": "newOwner", + "type": "address", + "internalType": "address" } ], "outputs": [], @@ -803,7 +1204,7 @@ { "name": "app", "type": "address", - "internalType": "contractIApp" + "internalType": "contract IApp" }, { "name": "metadataURI", @@ -821,22 +1222,22 @@ { "name": "app", "type": "address", - "internalType": "contractIApp" + "internalType": "contract IApp" }, { "name": "release", "type": "tuple", - "internalType": "structIAppController.Release", + "internalType": "struct IAppController.Release", "components": [ { "name": "rmsRelease", "type": "tuple", - "internalType": "structIReleaseManagerTypes.Release", + "internalType": "struct IReleaseManagerTypes.Release", "components": [ { "name": "artifacts", "type": "tuple[]", - "internalType": "structIReleaseManagerTypes.Artifact[]", + "internalType": "struct IReleaseManagerTypes.Artifact[]", "components": [ { "name": "digest", @@ -897,7 +1298,7 @@ "name": "AppCreated", "inputs": [ { - "name": "creator", + "name": "owner", "type": "address", "indexed": true, "internalType": "address" @@ -906,7 +1307,7 @@ "name": "app", "type": "address", "indexed": true, - "internalType": "contractIApp" + "internalType": "contract IApp" }, { "name": "operatorSetId", @@ -925,7 +1326,7 @@ "name": "app", "type": "address", "indexed": true, - "internalType": "contractIApp" + "internalType": "contract IApp" }, { "name": "metadataURI", @@ -936,6 +1337,31 @@ ], "anonymous": false }, + { + "type": "event", + "name": "AppOwnershipTransferred", + "inputs": [ + { + "name": "app", + "type": "address", + "indexed": true, + "internalType": "contract IApp" + }, + { + "name": "previousOwner", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "newOwner", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, { "type": "event", "name": "AppStarted", @@ -944,7 +1370,7 @@ "name": "app", "type": "address", "indexed": true, - "internalType": "contractIApp" + "internalType": "contract IApp" } ], "anonymous": false @@ -957,7 +1383,7 @@ "name": "app", "type": "address", "indexed": true, - "internalType": "contractIApp" + "internalType": "contract IApp" } ], "anonymous": false @@ -970,7 +1396,7 @@ "name": "app", "type": "address", "indexed": true, - "internalType": "contractIApp" + "internalType": "contract IApp" } ], "anonymous": false @@ -983,7 +1409,7 @@ "name": "app", "type": "address", "indexed": true, - "internalType": "contractIApp" + "internalType": "contract IApp" } ], "anonymous": false @@ -996,7 +1422,7 @@ "name": "app", "type": "address", "indexed": true, - "internalType": "contractIApp" + "internalType": "contract IApp" } ], "anonymous": false @@ -1009,7 +1435,7 @@ "name": "app", "type": "address", "indexed": true, - "internalType": "contractIApp" + "internalType": "contract IApp" }, { "name": "rmsReleaseId", @@ -1021,17 +1447,17 @@ "name": "release", "type": "tuple", "indexed": false, - "internalType": "structIAppController.Release", + "internalType": "struct IAppController.Release", "components": [ { "name": "rmsRelease", "type": "tuple", - "internalType": "structIReleaseManagerTypes.Release", + "internalType": "struct IReleaseManagerTypes.Release", "components": [ { "name": "artifacts", "type": "tuple[]", - "internalType": "structIReleaseManagerTypes.Artifact[]", + "internalType": "struct IReleaseManagerTypes.Artifact[]", "components": [ { "name": "digest", @@ -1112,6 +1538,81 @@ ], "anonymous": false }, + { + "type": "event", + "name": "RoleAdminChanged", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "previousAdminRole", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "newAdminRole", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RoleGranted", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "sender", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RoleRevoked", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "sender", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, { "type": "error", "name": "AccountHasActiveApps", @@ -1127,6 +1628,11 @@ "name": "AppDoesNotExist", "inputs": [] }, + { + "type": "error", + "name": "CannotRevokeLastAdmin", + "inputs": [] + }, { "type": "error", "name": "GlobalMaxActiveAppsExceeded", diff --git a/packages/sdk/src/client/common/abis/SafeTimelockFactory.json b/packages/sdk/src/client/common/abis/SafeTimelockFactory.json new file mode 100644 index 00000000..b556fe1a --- /dev/null +++ b/packages/sdk/src/client/common/abis/SafeTimelockFactory.json @@ -0,0 +1,418 @@ +[ + { + "type": "constructor", + "inputs": [ + { + "name": "_safeSingleton", + "type": "address", + "internalType": "address" + }, + { + "name": "_safeProxyFactory", + "type": "address", + "internalType": "address" + }, + { + "name": "_safeFallbackHandler", + "type": "address", + "internalType": "address" + }, + { + "name": "_timelockImplementation", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "calculateSafeAddress", + "inputs": [ + { + "name": "deployer", + "type": "address", + "internalType": "address" + }, + { + "name": "config", + "type": "tuple", + "internalType": "struct ISafeTimelockFactory.SafeConfig", + "components": [ + { + "name": "owners", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "threshold", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "calculateTimelockAddress", + "inputs": [ + { + "name": "deployer", + "type": "address", + "internalType": "address" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "deploySafe", + "inputs": [ + { + "name": "config", + "type": "tuple", + "internalType": "struct ISafeTimelockFactory.SafeConfig", + "components": [ + { + "name": "owners", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "threshold", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "safe", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "deployTimelock", + "inputs": [ + { + "name": "config", + "type": "tuple", + "internalType": "struct ISafeTimelockFactory.TimelockConfig", + "components": [ + { + "name": "minDelay", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "proposers", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "executors", + "type": "address[]", + "internalType": "address[]" + } + ] + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "timelock", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "getSafesByDeployer", + "inputs": [ + { + "name": "deployer", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "address[]", + "internalType": "address[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getTimelocksByDeployer", + "inputs": [ + { + "name": "deployer", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "address[]", + "internalType": "address[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "initialize", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "isSafe", + "inputs": [ + { + "name": "safe", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isTimelock", + "inputs": [ + { + "name": "timelock", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "safeFallbackHandler", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "safeProxyFactory", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "safeSingleton", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "timelockImplementation", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint8", + "indexed": false, + "internalType": "uint8" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "SafeDeployed", + "inputs": [ + { + "name": "deployer", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "safe", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "owners", + "type": "address[]", + "indexed": false, + "internalType": "address[]" + }, + { + "name": "threshold", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "salt", + "type": "bytes32", + "indexed": false, + "internalType": "bytes32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "TimelockDeployed", + "inputs": [ + { + "name": "deployer", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "timelock", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "minDelay", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "proposers", + "type": "address[]", + "indexed": false, + "internalType": "address[]" + }, + { + "name": "executors", + "type": "address[]", + "indexed": false, + "internalType": "address[]" + }, + { + "name": "salt", + "type": "bytes32", + "indexed": false, + "internalType": "bytes32" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "NoExecutors", + "inputs": [] + }, + { + "type": "error", + "name": "NoProposers", + "inputs": [] + }, + { + "type": "error", + "name": "ZeroAddressExecutor", + "inputs": [] + }, + { + "type": "error", + "name": "ZeroAddressProposer", + "inputs": [] + } +] \ No newline at end of file diff --git a/packages/sdk/src/client/common/abis/TimelockController.json b/packages/sdk/src/client/common/abis/TimelockController.json new file mode 100644 index 00000000..0cb66a92 --- /dev/null +++ b/packages/sdk/src/client/common/abis/TimelockController.json @@ -0,0 +1,121 @@ +[ + { + "type": "function", + "name": "getMinDelay", + "inputs": [], + "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "hasRole", + "inputs": [ + { "name": "role", "type": "bytes32", "internalType": "bytes32" }, + { "name": "account", "type": "address", "internalType": "address" } + ], + "outputs": [{ "name": "", "type": "bool", "internalType": "bool" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "schedule", + "inputs": [ + { "name": "target", "type": "address", "internalType": "address" }, + { "name": "value", "type": "uint256", "internalType": "uint256" }, + { "name": "data", "type": "bytes", "internalType": "bytes" }, + { "name": "predecessor", "type": "bytes32", "internalType": "bytes32" }, + { "name": "salt", "type": "bytes32", "internalType": "bytes32" }, + { "name": "delay", "type": "uint256", "internalType": "uint256" } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "execute", + "inputs": [ + { "name": "target", "type": "address", "internalType": "address" }, + { "name": "value", "type": "uint256", "internalType": "uint256" }, + { "name": "payload", "type": "bytes", "internalType": "bytes" }, + { "name": "predecessor", "type": "bytes32", "internalType": "bytes32" }, + { "name": "salt", "type": "bytes32", "internalType": "bytes32" } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "hashOperation", + "inputs": [ + { "name": "target", "type": "address", "internalType": "address" }, + { "name": "value", "type": "uint256", "internalType": "uint256" }, + { "name": "data", "type": "bytes", "internalType": "bytes" }, + { "name": "predecessor", "type": "bytes32", "internalType": "bytes32" }, + { "name": "salt", "type": "bytes32", "internalType": "bytes32" } + ], + "outputs": [{ "name": "", "type": "bytes32", "internalType": "bytes32" }], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "getTimestamp", + "inputs": [{ "name": "id", "type": "bytes32", "internalType": "bytes32" }], + "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getPendingOperationIds", + "inputs": [], + "outputs": [{ "name": "", "type": "bytes32[]", "internalType": "bytes32[]" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getPendingOperations", + "inputs": [], + "outputs": [{ + "name": "ops", + "type": "tuple[]", + "internalType": "struct TimelockControllerImpl.PendingOp[]", + "components": [ + { "name": "id", "type": "bytes32", "internalType": "bytes32" }, + { "name": "target", "type": "address", "internalType": "address" }, + { "name": "data", "type": "bytes", "internalType": "bytes" }, + { "name": "executableAt", "type": "uint256", "internalType": "uint256" } + ] + }], + "stateMutability": "view" + }, + { + "type": "event", + "name": "CallScheduled", + "inputs": [ + { "name": "id", "type": "bytes32", "indexed": true, "internalType": "bytes32" }, + { "name": "index", "type": "uint256", "indexed": true, "internalType": "uint256" }, + { "name": "target", "type": "address", "indexed": false, "internalType": "address" }, + { "name": "value", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "data", "type": "bytes", "indexed": false, "internalType": "bytes" }, + { "name": "predecessor", "type": "bytes32", "indexed": false, "internalType": "bytes32" }, + { "name": "delay", "type": "uint256", "indexed": false, "internalType": "uint256" } + ] + }, + { + "type": "event", + "name": "CallExecuted", + "inputs": [ + { "name": "id", "type": "bytes32", "indexed": true, "internalType": "bytes32" }, + { "name": "index", "type": "uint256", "indexed": true, "internalType": "uint256" }, + { "name": "target", "type": "address", "indexed": false, "internalType": "address" }, + { "name": "value", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "data", "type": "bytes", "indexed": false, "internalType": "bytes" } + ] + }, + { + "type": "event", + "name": "Cancelled", + "inputs": [ + { "name": "id", "type": "bytes32", "indexed": true, "internalType": "bytes32" } + ] + } +] diff --git a/packages/sdk/src/client/common/config/environment.ts b/packages/sdk/src/client/common/config/environment.ts index 5fdfe9a6..ee8e7115 100644 --- a/packages/sdk/src/client/common/config/environment.ts +++ b/packages/sdk/src/client/common/config/environment.ts @@ -39,11 +39,11 @@ const ENVIRONMENTS: Record> = { "sepolia-dev": { name: "sepolia", build: "dev", - appControllerAddress: "0xa86DC1C47cb2518327fB4f9A1627F51966c83B92", + appControllerAddress: "0x648295953688895D4dFc1991D24Ab79b1C038579", permissionControllerAddress: ChainAddresses[SEPOLIA_CHAIN_ID].PermissionController, erc7702DelegatorAddress: CommonAddresses.ERC7702Delegator, kmsServerURL: "http://10.128.0.57:8080", - userApiServerURL: "https://userapi-compute-sepolia-dev.eigencloud.xyz", + userApiServerURL: "http://localhost:8080", defaultRPCURL: "https://ethereum-sepolia-rpc.publicnode.com", usdcCreditsAddress: "0xbdA3897c3A428763B59015C64AB766c288C97376", }, @@ -153,7 +153,7 @@ export function getBuildType(): "dev" | "prod" { // Fall back to runtime environment variable const runtimeType = process.env.BUILD_TYPE?.toLowerCase(); - const buildType = buildTimeType || runtimeType; + const buildType = runtimeType || buildTimeType; if (buildType === "dev") { return "dev"; diff --git a/packages/sdk/src/client/common/contract/caller.test.ts b/packages/sdk/src/client/common/contract/caller.test.ts new file mode 100644 index 00000000..828882a1 --- /dev/null +++ b/packages/sdk/src/client/common/contract/caller.test.ts @@ -0,0 +1,4 @@ +// Tests for caller.ts utility functions. +// getScheduledRelease was removed along with AppController.scheduleUpgrade / +// executeUpgrade / cancelUpgrade — all timelocked ops now go through the +// generic Timelock.schedule → execute flow (scheduleTimelockOp / executeTimelockOp). diff --git a/packages/sdk/src/client/common/contract/caller.ts b/packages/sdk/src/client/common/contract/caller.ts index 0999f9f0..16ffb07b 100644 --- a/packages/sdk/src/client/common/contract/caller.ts +++ b/packages/sdk/src/client/common/contract/caller.ts @@ -19,7 +19,7 @@ */ import { executeBatch, checkERC7702Delegation } from "./eip7702"; -import { Address, Hex, encodeFunctionData, decodeErrorResult, bytesToHex } from "viem"; +import { Address, Hex, encodeFunctionData, decodeErrorResult, bytesToHex, decodeFunctionData } from "viem"; import type { WalletClient, PublicClient } from "viem"; import { @@ -36,6 +36,8 @@ import { getChainFromID } from "../utils/helpers"; import AppControllerABI from "../abis/AppController.json"; import PermissionControllerABI from "../abis/PermissionController.json"; +import SafeTimelockFactoryABI from "../abis/SafeTimelockFactory.json"; +import TimelockControllerABI from "../abis/TimelockController.json"; /** * Gas estimation result @@ -236,7 +238,6 @@ export async function prepareDeployBatch( // Verify the app ID calculation matches what createApp will deploy logger.debug(`App ID calculated: ${appId}`); - logger.debug(`This address will be used for acceptAdmin call`); // 2. Pack create app call const saltHexString = bytesToHex(salt).slice(2); @@ -263,15 +264,7 @@ export async function prepareDeployBatch( args: [saltHex, releaseForViem], }); - // 3. Pack accept admin call - const acceptAdminData = encodeFunctionData({ - abi: PermissionControllerABI, - functionName: "acceptAdmin", - args: [appId], - }); - - // 4. Assemble executions - // CRITICAL: Order matters! createApp must complete first + // 3. Assemble executions const executions: Array<{ target: Address; value: bigint; @@ -282,14 +275,9 @@ export async function prepareDeployBatch( value: 0n, callData: createData, }, - { - target: environmentConfig.permissionControllerAddress as Address, - value: 0n, - callData: acceptAdminData, - }, ]; - // 5. Add public logs permission if requested + // 4. Add public logs permission if requested if (publicLogs) { const anyoneCanViewLogsData = encodeFunctionData({ abi: PermissionControllerABI, @@ -407,12 +395,11 @@ export interface ExecuteDeploySequentialOptions { * Execute deployment as sequential transactions (non-EIP-7702 fallback) * * Use this for browser wallets (JSON-RPC accounts) that don't support signAuthorization. - * This requires 2-3 wallet signatures instead of 1, but works with all wallet types. + * This requires 1-2 wallet signatures instead of 1, but works with all wallet types. * * Steps: * 1. createApp - Creates the app on-chain - * 2. acceptAdmin - Accepts admin role for the app - * 3. setAppointee (optional) - Sets public logs permission + * 2. setAppointee (optional) - Sets public logs permission */ export async function executeDeploySequential( options: ExecuteDeploySequentialOptions, @@ -432,7 +419,8 @@ export async function executeDeploySequential( }; // Step 1: Create App - logger.info("Step 1/3: Creating app..."); + const totalSteps = publicLogs ? "2" : "1"; + logger.info(`Step 1/${totalSteps}: Creating app...`); onProgress?.("createApp"); const createAppExecution = data.executions[0]; @@ -454,37 +442,12 @@ export async function executeDeploySequential( txHashes.createApp = createAppHash; logger.info(`createApp confirmed in block ${createAppReceipt.blockNumber}`); - // Step 2: Accept Admin - logger.info("Step 2/3: Accepting admin role..."); - onProgress?.("acceptAdmin", createAppHash); - - const acceptAdminExecution = data.executions[1]; - const acceptAdminHash = await walletClient.sendTransaction({ - account, - to: acceptAdminExecution.target, - data: acceptAdminExecution.callData, - value: acceptAdminExecution.value, - chain, - }); - - logger.info(`acceptAdmin transaction sent: ${acceptAdminHash}`); - const acceptAdminReceipt = await publicClient.waitForTransactionReceipt({ - hash: acceptAdminHash, - }); - - if (acceptAdminReceipt.status === "reverted") { - throw new Error(`acceptAdmin transaction reverted: ${acceptAdminHash}`); - } + // Step 2: Set Public Logs (if requested and present in executions) + if (publicLogs && data.executions.length > 1) { + logger.info(`Step 2/${totalSteps}: Setting public logs permission...`); + onProgress?.("setPublicLogs", createAppHash); - txHashes.acceptAdmin = acceptAdminHash; - logger.info(`acceptAdmin confirmed in block ${acceptAdminReceipt.blockNumber}`); - - // Step 3: Set Public Logs (if requested and present in executions) - if (publicLogs && data.executions.length > 2) { - logger.info("Step 3/3: Setting public logs permission..."); - onProgress?.("setPublicLogs", acceptAdminHash); - - const setAppointeeExecution = data.executions[2]; + const setAppointeeExecution = data.executions[1]; const setAppointeeHash = await walletClient.sendTransaction({ account, to: setAppointeeExecution.target, @@ -506,7 +469,7 @@ export async function executeDeploySequential( logger.info(`setAppointee confirmed in block ${setAppointeeReceipt.blockNumber}`); } - onProgress?.("complete", txHashes.setPublicLogs || txHashes.acceptAdmin); + onProgress?.("complete", txHashes.setPublicLogs || txHashes.createApp); logger.info(`Deployment complete! App ID: ${data.appId}`); @@ -607,7 +570,7 @@ export async function executeDeployBatched( // If public logs is false but executions include the permission call, filter it out // (This shouldn't happen if prepareDeployBatch was called correctly, but be safe) - const filteredCalls = publicLogs ? calls : calls.slice(0, 2); + const filteredCalls = publicLogs ? calls : calls.slice(0, 1); logger.info(`Deploying with EIP-5792 sendCalls (${filteredCalls.length} calls)...`); onProgress?.("createApp"); @@ -1232,6 +1195,177 @@ export async function getBlockTimestamps( return timestamps; } +/** + * Get whether an app is timelocked (owner is a Timelock — sensitive ops go through Timelock.schedule → execute) + */ +export async function getAppTimelocked( + publicClient: PublicClient, + environmentConfig: EnvironmentConfig, + appID: Address, +): Promise { + const timelocked = await publicClient.readContract({ + address: environmentConfig.appControllerAddress as Address, + abi: AppControllerABI, + functionName: "getAppTimelocked", + args: [appID], + }); + + return timelocked as boolean; +} + +/** + * Options for transferring app ownership + */ +export interface TransferOwnershipOptions { + walletClient: WalletClient; + publicClient: PublicClient; + environmentConfig: EnvironmentConfig; + appID: Address; + newOwner: Address; + gas?: GasEstimate; +} + +/** + * Transfer ownership of an app to a new address. + * If newOwner is a Safe or Timelock deployed by SafeTimelockFactory, governance mode is enabled automatically. + */ +export async function transferAppOwnership( + options: TransferOwnershipOptions, + logger: Logger = noopLogger, +): Promise { + const { walletClient, publicClient, environmentConfig, appID, newOwner, gas } = options; + + const data = encodeFunctionData({ + abi: AppControllerABI, + functionName: "transferOwnership", + args: [appID, newOwner], + }); + + return sendAndWaitForTransaction( + { + walletClient, + publicClient, + environmentConfig, + to: environmentConfig.appControllerAddress as Address, + data, + pendingMessage: `Transferring ownership of app ${appID} to ${newOwner}...`, + txDescription: "TransferOwnership", + gas, + }, + logger, + ); +} + +/** + * Team role enum matching the contract's TeamRole enum. + */ +export enum TeamRole { + ADMIN = 0, + PAUSER = 1, + DEVELOPER = 2, +} + +export interface GrantTeamRoleOptions { + walletClient: WalletClient; + publicClient: PublicClient; + environmentConfig: EnvironmentConfig; + team: Address; + role: TeamRole; + account: Address; + gas?: GasEstimate; +} + +export async function grantTeamRole( + options: GrantTeamRoleOptions, + logger: Logger = noopLogger, +): Promise { + const { walletClient, publicClient, environmentConfig, team, role, account, gas } = options; + + const data = encodeFunctionData({ + abi: AppControllerABI, + functionName: "grantTeamRole", + args: [team, role, account], + }); + + return sendAndWaitForTransaction( + { + walletClient, + publicClient, + environmentConfig, + to: environmentConfig.appControllerAddress as Address, + data, + pendingMessage: `Granting ${TeamRole[role]} role to ${account}...`, + txDescription: "GrantTeamRole", + gas, + }, + logger, + ); +} + +export interface RevokeTeamRoleOptions { + walletClient: WalletClient; + publicClient: PublicClient; + environmentConfig: EnvironmentConfig; + team: Address; + role: TeamRole; + account: Address; + gas?: GasEstimate; +} + +export async function revokeTeamRole( + options: RevokeTeamRoleOptions, + logger: Logger = noopLogger, +): Promise { + const { walletClient, publicClient, environmentConfig, team, role, account, gas } = options; + + const data = encodeFunctionData({ + abi: AppControllerABI, + functionName: "revokeTeamRole", + args: [team, role, account], + }); + + return sendAndWaitForTransaction( + { + walletClient, + publicClient, + environmentConfig, + to: environmentConfig.appControllerAddress as Address, + data, + pendingMessage: `Revoking ${TeamRole[role]} role from ${account}...`, + txDescription: "RevokeTeamRole", + gas, + }, + logger, + ); +} + +export async function getTeamRoleMembers( + publicClient: PublicClient, + environmentConfig: EnvironmentConfig, + team: Address, + role: TeamRole, +): Promise { + return (await publicClient.readContract({ + address: environmentConfig.appControllerAddress as Address, + abi: AppControllerABI, + functionName: "getTeamRoleMembers", + args: [team, role], + })) as Address[]; +} + +export async function getAppOwner( + publicClient: PublicClient, + environmentConfig: EnvironmentConfig, + appID: Address, +): Promise
{ + return (await publicClient.readContract({ + address: environmentConfig.appControllerAddress as Address, + abi: AppControllerABI, + functionName: "getAppOwner", + args: [appID], + })) as Address; +} + /** * Suspend options */ @@ -1362,3 +1496,419 @@ export async function undelegate( return hash; } + +// ─── SafeTimelockFactory ──────────────────────────────────────────────────── + +/** + * Read the SafeTimelockFactory proxy address from AppController + */ +export async function getSafeTimelockFactoryAddress( + publicClient: PublicClient, + environmentConfig: EnvironmentConfig, +): Promise
{ + return publicClient.readContract({ + address: environmentConfig.appControllerAddress as Address, + abi: AppControllerABI as any, + functionName: "safeTimelockFactory", + args: [], + }) as Promise
; +} + +/** + * Canonical salt used for Timelock deployments via SafeTimelockFactory. + * + * Fixed at zero so that a single private key deterministically derives its + * associated Timelock address — you can always reconstruct it from the EOA + * without storing any extra state. Safe addresses are discovered via the + * Safe Transaction Service API, not derived from this salt. + */ +export const CANONICAL_SALT: Hex = "0x0000000000000000000000000000000000000000000000000000000000000000"; + +export interface DeploySafeOptions { + walletClient: WalletClient; + publicClient: PublicClient; + environmentConfig: EnvironmentConfig; + owners: Address[]; + threshold: number; +} + +/** + * Deploy a Gnosis Safe via SafeTimelockFactory + */ +export async function deploySafe( + options: DeploySafeOptions, + logger: Logger = noopLogger, +): Promise<{ tx: Hex | null; safe: Address; alreadyExisted?: boolean }> { + const { walletClient, publicClient, environmentConfig, owners, threshold } = options; + const salt = CANONICAL_SALT; + + const factoryAddress = await getSafeTimelockFactoryAddress(publicClient, environmentConfig); + const account = walletClient.account!; + const chain = getChainFromID(environmentConfig.chainID); + + // Predict the Safe address first. If bytecode already exists there, the Safe was + // deployed previously (same deployer + same salt = same Create2 address). Skip + // the deploy and return the existing address without sending a transaction. + const predictedSafe = await publicClient.readContract({ + address: factoryAddress, + abi: SafeTimelockFactoryABI, + functionName: "calculateSafeAddress", + args: [account.address, { owners, threshold: BigInt(threshold) }, salt], + }) as Address; + + const existingCode = await publicClient.getCode({ address: predictedSafe }); + if (existingCode && existingCode !== "0x") { + logger.info(`Safe already exists at ${predictedSafe}, skipping deploy`); + return { tx: null, safe: predictedSafe, alreadyExisted: true }; + } + + const data = encodeFunctionData({ + abi: SafeTimelockFactoryABI, + functionName: "deploySafe", + args: [{ owners, threshold: BigInt(threshold) }, salt], + }); + + logger.debug(`Deploying Safe via factory ${factoryAddress}`); + + const hash = await walletClient.sendTransaction({ account, to: factoryAddress, data, chain }); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + + if (receipt.status === "reverted") { + throw new Error(`deploySafe transaction (${hash}) reverted`); + } + + // Parse SafeDeployed event to get the deployed address + // Use the second indexed topic (safe address) from the log + const log = receipt.logs.find( + (l) => l.address.toLowerCase() === factoryAddress.toLowerCase(), + ); + if (!log || log.topics.length < 2) { + throw new Error("SafeDeployed event not found in receipt"); + } + const safe = ("0x" + log.topics[2]!.slice(26)) as Address; + + logger.info(`Safe deployed at ${safe}`); + return { tx: hash, safe }; +} + +export interface DeployTimelockOptions { + walletClient: WalletClient; + publicClient: PublicClient; + environmentConfig: EnvironmentConfig; + minDelay: bigint; + proposers: Address[]; + executors: Address[]; + /** Salt for CREATE2 deployment. Defaults to CANONICAL_SALT (bytes32(0)). */ + salt?: Hex; +} + +/** + * Deploy a TimelockController via SafeTimelockFactory + */ +export async function deployTimelock( + options: DeployTimelockOptions, + logger: Logger = noopLogger, +): Promise<{ tx: Hex; timelock: Address }> { + const { walletClient, publicClient, environmentConfig, minDelay, proposers, executors } = options; + const salt = options.salt ?? CANONICAL_SALT; + + const factoryAddress = await getSafeTimelockFactoryAddress(publicClient, environmentConfig); + const account = walletClient.account!; + const chain = getChainFromID(environmentConfig.chainID); + + const data = encodeFunctionData({ + abi: SafeTimelockFactoryABI, + functionName: "deployTimelock", + args: [{ minDelay, proposers, executors }, salt], + }); + + logger.debug(`Deploying Timelock via factory ${factoryAddress}`); + + const hash = await walletClient.sendTransaction({ account, to: factoryAddress, data, chain }); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + + if (receipt.status === "reverted") { + throw new Error(`deployTimelock transaction (${hash}) reverted`); + } + + // Parse TimelockDeployed event — second indexed topic is the timelock address + const log = receipt.logs.find( + (l) => l.address.toLowerCase() === factoryAddress.toLowerCase(), + ); + if (!log || log.topics.length < 2) { + throw new Error("TimelockDeployed event not found in receipt"); + } + const timelock = ("0x" + log.topics[2]!.slice(26)) as Address; + + logger.info(`Timelock deployed at ${timelock}`); + return { tx: hash, timelock }; +} + +export interface DiscoveredTimelock { + address: Address; + minDelay: bigint; +} + +/** + * Discover the canonical Timelock for an EOA address. + * + * Uses calculateTimelockAddress(eoa, bytes32(0)) to predict the deterministic + * address, then checks isTimelock() to see if it has been deployed. + * Returns null if no Timelock exists for this EOA. + */ +// ─── Timelocked operations via TimelockController ──────────────────────────── +// +// All sensitive ops (upgradeApp, transferOwnership, terminateApp, grantTeamRole) +// go through TimelockController.schedule() → execute() uniformly when the app +// owner is a Timelock. The generic scheduleTimelockOp / executeTimelockOp +// helpers below handle any AppController calldata. +// +// We use predecessor=0 and salt=0 so the operation hash is deterministic from +// (target, calldata) alone. + +const ZERO_BYTES32 = "0x0000000000000000000000000000000000000000000000000000000000000000" as Hex; + +export interface ScheduleTimelockOpOptions { + walletClient: WalletClient; + publicClient: PublicClient; + environmentConfig: EnvironmentConfig; + timelockAddress: Address; + calldata: Hex; + delaySeconds: bigint; + gas?: GasEstimate; +} + +/** + * Queue an AppController call through a TimelockController. + * The wallet must hold the PROPOSER_ROLE on the given Timelock. + */ +export async function scheduleTimelockOp( + options: ScheduleTimelockOpOptions, + logger: Logger = noopLogger, +): Promise { + const { walletClient, publicClient, environmentConfig, timelockAddress, calldata, delaySeconds, gas } = options; + + const data = encodeFunctionData({ + abi: TimelockControllerABI, + functionName: "schedule", + args: [ + environmentConfig.appControllerAddress as Address, + 0n, + calldata, + ZERO_BYTES32, + ZERO_BYTES32, + delaySeconds, + ], + }); + + return sendAndWaitForTransaction( + { + walletClient, + publicClient, + environmentConfig, + to: timelockAddress, + data, + pendingMessage: `Queuing operation on Timelock ${timelockAddress}...`, + txDescription: "TimelockSchedule", + gas, + }, + logger, + ); +} + +export interface ExecuteTimelockOpOptions { + walletClient: WalletClient; + publicClient: PublicClient; + environmentConfig: EnvironmentConfig; + timelockAddress: Address; + calldata: Hex; + gas?: GasEstimate; +} + +/** + * Execute a previously queued AppController call through a TimelockController. + * The wallet must hold the EXECUTOR_ROLE (or the role must be open). + */ +export async function executeTimelockOp( + options: ExecuteTimelockOpOptions, + logger: Logger = noopLogger, +): Promise { + const { walletClient, publicClient, environmentConfig, timelockAddress, calldata, gas } = options; + + const data = encodeFunctionData({ + abi: TimelockControllerABI, + functionName: "execute", + args: [ + environmentConfig.appControllerAddress as Address, + 0n, + calldata, + ZERO_BYTES32, + ZERO_BYTES32, + ], + }); + + return sendAndWaitForTransaction( + { + walletClient, + publicClient, + environmentConfig, + to: timelockAddress, + data, + pendingMessage: `Executing queued operation on Timelock ${timelockAddress}...`, + txDescription: "TimelockExecute", + gas, + }, + logger, + ); +} + +/** + * Return the timestamp at which a queued operation becomes executable. + * Returns 0 if the operation is not scheduled, 1 if it has already been executed. + */ +export async function getTimelockOpTimestamp( + publicClient: PublicClient, + timelockAddress: Address, + appControllerAddress: Address, + calldata: Hex, +): Promise { + const id = (await publicClient.readContract({ + address: timelockAddress, + abi: TimelockControllerABI, + functionName: "hashOperation", + args: [appControllerAddress as Address, 0n, calldata, ZERO_BYTES32, ZERO_BYTES32], + })) as Hex; + + return (await publicClient.readContract({ + address: timelockAddress, + abi: TimelockControllerABI, + functionName: "getTimestamp", + args: [id], + })) as bigint; +} + +export async function discoverTimelock( + publicClient: PublicClient, + environmentConfig: EnvironmentConfig, + proposerAddress: Address, +): Promise { + const factoryAddress = await getSafeTimelockFactoryAddress(publicClient, environmentConfig); + + const timelockAddress = await publicClient.readContract({ + address: factoryAddress, + abi: SafeTimelockFactoryABI, + functionName: "calculateTimelockAddress", + args: [proposerAddress, CANONICAL_SALT], + }) as Address; + + const exists = await publicClient.readContract({ + address: factoryAddress, + abi: SafeTimelockFactoryABI, + functionName: "isTimelock", + args: [timelockAddress], + }) as boolean; + + if (!exists) return null; + + const minDelay = await publicClient.readContract({ + address: timelockAddress, + abi: TimelockControllerABI, + functionName: "getMinDelay", + args: [], + }) as bigint; + + return { address: timelockAddress, minDelay }; +} + +/** @deprecated Use discoverTimelock instead */ +export const discoverTimelockForEOA = discoverTimelock; + +/** + * Returns all Timelocks deployed by the given deployer via SafeTimelockFactory. + * Use this for identity recovery — no salt assumptions required. + */ +export async function getTimelocksByDeployer( + publicClient: PublicClient, + environmentConfig: EnvironmentConfig, + deployer: Address, +): Promise { + const factoryAddress = await getSafeTimelockFactoryAddress(publicClient, environmentConfig); + return (await publicClient.readContract({ + address: factoryAddress, + abi: SafeTimelockFactoryABI, + functionName: "getTimelocksByDeployer", + args: [deployer], + })) as Address[]; +} + +/** + * Returns all Safes deployed by the given deployer via SafeTimelockFactory. + * Use this for identity recovery — no external API required. + */ +export async function getSafesByDeployer( + publicClient: PublicClient, + environmentConfig: EnvironmentConfig, + deployer: Address, +): Promise { + const factoryAddress = await getSafeTimelockFactoryAddress(publicClient, environmentConfig); + return (await publicClient.readContract({ + address: factoryAddress, + abi: SafeTimelockFactoryABI, + functionName: "getSafesByDeployer", + args: [deployer], + })) as Address[]; +} + +export interface PendingTimelockOp { + id: Hex; + calldata: Hex; + description: string; + executableAt: bigint; + ready: boolean; +} + +function describeCalldata(calldata: Hex): string { + try { + const decoded = decodeFunctionData({ abi: AppControllerABI, data: calldata }); + const args = decoded.args; + if (!args || args.length === 0) return decoded.functionName; + + const addressArgs = args.filter((a): a is string => typeof a === "string" && /^0x[0-9a-fA-F]{40}$/.test(a)); + if (addressArgs.length > 0) { + return `${decoded.functionName}(${addressArgs.join(", ")})`; + } + return decoded.functionName; + } catch { + return "unknown"; + } +} + +export async function getPendingTimelockOps( + publicClient: PublicClient, + timelockAddress: Address, +): Promise { + // Uses getPendingOperations() from TimelockControllerImpl — single view call, no log scanning. + let ops: { id: Hex; target: Address; data: Hex; executableAt: bigint }[]; + try { + ops = (await publicClient.readContract({ + address: timelockAddress, + abi: TimelockControllerABI, + functionName: "getPendingOperations", + args: [], + })) as { id: Hex; target: Address; data: Hex; executableAt: bigint }[]; + } catch { + // Timelock deployed before upgrade — getPendingOperations not available + return []; + } + + if (ops.length === 0) return []; + + const now = BigInt(Math.floor(Date.now() / 1000)); + return ops.map((op) => ({ + id: op.id, + calldata: op.data, + description: op.data && op.data !== "0x" ? describeCalldata(op.data) : "batch op", + executableAt: op.executableAt, + ready: now >= op.executableAt, + })); +} diff --git a/packages/sdk/src/client/common/contract/identity-router.ts b/packages/sdk/src/client/common/contract/identity-router.ts new file mode 100644 index 00000000..cf7c252f --- /dev/null +++ b/packages/sdk/src/client/common/contract/identity-router.ts @@ -0,0 +1,223 @@ +/** + * Identity-aware transaction routing + * + * Routes transactions based on the active identity type: + * - EOA: sign and send directly + * - Safe: propose via Safe Transaction Service + * - Timelock(EOA): schedule on Timelock, then execute after delay + * - Timelock(Safe): propose schedule to Safe, then propose execute after delay + */ + +import { + type Address, + type Hex, + type PublicClient, + type WalletClient, + encodeFunctionData, +} from "viem"; +import { proposeSafeTransaction, type SafeProposalResult } from "./safe"; +import { sendAndWaitForTransaction, type GasEstimate } from "./caller"; +import { type EnvironmentConfig } from "../types"; +import TimelockControllerABI from "../abis/TimelockController.json"; + +const ZERO_BYTES32 = "0x0000000000000000000000000000000000000000000000000000000000000000" as Hex; + +export interface StoredIdentity { + type: "eoa" | "safe" | "timelock"; + address: string; + delay?: string; + safeAddress?: string; + environment?: string; +} + +export type TransactionResult = + | { type: "direct"; txHash: Hex } + | { type: "safe-proposal"; proposal: SafeProposalResult } + | { type: "timelock-scheduled"; txHash: Hex; timelockAddress: string; delayLabel: string } + | { type: "safe-proposal-for-timelock"; proposal: SafeProposalResult; timelockAddress: string; delayLabel: string }; + +export interface IdentityRouterOptions { + identity: StoredIdentity; + walletClient: WalletClient; + publicClient: PublicClient; + environmentConfig: EnvironmentConfig; + to: Address; + data: Hex; + value?: bigint; + environment: string; + pendingMessage?: string; + txDescription?: string; + gas?: GasEstimate; + delayOverride?: string; +} + +/** + * Parse delay string to seconds (e.g., "24h" → 86400n) + */ +function parseDelayToSeconds(delay?: string): bigint { + if (!delay) return 86400n; // default 24h + const match = delay.trim().match(/^(\d+)(s|m|h|d)?$/i); + if (!match) return 86400n; + const n = parseInt(match[1], 10); + const unit = (match[2] || "s").toLowerCase(); + const multipliers: Record = { s: 1, m: 60, h: 3600, d: 86400 }; + return BigInt(n * multipliers[unit]); +} + +/** + * Route a transaction based on the active identity. + * + * - EOA: sign and send directly + * - Safe: propose via Safe Transaction Service (returns proposal, not a tx hash) + * - Timelock(EOA): encode as Timelock.schedule(), sign and send + * - Timelock(Safe): encode as Timelock.schedule(), propose to Safe + */ +export async function sendWithIdentity( + options: IdentityRouterOptions, +): Promise { + const { + identity, + walletClient, + publicClient, + environmentConfig, + to, + data, + value = 0n, + environment, + pendingMessage, + txDescription, + gas, + } = options; + + switch (identity.type) { + case "eoa": { + // Direct transaction + const txHash = await sendAndWaitForTransaction( + { + walletClient, + publicClient, + environmentConfig, + to, + data, + value, + pendingMessage: pendingMessage || "Sending transaction...", + txDescription: txDescription || "Transaction", + gas, + }, + ); + return { type: "direct", txHash }; + } + + case "safe": { + // Propose to Safe + const proposal = await proposeSafeTransaction({ + walletClient, + publicClient, + safeAddress: identity.address as Address, + to, + data, + value, + environment, + }); + return { type: "safe-proposal", proposal }; + } + + case "timelock": { + const timelockAddress = identity.address as Address; + const effectiveDelay = options.delayOverride || identity.delay; + const delaySeconds = parseDelayToSeconds(effectiveDelay); + const delayLabel = effectiveDelay || "24h"; + + // Encode the Timelock.schedule() call + const scheduleData = encodeFunctionData({ + abi: TimelockControllerABI, + functionName: "schedule", + args: [ + to, // target + value, // value + data, // calldata + ZERO_BYTES32, // predecessor + ZERO_BYTES32, // salt + delaySeconds, // delay + ], + }); + + if (identity.safeAddress) { + // Timelock(Safe): propose schedule() to the Safe + const proposal = await proposeSafeTransaction({ + walletClient, + publicClient, + safeAddress: identity.safeAddress as Address, + to: timelockAddress, + data: scheduleData, + environment, + }); + return { + type: "safe-proposal-for-timelock", + proposal, + timelockAddress: timelockAddress as string, + delayLabel, + }; + } else { + // Timelock(EOA): send schedule() directly + const txHash = await sendAndWaitForTransaction( + { + walletClient, + publicClient, + environmentConfig, + to: timelockAddress, + data: scheduleData, + pendingMessage: pendingMessage || `Scheduling on Timelock (${delayLabel} delay)...`, + txDescription: txDescription || "TimelockSchedule", + gas, + }, + ); + return { type: "timelock-scheduled", txHash, timelockAddress: timelockAddress as string, delayLabel }; + } + } + + default: + throw new Error(`Unknown identity type: ${(identity as any).type}`); + } +} + +/** + * Format the result of sendWithIdentity for display + */ +export function formatTransactionResult(result: TransactionResult): string[] { + switch (result.type) { + case "direct": + return [`✓ Transaction sent: ${result.txHash}`]; + + case "safe-proposal": + return [ + `✓ Proposed to Safe ${result.proposal.safeAddress}`, + ` Safe tx hash: ${result.proposal.safeTxHash}`, + ` Proposer: ${result.proposal.proposer}`, + ``, + ` Waiting for approval at:`, + ` ${result.proposal.safeUrl}`, + ]; + + case "timelock-scheduled": + return [ + `✓ Scheduled on Timelock ${result.timelockAddress}`, + ` Tx: ${result.txHash}`, + ` Delay: ${result.delayLabel}`, + ``, + ` After the delay elapses, execute the queued operation on the Timelock.`, + ]; + + case "safe-proposal-for-timelock": + return [ + `✓ Proposed schedule to Safe`, + ` Safe tx hash: ${result.proposal.safeTxHash}`, + ` Timelock: ${result.timelockAddress} (${result.delayLabel} delay)`, + ``, + ` Step 1: Approve the schedule at:`, + ` ${result.proposal.safeUrl}`, + ``, + ` Step 2: After Safe approval + ${result.delayLabel} delay, execute the queued operation on the Timelock.`, + ]; + } +} diff --git a/packages/sdk/src/client/common/contract/safe.ts b/packages/sdk/src/client/common/contract/safe.ts new file mode 100644 index 00000000..50e79147 --- /dev/null +++ b/packages/sdk/src/client/common/contract/safe.ts @@ -0,0 +1,160 @@ +/** + * Safe Transaction Service integration + * + * Proposes transactions to a Gnosis Safe via the Safe Transaction Service API. + * The EOA signs the transaction hash and submits the proposal. Other Safe owners + * approve it externally (e.g., at app.safe.global). + */ + +import { + type Address, + type Hex, + type PublicClient, + type WalletClient, + encodePacked, + keccak256, + encodeFunctionData, + parseAbi, + zeroAddress, +} from "viem"; + +// Minimal Safe ABI for reading state +const SafeABI = parseAbi([ + "function nonce() view returns (uint256)", + "function getTransactionHash(address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, uint256 _nonce) view returns (bytes32)", + "function getThreshold() view returns (uint256)", + "function getOwners() view returns (address[])", +]); + +export interface ProposeSafeTransactionOptions { + walletClient: WalletClient; + publicClient: PublicClient; + safeAddress: Address; + to: Address; + data: Hex; + value?: bigint; + environment: string; +} + +export interface SafeProposalResult { + safeTxHash: string; + safeAddress: string; + proposer: string; + safeUrl: string; +} + +/** + * Get the Safe Transaction Service URL for the given environment + */ +function getSafeServiceUrl(environment: string): string { + if (environment === "mainnet-alpha") { + return "https://safe-transaction-mainnet.safe.global"; + } + return "https://safe-transaction-sepolia.safe.global"; +} + +/** + * Propose a transaction to a Gnosis Safe via the Transaction Service. + * + * The EOA signs the Safe transaction hash and posts the proposal. + * Other signers approve at app.safe.global or via the Safe API. + * + * Returns the Safe transaction hash and a URL to track approval. + */ +export async function proposeSafeTransaction( + options: ProposeSafeTransactionOptions, +): Promise { + const { + walletClient, + publicClient, + safeAddress, + to, + data, + value = 0n, + environment, + } = options; + + const account = walletClient.account; + if (!account) { + throw new Error("WalletClient must have an account attached"); + } + + // Read Safe nonce + const nonce = await publicClient.readContract({ + address: safeAddress, + abi: SafeABI, + functionName: "nonce", + }); + + // Get the Safe transaction hash (EIP-712 typed hash) + const safeTxHash = await publicClient.readContract({ + address: safeAddress, + abi: SafeABI, + functionName: "getTransactionHash", + args: [ + to, // to + value, // value + data, // data + 0, // operation (0 = Call) + 0n, // safeTxGas + 0n, // baseGas + 0n, // gasPrice + zeroAddress, // gasToken + zeroAddress, // refundReceiver + nonce, // nonce + ], + }) as Hex; + + // Sign the hash with the EOA + const signature = await walletClient.signMessage({ + account, + message: { raw: safeTxHash }, + }); + + // Adjust signature: Safe expects v = v + 4 for eth_sign signatures + const sigBytes = Buffer.from(signature.slice(2), "hex"); + const v = sigBytes[sigBytes.length - 1]; + sigBytes[sigBytes.length - 1] = v + 4; + const adjustedSignature = ("0x" + sigBytes.toString("hex")) as Hex; + + // Post to Safe Transaction Service + const serviceUrl = getSafeServiceUrl(environment); + const endpoint = `${serviceUrl}/api/v1/safes/${safeAddress}/multisig-transactions/`; + + const body = { + to, + value: value.toString(), + data, + operation: 0, + safeTxGas: "0", + baseGas: "0", + gasPrice: "0", + gasToken: zeroAddress, + refundReceiver: zeroAddress, + nonce: Number(nonce), + contractTransactionHash: safeTxHash, + sender: account.address, + signature: adjustedSignature, + }; + + const response = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Safe Transaction Service error (${response.status}): ${text}`); + } + + const chainPrefix = environment === "mainnet-alpha" ? "eth" : "sep"; + const safeUrl = `https://app.safe.global/transactions/queue?safe=${chainPrefix}:${safeAddress}`; + + return { + safeTxHash: safeTxHash as string, + safeAddress: safeAddress as string, + proposer: account.address as string, + safeUrl, + }; +} diff --git a/packages/sdk/src/client/common/types/index.ts b/packages/sdk/src/client/common/types/index.ts index c42a8d71..0b828834 100644 --- a/packages/sdk/src/client/common/types/index.ts +++ b/packages/sdk/src/client/common/types/index.ts @@ -442,7 +442,7 @@ export interface SequentialDeployResult { appId: AppId; txHashes: { createApp: Hex; - acceptAdmin: Hex; + acceptAdmin: Hex; // kept for backward compat; always "0x" after acceptAdmin removal setPublicLogs?: Hex; }; } diff --git a/packages/sdk/src/client/common/utils/userapi.ts b/packages/sdk/src/client/common/utils/userapi.ts index 2954c40e..9868306b 100644 --- a/packages/sdk/src/client/common/utils/userapi.ts +++ b/packages/sdk/src/client/common/utils/userapi.ts @@ -153,6 +153,14 @@ export interface UserApiClientOptions { * When false (default), each request is signed individually. */ useSession?: boolean; + /** + * On-chain identities the caller is acting as. Sent via the X-eigenx-identity + * header so the server evaluates permissions against these addresses instead + * of the recovered EOA. Leave empty to preserve legacy behavior (server + * infers identity from the signing EOA). When multiple identities are + * supplied (e.g., for list endpoints) they are comma-joined on the wire. + */ + identities?: Address[]; } /** @@ -161,6 +169,7 @@ export interface UserApiClientOptions { export class UserApiClient { private readonly clientId: string; private readonly useSession: boolean; + private identities: Address[]; constructor( private readonly config: EnvironmentConfig, @@ -170,6 +179,16 @@ export class UserApiClient { ) { this.clientId = options?.clientId || getDefaultClientId(); this.useSession = options?.useSession ?? false; + this.identities = options?.identities ?? []; + } + + /** + * Override the identities the client declares on subsequent requests. + * Useful when one UserApiClient instance is reused across multiple identity + * contexts (e.g., `app list` iterating over every identity the user holds). + */ + setIdentities(identities: Address[]): void { + this.identities = identities; } /** @@ -372,6 +391,7 @@ export class UserApiClient { const authHeaders = await this.generateAuthHeaders(CanUpdateAppProfilePermission, expiry); Object.assign(headers, authHeaders); } + this.addIdentityHeader(headers); try { const response: AxiosResponse = await axios.post(endpoint, formData, { @@ -436,6 +456,7 @@ export class UserApiClient { const authHeaders = await this.generateAuthHeaders(permission, expiry); Object.assign(headers, authHeaders); } + this.addIdentityHeader(headers); try { const response: AxiosResponse = await requestWithRetry({ @@ -483,6 +504,17 @@ export class UserApiClient { } } + /** + * Apply the X-eigenx-identity header to an outgoing request when identities + * are configured. Platform reads this header to resolve permissions against + * the declared identities instead of the recovered EOA. Case doesn't matter + * on the wire; we use canonical checksum form for readability in logs. + */ + private addIdentityHeader(headers: Record): void { + if (this.identities.length === 0) return; + headers["X-eigenx-identity"] = this.identities.join(","); + } + /** * Generate authentication headers for UserAPI requests */ diff --git a/packages/sdk/src/client/index.ts b/packages/sdk/src/client/index.ts index 9a3b1fd2..41dda205 100644 --- a/packages/sdk/src/client/index.ts +++ b/packages/sdk/src/client/index.ts @@ -57,6 +57,9 @@ export { encodeStartAppData, encodeStopAppData, encodeTerminateAppData, + encodeTransferOwnershipData, + encodeGrantTeamRoleData, + encodeRevokeTeamRoleData, } from "./modules/compute"; export { createBillingModule, @@ -98,10 +101,50 @@ export { getBillingType, getAppsByBillingAccount, calculateAppID, + getAppTimelocked, + transferAppOwnership, + grantTeamRole, + revokeTeamRole, + getTeamRoleMembers, + getAppOwner, + TeamRole, type GasEstimate, type EstimateGasOptions, + type TransferOwnershipOptions, + type GrantTeamRoleOptions, + type RevokeTeamRoleOptions, + getSafeTimelockFactoryAddress, + deploySafe, + deployTimelock, + discoverTimelock, + discoverTimelockForEOA, + getTimelocksByDeployer, + getSafesByDeployer, + getPendingTimelockOps, + executeTimelockOp, + CANONICAL_SALT, + type DeploySafeOptions, + type DeployTimelockOptions, + type DiscoveredTimelock, + type PendingTimelockOp, } from "./common/contract/caller"; +// Safe Transaction Service +export { + proposeSafeTransaction, + type ProposeSafeTransactionOptions, + type SafeProposalResult, +} from "./common/contract/safe"; + +// Identity-aware transaction routing +export { + sendWithIdentity, + formatTransactionResult, + type StoredIdentity as SdkStoredIdentity, + type TransactionResult, + type IdentityRouterOptions, +} from "./common/contract/identity-router"; + // Export batch gas estimation and delegation check export { estimateBatchGas, diff --git a/packages/sdk/src/client/modules/compute/app/index.ts b/packages/sdk/src/client/modules/compute/app/index.ts index ccf6921a..b04b3465 100644 --- a/packages/sdk/src/client/modules/compute/app/index.ts +++ b/packages/sdk/src/client/modules/compute/app/index.ts @@ -34,6 +34,13 @@ import { isDelegated, getBillingType, getAppsByBillingAccount, + getAppTimelocked, + transferAppOwnership, + grantTeamRole as grantTeamRoleCaller, + revokeTeamRole as revokeTeamRoleCaller, + getTeamRoleMembers as getTeamRoleMembersCaller, + getAppOwner, + TeamRole, type GasEstimate, type AppConfig, } from "../../../common/contract/caller"; @@ -63,6 +70,9 @@ const CONTROLLER_ABI = parseAbi([ "function startApp(address appId)", "function stopApp(address appId)", "function terminateApp(address appId)", + "function transferOwnership(address appId, address newOwner)", + "function grantTeamRole(address team, uint8 role, address account)", + "function revokeTeamRole(address team, uint8 role, address account)", ]); /** @@ -98,6 +108,39 @@ export function encodeTerminateAppData(appId: AppId): Hex { }); } +/** + * Encode transferOwnership call data for gas estimation / identity routing + */ +export function encodeTransferOwnershipData(appId: AppId, newOwner: Address): Hex { + return encodeFunctionData({ + abi: CONTROLLER_ABI, + functionName: "transferOwnership", + args: [appId, newOwner], + }); +} + +/** + * Encode grantTeamRole call data for gas estimation / identity routing + */ +export function encodeGrantTeamRoleData(team: Address, role: TeamRole, account: Address): Hex { + return encodeFunctionData({ + abi: CONTROLLER_ABI, + functionName: "grantTeamRole", + args: [team, role, account], + }); +} + +/** + * Encode revokeTeamRole call data for identity routing + */ +export function encodeRevokeTeamRoleData(team: Address, role: TeamRole, account: Address): Hex { + return encodeFunctionData({ + abi: CONTROLLER_ABI, + functionName: "revokeTeamRole", + args: [team, role, account], + }); +} + export interface AppModule { // Project creation create: (opts: CreateAppOpts) => Promise; @@ -167,6 +210,15 @@ export interface AppModule { // Delegation isDelegated: () => Promise; undelegate: () => Promise<{ tx: Hex | false }>; + + // Governance + isTimelocked: (appId: AppId) => Promise; + transferOwnership: (appId: AppId, newOwner: Address, opts?: { gas?: GasEstimate }) => Promise<{ tx: Hex }>; + + // Team role management + grantTeamRole: (appId: AppId, role: TeamRole, account: Address, opts?: { gas?: GasEstimate }) => Promise<{ tx: Hex }>; + revokeTeamRole: (appId: AppId, role: TeamRole, account: Address, opts?: { gas?: GasEstimate }) => Promise<{ tx: Hex }>; + getTeamRoleMembers: (appId: AppId, role: TeamRole) => Promise; } export interface AppModuleConfig { @@ -568,5 +620,90 @@ export function createAppModule(ctx: AppModuleConfig): AppModule { }, ); }, + + async isTimelocked(appId) { + return getAppTimelocked(publicClient, environment, appId as Address); + }, + + async transferOwnership(appId, newOwner, opts) { + return withSDKTelemetry( + { + functionName: "transferOwnership", + skipTelemetry, + properties: { environment: ctx.environment }, + }, + async () => { + const tx = await transferAppOwnership( + { + walletClient, + publicClient, + environmentConfig: environment, + appID: appId as Address, + newOwner: newOwner as Address, + gas: opts?.gas, + }, + logger, + ); + return { tx }; + }, + ); + }, + + async grantTeamRole(appId, role, account, opts) { + return withSDKTelemetry( + { + functionName: "grantTeamRole", + skipTelemetry, + properties: { environment: ctx.environment }, + }, + async () => { + const team = await getAppOwner(publicClient, environment, appId as Address); + const tx = await grantTeamRoleCaller( + { + walletClient, + publicClient, + environmentConfig: environment, + team, + role, + account: account as Address, + gas: opts?.gas, + }, + logger, + ); + return { tx }; + }, + ); + }, + + async revokeTeamRole(appId, role, account, opts) { + return withSDKTelemetry( + { + functionName: "revokeTeamRole", + skipTelemetry, + properties: { environment: ctx.environment }, + }, + async () => { + const team = await getAppOwner(publicClient, environment, appId as Address); + const tx = await revokeTeamRoleCaller( + { + walletClient, + publicClient, + environmentConfig: environment, + team, + role, + account: account as Address, + gas: opts?.gas, + }, + logger, + ); + return { tx }; + }, + ); + }, + + async getTeamRoleMembers(appId, role) { + const team = await getAppOwner(publicClient, environment, appId as Address); + return getTeamRoleMembersCaller(publicClient, environment, team, role); + }, }; } diff --git a/packages/sdk/src/client/modules/compute/app/upgrade.ts b/packages/sdk/src/client/modules/compute/app/upgrade.ts index 4c857e9d..3855f8ee 100644 --- a/packages/sdk/src/client/modules/compute/app/upgrade.ts +++ b/packages/sdk/src/client/modules/compute/app/upgrade.ts @@ -37,6 +37,7 @@ import { LogVisibility, ResourceUsageMonitoring, } from "../../../common/utils/validation"; + import { doPreflightChecks } from "../../../common/utils/preflight"; import { checkAppLogPermission } from "../../../common/utils/permissions"; import { defaultLogger } from "../../../common/utils"; @@ -537,6 +538,7 @@ export async function executeUpgrade(options: ExecuteUpgradeOptions): Promise