From 4b974a24d9cc992b6fde3acdd0ec09996c923e11 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Fri, 6 Mar 2026 09:16:57 -0800 Subject: [PATCH 01/41] =?UTF-8?q?feat:=20add=20governance=20SDK=20function?= =?UTF-8?q?s=20=E2=80=94=20transferOwnership,=20scheduleUpgrade,=20execute?= =?UTF-8?q?GovernedUpgrade,=20getAppGoverned?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/client/common/abis/AppController.json | 326 ++++++++++++++++++ .../sdk/src/client/common/contract/caller.ts | 216 ++++++++++++ packages/sdk/src/client/index.ts | 14 + .../src/client/modules/compute/app/index.ts | 103 ++++++ .../src/client/modules/compute/app/upgrade.ts | 184 ++++++++++ 5 files changed, 843 insertions(+) diff --git a/packages/sdk/src/client/common/abis/AppController.json b/packages/sdk/src/client/common/abis/AppController.json index 4608a2ed..60a7e094 100644 --- a/packages/sdk/src/client/common/abis/AppController.json +++ b/packages/sdk/src/client/common/abis/AppController.json @@ -892,6 +892,216 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "safeTimelockFactory", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contractISafeTimelockFactory" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getAppGoverned", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getPendingUpgrade", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "outputs": [ + { + "name": "", + "type": "tuple", + "internalType": "structIAppController.PendingUpgrade", + "components": [ + { + "name": "releaseHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "readyAt", + "type": "uint256", + "internalType": "uint256" + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "transferOwnership", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + }, + { + "name": "newOwner", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "scheduleUpgrade", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + }, + { + "name": "release", + "type": "tuple", + "internalType": "structIAppController.Release", + "components": [ + { + "name": "rmsRelease", + "type": "tuple", + "internalType": "structIReleaseManagerTypes.Release", + "components": [ + { + "name": "artifacts", + "type": "tuple[]", + "internalType": "structIReleaseManagerTypes.Artifact[]", + "components": [ + { + "name": "digest", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "registry", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "upgradeByTime", + "type": "uint32", + "internalType": "uint32" + } + ] + }, + { + "name": "publicEnv", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "encryptedEnv", + "type": "bytes", + "internalType": "bytes" + } + ] + }, + { + "name": "delay", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "executeUpgrade", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + }, + { + "name": "release", + "type": "tuple", + "internalType": "structIAppController.Release", + "components": [ + { + "name": "rmsRelease", + "type": "tuple", + "internalType": "structIReleaseManagerTypes.Release", + "components": [ + { + "name": "artifacts", + "type": "tuple[]", + "internalType": "structIReleaseManagerTypes.Artifact[]", + "components": [ + { + "name": "digest", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "registry", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "upgradeByTime", + "type": "uint32", + "internalType": "uint32" + } + ] + }, + { + "name": "publicEnv", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "encryptedEnv", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "nonpayable" + }, { "type": "event", "name": "AppCreated", @@ -1112,6 +1322,97 @@ ], "anonymous": false }, + { + "type": "event", + "name": "AppOwnershipTransferred", + "inputs": [ + { + "name": "app", + "type": "address", + "indexed": true, + "internalType": "contractIApp" + }, + { + "name": "previousOwner", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "newOwner", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "AppUpgradeScheduled", + "inputs": [ + { + "name": "app", + "type": "address", + "indexed": true, + "internalType": "contractIApp" + }, + { + "name": "readyAt", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "release", + "type": "tuple", + "indexed": false, + "internalType": "structIAppController.Release", + "components": [ + { + "name": "rmsRelease", + "type": "tuple", + "internalType": "structIReleaseManagerTypes.Release", + "components": [ + { + "name": "artifacts", + "type": "tuple[]", + "internalType": "structIReleaseManagerTypes.Artifact[]", + "components": [ + { + "name": "digest", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "registry", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "upgradeByTime", + "type": "uint32", + "internalType": "uint32" + } + ] + }, + { + "name": "publicEnv", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "encryptedEnv", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "anonymous": false + }, { "type": "error", "name": "AccountHasActiveApps", @@ -1182,5 +1483,30 @@ "internalType": "string" } ] + }, + { + "type": "error", + "name": "DirectUpgradeNotAllowed", + "inputs": [] + }, + { + "type": "error", + "name": "GovernanceRequired", + "inputs": [] + }, + { + "type": "error", + "name": "UpgradeNotReady", + "inputs": [] + }, + { + "type": "error", + "name": "NoScheduledUpgrade", + "inputs": [] + }, + { + "type": "error", + "name": "ReleaseMismatch", + "inputs": [] } ] diff --git a/packages/sdk/src/client/common/contract/caller.ts b/packages/sdk/src/client/common/contract/caller.ts index 0999f9f0..a3b5585f 100644 --- a/packages/sdk/src/client/common/contract/caller.ts +++ b/packages/sdk/src/client/common/contract/caller.ts @@ -738,6 +738,14 @@ export async function prepareUpgradeBatch( needsPermissionChange, } = options; + // 0. Check governance — governed apps cannot use direct upgradeApp() + const governed = await getAppGoverned(publicClient, environmentConfig, appID); + if (governed) { + throw new Error( + "this app is governed — use 'ecloud compute app upgrade schedule' and 'ecloud compute app upgrade execute' instead of a direct upgrade", + ); + } + // 1. Pack upgrade app call // Convert Release Uint8Array values to hex strings for viem const releaseForViem = { @@ -1002,6 +1010,18 @@ function formatAppControllerError(decoded: { return new Error("invalid release metadata URI provided"); case "InvalidShortString": return new Error("invalid short string format"); + case "DirectUpgradeNotAllowed": + return new Error( + "this app is governed — use 'ecloud compute app upgrade schedule' and 'ecloud compute app upgrade execute' instead of a direct upgrade", + ); + case "GovernanceRequired": + return new Error("this operation requires governance mode — transfer ownership to a Safe or Timelock first"); + case "UpgradeNotReady": + return new Error("the scheduled upgrade delay has not elapsed yet"); + case "NoScheduledUpgrade": + return new Error("no upgrade is scheduled for this app"); + case "ReleaseMismatch": + return new Error("the provided release does not match the scheduled upgrade"); default: return new Error(`contract error: ${errorName}`); } @@ -1232,6 +1252,202 @@ export async function getBlockTimestamps( return timestamps; } +/** + * Get whether an app is in governance mode (requires scheduleUpgrade + executeUpgrade) + */ +export async function getAppGoverned( + publicClient: PublicClient, + environmentConfig: EnvironmentConfig, + appID: Address, +): Promise { + const governed = await publicClient.readContract({ + address: environmentConfig.appControllerAddress as Address, + abi: AppControllerABI, + functionName: "getAppGoverned", + args: [appID], + }); + + return governed as boolean; +} + +/** + * Get the pending upgrade for a governed app + */ +export interface PendingUpgrade { + releaseHash: Hex; + readyAt: bigint; +} + +export async function getPendingAppUpgrade( + publicClient: PublicClient, + environmentConfig: EnvironmentConfig, + appID: Address, +): Promise { + const result = (await publicClient.readContract({ + address: environmentConfig.appControllerAddress as Address, + abi: AppControllerABI, + functionName: "getPendingUpgrade", + args: [appID], + })) as { releaseHash: Hex; readyAt: bigint }; + + return { releaseHash: result.releaseHash, readyAt: result.readyAt }; +} + +/** + * 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, + ); +} + +/** + * Options for scheduling a governed upgrade + */ +export interface ScheduleUpgradeOptions { + walletClient: WalletClient; + publicClient: PublicClient; + environmentConfig: EnvironmentConfig; + appID: Address; + release: Release; + /** Delay in seconds before the upgrade can be executed */ + delaySeconds: bigint; + gas?: GasEstimate; +} + +/** + * Schedule an upgrade for a governed app (Safe/Timelock owner). + * Emits AppUpgradeScheduled; controller takes no action until executeGovernedUpgrade is called. + */ +export async function scheduleAppUpgrade( + options: ScheduleUpgradeOptions, + logger: Logger = noopLogger, +): Promise { + const { walletClient, publicClient, environmentConfig, appID, release, delaySeconds, gas } = options; + + const releaseForViem = { + rmsRelease: { + artifacts: release.rmsRelease.artifacts.map((artifact) => ({ + digest: `0x${bytesToHex(artifact.digest).slice(2).padStart(64, "0")}` as Hex, + registry: artifact.registry, + })), + upgradeByTime: release.rmsRelease.upgradeByTime, + }, + publicEnv: bytesToHex(release.publicEnv) as Hex, + encryptedEnv: bytesToHex(release.encryptedEnv) as Hex, + }; + + const data = encodeFunctionData({ + abi: AppControllerABI, + functionName: "scheduleUpgrade", + args: [appID, releaseForViem, delaySeconds], + }); + + return sendAndWaitForTransaction( + { + walletClient, + publicClient, + environmentConfig, + to: environmentConfig.appControllerAddress as Address, + data, + pendingMessage: `Scheduling upgrade for app ${appID} (delay: ${delaySeconds}s)...`, + txDescription: "ScheduleUpgrade", + gas, + }, + logger, + ); +} + +/** + * Options for executing a scheduled governed upgrade + */ +export interface ExecuteGovernedUpgradeOptions { + walletClient: WalletClient; + publicClient: PublicClient; + environmentConfig: EnvironmentConfig; + appID: Address; + release: Release; + gas?: GasEstimate; +} + +/** + * Execute a previously scheduled upgrade for a governed app. + * The release must match the hash committed in scheduleUpgrade. + */ +export async function executeGovernedUpgrade( + options: ExecuteGovernedUpgradeOptions, + logger: Logger = noopLogger, +): Promise { + const { walletClient, publicClient, environmentConfig, appID, release, gas } = options; + + const releaseForViem = { + rmsRelease: { + artifacts: release.rmsRelease.artifacts.map((artifact) => ({ + digest: `0x${bytesToHex(artifact.digest).slice(2).padStart(64, "0")}` as Hex, + registry: artifact.registry, + })), + upgradeByTime: release.rmsRelease.upgradeByTime, + }, + publicEnv: bytesToHex(release.publicEnv) as Hex, + encryptedEnv: bytesToHex(release.encryptedEnv) as Hex, + }; + + const data = encodeFunctionData({ + abi: AppControllerABI, + functionName: "executeUpgrade", + args: [appID, releaseForViem], + }); + + return sendAndWaitForTransaction( + { + walletClient, + publicClient, + environmentConfig, + to: environmentConfig.appControllerAddress as Address, + data, + pendingMessage: `Executing scheduled upgrade for app ${appID}...`, + txDescription: "ExecuteUpgrade", + gas, + }, + logger, + ); +} + /** * Suspend options */ diff --git a/packages/sdk/src/client/index.ts b/packages/sdk/src/client/index.ts index 9a3b1fd2..e7af1841 100644 --- a/packages/sdk/src/client/index.ts +++ b/packages/sdk/src/client/index.ts @@ -46,7 +46,12 @@ export { prepareUpgradeFromVerifiableBuild, executeUpgrade, watchUpgrade, + scheduleUpgrade, + executeGovernedUpgrade, type PrepareUpgradeResult, + type GovernedUpgradeResult, + type ScheduleUpgradeOptions, + type ExecuteGovernedUpgradeOptions, } from "./modules/compute/app/upgrade"; // Export compute module for standalone use @@ -98,8 +103,17 @@ export { getBillingType, getAppsByBillingAccount, calculateAppID, + getAppGoverned, + getPendingAppUpgrade, + transferAppOwnership, + scheduleAppUpgrade, + executeGovernedUpgrade as executeGovernedUpgradeOnChain, type GasEstimate, type EstimateGasOptions, + type PendingUpgrade, + type TransferOwnershipOptions, + type ScheduleUpgradeOptions as ScheduleUpgradeCallerOptions, + type ExecuteGovernedUpgradeOptions as ExecuteGovernedUpgradeCallerOptions, } from "./common/contract/caller"; // Export batch gas estimation and delegation check diff --git a/packages/sdk/src/client/modules/compute/app/index.ts b/packages/sdk/src/client/modules/compute/app/index.ts index ccf6921a..48259479 100644 --- a/packages/sdk/src/client/modules/compute/app/index.ts +++ b/packages/sdk/src/client/modules/compute/app/index.ts @@ -34,8 +34,14 @@ import { isDelegated, getBillingType, getAppsByBillingAccount, + getAppGoverned, + getPendingAppUpgrade, + transferAppOwnership, + scheduleAppUpgrade, + executeGovernedUpgrade, type GasEstimate, type AppConfig, + type PendingUpgrade, } from "../../../common/contract/caller"; import { withSDKTelemetry } from "../../../common/telemetry/wrapper"; import { UserApiClient } from "../../../common/utils/userapi"; @@ -167,6 +173,22 @@ export interface AppModule { // Delegation isDelegated: () => Promise; undelegate: () => Promise<{ tx: Hex | false }>; + + // Governance + isGoverned: (appId: AppId) => Promise; + getPendingUpgrade: (appId: AppId) => Promise; + transferOwnership: (appId: AppId, newOwner: Address, opts?: { gas?: GasEstimate }) => Promise<{ tx: Hex }>; + scheduleUpgrade: ( + appId: AppId, + release: import("../../../common/types").Release, + delaySeconds: bigint, + opts?: { gas?: GasEstimate }, + ) => Promise<{ tx: Hex }>; + executeGovernedUpgrade: ( + appId: AppId, + release: import("../../../common/types").Release, + opts?: { gas?: GasEstimate }, + ) => Promise<{ tx: Hex }>; } export interface AppModuleConfig { @@ -568,5 +590,86 @@ export function createAppModule(ctx: AppModuleConfig): AppModule { }, ); }, + + async isGoverned(appId) { + return getAppGoverned(publicClient, environment, appId as Address); + }, + + async getPendingUpgrade(appId) { + return getPendingAppUpgrade(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 scheduleUpgrade(appId, release, delaySeconds, opts) { + return withSDKTelemetry( + { + functionName: "scheduleUpgrade", + skipTelemetry, + properties: { environment: ctx.environment }, + }, + async () => { + const tx = await scheduleAppUpgrade( + { + walletClient, + publicClient, + environmentConfig: environment, + appID: appId as Address, + release, + delaySeconds, + gas: opts?.gas, + }, + logger, + ); + return { tx }; + }, + ); + }, + + async executeGovernedUpgrade(appId, release, opts) { + return withSDKTelemetry( + { + functionName: "executeGovernedUpgrade", + skipTelemetry, + properties: { environment: ctx.environment }, + }, + async () => { + const tx = await executeGovernedUpgrade( + { + walletClient, + publicClient, + environmentConfig: environment, + appID: appId as Address, + release, + gas: opts?.gas, + }, + logger, + ); + return { tx }; + }, + ); + }, }; } diff --git a/packages/sdk/src/client/modules/compute/app/upgrade.ts b/packages/sdk/src/client/modules/compute/app/upgrade.ts index 4c857e9d..e2c98c5f 100644 --- a/packages/sdk/src/client/modules/compute/app/upgrade.ts +++ b/packages/sdk/src/client/modules/compute/app/upgrade.ts @@ -24,7 +24,11 @@ import { upgradeApp, prepareUpgradeBatch, executeUpgradeBatch, + scheduleAppUpgrade, + executeGovernedUpgrade as executeGovernedUpgradeOnChain, + getPendingAppUpgrade, type GasEstimate, + type PendingUpgrade, } from "../../../common/contract/caller"; import { estimateBatchGas, createAuthorizationList } from "../../../common/contract/eip7702"; import { watchUntilUpgradeComplete } from "../../../common/contract/watcher"; @@ -537,6 +541,186 @@ export async function executeUpgrade(options: ExecuteUpgradeOptions): Promise { + /** Delay in seconds before the upgrade can be executed */ + delaySeconds: bigint; + gas?: GasEstimate; +} + +/** + * Schedule an upgrade for a governed app (requires prior scheduleUpgrade + executeUpgrade flow). + * + * Runs the full build pipeline, then calls scheduleUpgrade on-chain instead of upgradeApp. + * The controller does NOT act on this event — call executeGovernedUpgrade after the delay. + */ +export async function scheduleUpgrade( + options: ScheduleUpgradeOptions, + logger: Logger = defaultLogger, +): Promise { + return withSDKTelemetry( + { + functionName: "scheduleUpgrade", + skipTelemetry: options.skipTelemetry, + properties: { environment: options.environment || "sepolia" }, + }, + async () => { + logger.debug("Performing preflight checks..."); + const preflightCtx = await doPreflightChecks( + { walletClient: options.walletClient, publicClient: options.publicClient, environment: options.environment }, + logger, + ); + + const appID = validateUpgradeOptions(options as SDKUpgradeOptions); + const { logRedirect } = validateLogVisibility(options.logVisibility); + const resourceUsageAllow = validateResourceUsageMonitoring(options.resourceUsageMonitoring); + + logger.debug("Checking Docker..."); + await ensureDockerIsRunning(); + + const dockerfilePath = options.dockerfilePath || ""; + const imageRef = options.imageRef || ""; + const envFilePath = options.envFilePath || ""; + + logger.info("Preparing release..."); + const { release, finalImageRef } = await prepareRelease( + { + dockerfilePath, + imageRef, + envFilePath, + logRedirect, + resourceUsageAllow, + instanceType: options.instanceType, + environmentConfig: preflightCtx.environmentConfig, + appId: appID, + }, + logger, + ); + + logger.info("Scheduling upgrade on-chain..."); + const txHash = await scheduleAppUpgrade( + { + walletClient: preflightCtx.walletClient, + publicClient: preflightCtx.publicClient, + environmentConfig: preflightCtx.environmentConfig, + appID, + release, + delaySeconds: options.delaySeconds, + gas: options.gas, + }, + logger, + ); + + return { appId: appID, imageRef: finalImageRef, txHash }; + }, + ); +} + +/** + * Options for executeGovernedUpgrade + */ +export type ExecuteGovernedUpgradeOptions = Omit & { gas?: GasEstimate }; + +/** + * Execute a previously scheduled upgrade for a governed app. + * + * Runs the full build pipeline with the same inputs as scheduleUpgrade to reconstruct + * the Release, then calls executeUpgrade on-chain (verifying hash match). + */ +export async function executeGovernedUpgrade( + options: ExecuteGovernedUpgradeOptions, + logger: Logger = defaultLogger, +): Promise { + return withSDKTelemetry( + { + functionName: "executeGovernedUpgrade", + skipTelemetry: options.skipTelemetry, + properties: { environment: options.environment || "sepolia" }, + }, + async () => { + logger.debug("Performing preflight checks..."); + const preflightCtx = await doPreflightChecks( + { walletClient: options.walletClient, publicClient: options.publicClient, environment: options.environment }, + logger, + ); + + const appID = validateUpgradeOptions(options as SDKUpgradeOptions); + const { logRedirect } = validateLogVisibility(options.logVisibility); + const resourceUsageAllow = validateResourceUsageMonitoring(options.resourceUsageMonitoring); + + // Check that a scheduled upgrade exists and is ready + const pending = await getPendingAppUpgrade(preflightCtx.publicClient, preflightCtx.environmentConfig, appID); + if (pending.readyAt === 0n) { + throw new Error("no upgrade is scheduled for this app"); + } + const now = BigInt(Math.floor(Date.now() / 1000)); + if (now < pending.readyAt) { + const remaining = pending.readyAt - now; + throw new Error(`upgrade is not ready yet — ${remaining}s remaining`); + } + + logger.debug("Checking Docker..."); + await ensureDockerIsRunning(); + + const dockerfilePath = options.dockerfilePath || ""; + const imageRef = options.imageRef || ""; + const envFilePath = options.envFilePath || ""; + + logger.info("Preparing release (must match scheduled release)..."); + const { release, finalImageRef } = await prepareRelease( + { + dockerfilePath, + imageRef, + envFilePath, + logRedirect, + resourceUsageAllow, + instanceType: options.instanceType, + environmentConfig: preflightCtx.environmentConfig, + appId: appID, + }, + logger, + ); + + logger.info("Executing scheduled upgrade on-chain..."); + const txHash = await executeGovernedUpgradeOnChain( + { + walletClient: preflightCtx.walletClient, + publicClient: preflightCtx.publicClient, + environmentConfig: preflightCtx.environmentConfig, + appID, + release, + gas: options.gas, + }, + logger, + ); + + logger.info("Waiting for upgrade to complete..."); + await watchUntilUpgradeComplete( + { + walletClient: preflightCtx.walletClient, + publicClient: preflightCtx.publicClient, + environmentConfig: preflightCtx.environmentConfig, + appId: appID, + }, + logger, + ); + + return { appId: appID, imageRef: finalImageRef, txHash }; + }, + ); +} + /** * Watch an upgrade until it completes * From 12e27704e7ec02ed7ff2ebcd0d10e3e63938fce4 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Fri, 6 Mar 2026 09:17:31 -0800 Subject: [PATCH 02/41] =?UTF-8?q?feat:=20add=20CLI=20commands=20=E2=80=94?= =?UTF-8?q?=20app=20ownership=20transfer,=20upgrade=20schedule,=20upgrade?= =?UTF-8?q?=20execute?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compute/app/ownership/transfer.ts | 82 ++++++++ .../cli/src/commands/compute/app/upgrade.ts | 11 + .../commands/compute/app/upgrade/execute.ts | 183 ++++++++++++++++ .../commands/compute/app/upgrade/schedule.ts | 198 ++++++++++++++++++ 4 files changed, 474 insertions(+) create mode 100644 packages/cli/src/commands/compute/app/ownership/transfer.ts create mode 100644 packages/cli/src/commands/compute/app/upgrade/execute.ts create mode 100644 packages/cli/src/commands/compute/app/upgrade/schedule.ts 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..d720f25e --- /dev/null +++ b/packages/cli/src/commands/compute/app/ownership/transfer.ts @@ -0,0 +1,82 @@ +import { Command, Args, Flags } from "@oclif/core"; +import { getEnvironmentConfig, isMainnet } from "@layr-labs/ecloud-sdk"; +import { withTelemetry } from "../../../../telemetry"; +import { commonFlags } from "../../../../flags"; +import { createComputeClient } from "../../../../client"; +import { getOrPromptAppID, confirm } from "../../../../utils/prompts"; +import { isAddress } 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: true, + description: "New owner address (Safe or Timelock address enables governance mode)", + env: "ECLOUD_NEW_OWNER", + }), + }; + + 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 = flags["private-key"]!; + + 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}`); + } + + // Check current governance state + const governed = await compute.app.isGoverned(appId); + + this.log(`\nApp: ${chalk.bold(appId)}`); + this.log(`New owner: ${chalk.bold(newOwner)}`); + if (!governed) { + this.log(chalk.yellow("\nNote: if the new owner is a Safe or Timelock deployed by SafeTimelockFactory, governance mode will be enabled automatically.")); + } + + if (isMainnet(environmentConfig)) { + const confirmed = await confirm("Continue with ownership transfer?"); + if (!confirmed) { + this.log(`\n${chalk.gray("Transfer cancelled")}`); + return; + } + } + + const res = await compute.app.transferOwnership(appId, newOwner); + + this.log(`\n✅ ${chalk.green(`Ownership transferred successfully (tx: ${res.tx})`)}`); + + // Check whether governance was enabled as a result + const nowGoverned = await compute.app.isGoverned(appId); + if (nowGoverned) { + this.log(chalk.cyan("\nGovernance mode enabled. Upgrades now require:")); + this.log(chalk.cyan(" ecloud compute app upgrade schedule --app= --after=")); + this.log(chalk.cyan(" ecloud compute app upgrade execute --app=")); + } + }); + } +} diff --git a/packages/cli/src/commands/compute/app/upgrade.ts b/packages/cli/src/commands/compute/app/upgrade.ts index e19dcbd1..8f4c7a86 100644 --- a/packages/cli/src/commands/compute/app/upgrade.ts +++ b/packages/cli/src/commands/compute/app/upgrade.ts @@ -146,6 +146,17 @@ export default class AppUpgrade extends Command { action: "upgrade", }); + // Check governance mode — governed apps cannot be directly upgraded + const governed = await compute.app.isGoverned(appID); + if (governed) { + this.error( + `App ${appID} is in governance mode (Safe/Timelock owner).\n` + + `Use the two-step governance flow instead:\n` + + ` ecloud compute app upgrade schedule --app=${appID} --after=\n` + + ` ecloud compute app upgrade execute --app=${appID}`, + ); + } + type VerifiableMode = "none" | "git" | "prebuilt"; let buildClient: Awaited> | undefined; const getBuildClient = async () => { diff --git a/packages/cli/src/commands/compute/app/upgrade/execute.ts b/packages/cli/src/commands/compute/app/upgrade/execute.ts new file mode 100644 index 00000000..44277df1 --- /dev/null +++ b/packages/cli/src/commands/compute/app/upgrade/execute.ts @@ -0,0 +1,183 @@ +import { Command, Args, Flags } from "@oclif/core"; +import { getEnvironmentConfig, isMainnet } from "@layr-labs/ecloud-sdk"; +import { withTelemetry } from "../../../../telemetry"; +import { commonFlags } from "../../../../flags"; +import { createComputeClient } from "../../../../client"; +import { createViemClients } from "../../../../utils/viemClients"; +import { + getDockerfileInteractive, + getImageReferenceInteractive, + getEnvFileInteractive, + getInstanceTypeInteractive, + getLogSettingsInteractive, + getResourceUsageMonitoringInteractive, + getOrPromptAppID, + LogVisibility, + ResourceUsageMonitoring, + confirm, +} from "../../../../utils/prompts"; +import chalk from "chalk"; +import { UserApiClient } from "@layr-labs/ecloud-sdk"; +import { getClientId } from "../../../../utils/version"; +import { executeGovernedUpgrade } from "@layr-labs/ecloud-sdk"; +import { setLinkedAppForDirectory } from "../../../../utils/globalConfig"; +import { getDashboardUrl } from "../../../../utils/dashboard"; + +export default class AppUpgradeExecute extends Command { + static description = "Execute a previously scheduled governed upgrade once the delay has elapsed"; + + static args = { + "app-id": Args.string({ + description: "App ID or name", + required: false, + }), + }; + + static flags = { + ...commonFlags, + dockerfile: Flags.string({ + required: false, + description: "Path to Dockerfile (must match what was used in schedule)", + env: "ECLOUD_DOCKERFILE_PATH", + }), + "image-ref": Flags.string({ + required: false, + description: "Image reference (must match what was used in schedule)", + env: "ECLOUD_IMAGE_REF", + }), + "env-file": Flags.string({ + required: false, + description: 'Environment file (must match what was used in schedule)', + default: ".env", + env: "ECLOUD_ENVFILE_PATH", + }), + "log-visibility": Flags.string({ + required: false, + description: "Log visibility setting: public, private, or off", + options: ["public", "private", "off"], + env: "ECLOUD_LOG_VISIBILITY", + }), + "instance-type": Flags.string({ + required: false, + description: "Machine instance type", + env: "ECLOUD_INSTANCE_TYPE", + }), + "resource-usage-monitoring": Flags.string({ + required: false, + description: "Resource usage monitoring: enable or disable", + options: ["enable", "disable"], + env: "ECLOUD_RESOURCE_USAGE_MONITORING", + }), + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(AppUpgradeExecute); + 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"]!; + + // Resolve app ID + const appID = await getOrPromptAppID({ + appID: args["app-id"], + environment, + privateKey, + rpcUrl, + action: "execute upgrade", + }); + + // Verify governance mode + const governed = await compute.app.isGoverned(appID); + if (!governed) { + this.error("This app is not in governance mode. Use 'ecloud compute app upgrade' for direct upgrades."); + } + + // Check scheduled upgrade status + const pending = await compute.app.getPendingUpgrade(appID); + if (pending.readyAt === 0n) { + this.error("No upgrade is scheduled for this app. Run 'ecloud compute app upgrade schedule' first."); + } + + const now = BigInt(Math.floor(Date.now() / 1000)); + if (now < pending.readyAt) { + const remaining = pending.readyAt - now; + const readyDate = new Date(Number(pending.readyAt) * 1000).toLocaleString(); + this.error(`Upgrade is not ready yet. Executable after ${chalk.bold(readyDate)} (${remaining}s remaining).`); + } + + this.log(chalk.cyan(`\nScheduled upgrade is ready. Proceeding with execution...`)); + this.log(chalk.yellow("Note: build inputs must exactly match what was used in 'upgrade schedule'.")); + + // Collect the same build inputs used during scheduling + const dockerfilePath = await getDockerfileInteractive(flags.dockerfile); + const buildFromDockerfile = dockerfilePath !== ""; + const imageRef = await getImageReferenceInteractive(flags["image-ref"], buildFromDockerfile); + const envFilePath = await getEnvFileInteractive(flags["env-file"]); + + const { publicClient, walletClient } = createViemClients({ privateKey, rpcUrl, environment }); + let currentInstanceType = ""; + try { + const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { clientId: getClientId() }); + const infos = await userApiClient.getInfos([appID], 1); + if (infos.length > 0) currentInstanceType = infos[0].machineType || ""; + } catch {} + + const availableTypes = await fetchAvailableInstanceTypes(environmentConfig, walletClient, publicClient); + const instanceType = await getInstanceTypeInteractive(flags["instance-type"], currentInstanceType, availableTypes); + + const logSettings = await getLogSettingsInteractive(flags["log-visibility"] as LogVisibility | undefined); + const resourceUsageMonitoring = await getResourceUsageMonitoringInteractive( + flags["resource-usage-monitoring"] as ResourceUsageMonitoring | undefined, + ); + const logVisibility = logSettings.publicLogs ? "public" : logSettings.logRedirect ? "private" : "off"; + + if (isMainnet(environmentConfig)) { + const confirmed = await confirm("Execute the scheduled upgrade?"); + if (!confirmed) { + this.log(`\n${chalk.gray("Execution cancelled")}`); + return; + } + } + + const res = await executeGovernedUpgrade( + { + appId: appID, + walletClient, + publicClient, + environment, + dockerfilePath, + imageRef, + envFilePath, + instanceType, + logVisibility: logVisibility as LogVisibility, + resourceUsageMonitoring: resourceUsageMonitoring as ResourceUsageMonitoring, + skipTelemetry: true, + }, + ); + + try { + const cwd = process.env.INIT_CWD || process.cwd(); + setLinkedAppForDirectory(environment, cwd, res.appId); + } catch {} + + this.log( + `\n✅ ${chalk.green(`App upgraded successfully ${chalk.bold(`(id: ${res.appId}, image: ${res.imageRef})`)}`)}`, + ); + + const dashboardUrl = getDashboardUrl(environment, res.appId); + this.log(`\n${chalk.gray("View your app:")} ${chalk.blue.underline(dashboardUrl)}`); + }); + } +} + +async function fetchAvailableInstanceTypes(environmentConfig: any, walletClient: any, publicClient: any) { + try { + const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { clientId: getClientId() }); + const skuList = await userApiClient.getSKUs(); + if (skuList.skus.length > 0) return skuList.skus; + } catch {} + return [{ sku: "g1-standard-4t", description: "4 vCPUs, 16 GB memory, TDX" }]; +} diff --git a/packages/cli/src/commands/compute/app/upgrade/schedule.ts b/packages/cli/src/commands/compute/app/upgrade/schedule.ts new file mode 100644 index 00000000..403e72db --- /dev/null +++ b/packages/cli/src/commands/compute/app/upgrade/schedule.ts @@ -0,0 +1,198 @@ +import { Command, Args, Flags } from "@oclif/core"; +import { getEnvironmentConfig, isMainnet } from "@layr-labs/ecloud-sdk"; +import { withTelemetry } from "../../../../telemetry"; +import { commonFlags } from "../../../../flags"; +import { createComputeClient } from "../../../../client"; +import { createViemClients } from "../../../../utils/viemClients"; +import { + getDockerfileInteractive, + getImageReferenceInteractive, + getEnvFileInteractive, + getInstanceTypeInteractive, + getLogSettingsInteractive, + getResourceUsageMonitoringInteractive, + getOrPromptAppID, + LogVisibility, + ResourceUsageMonitoring, + confirm, +} from "../../../../utils/prompts"; +import chalk from "chalk"; +import { UserApiClient } from "@layr-labs/ecloud-sdk"; +import { getClientId } from "../../../../utils/version"; +import { scheduleUpgrade } from "@layr-labs/ecloud-sdk"; + +/** + * Parse a human-readable duration string into seconds. + * Supported: 30s, 5m, 2h, 1d + */ +function parseDurationToSeconds(input: string): bigint { + const match = input.match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)?$/i); + if (!match) { + throw new Error(`Invalid duration "${input}". Use format: 30s, 5m, 2h, 1d`); + } + const value = parseFloat(match[1]); + const unit = (match[2] || "s").toLowerCase(); + const multipliers: Record = { s: 1, m: 60, h: 3600, d: 86400 }; + return BigInt(Math.ceil(value * multipliers[unit])); +} + +export default class AppUpgradeSchedule extends Command { + static description = "Schedule a governed upgrade (Safe/Timelock multi-sig flow). The upgrade becomes executable after the specified delay."; + + static args = { + "app-id": Args.string({ + description: "App ID or name to upgrade", + required: false, + }), + }; + + static flags = { + ...commonFlags, + after: Flags.string({ + required: true, + description: "Delay before upgrade can execute (e.g. 30s, 5m, 2h, 1d)", + env: "ECLOUD_UPGRADE_DELAY", + }), + dockerfile: Flags.string({ + required: false, + description: "Path to Dockerfile", + env: "ECLOUD_DOCKERFILE_PATH", + }), + "image-ref": Flags.string({ + required: false, + description: "Image reference pointing to registry", + env: "ECLOUD_IMAGE_REF", + }), + "env-file": Flags.string({ + required: false, + description: 'Environment file to use (default: ".env")', + default: ".env", + env: "ECLOUD_ENVFILE_PATH", + }), + "log-visibility": Flags.string({ + required: false, + description: "Log visibility setting: public, private, or off", + options: ["public", "private", "off"], + env: "ECLOUD_LOG_VISIBILITY", + }), + "instance-type": Flags.string({ + required: false, + description: "Machine instance type", + env: "ECLOUD_INSTANCE_TYPE", + }), + "resource-usage-monitoring": Flags.string({ + required: false, + description: "Resource usage monitoring: enable or disable", + options: ["enable", "disable"], + env: "ECLOUD_RESOURCE_USAGE_MONITORING", + }), + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(AppUpgradeSchedule); + 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"]!; + + // Parse delay + let delaySeconds: bigint; + try { + delaySeconds = parseDurationToSeconds(flags.after); + } catch (e: any) { + this.error(e.message); + } + + // Resolve app ID + const appID = await getOrPromptAppID({ + appID: args["app-id"], + environment, + privateKey, + rpcUrl, + action: "schedule upgrade", + }); + + // Verify governance mode + const governed = await compute.app.isGoverned(appID); + if (!governed) { + this.error( + "This app is not in governance mode. Use 'ecloud compute app upgrade' for direct upgrades, or transfer ownership to a Safe/Timelock first.", + ); + } + + // Collect build inputs + const dockerfilePath = await getDockerfileInteractive(flags.dockerfile); + const buildFromDockerfile = dockerfilePath !== ""; + const imageRef = await getImageReferenceInteractive(flags["image-ref"], buildFromDockerfile); + const envFilePath = await getEnvFileInteractive(flags["env-file"]); + + // Instance type + const { publicClient, walletClient } = createViemClients({ privateKey, rpcUrl, environment }); + let currentInstanceType = ""; + try { + const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { clientId: getClientId() }); + const infos = await userApiClient.getInfos([appID], 1); + if (infos.length > 0) currentInstanceType = infos[0].machineType || ""; + } catch { /* best-effort */ } + + const availableTypes = await fetchAvailableInstanceTypes(environmentConfig, walletClient, publicClient); + const instanceType = await getInstanceTypeInteractive(flags["instance-type"], currentInstanceType, availableTypes); + + const logSettings = await getLogSettingsInteractive(flags["log-visibility"] as LogVisibility | undefined); + const resourceUsageMonitoring = await getResourceUsageMonitoringInteractive( + flags["resource-usage-monitoring"] as ResourceUsageMonitoring | undefined, + ); + const logVisibility = logSettings.publicLogs ? "public" : logSettings.logRedirect ? "private" : "off"; + + const readyAt = Math.floor(Date.now() / 1000) + Number(delaySeconds); + const readyDate = new Date(readyAt * 1000).toLocaleString(); + + this.log(`\nApp: ${chalk.bold(appID)}`); + this.log(`Delay: ${chalk.bold(flags.after)} (executable after ${chalk.bold(readyDate)})`); + this.log(`Image: ${chalk.bold(imageRef || dockerfilePath)}`); + + if (isMainnet(environmentConfig)) { + const confirmed = await confirm("Schedule this upgrade?"); + if (!confirmed) { + this.log(`\n${chalk.gray("Upgrade scheduling cancelled")}`); + return; + } + } + + const res = await scheduleUpgrade( + { + appId: appID, + walletClient, + publicClient, + environment, + dockerfilePath, + imageRef, + envFilePath, + instanceType, + logVisibility: logVisibility as LogVisibility, + resourceUsageMonitoring: resourceUsageMonitoring as ResourceUsageMonitoring, + delaySeconds, + skipTelemetry: true, + }, + ); + + this.log( + `\n✅ ${chalk.green(`Upgrade scheduled (tx: ${res.txHash})`)}`, + ); + this.log(chalk.cyan(`\nExecutable after: ${chalk.bold(readyDate)}`)); + this.log(chalk.cyan(`Run to execute: ecloud compute app upgrade execute --app=${appID}`)); + }); + } +} + +async function fetchAvailableInstanceTypes(environmentConfig: any, walletClient: any, publicClient: any) { + try { + const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { clientId: getClientId() }); + const skuList = await userApiClient.getSKUs(); + if (skuList.skus.length > 0) return skuList.skus; + } catch {} + return [{ sku: "g1-standard-4t", description: "4 vCPUs, 16 GB memory, TDX" }]; +} From 764f38acee3da40f1c91e45acad0107aaf1a1b8b Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Fri, 6 Mar 2026 11:40:11 -0800 Subject: [PATCH 03/41] =?UTF-8?q?feat:=20rename=20governed=E2=86=92timeloc?= =?UTF-8?q?ked,=20add=20ownership/upgrade=20governance=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ABI: getAppGoverned→getAppTimelocked, DirectUpgradeNotAllowed→TimelockRequired, GovernanceRequired→NotTimelocked - SDK caller: getAppGoverned→getAppTimelocked, update error messages - SDK app module: isGoverned→isTimelocked in interface and implementation - CLI upgrade: block direct upgrade when app is timelocked - CLI upgrade schedule/execute: new commands for timelocked two-step flow - CLI ownership transfer: new command, shows timelocked mode note post-transfer - Docs: add governance-commands.md documenting all new commands and flows --- docs/governance-commands.md | 201 ++++++++++++++++++ .../compute/app/ownership/transfer.ts | 16 +- .../cli/src/commands/compute/app/upgrade.ts | 10 +- .../commands/compute/app/upgrade/execute.ts | 10 +- .../commands/compute/app/upgrade/schedule.ts | 10 +- .../src/client/common/abis/AppController.json | 6 +- .../sdk/src/client/common/contract/caller.ts | 26 +-- packages/sdk/src/client/index.ts | 2 +- .../src/client/modules/compute/app/index.ts | 8 +- 9 files changed, 245 insertions(+), 44 deletions(-) create mode 100644 docs/governance-commands.md diff --git a/docs/governance-commands.md b/docs/governance-commands.md new file mode 100644 index 00000000..5d9ebcc2 --- /dev/null +++ b/docs/governance-commands.md @@ -0,0 +1,201 @@ +# Timelocked Upgrade Commands + +EigenCloud supports two upgrade flows depending on who owns the app: + +- **EOA or Safe owner** — direct upgrade via `upgradeApp`, controller acts immediately (Safe handles threshold approval externally) +- **Timelock owner** — two-step flow: schedule → wait → execute + +Timelocked mode is set automatically when ownership is transferred to a Timelock deployed by `SafeTimelockFactory`. + +--- + +## Commands + +### `ecloud compute app ownership transfer` + +Transfer ownership of an app to a new address. + +``` +ecloud compute app ownership transfer --app= --to=
+``` + +| Flag | Required | Description | +|------|----------|-------------| +| `--app` | yes | App ID or name | +| `--to` | yes | New owner address | + +If `--to` is a Timelock deployed by `SafeTimelockFactory`, **timelocked mode is enabled automatically** and direct upgrades are blocked. Transferring to a Safe or EOA does not enable timelocked mode. + +**Examples:** + +```sh +# Transfer to another EOA — no governance change +ecloud compute app ownership transfer \ + --app=0xAbc...123 \ + --to=0xDef...456 + +# Transfer to a Timelock — timelocked mode enabled +ecloud compute app ownership transfer \ + --app=0xAbc...123 \ + --to=0xTimelock...789 +``` + +--- + +### `ecloud compute app upgrade schedule` + +Schedule an upgrade for a timelocked app. Builds the image and commits a hash on-chain. The controller takes no action until `execute` is called after the delay. + +``` +ecloud compute app upgrade schedule --app= --after= [build flags] +``` + +| Flag | Required | Description | +|------|----------|-------------| +| `--app` | yes | App ID or name | +| `--after` | yes | Delay before upgrade can execute: `30s`, `5m`, `2h`, `1d` | +| `--image-ref` | no | Image reference pointing to registry | +| `--dockerfile` | no | Path to Dockerfile (alternative to `--image-ref`) | +| `--env-file` | no | Environment file (default: `.env`) | +| `--instance-type` | no | Machine instance type | +| `--log-visibility` | no | `public`, `private`, or `off` | +| `--resource-usage-monitoring` | no | `enable` or `disable` | + +**Example:** + +```sh +ecloud compute app upgrade schedule \ + --app=0xAbc...123 \ + --after=2h \ + --image-ref=myrepo/myapp:v2 \ + --env-file=.env.prod \ + --instance-type=g1-standard-4t \ + --log-visibility=public +``` + +``` +App: 0xAbc...123 +Delay: 2h (executable after 3/7/2026, 4:00:00 PM) +Image: myrepo/myapp:v2 + +✅ Upgrade scheduled (tx: 0x...) + +Executable after: 3/7/2026, 4:00:00 PM +Run to execute: ecloud compute app upgrade execute --app=0xAbc...123 +``` + +The `AppUpgradeScheduled` event is emitted on-chain. Multi-sig participants can review the pending upgrade during the delay window. + +--- + +### `ecloud compute app upgrade execute` + +Execute a previously scheduled upgrade once the delay has elapsed. Must be called with the **same build inputs** used in `schedule` — the release is reconstructed and its hash is verified against the on-chain commitment. + +``` +ecloud compute app upgrade execute --app= [same build flags as schedule] +``` + +| Flag | Required | Description | +|------|----------|-------------| +| `--app` | yes | App ID or name | +| `--image-ref` | no | Must match what was used in `schedule` | +| `--dockerfile` | no | Must match what was used in `schedule` | +| `--env-file` | no | Must match what was used in `schedule` | +| `--instance-type` | no | Must match what was used in `schedule` | +| `--log-visibility` | no | Must match what was used in `schedule` | +| `--resource-usage-monitoring` | no | Must match what was used in `schedule` | + +**Example:** + +```sh +ecloud compute app upgrade execute \ + --app=0xAbc...123 \ + --image-ref=myrepo/myapp:v2 \ + --env-file=.env.prod \ + --instance-type=g1-standard-4t \ + --log-visibility=public +``` + +``` +Scheduled upgrade is ready. Proceeding with execution... +Note: build inputs must exactly match what was used in 'upgrade schedule'. + +✅ App upgraded successfully (id: 0xAbc...123, image: myrepo/myapp:v2) + +View your app: https://app.eigencloud.xyz/apps/0xAbc...123 +``` + +**Error cases:** + +``` +# Delay not elapsed +✗ Upgrade is not ready yet. Executable after 3/7/2026, 4:00:00 PM (6847s remaining). + +# No scheduled upgrade +✗ No upgrade is scheduled for this app. Run 'ecloud compute app upgrade schedule' first. + +# Release mismatch (wrong inputs) +✗ contract error: ReleaseMismatch +``` + +--- + +### `ecloud compute app upgrade` (unchanged for EOA apps) + +Direct upgrade — unchanged behavior for non-governed apps. + +``` +ecloud compute app upgrade --app= [build flags] +``` + +If called on a timelocked app: + +``` +✗ App 0xAbc...123 is timelocked (Timelock owner). + Use the two-step timelocked flow instead: + ecloud compute app upgrade schedule --app=0xAbc...123 --after= + ecloud compute app upgrade execute --app=0xAbc...123 +``` + +--- + +## Flow summary + +``` +EOA or Safe-owned app +────────────────────── +ecloud compute app upgrade + └─ upgradeApp() on-chain + └─ AppUpgraded event → controller acts immediately + (Safe handles multi-sig threshold externally before calling this) + +Timelock-owned app +─────────────────── +ecloud compute app upgrade schedule --after=2h + └─ scheduleUpgrade() on-chain + └─ AppUpgradeScheduled event (no controller action) + └─ [2h delay — participants can review or cancel] + +ecloud compute app upgrade execute + └─ executeUpgrade() on-chain (verifies hash, checks delay) + └─ AppUpgraded event → controller acts +``` + +--- + +## Ownership transfer flow + +``` +ecloud compute app ownership transfer --app= --to= + └─ transferOwnership() on-chain + └─ SafeTimelockFactory.isTimelock(newOwner) → true → timelocked = true + └─ AppOwnershipTransferred event + └─ direct upgradeApp() now blocked for this app + +ecloud compute app ownership transfer --app= --to= + └─ transferOwnership() on-chain + └─ SafeTimelockFactory.isTimelock(newOwner) → false → timelocked = false + └─ AppOwnershipTransferred event + └─ direct upgradeApp() still available (Safe handles threshold externally) +``` diff --git a/packages/cli/src/commands/compute/app/ownership/transfer.ts b/packages/cli/src/commands/compute/app/ownership/transfer.ts index d720f25e..e7ef6371 100644 --- a/packages/cli/src/commands/compute/app/ownership/transfer.ts +++ b/packages/cli/src/commands/compute/app/ownership/transfer.ts @@ -49,13 +49,13 @@ export default class AppOwnershipTransfer extends Command { this.error(`Invalid address: ${newOwner}`); } - // Check current governance state - const governed = await compute.app.isGoverned(appId); + // Check current timelocked state + const timelocked = await compute.app.isTimelocked(appId); this.log(`\nApp: ${chalk.bold(appId)}`); this.log(`New owner: ${chalk.bold(newOwner)}`); - if (!governed) { - this.log(chalk.yellow("\nNote: if the new owner is a Safe or Timelock deployed by SafeTimelockFactory, governance mode will be enabled automatically.")); + if (!timelocked) { + this.log(chalk.yellow("\nNote: if the new owner is a Timelock deployed by SafeTimelockFactory, timelocked mode will be enabled automatically.")); } if (isMainnet(environmentConfig)) { @@ -70,10 +70,10 @@ export default class AppOwnershipTransfer extends Command { this.log(`\n✅ ${chalk.green(`Ownership transferred successfully (tx: ${res.tx})`)}`); - // Check whether governance was enabled as a result - const nowGoverned = await compute.app.isGoverned(appId); - if (nowGoverned) { - this.log(chalk.cyan("\nGovernance mode enabled. Upgrades now require:")); + // 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. Upgrades now require:")); this.log(chalk.cyan(" ecloud compute app upgrade schedule --app= --after=")); this.log(chalk.cyan(" ecloud compute app upgrade execute --app=")); } diff --git a/packages/cli/src/commands/compute/app/upgrade.ts b/packages/cli/src/commands/compute/app/upgrade.ts index 8f4c7a86..542cd407 100644 --- a/packages/cli/src/commands/compute/app/upgrade.ts +++ b/packages/cli/src/commands/compute/app/upgrade.ts @@ -146,12 +146,12 @@ export default class AppUpgrade extends Command { action: "upgrade", }); - // Check governance mode — governed apps cannot be directly upgraded - const governed = await compute.app.isGoverned(appID); - if (governed) { + // Check timelocked mode — timelocked apps cannot be directly upgraded + const timelocked = await compute.app.isTimelocked(appID); + if (timelocked) { this.error( - `App ${appID} is in governance mode (Safe/Timelock owner).\n` + - `Use the two-step governance flow instead:\n` + + `App ${appID} is timelocked (Timelock owner).\n` + + `Use the two-step timelocked flow instead:\n` + ` ecloud compute app upgrade schedule --app=${appID} --after=\n` + ` ecloud compute app upgrade execute --app=${appID}`, ); diff --git a/packages/cli/src/commands/compute/app/upgrade/execute.ts b/packages/cli/src/commands/compute/app/upgrade/execute.ts index 44277df1..868c7745 100644 --- a/packages/cli/src/commands/compute/app/upgrade/execute.ts +++ b/packages/cli/src/commands/compute/app/upgrade/execute.ts @@ -24,7 +24,7 @@ import { setLinkedAppForDirectory } from "../../../../utils/globalConfig"; import { getDashboardUrl } from "../../../../utils/dashboard"; export default class AppUpgradeExecute extends Command { - static description = "Execute a previously scheduled governed upgrade once the delay has elapsed"; + static description = "Execute a previously scheduled upgrade for a timelocked app once the delay has elapsed"; static args = { "app-id": Args.string({ @@ -89,10 +89,10 @@ export default class AppUpgradeExecute extends Command { action: "execute upgrade", }); - // Verify governance mode - const governed = await compute.app.isGoverned(appID); - if (!governed) { - this.error("This app is not in governance mode. Use 'ecloud compute app upgrade' for direct upgrades."); + // Verify timelocked mode + const timelocked = await compute.app.isTimelocked(appID); + if (!timelocked) { + this.error("This app is not timelocked. Use 'ecloud compute app upgrade' for direct upgrades."); } // Check scheduled upgrade status diff --git a/packages/cli/src/commands/compute/app/upgrade/schedule.ts b/packages/cli/src/commands/compute/app/upgrade/schedule.ts index 403e72db..43f696b0 100644 --- a/packages/cli/src/commands/compute/app/upgrade/schedule.ts +++ b/packages/cli/src/commands/compute/app/upgrade/schedule.ts @@ -37,7 +37,7 @@ function parseDurationToSeconds(input: string): bigint { } export default class AppUpgradeSchedule extends Command { - static description = "Schedule a governed upgrade (Safe/Timelock multi-sig flow). The upgrade becomes executable after the specified delay."; + static description = "Schedule an upgrade for a timelocked app. The upgrade becomes executable after the specified delay."; static args = { "app-id": Args.string({ @@ -115,11 +115,11 @@ export default class AppUpgradeSchedule extends Command { action: "schedule upgrade", }); - // Verify governance mode - const governed = await compute.app.isGoverned(appID); - if (!governed) { + // Verify timelocked mode + const timelocked = await compute.app.isTimelocked(appID); + if (!timelocked) { this.error( - "This app is not in governance mode. Use 'ecloud compute app upgrade' for direct upgrades, or transfer ownership to a Safe/Timelock first.", + "This app is not timelocked. Use 'ecloud compute app upgrade' for direct upgrades, or transfer ownership to a Timelock first.", ); } diff --git a/packages/sdk/src/client/common/abis/AppController.json b/packages/sdk/src/client/common/abis/AppController.json index 60a7e094..c6dfe2ee 100644 --- a/packages/sdk/src/client/common/abis/AppController.json +++ b/packages/sdk/src/client/common/abis/AppController.json @@ -907,7 +907,7 @@ }, { "type": "function", - "name": "getAppGoverned", + "name": "getAppTimelocked", "inputs": [ { "name": "app", @@ -1486,12 +1486,12 @@ }, { "type": "error", - "name": "DirectUpgradeNotAllowed", + "name": "TimelockRequired", "inputs": [] }, { "type": "error", - "name": "GovernanceRequired", + "name": "NotTimelocked", "inputs": [] }, { diff --git a/packages/sdk/src/client/common/contract/caller.ts b/packages/sdk/src/client/common/contract/caller.ts index a3b5585f..7f642084 100644 --- a/packages/sdk/src/client/common/contract/caller.ts +++ b/packages/sdk/src/client/common/contract/caller.ts @@ -738,11 +738,11 @@ export async function prepareUpgradeBatch( needsPermissionChange, } = options; - // 0. Check governance — governed apps cannot use direct upgradeApp() - const governed = await getAppGoverned(publicClient, environmentConfig, appID); - if (governed) { + // 0. Check timelocked — timelocked apps cannot use direct upgradeApp() + const timelocked = await getAppTimelocked(publicClient, environmentConfig, appID); + if (timelocked) { throw new Error( - "this app is governed — use 'ecloud compute app upgrade schedule' and 'ecloud compute app upgrade execute' instead of a direct upgrade", + "this app is timelocked — use 'ecloud compute app upgrade schedule' and 'ecloud compute app upgrade execute' instead of a direct upgrade", ); } @@ -1010,12 +1010,12 @@ function formatAppControllerError(decoded: { return new Error("invalid release metadata URI provided"); case "InvalidShortString": return new Error("invalid short string format"); - case "DirectUpgradeNotAllowed": + case "TimelockRequired": return new Error( - "this app is governed — use 'ecloud compute app upgrade schedule' and 'ecloud compute app upgrade execute' instead of a direct upgrade", + "this app is timelocked — use 'ecloud compute app upgrade schedule' and 'ecloud compute app upgrade execute' instead of a direct upgrade", ); - case "GovernanceRequired": - return new Error("this operation requires governance mode — transfer ownership to a Safe or Timelock first"); + case "NotTimelocked": + return new Error("this operation requires a timelocked app — transfer ownership to a Timelock first"); case "UpgradeNotReady": return new Error("the scheduled upgrade delay has not elapsed yet"); case "NoScheduledUpgrade": @@ -1253,21 +1253,21 @@ export async function getBlockTimestamps( } /** - * Get whether an app is in governance mode (requires scheduleUpgrade + executeUpgrade) + * Get whether an app is timelocked (requires scheduleUpgrade + executeUpgrade) */ -export async function getAppGoverned( +export async function getAppTimelocked( publicClient: PublicClient, environmentConfig: EnvironmentConfig, appID: Address, ): Promise { - const governed = await publicClient.readContract({ + const timelocked = await publicClient.readContract({ address: environmentConfig.appControllerAddress as Address, abi: AppControllerABI, - functionName: "getAppGoverned", + functionName: "getAppTimelocked", args: [appID], }); - return governed as boolean; + return timelocked as boolean; } /** diff --git a/packages/sdk/src/client/index.ts b/packages/sdk/src/client/index.ts index e7af1841..8a53d8b9 100644 --- a/packages/sdk/src/client/index.ts +++ b/packages/sdk/src/client/index.ts @@ -103,7 +103,7 @@ export { getBillingType, getAppsByBillingAccount, calculateAppID, - getAppGoverned, + getAppTimelocked, getPendingAppUpgrade, transferAppOwnership, scheduleAppUpgrade, diff --git a/packages/sdk/src/client/modules/compute/app/index.ts b/packages/sdk/src/client/modules/compute/app/index.ts index 48259479..004505cd 100644 --- a/packages/sdk/src/client/modules/compute/app/index.ts +++ b/packages/sdk/src/client/modules/compute/app/index.ts @@ -34,7 +34,7 @@ import { isDelegated, getBillingType, getAppsByBillingAccount, - getAppGoverned, + getAppTimelocked, getPendingAppUpgrade, transferAppOwnership, scheduleAppUpgrade, @@ -175,7 +175,7 @@ export interface AppModule { undelegate: () => Promise<{ tx: Hex | false }>; // Governance - isGoverned: (appId: AppId) => Promise; + isTimelocked: (appId: AppId) => Promise; getPendingUpgrade: (appId: AppId) => Promise; transferOwnership: (appId: AppId, newOwner: Address, opts?: { gas?: GasEstimate }) => Promise<{ tx: Hex }>; scheduleUpgrade: ( @@ -591,8 +591,8 @@ export function createAppModule(ctx: AppModuleConfig): AppModule { ); }, - async isGoverned(appId) { - return getAppGoverned(publicClient, environment, appId as Address); + async isTimelocked(appId) { + return getAppTimelocked(publicClient, environment, appId as Address); }, async getPendingUpgrade(appId) { From 0b144a19afc1c527827d82a9b249e44e55151188 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 12 Mar 2026 11:00:36 -0700 Subject: [PATCH 04/41] feat: add cancelUpgrade, team grant/revoke/list commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SDK: - Update AppController ABI from latest contract (adds cancelUpgrade, team role functions, getAppOwner) - Add cancelAppUpgrade, grantTeamRole, revokeTeamRole, getTeamRoleMembers, getAppOwner to caller.ts - Add TeamRole enum to SDK - Add cancelUpgrade, grantTeamRole, revokeTeamRole, getTeamRoleMembers to AppModule interface and implementation - Export new functions and types from client/index.ts CLI: - ecloud compute app upgrade cancel — cancel a pending scheduled upgrade - ecloud compute team grant --role=PAUSER|DEVELOPER — grant team role - ecloud compute team revoke --role=PAUSER|DEVELOPER — revoke team role - ecloud compute team list — show ADMIN/PAUSER/DEVELOPER members for an app --- .../commands/compute/app/upgrade/cancel.ts | 65 + .../cli/src/commands/compute/team/grant.ts | 78 ++ .../cli/src/commands/compute/team/list.ts | 65 + .../cli/src/commands/compute/team/revoke.ts | 78 ++ .../src/client/common/abis/AppController.json | 1156 +++++++++++++---- .../sdk/src/client/common/contract/caller.ts | 148 +++ packages/sdk/src/client/index.ts | 9 + .../src/client/modules/compute/app/index.ts | 94 ++ 8 files changed, 1423 insertions(+), 270 deletions(-) create mode 100644 packages/cli/src/commands/compute/app/upgrade/cancel.ts create mode 100644 packages/cli/src/commands/compute/team/grant.ts create mode 100644 packages/cli/src/commands/compute/team/list.ts create mode 100644 packages/cli/src/commands/compute/team/revoke.ts diff --git a/packages/cli/src/commands/compute/app/upgrade/cancel.ts b/packages/cli/src/commands/compute/app/upgrade/cancel.ts new file mode 100644 index 00000000..cd30b333 --- /dev/null +++ b/packages/cli/src/commands/compute/app/upgrade/cancel.ts @@ -0,0 +1,65 @@ +import { Command, Args } from "@oclif/core"; +import { getEnvironmentConfig, isMainnet } from "@layr-labs/ecloud-sdk"; +import { withTelemetry } from "../../../../telemetry"; +import { commonFlags } from "../../../../flags"; +import { createComputeClient } from "../../../../client"; +import { getOrPromptAppID, confirm } from "../../../../utils/prompts"; +import chalk from "chalk"; + +export default class AppUpgradeCancel extends Command { + static description = "Cancel a pending scheduled upgrade for a timelocked app"; + + static args = { + "app-id": Args.string({ + description: "App ID", + required: false, + }), + }; + + static flags = { ...commonFlags }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(AppUpgradeCancel); + 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: args["app-id"], + environment, + privateKey, + rpcUrl, + action: "cancel upgrade", + }); + + const timelocked = await compute.app.isTimelocked(appID); + if (!timelocked) { + this.error("This app is not timelocked. Only timelocked apps have scheduled upgrades."); + } + + const pending = await compute.app.getPendingUpgrade(appID); + if (pending.readyAt === 0n) { + this.error("No upgrade is scheduled for this app."); + } + + const readyDate = new Date(Number(pending.readyAt) * 1000).toLocaleString(); + this.log(`\nApp: ${chalk.bold(appID)}`); + this.log(`Scheduled: ${chalk.bold(readyDate)}`); + + if (isMainnet(environmentConfig)) { + const confirmed = await confirm("Cancel this scheduled upgrade?"); + if (!confirmed) { + this.log(`\n${chalk.gray("Cancellation aborted")}`); + return; + } + } + + const res = await compute.app.cancelUpgrade(appID); + this.log(`\n✅ ${chalk.green(`Scheduled upgrade cancelled (tx: ${res.tx})`)}`); + }); + } +} 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..94033e88 --- /dev/null +++ b/packages/cli/src/commands/compute/team/grant.ts @@ -0,0 +1,78 @@ +import { Command, Args, Flags } from "@oclif/core"; +import { getEnvironmentConfig, isMainnet, TeamRole } from "@layr-labs/ecloud-sdk"; +import { withTelemetry } from "../../../telemetry"; +import { commonFlags } from "../../../flags"; +import { createComputeClient } from "../../../client"; +import { getOrPromptAppID, confirm } from "../../../utils/prompts"; +import { isAddress } from "viem"; +import chalk from "chalk"; + +const ROLE_CHOICES = ["PAUSER", "DEVELOPER"] as const; +type RoleChoice = (typeof ROLE_CHOICES)[number]; + +export default class TeamGrant extends Command { + static description = "Grant a team role (PAUSER or DEVELOPER) to an address for an app's team"; + + static args = { + address: Args.string({ + description: "Address to grant the role to", + required: true, + }), + }; + + 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: true, + description: "Role to grant: PAUSER or DEVELOPER", + options: ROLE_CHOICES as unknown as string[], + env: "ECLOUD_TEAM_ROLE", + }), + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(TeamGrant); + 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 account = args.address; + 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)}`); + + if (isMainnet(environmentConfig)) { + const confirmed = await confirm("Grant this role?"); + if (!confirmed) { + this.log(`\n${chalk.gray("Cancelled")}`); + return; + } + } + + const res = await compute.app.grantTeamRole(appID, role, account); + this.log(`\n✅ ${chalk.green(`${flags.role} role granted to ${account} (tx: ${res.tx})`)}`); + }); + } +} 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..91256bda --- /dev/null +++ b/packages/cli/src/commands/compute/team/revoke.ts @@ -0,0 +1,78 @@ +import { Command, Args, Flags } from "@oclif/core"; +import { getEnvironmentConfig, isMainnet, TeamRole } from "@layr-labs/ecloud-sdk"; +import { withTelemetry } from "../../../telemetry"; +import { commonFlags } from "../../../flags"; +import { createComputeClient } from "../../../client"; +import { getOrPromptAppID, confirm } from "../../../utils/prompts"; +import { isAddress } 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: true, + }), + }; + + 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: true, + description: "Role to revoke: PAUSER or DEVELOPER", + options: ROLE_CHOICES as unknown as string[], + env: "ECLOUD_TEAM_ROLE", + }), + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(TeamRevoke); + 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 account = args.address; + 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)}`); + + if (isMainnet(environmentConfig)) { + const confirmed = await confirm("Revoke this role?"); + if (!confirmed) { + this.log(`\n${chalk.gray("Cancelled")}`); + return; + } + } + + const res = await compute.app.revokeTeamRole(appID, role, account); + this.log(`\n✅ ${chalk.green(`${flags.role} role revoked from ${account} (tx: ${res.tx})`)}`); + }); + } +} diff --git a/packages/sdk/src/client/common/abis/AppController.json b/packages/sdk/src/client/common/abis/AppController.json index c6dfe2ee..fdf07872 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,11 +122,24 @@ { "name": "", "type": "address", - "internalType": "contractIApp" + "internalType": "contract IApp" } ], "stateMutability": "view" }, + { + "type": "function", + "name": "cancelUpgrade", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contract IApp" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "computeAVSRegistrar", @@ -117,7 +148,7 @@ { "name": "", "type": "address", - "internalType": "contractIComputeAVSRegistrar" + "internalType": "contract IComputeAVSRegistrar" } ], "stateMutability": "view" @@ -130,7 +161,7 @@ { "name": "", "type": "address", - "internalType": "contractIComputeOperator" + "internalType": "contract IComputeOperator" } ], "stateMutability": "view" @@ -147,17 +178,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,7 +226,77 @@ { "name": "app", "type": "address", - "internalType": "contractIApp" + "internalType": "contract IApp" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "createAppForTeam", + "inputs": [ + { + "name": "team", + "type": "address", + "internalType": "address" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "release", + "type": "tuple", + "internalType": "struct IAppController.Release", + "components": [ + { + "name": "rmsRelease", + "type": "tuple", + "internalType": "struct IReleaseManagerTypes.Release", + "components": [ + { + "name": "artifacts", + "type": "tuple[]", + "internalType": "struct IReleaseManagerTypes.Artifact[]", + "components": [ + { + "name": "digest", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "registry", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "upgradeByTime", + "type": "uint32", + "internalType": "uint32" + } + ] + }, + { + "name": "publicEnv", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "encryptedEnv", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "outputs": [ + { + "name": "app", + "type": "address", + "internalType": "contract IApp" } ], "stateMutability": "nonpayable" @@ -278,6 +379,71 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "executeUpgrade", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contract IApp" + }, + { + "name": "release", + "type": "tuple", + "internalType": "struct IAppController.Release", + "components": [ + { + "name": "rmsRelease", + "type": "tuple", + "internalType": "struct IReleaseManagerTypes.Release", + "components": [ + { + "name": "artifacts", + "type": "tuple[]", + "internalType": "struct IReleaseManagerTypes.Artifact[]", + "components": [ + { + "name": "digest", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "registry", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "upgradeByTime", + "type": "uint32", + "internalType": "uint32" + } + ] + }, + { + "name": "publicEnv", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "encryptedEnv", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "getActiveAppCount", @@ -342,7 +508,7 @@ { "name": "app", "type": "address", - "internalType": "contractIApp" + "internalType": "contract IApp" } ], "outputs": [ @@ -361,7 +527,7 @@ { "name": "app", "type": "address", - "internalType": "contractIApp" + "internalType": "contract IApp" } ], "outputs": [ @@ -373,6 +539,25 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "getAppOwner", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contract IApp" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "getAppStatus", @@ -380,14 +565,33 @@ { "name": "app", "type": "address", - "internalType": "contractIApp" + "internalType": "contract IApp" } ], "outputs": [ { "name": "", "type": "uint8", - "internalType": "enumIAppController.AppStatus" + "internalType": "enum IAppController.AppStatus" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getAppTimelocked", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contract IApp" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" } ], "stateMutability": "view" @@ -411,15 +615,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 +640,12 @@ { "name": "status", "type": "uint8", - "internalType": "enumIAppController.AppStatus" + "internalType": "enum IAppController.AppStatus" + }, + { + "name": "timelocked", + "type": "bool", + "internalType": "bool" } ] } @@ -523,15 +732,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 +757,12 @@ { "name": "status", "type": "uint8", - "internalType": "enumIAppController.AppStatus" + "internalType": "enum IAppController.AppStatus" + }, + { + "name": "timelocked", + "type": "bool", + "internalType": "bool" } ] } @@ -579,15 +793,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" }, @@ -604,7 +818,12 @@ { "name": "status", "type": "uint8", - "internalType": "enumIAppController.AppStatus" + "internalType": "enum IAppController.AppStatus" + }, + { + "name": "timelocked", + "type": "bool", + "internalType": "bool" } ] } @@ -613,27 +832,60 @@ }, { "type": "function", - "name": "getMaxActiveAppsPerUser", + "name": "getAppsForAccount", "inputs": [ { - "name": "user", + "name": "account", "type": "address", "internalType": "address" + }, + { + "name": "offset", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "limit", + "type": "uint256", + "internalType": "uint256" } ], "outputs": [ { - "name": "", - "type": "uint32", - "internalType": "uint32" + "name": "appRoles", + "type": "tuple[]", + "internalType": "struct IAppController.AppRoles[]", + "components": [ + { + "name": "app", + "type": "address", + "internalType": "contract IApp" + }, + { + "name": "isOwner", + "type": "bool", + "internalType": "bool" + }, + { + "name": "roles", + "type": "uint8[]", + "internalType": "enum IAppController.TeamRole[]" + } + ] } ], "stateMutability": "view" }, { "type": "function", - "name": "globalActiveAppCount", - "inputs": [], + "name": "getMaxActiveAppsPerUser", + "inputs": [ + { + "name": "user", + "type": "address", + "internalType": "address" + } + ], "outputs": [ { "name": "", @@ -645,95 +897,200 @@ }, { "type": "function", - "name": "initialize", + "name": "getPendingUpgrade", "inputs": [ { - "name": "admin", + "name": "app", "type": "address", - "internalType": "address" + "internalType": "contract IApp" } ], - "outputs": [], - "stateMutability": "nonpayable" - }, + "outputs": [ + { + "name": "", + "type": "tuple", + "internalType": "struct IAppController.PendingUpgrade", + "components": [ + { + "name": "releaseHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "readyAt", + "type": "uint256", + "internalType": "uint256" + } + ] + } + ], + "stateMutability": "view" + }, { "type": "function", - "name": "maxGlobalActiveApps", - "inputs": [], + "name": "getRoleAdmin", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + } + ], "outputs": [ { "name": "", - "type": "uint32", - "internalType": "uint32" + "type": "bytes32", + "internalType": "bytes32" } ], "stateMutability": "view" }, { "type": "function", - "name": "permissionController", - "inputs": [], + "name": "getRoleMember", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "index", + "type": "uint256", + "internalType": "uint256" + } + ], "outputs": [ { "name": "", "type": "address", - "internalType": "contractIPermissionController" + "internalType": "address" } ], "stateMutability": "view" }, { "type": "function", - "name": "releaseManager", - "inputs": [], + "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": "contractIReleaseManager" + "internalType": "address" } ], "stateMutability": "view" }, { "type": "function", - "name": "setMaxActiveAppsPerUser", + "name": "getTeamRoleMemberCount", "inputs": [ { - "name": "user", + "name": "team", "type": "address", "internalType": "address" }, { - "name": "limit", - "type": "uint32", - "internalType": "uint32" + "name": "role", + "type": "uint8", + "internalType": "enum IAppController.TeamRole" } ], - "outputs": [], - "stateMutability": "nonpayable" + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" }, { "type": "function", - "name": "setMaxGlobalActiveApps", + "name": "getTeamRoleMembers", "inputs": [ { - "name": "limit", + "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", + "inputs": [], + "outputs": [ + { + "name": "", "type": "uint32", "internalType": "uint32" } ], - "outputs": [], - "stateMutability": "nonpayable" + "stateMutability": "view" }, { "type": "function", - "name": "startApp", + "name": "grantRole", "inputs": [ { - "name": "app", + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "account", "type": "address", - "internalType": "contractIApp" + "internalType": "address" } ], "outputs": [], @@ -741,12 +1098,22 @@ }, { "type": "function", - "name": "stopApp", + "name": "grantTeamRole", "inputs": [ { - "name": "app", + "name": "team", "type": "address", - "internalType": "contractIApp" + "internalType": "address" + }, + { + "name": "role", + "type": "uint8", + "internalType": "enum IAppController.TeamRole" + }, + { + "name": "account", + "type": "address", + "internalType": "address" } ], "outputs": [], @@ -754,17 +1121,91 @@ }, { "type": "function", - "name": "suspend", + "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", + "inputs": [ + { + "name": "admin", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "maxGlobalActiveApps", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint32", + "internalType": "uint32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "migrateAdmins", + "inputs": [ { "name": "apps", "type": "address[]", - "internalType": "contractIApp[]" + "internalType": "contract IApp[]" } ], "outputs": [], @@ -772,12 +1213,43 @@ }, { "type": "function", - "name": "terminateApp", + "name": "permissionController", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IPermissionController" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "releaseManager", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IReleaseManager" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "renounceRole", "inputs": [ { - "name": "app", + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "account", "type": "address", - "internalType": "contractIApp" + "internalType": "address" } ], "outputs": [], @@ -785,12 +1257,17 @@ }, { "type": "function", - "name": "terminateAppByAdmin", + "name": "renounceTeamRole", "inputs": [ { - "name": "app", + "name": "team", "type": "address", - "internalType": "contractIApp" + "internalType": "address" + }, + { + "name": "role", + "type": "uint8", + "internalType": "enum IAppController.TeamRole" } ], "outputs": [], @@ -798,17 +1275,40 @@ }, { "type": "function", - "name": "updateAppMetadataURI", + "name": "revokeRole", "inputs": [ { - "name": "app", + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "account", "type": "address", - "internalType": "contractIApp" + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "revokeTeamRole", + "inputs": [ + { + "name": "team", + "type": "address", + "internalType": "address" }, { - "name": "metadataURI", - "type": "string", - "internalType": "string" + "name": "role", + "type": "uint8", + "internalType": "enum IAppController.TeamRole" + }, + { + "name": "account", + "type": "address", + "internalType": "address" } ], "outputs": [], @@ -816,27 +1316,40 @@ }, { "type": "function", - "name": "upgradeApp", + "name": "safeTimelockFactory", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract ISafeTimelockFactory" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "scheduleUpgrade", "inputs": [ { "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", @@ -868,51 +1381,81 @@ "internalType": "bytes" } ] - } - ], - "outputs": [ + }, { - "name": "", + "name": "delay", "type": "uint256", "internalType": "uint256" } ], + "outputs": [], "stateMutability": "nonpayable" }, { "type": "function", - "name": "version", - "inputs": [], - "outputs": [ + "name": "setMaxActiveAppsPerUser", + "inputs": [ { - "name": "", - "type": "string", - "internalType": "string" + "name": "user", + "type": "address", + "internalType": "address" + }, + { + "name": "limit", + "type": "uint32", + "internalType": "uint32" } ], - "stateMutability": "view" + "outputs": [], + "stateMutability": "nonpayable" }, { "type": "function", - "name": "safeTimelockFactory", - "inputs": [], - "outputs": [ + "name": "setMaxGlobalActiveApps", + "inputs": [ { - "name": "", + "name": "limit", + "type": "uint32", + "internalType": "uint32" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "startApp", + "inputs": [ + { + "name": "app", "type": "address", - "internalType": "contractISafeTimelockFactory" + "internalType": "contract IApp" } ], - "stateMutability": "view" + "outputs": [], + "stateMutability": "nonpayable" }, { "type": "function", - "name": "getAppTimelocked", + "name": "stopApp", "inputs": [ { "name": "app", "type": "address", - "internalType": "contractIApp" + "internalType": "contract IApp" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "supportsInterface", + "inputs": [ + { + "name": "interfaceId", + "type": "bytes4", + "internalType": "bytes4" } ], "outputs": [ @@ -926,34 +1469,47 @@ }, { "type": "function", - "name": "getPendingUpgrade", + "name": "suspend", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "apps", + "type": "address[]", + "internalType": "contract IApp[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "terminateApp", "inputs": [ { "name": "app", "type": "address", - "internalType": "contractIApp" + "internalType": "contract IApp" } ], - "outputs": [ + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "terminateAppByAdmin", + "inputs": [ { - "name": "", - "type": "tuple", - "internalType": "structIAppController.PendingUpgrade", - "components": [ - { - "name": "releaseHash", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "readyAt", - "type": "uint256", - "internalType": "uint256" - } - ] + "name": "app", + "type": "address", + "internalType": "contract IApp" } ], - "stateMutability": "view" + "outputs": [], + "stateMutability": "nonpayable" }, { "type": "function", @@ -962,7 +1518,7 @@ { "name": "app", "type": "address", - "internalType": "contractIApp" + "internalType": "contract IApp" }, { "name": "newOwner", @@ -975,63 +1531,17 @@ }, { "type": "function", - "name": "scheduleUpgrade", + "name": "updateAppMetadataURI", "inputs": [ { "name": "app", "type": "address", - "internalType": "contractIApp" + "internalType": "contract IApp" }, { - "name": "release", - "type": "tuple", - "internalType": "structIAppController.Release", - "components": [ - { - "name": "rmsRelease", - "type": "tuple", - "internalType": "structIReleaseManagerTypes.Release", - "components": [ - { - "name": "artifacts", - "type": "tuple[]", - "internalType": "structIReleaseManagerTypes.Artifact[]", - "components": [ - { - "name": "digest", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "registry", - "type": "string", - "internalType": "string" - } - ] - }, - { - "name": "upgradeByTime", - "type": "uint32", - "internalType": "uint32" - } - ] - }, - { - "name": "publicEnv", - "type": "bytes", - "internalType": "bytes" - }, - { - "name": "encryptedEnv", - "type": "bytes", - "internalType": "bytes" - } - ] - }, - { - "name": "delay", - "type": "uint256", - "internalType": "uint256" + "name": "metadataURI", + "type": "string", + "internalType": "string" } ], "outputs": [], @@ -1039,27 +1549,27 @@ }, { "type": "function", - "name": "executeUpgrade", + "name": "upgradeApp", "inputs": [ { "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", @@ -1102,12 +1612,25 @@ ], "stateMutability": "nonpayable" }, + { + "type": "function", + "name": "version", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "view" + }, { "type": "event", "name": "AppCreated", "inputs": [ { - "name": "creator", + "name": "owner", "type": "address", "indexed": true, "internalType": "address" @@ -1116,7 +1639,7 @@ "name": "app", "type": "address", "indexed": true, - "internalType": "contractIApp" + "internalType": "contract IApp" }, { "name": "operatorSetId", @@ -1135,7 +1658,7 @@ "name": "app", "type": "address", "indexed": true, - "internalType": "contractIApp" + "internalType": "contract IApp" }, { "name": "metadataURI", @@ -1146,6 +1669,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", @@ -1154,7 +1702,7 @@ "name": "app", "type": "address", "indexed": true, - "internalType": "contractIApp" + "internalType": "contract IApp" } ], "anonymous": false @@ -1167,7 +1715,7 @@ "name": "app", "type": "address", "indexed": true, - "internalType": "contractIApp" + "internalType": "contract IApp" } ], "anonymous": false @@ -1180,7 +1728,7 @@ "name": "app", "type": "address", "indexed": true, - "internalType": "contractIApp" + "internalType": "contract IApp" } ], "anonymous": false @@ -1193,7 +1741,7 @@ "name": "app", "type": "address", "indexed": true, - "internalType": "contractIApp" + "internalType": "contract IApp" } ], "anonymous": false @@ -1206,7 +1754,86 @@ "name": "app", "type": "address", "indexed": true, - "internalType": "contractIApp" + "internalType": "contract IApp" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "AppUpgradeCancelled", + "inputs": [ + { + "name": "app", + "type": "address", + "indexed": true, + "internalType": "contract IApp" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "AppUpgradeScheduled", + "inputs": [ + { + "name": "app", + "type": "address", + "indexed": true, + "internalType": "contract IApp" + }, + { + "name": "readyAt", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "release", + "type": "tuple", + "indexed": false, + "internalType": "struct IAppController.Release", + "components": [ + { + "name": "rmsRelease", + "type": "tuple", + "internalType": "struct IReleaseManagerTypes.Release", + "components": [ + { + "name": "artifacts", + "type": "tuple[]", + "internalType": "struct IReleaseManagerTypes.Artifact[]", + "components": [ + { + "name": "digest", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "registry", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "upgradeByTime", + "type": "uint32", + "internalType": "uint32" + } + ] + }, + { + "name": "publicEnv", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "encryptedEnv", + "type": "bytes", + "internalType": "bytes" + } + ] } ], "anonymous": false @@ -1219,7 +1846,7 @@ "name": "app", "type": "address", "indexed": true, - "internalType": "contractIApp" + "internalType": "contract IApp" }, { "name": "rmsReleaseId", @@ -1231,17 +1858,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", @@ -1324,22 +1951,47 @@ }, { "type": "event", - "name": "AppOwnershipTransferred", + "name": "RoleAdminChanged", "inputs": [ { - "name": "app", - "type": "address", + "name": "role", + "type": "bytes32", "indexed": true, - "internalType": "contractIApp" + "internalType": "bytes32" }, { - "name": "previousOwner", + "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": "newOwner", + "name": "sender", "type": "address", "indexed": true, "internalType": "address" @@ -1349,66 +2001,25 @@ }, { "type": "event", - "name": "AppUpgradeScheduled", + "name": "RoleRevoked", "inputs": [ { - "name": "app", - "type": "address", + "name": "role", + "type": "bytes32", "indexed": true, - "internalType": "contractIApp" + "internalType": "bytes32" }, { - "name": "readyAt", - "type": "uint256", - "indexed": false, - "internalType": "uint256" + "name": "account", + "type": "address", + "indexed": true, + "internalType": "address" }, { - "name": "release", - "type": "tuple", - "indexed": false, - "internalType": "structIAppController.Release", - "components": [ - { - "name": "rmsRelease", - "type": "tuple", - "internalType": "structIReleaseManagerTypes.Release", - "components": [ - { - "name": "artifacts", - "type": "tuple[]", - "internalType": "structIReleaseManagerTypes.Artifact[]", - "components": [ - { - "name": "digest", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "registry", - "type": "string", - "internalType": "string" - } - ] - }, - { - "name": "upgradeByTime", - "type": "uint32", - "internalType": "uint32" - } - ] - }, - { - "name": "publicEnv", - "type": "bytes", - "internalType": "bytes" - }, - { - "name": "encryptedEnv", - "type": "bytes", - "internalType": "bytes" - } - ] + "name": "sender", + "type": "address", + "indexed": true, + "internalType": "address" } ], "anonymous": false @@ -1428,6 +2039,11 @@ "name": "AppDoesNotExist", "inputs": [] }, + { + "type": "error", + "name": "CannotRevokeLastAdmin", + "inputs": [] + }, { "type": "error", "name": "GlobalMaxActiveAppsExceeded", @@ -1470,43 +2086,43 @@ }, { "type": "error", - "name": "SignatureExpired", + "name": "NoScheduledUpgrade", "inputs": [] }, { "type": "error", - "name": "StringTooLong", - "inputs": [ - { - "name": "str", - "type": "string", - "internalType": "string" - } - ] + "name": "NotTimelocked", + "inputs": [] }, { "type": "error", - "name": "TimelockRequired", + "name": "ReleaseMismatch", "inputs": [] }, { "type": "error", - "name": "NotTimelocked", + "name": "SignatureExpired", "inputs": [] }, { "type": "error", - "name": "UpgradeNotReady", - "inputs": [] + "name": "StringTooLong", + "inputs": [ + { + "name": "str", + "type": "string", + "internalType": "string" + } + ] }, { "type": "error", - "name": "NoScheduledUpgrade", + "name": "TimelockRequired", "inputs": [] }, { "type": "error", - "name": "ReleaseMismatch", + "name": "UpgradeNotReady", "inputs": [] } -] +] \ No newline at end of file diff --git a/packages/sdk/src/client/common/contract/caller.ts b/packages/sdk/src/client/common/contract/caller.ts index 7f642084..9641699f 100644 --- a/packages/sdk/src/client/common/contract/caller.ts +++ b/packages/sdk/src/client/common/contract/caller.ts @@ -1448,6 +1448,154 @@ export async function executeGovernedUpgrade( ); } +/** + * Cancel a pending scheduled upgrade for a timelocked app. + */ +export interface CancelUpgradeOptions { + walletClient: WalletClient; + publicClient: PublicClient; + environmentConfig: EnvironmentConfig; + appID: Address; + gas?: GasEstimate; +} + +export async function cancelAppUpgrade( + options: CancelUpgradeOptions, + logger: Logger = noopLogger, +): Promise { + const { walletClient, publicClient, environmentConfig, appID, gas } = options; + + const data = encodeFunctionData({ + abi: AppControllerABI, + functionName: "cancelUpgrade", + args: [appID], + }); + + return sendAndWaitForTransaction( + { + walletClient, + publicClient, + environmentConfig, + to: environmentConfig.appControllerAddress as Address, + data, + pendingMessage: `Cancelling scheduled upgrade for app ${appID}...`, + txDescription: "CancelUpgrade", + 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 */ diff --git a/packages/sdk/src/client/index.ts b/packages/sdk/src/client/index.ts index 8a53d8b9..8cb8ea00 100644 --- a/packages/sdk/src/client/index.ts +++ b/packages/sdk/src/client/index.ts @@ -108,12 +108,21 @@ export { transferAppOwnership, scheduleAppUpgrade, executeGovernedUpgrade as executeGovernedUpgradeOnChain, + cancelAppUpgrade, + grantTeamRole, + revokeTeamRole, + getTeamRoleMembers, + getAppOwner, + TeamRole, type GasEstimate, type EstimateGasOptions, type PendingUpgrade, type TransferOwnershipOptions, type ScheduleUpgradeOptions as ScheduleUpgradeCallerOptions, type ExecuteGovernedUpgradeOptions as ExecuteGovernedUpgradeCallerOptions, + type CancelUpgradeOptions, + type GrantTeamRoleOptions, + type RevokeTeamRoleOptions, } from "./common/contract/caller"; // Export batch gas estimation and delegation check diff --git a/packages/sdk/src/client/modules/compute/app/index.ts b/packages/sdk/src/client/modules/compute/app/index.ts index 004505cd..6b5499d6 100644 --- a/packages/sdk/src/client/modules/compute/app/index.ts +++ b/packages/sdk/src/client/modules/compute/app/index.ts @@ -39,6 +39,12 @@ import { transferAppOwnership, scheduleAppUpgrade, executeGovernedUpgrade, + cancelAppUpgrade, + grantTeamRole as grantTeamRoleCaller, + revokeTeamRole as revokeTeamRoleCaller, + getTeamRoleMembers as getTeamRoleMembersCaller, + getAppOwner, + TeamRole, type GasEstimate, type AppConfig, type PendingUpgrade, @@ -189,6 +195,14 @@ export interface AppModule { release: import("../../../common/types").Release, opts?: { gas?: GasEstimate }, ) => Promise<{ tx: Hex }>; + + // Cancel scheduled upgrade + cancelUpgrade: (appId: AppId, 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 { @@ -671,5 +685,85 @@ export function createAppModule(ctx: AppModuleConfig): AppModule { }, ); }, + + async cancelUpgrade(appId, opts) { + return withSDKTelemetry( + { + functionName: "cancelUpgrade", + skipTelemetry, + properties: { environment: ctx.environment }, + }, + async () => { + const tx = await cancelAppUpgrade( + { + walletClient, + publicClient, + environmentConfig: environment, + appID: appId 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); + }, }; } From 7801973c5fa62eae5f9f707ceb22916992083611 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 12 Mar 2026 11:40:41 -0700 Subject: [PATCH 05/41] add command identity matrix --- docs/identity-command-matrix.md | 260 ++++++++++++++++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 docs/identity-command-matrix.md diff --git a/docs/identity-command-matrix.md b/docs/identity-command-matrix.md new file mode 100644 index 00000000..027613d8 --- /dev/null +++ b/docs/identity-command-matrix.md @@ -0,0 +1,260 @@ +# Identity × Command Matrix + +## Running the demo + +The CLI ships with a stateful demo mode that simulates all governance flows without hitting real contracts. + +**Setup:** +```bash +# from the ecloud repo root +alias ecloud="node packages/cli/bin/run.js" + +cd packages/cli && npm run build +``` + +**Start demo mode** (no flags needed — demo is active by default): +```bash +ecloud auth login +``` + +Demo state is stored in `/tmp/ecloud-demo-state.json` and persists across commands. To reset: +```bash +rm /tmp/ecloud-demo-state.json +``` + +--- + +Behaviour of each CLI command per identity type. + +**Identity types:** +- **EOA** — plain wallet, signs directly +- **Timelock(EOA)** — Timelock contract with an EOA as proposer/executor +- **Safe** — Gnosis Safe (multi-sig threshold) +- **Timelock(Safe)** — Timelock contract with a Safe as proposer/executor +- **PAUSER** — EOA (or Safe) granted PAUSER role by an ADMIN; can stop apps only +- **DEVELOPER** — EOA granted DEVELOPER role by an ADMIN; read-only + metadata ops + +## Identity migration + +How accounts can be created and upgraded to stronger security models. + +```mermaid +graph TD + A["ecloud auth new → EOA"] -->|"ecloud auth new\n→ Timelock (EOA proposer)"| B["Timelock(EOA)"] + A -->|"ecloud auth new → Safe"| C["Safe"] + C -->|"ecloud auth new\n→ Timelock (Safe proposer)"| D["Timelock(Safe)"] + C -->|"ecloud auth new → Safe\n→ Add timelock delay? yes"| D +``` + +**App ownership migration** — transfer the app to a stronger owner: + +```mermaid +graph TD + E["App owned by EOA"] + -->|"ecloud compute app ownership transfer --to=<safe-addr>"| F["App owned by Safe"] + -->|"ecloud compute app ownership transfer --to=<timelock-addr>"| G["App owned by Timelock(Safe)"] + + F -. "upgrades require Safe propose" .-> F + G -. "upgrades require schedule + execute + Safe propose" .-> G +``` + +**Upgrade behaviour changes with each step:** + +| App owner | Upgrade command | Flow | +|---|---|---| +| EOA | `ecloud compute app upgrade` | direct | +| Safe | `ecloud compute app upgrade` | Safe propose → approved | +| Timelock(Safe) | `ecloud compute app upgrade schedule` + `execute` | Safe propose → delay → Safe propose | + +--- + +**Legend:** +- `direct` — CLI signs and submits immediately, no extra steps +- `Safe propose` — CLI proposes tx to Safe; threshold of signers must approve at app.safe.global +- `schedule + execute` — two-step timelocked flow; delay must elapse between steps +- `schedule + execute + Safe propose` — same two-step flow, but each step also requires Safe approval +- `❌ error` — command is blocked; CLI shows a descriptive error with the correct alternative +- `—` — not applicable / not shown + +--- + +## Auth + +| Command | EOA | Timelock(EOA) | Safe | Timelock(Safe) | PAUSER | DEVELOPER | +|---|---|---|---|---|---|---| +| `ecloud auth login` | select identity | select identity | select identity | select identity | select identity | select identity | +| `ecloud auth new` | create EOA key | create Timelock | create Safe | create Timelock | — | — | + +--- + +## App lifecycle + +| Command | EOA | Timelock(EOA) | Safe | Timelock(Safe) | PAUSER | DEVELOPER | +|---|---|---|---|---|---|---| +| `ecloud compute app deploy` | direct | direct | Safe propose | Safe propose | ❌ no permission | ❌ no permission | +| `ecloud compute app upgrade` | direct | ❌ TimelockRequired | Safe propose | ❌ TimelockRequired | ❌ no permission | ❌ no permission | +| `ecloud compute app start` | direct | direct | Safe propose | Safe propose | ❌ no permission | ❌ no permission | +| `ecloud compute app stop` | direct | direct | Safe propose | Safe propose | direct | ❌ no permission | +| `ecloud compute app terminate` | direct | direct | Safe propose | Safe propose | ❌ no permission | ❌ no permission | + +--- + +## App metadata & observability + +| Command | EOA | Timelock(EOA) | Safe | Timelock(Safe) | PAUSER | DEVELOPER | +|---|---|---|---|---|---|---| +| `ecloud compute app profile set` | direct | direct | direct | direct | ❌ no permission | direct | +| `ecloud compute app info` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `ecloud compute app logs` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `ecloud compute app list` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `ecloud compute app releases` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | + +--- + +## Timelocked upgrade flow + +Only available when identity is `Timelock(EOA)` or `Timelock(Safe)`. Blocked for all other identities. + +| Command | EOA | Timelock(EOA) | Safe | Timelock(Safe) | PAUSER | DEVELOPER | +|---|---|---|---|---|---|---| +| `ecloud compute app upgrade schedule --after=` | ❌ not timelocked | ✅ schedules | ❌ not timelocked | ✅ schedules + Safe propose | ❌ no permission | ❌ no permission | +| `ecloud compute app upgrade execute ` | ❌ not timelocked | ✅ executes after delay | ❌ not timelocked | ✅ executes + Safe propose | ❌ no permission | ❌ no permission | +| `ecloud demo fastforward` | — | ✅ skips delay | — | ✅ skips delay | — | — | + +--- + +## App ownership + +| Command | EOA | Timelock(EOA) | Safe | Timelock(Safe) | PAUSER | DEVELOPER | +|---|---|---|---|---|---|---| +| `ecloud compute app ownership transfer --to=` | direct | direct | Safe propose | Safe propose | ❌ no permission | ❌ no permission | + +> Transferring to a Timelock address automatically enables timelocked mode on the app. + +--- + +## Team roles + +| Command | EOA | Timelock(EOA) | Safe | Timelock(Safe) | PAUSER | DEVELOPER | +|---|---|---|---|---|---|---| +| `ecloud compute team grant ` | direct | direct | Safe propose | Safe propose | ❌ no permission | ❌ no permission | +| `ecloud compute team revoke ` | direct | direct | Safe propose | Safe propose | ❌ no permission | ❌ no permission | +| `ecloud compute team list` | — | — | ✅ shows roles | ✅ shows roles | — | — | + +> Team roles (ADMIN, PAUSER, DEVELOPER) are only shown in `ecloud compute app info` and `ecloud compute team list` when the app owner is a Safe or Timelock(Safe). +> ADMIN is the Safe or Timelock address — never an individual EOA in a Safe-governed app. + +#### Why you should never grant ADMIN to an EOA in a Safe-governed app + +AppController's admin check is purely role-based: it verifies `msg.sender` holds `keccak256(owner, ADMIN)`. It does **not** enforce that the caller went through Safe's threshold signing. + +This means: if you grant ADMIN to an EOA, that EOA can call `upgradeApp`, `terminateApp`, `startApp`, etc. **directly** — bypassing the Safe entirely. The entire point of Safe ownership (threshold approval, no single point of failure) is defeated. + +**The correct model for Safe-owned apps:** + +| Role | Holder | How ops are authorized | +|---|---|---| +| ADMIN | Safe (or Timelock) only | Requires Safe threshold signature | +| PAUSER | Individual EOA | Direct — intentional, for emergency stop without delay | +| DEVELOPER | Individual EOA | Direct — limited to metadata and observability | + +The contract does not hard-enforce this convention today — it is an operational rule. Granting ADMIN to an EOA is technically possible but breaks the security model. Since granting any role requires being ADMIN, and Safe is the sole ADMIN, any such grant would itself require Safe approval — making it a deliberate, visible act rather than an accident. + +--- + +## App info + +| Field shown | EOA | Timelock(EOA) | Safe | Timelock(Safe) | PAUSER | DEVELOPER | +|---|---|---|---|---|---|---| +| Owner | ✅ | ✅ with delay label | ✅ | ✅ with delay + Safe label | ✅ | ✅ | +| Status / Image / Instance | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Team Roles section | — | — | ✅ | ✅ | — | — | + +--- + +## Full upgrade flows by identity + +### EOA +``` +ecloud compute app deploy --image-ref myrepo/myapp:v1 +ecloud compute app upgrade --image-ref myrepo/myapp:v2 +``` + +### Timelock(EOA) +``` +ecloud compute app deploy --image-ref myrepo/myapp:v1 +ecloud compute app upgrade schedule --after=24h +# wait for delay (or: ecloud demo fastforward) +ecloud compute app upgrade execute +``` + +### Safe +``` +ecloud compute app deploy → Safe propose → approved → done +ecloud compute app upgrade → Safe propose → approved → done +ecloud compute team grant → Safe propose → approved → done +ecloud compute app stop → Safe propose → approved → done +``` + +### Timelock(Safe) +``` +ecloud compute app deploy → Safe propose → approved → done +ecloud compute app upgrade schedule --after=24h → Safe propose → approved → scheduled +# wait for delay (or: ecloud demo fastforward) +ecloud compute app upgrade execute → Safe propose → approved → done +ecloud compute team grant → Safe propose → approved → done +ecloud compute app stop → Safe propose → approved → done +``` + +### Safe → Timelock(Safe) transition (adding upgrade delay) +``` +# 1. Start as Safe, deploy the app +ecloud auth login → select 3/5 Safe +ecloud compute app deploy --image-ref myrepo/myapp:v1 + → Safe propose → approved → done + +# 2. Transfer ownership to a Timelock (adds upgrade delay on top of Safe) +ecloud compute app ownership transfer --to= + → Safe propose → approved → done + → Timelocked mode enabled + +# 3. Switch identity to the Timelock +ecloud auth login → select Timelock (24h delay) via 2/3 Safe + +# 4. Direct upgrade is now blocked +ecloud compute app upgrade → ❌ TimelockRequired + → use: ecloud compute app upgrade schedule --after= + → ecloud compute app upgrade execute + +# 5. Use the two-step timelocked flow +ecloud compute app upgrade schedule --after=24h + → Safe propose → approved → scheduled +# wait for delay (or: ecloud demo fastforward) +ecloud compute app upgrade execute → Safe propose → approved → done +``` + +--- + +### PAUSER role (granted by Safe) +``` +# Admin grants PAUSER role to 0x5678... +ecloud compute team grant 0x5678567856785678567856785678567856785678 → Safe propose → approved → done + +# PAUSER acts directly, no Safe needed +ecloud auth login → select PAUSER identity (0x5678...5678) +ecloud compute app stop → direct +``` + +### DEVELOPER role (granted by Admin) +``` +# Admin grants DEVELOPER role to 0x9999... +ecloud compute team grant 0x9999... → (direct | Safe propose) → done + +# DEVELOPER can view info, update metadata, view logs; cannot perform admin ops +ecloud auth login → select DEVELOPER identity (0x9999...) +ecloud compute app info → shows status, image, instance type +ecloud compute app logs → stream app logs +ecloud compute app profile set → update name, website, description, image +ecloud compute app upgrade → ❌ no permission +ecloud compute app stop → ❌ no permission +``` From 58989368217220bdd8fa93f4b03173adf2b0da36 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Fri, 13 Mar 2026 18:44:42 -0700 Subject: [PATCH 06/41] =?UTF-8?q?feat:=20identity-centric=20auth=20?= =?UTF-8?q?=E2=80=94=20EOA/Safe/Timelock=20identities=20with=20active=20se?= =?UTF-8?q?lection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLI now stores multiple identities (EOA, Safe, Timelock) per user in config - Active identity is tracked per environment - auth login: shows identity selector; 1 identity auto-prompts, replacing key wipes old identities and re-discovers Timelock for new EOA - auth new/generate: rewritten with EOA/Safe/Timelock flows; enforces CANONICAL_SALT for deterministic Timelock addresses - auth whoami: shows all identities with active marker and signing key status - SDK: add deploySafe, deployTimelock, discoverTimelockForEOA, CANONICAL_SALT - SDK: add SafeTimelockFactory and TimelockController ABIs --- packages/cli/src/commands/auth/generate.ts | 297 ++++++++++++++---- packages/cli/src/commands/auth/login.ts | 91 +++++- packages/cli/src/commands/auth/whoami.ts | 65 ++-- packages/cli/src/utils/globalConfig.ts | 108 +++++++ .../common/abis/SafeTimelockFactory.json | 111 +++++++ .../common/abis/TimelockController.json | 19 ++ .../sdk/src/client/common/contract/caller.ts | 170 ++++++++++ packages/sdk/src/client/index.ts | 8 + 8 files changed, 778 insertions(+), 91 deletions(-) create mode 100644 packages/sdk/src/client/common/abis/SafeTimelockFactory.json create mode 100644 packages/sdk/src/client/common/abis/TimelockController.json diff --git a/packages/cli/src/commands/auth/generate.ts b/packages/cli/src/commands/auth/generate.ts index 430211ad..e1f7ddd1 100644 --- a/packages/cli/src/commands/auth/generate.ts +++ b/packages/cli/src/commands/auth/generate.ts @@ -1,17 +1,49 @@ /** * Auth Generate Command * - * Generate a new private key and optionally store it in OS keyring + * Create a new identity: EOA private key, Gnosis Safe, or Timelock (wrapping EOA or Safe). */ import { Command, Flags } from "@oclif/core"; -import { confirm } from "@inquirer/prompts"; -import { generateNewPrivateKey, storePrivateKey, keyExists } from "@layr-labs/ecloud-sdk"; +import { confirm, select, input } from "@inquirer/prompts"; +import { + generateNewPrivateKey, + storePrivateKey, + keyExists, + getEnvironmentConfig, + deploySafe, + deployTimelock, + type DeploySafeOptions, + type DeployTimelockOptions, +} from "@layr-labs/ecloud-sdk"; import { showPrivateKey, displayWarning } from "../../utils/security"; import { withTelemetry } from "../../telemetry"; +import { commonFlags, validateCommonFlags } from "../../flags"; +import { createViemClients } from "../../utils/viemClients"; +import { addIdentity, setActiveIdentity, replaceAllIdentities } from "../../utils/globalConfig"; +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".`); + 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]); +} + +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 AuthGenerate extends Command { - static description = "Generate a new private key"; + static description = "Create a new identity: EOA private key, Gnosis Safe, or Timelock"; static aliases = ["auth:gen", "auth:new"]; @@ -21,8 +53,9 @@ export default class AuthGenerate extends Command { ]; static flags = { + ...commonFlags, store: Flags.boolean({ - description: "Automatically store in OS keyring", + description: "Automatically store EOA key in OS keyring", default: false, }), }; @@ -31,12 +64,31 @@ 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"); - const { privateKey, address } = generateNewPrivateKey(); + const kind = await select({ + message: "What would you like to create?", + choices: [ + { name: "EOA (new private key)", value: "eoa" }, + { name: "Gnosis Safe", value: "safe" }, + { name: "Timelock (for existing EOA or Safe)", value: "timelock" }, + ], + }); + + this.log(""); + + if (kind === "eoa") { + await this._runEOA(flags); + } else if (kind === "safe") { + await this._runSafe(flags); + } else { + await this._runTimelock(flags); + } + }); + } - // Display key securely - const content = ` + private async _runEOA(flags: any): Promise { + const { privateKey, address } = generateNewPrivateKey(); + + const content = ` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ A new private key was generated for you. @@ -55,62 +107,187 @@ Private key: ${privateKey} Press 'q' to exit and continue... `; - const displayed = await showPrivateKey(content); + const displayed = await showPrivateKey(content); + if (!displayed) { + this.log("Key generation cancelled."); + return; + } + + let shouldStore = flags.store; + if (!shouldStore) { + shouldStore = await confirm({ message: "Store this key in your OS keyring?", default: true }); + } - if (!displayed) { - this.log("Key generation cancelled."); - return; + if (shouldStore) { + 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; + } + } + try { + await storePrivateKey(privateKey); + // New signing key — wipe all identities (they belonged to the previous EOA) + 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."); + } catch (err: any) { + this.error(`Failed to store key: ${err.message}`); } + } else { + this.log("\nKey not stored in keyring."); + this.log("Remember to save the key shown above in a secure location."); + } + } - // Ask about storing - let shouldStore = flags.store; + private async _runSafe(flags: any): Promise { + await validateCommonFlags(flags, { requirePrivateKey: true }); - if (!shouldStore && displayed) { - shouldStore = await confirm({ - message: "Store this key in your OS keyring?", - default: true, - }); - } + const ownersRaw = await input({ + message: "Enter owner addresses (comma-separated):", + validate: (v) => (v.trim().length > 0 ? true : "At least one owner is required"), + }); + const owners = ownersRaw.split(",").map((a) => a.trim() as Address); - 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; - } - } + 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); - // Store the key - try { - await storePrivateKey(privateKey); - 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."); - } catch (err: any) { - this.error(`Failed to store key: ${err.message}`); - } - } else { - this.log("\nKey not stored in keyring."); - this.log("Remember to save the key shown above in a secure location."); - } + 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" }); + } + + const environmentConfig = getEnvironmentConfig(flags.environment); + const { walletClient, publicClient } = createViemClients({ + privateKey: flags["private-key"] as string, + rpcUrl: flags["rpc-url"], + environment: flags.environment, + }); + 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 } = await deploySafe( + { walletClient, publicClient, environmentConfig, owners, threshold } as DeploySafeOptions, + logger, + ); + 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: "safe", address: safe, environment: flags.environment }); + 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 }); + 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 === 0n) { + this.error(`Account ${signerAddress} has no ETH. Fund it before deploying.`); + } + + const proposerKind = await select({ + message: "Is the proposer/executor an EOA or a Safe?", + choices: [ + { name: "EOA (single private key)", value: "eoa" }, + { name: "Gnosis Safe (multi-sig)", value: "safe" }, + ], + }); + + const proposer = await input({ + message: "Proposer/executor address:", + validate: (v) => (v.trim().startsWith("0x") ? true : "Must be a 0x address"), + }) as Address; + + const delayStr = await input({ + message: 'Minimum delay (e.g., "24h", "7d"):', + default: "24h", + }); + 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], + } 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/login.ts b/packages/cli/src/commands/auth/login.ts index 62fc1060..5e917f4a 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -14,13 +14,26 @@ import { getLegacyKeys, getLegacyPrivateKey, deleteLegacyPrivateKey, + getEnvironmentConfig, + discoverTimelockForEOA, type LegacyKey, } from "@layr-labs/ecloud-sdk"; import { getHiddenInput, displayWarning } from "../../utils/security"; import { withTelemetry } from "../../telemetry"; +import { commonFlags } from "../../flags"; +import { + getIdentities, + addIdentity, + replaceAllIdentities, + setActiveIdentity, + getActiveIdentityAddress, + formatIdentity, +} 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 = "Store your private key in OS keyring, or switch active identity"; static examples = [ "<%= config.bin %> auth login", @@ -37,17 +50,20 @@ export default class AuthLogin extends Command { 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 - const exists = await keyExists(); + const identities = getIdentities(); - if (exists) { + // One or more identities — show selector + if (identities.length > 0) { if (isNonInteractive) { if (!flags.force) { this.error( @@ -55,19 +71,41 @@ export default class AuthLogin extends Command { ); } } else { + const activeAddress = getActiveIdentityAddress(environment); + const choices = [ + ...identities.map((id) => ({ + name: formatIdentity(id) + (id.address.toLowerCase() === activeAddress?.toLowerCase() ? " ✓ active" : ""), + value: id.address, + })), + { name: "─── Replace signing key ───", value: "__key__" }, + ]; + + const selected = await select({ + message: `Select active identity for ${environment}:`, + choices, + }); + + if (selected !== "__key__") { + setActiveIdentity(environment, selected); + const id = identities.find((i) => i.address.toLowerCase() === selected.toLowerCase())!; + this.log(`\n✓ Active identity: ${formatIdentity(id)}`); + return; + } + + // User chose to replace signing key — warn before proceeding 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.", + "WARNING: Replacing the signing key will remove all current identities.", + "Contract identities (Safe, Timelock) tied to the old key will be cleared.", ]); + this.log(""); const confirmReplace = await confirm({ - message: "Replace existing key?", + message: "Replace current signing key?", default: false, }); if (!confirmReplace) { - this.log("\nLogin cancelled."); + this.log("\nCancelled."); return; } } @@ -159,6 +197,41 @@ export default class AuthLogin extends Command { 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."); + // Switching signing key — wipe all identities (they belonged to the previous EOA) + replaceAllIdentities([{ type: "eoa", address }]); + setActiveIdentity(environment, address); + + // Discover canonical Timelock on-chain for this EOA + this.log(`\nScanning chain for Timelock associated with ${address}...`); + try { + const publicClient = createPublicClientOnly({ environment, rpcUrl: flags["rpc-url"] }); + const environmentConfig = getEnvironmentConfig(environment); + const found = await discoverTimelockForEOA(publicClient, environmentConfig, address as Address); + + if (found) { + const delayHours = Number(found.minDelay) / 3600; + const delayLabel = delayHours >= 24 ? `${delayHours / 24}d` : `${delayHours}h`; + this.log(`Found Timelock: ${found.address} (${delayLabel} delay)`); + + const alreadyKnown = getIdentities().some( + (id) => id.address.toLowerCase() === found.address.toLowerCase(), + ); + if (!alreadyKnown) { + const addIt = await confirm({ message: "Add this Timelock to your identities?", default: true }); + if (addIt) { + addIdentity({ type: "timelock", address: found.address, delay: delayLabel, environment }); + this.log(`✓ Timelock added to identities`); + } + } else { + this.log(`✓ Timelock already in your identities`); + } + } else { + this.log(`No Timelock found for this EOA on ${environment}`); + } + } catch { + this.log(`(Timelock scan skipped — chain not reachable)`); + } + // Ask if user wants to delete the legacy key (only if save was successful) if (selectedKey) { this.log(""); diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts index e7ed885d..88cfacd9 100644 --- a/packages/cli/src/commands/auth/whoami.ts +++ b/packages/cli/src/commands/auth/whoami.ts @@ -1,53 +1,74 @@ /** * 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 { commonFlags } from "../../flags"; import { withTelemetry } from "../../telemetry"; +import { + getIdentities, + getActiveIdentityAddress, + formatIdentity, +} from "../../utils/globalConfig"; 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, }; async run(): Promise { return withTelemetry(this, async () => { const { flags } = await this.parse(AuthWhoami); + const environment = flags.environment as string; - // 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(""); + + // Identities + const identities = getIdentities(); + const activeAddress = getActiveIdentityAddress(environment); - if (!result) { - this.log("Not authenticated"); + 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 new' to create an identity."); return; } - // Get address from private key - const address = getAddressFromPrivateKey(result.key); + 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)}${active}`); + } + + // 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 login' to switch active identity."); + } }); } } diff --git a/packages/cli/src/utils/globalConfig.ts b/packages/cli/src/utils/globalConfig.ts index 9618d310..e69d58aa 100644 --- a/packages/cli/src/utils/globalConfig.ts +++ b/packages/cli/src/utils/globalConfig.ts @@ -23,6 +23,17 @@ 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; +} + export interface GlobalConfig { first_run?: boolean; telemetry_enabled?: boolean; @@ -38,6 +49,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 +388,94 @@ 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); +} + +/** + * 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): string { + const short = id.address.slice(0, 6) + "..." + id.address.slice(-4); + if (id.type === "eoa") return `${short} (EOA)`; + if (id.type === "safe") return `${short} (Safe${id.environment ? ` · ${id.environment}` : ""})`; + if (id.type === "timelock") { + const via = id.safeAddress + ? `via Safe ${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/sdk/src/client/common/abis/SafeTimelockFactory.json b/packages/sdk/src/client/common/abis/SafeTimelockFactory.json new file mode 100644 index 00000000..7919a28c --- /dev/null +++ b/packages/sdk/src/client/common/abis/SafeTimelockFactory.json @@ -0,0 +1,111 @@ +[ + { + "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": "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": "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": "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": [] } +] 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..81509efb --- /dev/null +++ b/packages/sdk/src/client/common/abis/TimelockController.json @@ -0,0 +1,19 @@ +[ + { + "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" + } +] diff --git a/packages/sdk/src/client/common/contract/caller.ts b/packages/sdk/src/client/common/contract/caller.ts index 9641699f..af236504 100644 --- a/packages/sdk/src/client/common/contract/caller.ts +++ b/packages/sdk/src/client/common/contract/caller.ts @@ -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 @@ -1726,3 +1728,171 @@ 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 all factory deployments — ensures deterministic addresses */ +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; safe: Address }> { + 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); + + 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 + const safeDeployedTopic = "0x" + Buffer.from("SafeDeployed(address,address,address[],uint256,bytes32)").toString("hex"); + // 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[]; +} + +/** + * 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 = 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. + */ +export async function discoverTimelockForEOA( + publicClient: PublicClient, + environmentConfig: EnvironmentConfig, + eoaAddress: Address, +): Promise { + const factoryAddress = await getSafeTimelockFactoryAddress(publicClient, environmentConfig); + + const timelockAddress = await publicClient.readContract({ + address: factoryAddress, + abi: SafeTimelockFactoryABI, + functionName: "calculateTimelockAddress", + args: [eoaAddress, 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 }; +} diff --git a/packages/sdk/src/client/index.ts b/packages/sdk/src/client/index.ts index 8cb8ea00..e1af09d6 100644 --- a/packages/sdk/src/client/index.ts +++ b/packages/sdk/src/client/index.ts @@ -123,6 +123,14 @@ export { type CancelUpgradeOptions, type GrantTeamRoleOptions, type RevokeTeamRoleOptions, + getSafeTimelockFactoryAddress, + deploySafe, + deployTimelock, + discoverTimelockForEOA, + CANONICAL_SALT, + type DeploySafeOptions, + type DeployTimelockOptions, + type DiscoveredTimelock, } from "./common/contract/caller"; // Export batch gas estimation and delegation check From 62efff3184a270e9a5d17815a49191545044e3a5 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Fri, 13 Mar 2026 18:51:29 -0700 Subject: [PATCH 07/41] feat: check for existing canonical Timelock before deploying in auth new --- packages/cli/src/commands/auth/generate.ts | 32 +++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/auth/generate.ts b/packages/cli/src/commands/auth/generate.ts index e1f7ddd1..1df19228 100644 --- a/packages/cli/src/commands/auth/generate.ts +++ b/packages/cli/src/commands/auth/generate.ts @@ -13,6 +13,7 @@ import { getEnvironmentConfig, deploySafe, deployTimelock, + discoverTimelockForEOA, type DeploySafeOptions, type DeployTimelockOptions, } from "@layr-labs/ecloud-sdk"; @@ -20,7 +21,7 @@ import { showPrivateKey, displayWarning } from "../../utils/security"; import { withTelemetry } from "../../telemetry"; import { commonFlags, validateCommonFlags } from "../../flags"; import { createViemClients } from "../../utils/viemClients"; -import { addIdentity, setActiveIdentity, replaceAllIdentities } from "../../utils/globalConfig"; +import { addIdentity, setActiveIdentity, replaceAllIdentities, getIdentities } from "../../utils/globalConfig"; import type { Address } from "viem"; /** Parse human delay strings like "24h", "7d", "30m" into seconds */ @@ -254,6 +255,35 @@ Press 'q' to exit and continue... validate: (v) => (v.trim().startsWith("0x") ? true : "Must be a 0x address"), }) as Address; + // Check if a canonical Timelock already exists for this EOA + if (proposerKind === "eoa") { + const existing = await discoverTimelockForEOA(publicClient, environmentConfig, proposer); + if (existing) { + const delayHours = Number(existing.minDelay) / 3600; + const delayLabel = delayHours >= 24 ? `${delayHours / 24}d` : `${delayHours}h`; + const alreadyInConfig = getIdentities().some( + (id) => id.address.toLowerCase() === existing.address.toLowerCase(), + ); + if (alreadyInConfig) { + this.log(`\nTimelock ${existing.address} is already in your identities.`); + const activate = await confirm({ message: "Set it as active identity?", default: true }); + if (activate) { + setActiveIdentity(flags.environment, existing.address); + this.log(`✓ Active identity set to Timelock ${existing.address}`); + } + } else { + this.log(`\nA Timelock already exists for this EOA: ${existing.address} (${delayLabel} delay)`); + const addIt = await confirm({ message: "Add it to your identities?", default: true }); + if (addIt) { + addIdentity({ type: "timelock", address: existing.address, delay: delayLabel, environment: flags.environment }); + setActiveIdentity(flags.environment, existing.address); + this.log(`✓ Timelock added and set as active identity`); + } + } + return; + } + } + const delayStr = await input({ message: 'Minimum delay (e.g., "24h", "7d"):', default: "24h", From 9e20d85919bfe4dffe5359e6b408cfda5366af2f Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Fri, 13 Mar 2026 19:27:14 -0700 Subject: [PATCH 08/41] fix: default Timelock proposer to signing key for EOA; prompt only for Safe --- packages/cli/src/commands/auth/generate.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/auth/generate.ts b/packages/cli/src/commands/auth/generate.ts index 1df19228..a790a25d 100644 --- a/packages/cli/src/commands/auth/generate.ts +++ b/packages/cli/src/commands/auth/generate.ts @@ -214,7 +214,6 @@ Press 'q' to exit and continue... } as DeployTimelockOptions, logger, ); - addIdentity({ type: "safe", address: safe, environment: flags.environment }); 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)`); @@ -245,15 +244,17 @@ Press 'q' to exit and continue... const proposerKind = await select({ message: "Is the proposer/executor an EOA or a Safe?", choices: [ - { name: "EOA (single private key)", value: "eoa" }, + { name: "EOA (signing key)", value: "eoa" }, { name: "Gnosis Safe (multi-sig)", value: "safe" }, ], }); - const proposer = await input({ - message: "Proposer/executor address:", - validate: (v) => (v.trim().startsWith("0x") ? true : "Must be a 0x address"), - }) as Address; + const proposer: Address = proposerKind === "eoa" + ? signerAddress + : await input({ + message: "Safe address:", + validate: (v) => (v.trim().startsWith("0x") ? true : "Must be a 0x address"), + }) as Address; // Check if a canonical Timelock already exists for this EOA if (proposerKind === "eoa") { From df38ffc51cbd8a694e70043b6f5b89cf402142fa Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Sat, 14 Mar 2026 10:24:43 -0700 Subject: [PATCH 09/41] feat: safe identity flows, consistent warnings, show existing key before replace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth new → Safe: auto-generate signing key if none exists, EOA always pre-filled as owner - auth new → Timelock: EOA proposer defaults to signing key, Safe prompts for address - Consistent warning message across all key-replace flows - Show existing key in pager before warning so user can back it up - Safe Transaction Service discovery in auth login (find Safes where EOA is owner) - Remove redundant Safe identity entry when Safe+Timelock deployed together --- packages/cli/src/commands/auth/generate.ts | 132 +++++++++-- packages/cli/src/commands/auth/login.ts | 244 +++++++++++---------- 2 files changed, 246 insertions(+), 130 deletions(-) diff --git a/packages/cli/src/commands/auth/generate.ts b/packages/cli/src/commands/auth/generate.ts index a790a25d..2fa526d7 100644 --- a/packages/cli/src/commands/auth/generate.ts +++ b/packages/cli/src/commands/auth/generate.ts @@ -10,6 +10,8 @@ import { generateNewPrivateKey, storePrivateKey, keyExists, + getPrivateKeyWithSource, + getAddressFromPrivateKey, getEnvironmentConfig, deploySafe, deployTimelock, @@ -122,16 +124,32 @@ Press 'q' to exit and continue... if (shouldStore) { const exists = await keyExists(); if (exists) { + // Show existing key so user can back it up before it's replaced + const existing = await getPrivateKeyWithSource({ privateKey: undefined }); + if (existing) { + const existingAddress = getAddressFromPrivateKey(existing.key); + const backupContent = ` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Your existing signing key is shown below. +Back it up before it is replaced. + +Address: ${existingAddress} +Private key: ${existing.key} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Press 'q' to exit and continue... +`; + await showPrivateKey(backupContent); + } + 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.", + "A signing key already exists.", + "Replacing it will clear all current identities.", + "Make sure you have backed up your existing key.", ]); - const confirmReplace = await confirm({ message: `Replace existing key for ecloud?`, default: false }); + const confirmReplace = await confirm({ message: "Replace existing key?", 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."); + this.log("\nCancelled."); return; } } @@ -155,13 +173,95 @@ Press 'q' to exit and continue... } private async _runSafe(flags: any): Promise { - await validateCommonFlags(flags, { requirePrivateKey: true }); + // Ensure a signing key exists — generate one if needed + let signingKey: string | undefined = flags["private-key"] as string | undefined; + + if (!signingKey) { + const exists = await keyExists(); + if (exists) { + const existing = await getPrivateKeyWithSource({ privateKey: undefined }); + if (existing) { + const existingAddress = getAddressFromPrivateKey(existing.key); + const backupContent = ` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Your existing signing key is shown below. +Back it up before it is replaced. + +Address: ${existingAddress} +Private key: ${existing.key} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Press 'q' to exit and continue... +`; + await showPrivateKey(backupContent); + } + + displayWarning([ + "A signing key already exists.", + "Replacing it will clear all current identities.", + "Make sure you have backed up your existing key.", + ]); + const confirmReplace = await confirm({ message: "Generate new signing key?", default: false }); + if (!confirmReplace) { + this.log("\nCancelled."); + return; + } + } + + const { privateKey, address } = generateNewPrivateKey(); + const content = ` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +A new private key was generated for you. + +IMPORTANT: You MUST backup this key now. + It will never be shown again. - const ownersRaw = await input({ - message: "Enter owner addresses (comma-separated):", - validate: (v) => (v.trim().length > 0 ? true : "At least one owner is required"), +Address: ${address} +Private key: ${privateKey} + +⚠️ SECURITY WARNING: + • Anyone with this key can control your account + • Never share it or commit it to version control + • Store it in a secure password manager +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Press 'q' to exit and continue... +`; + const displayed = await showPrivateKey(content); + if (!displayed) { + this.log("Cancelled."); + return; + } + + await storePrivateKey(privateKey); + 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("You can now use ecloud commands without --private-key flag.\n"); + signingKey = privateKey; + } + + 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 owners = ownersRaw.split(",").map((a) => a.trim() as Address); + 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}):`, @@ -178,15 +278,9 @@ Press 'q' to exit and continue... if (addTimelock) { delayStr = await input({ message: 'Minimum delay (e.g., "24h", "7d"):', default: "24h" }); } - - const environmentConfig = getEnvironmentConfig(flags.environment); - const { walletClient, publicClient } = createViemClients({ - privateKey: flags["private-key"] as string, - rpcUrl: flags["rpc-url"], - environment: flags.environment, - }); 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...`); diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 5e917f4a..67481f2b 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -4,13 +4,14 @@ * Store an existing private key in OS keyring */ -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, @@ -18,7 +19,7 @@ import { discoverTimelockForEOA, type LegacyKey, } from "@layr-labs/ecloud-sdk"; -import { getHiddenInput, displayWarning } from "../../utils/security"; +import { getHiddenInput, displayWarning, showPrivateKey } from "../../utils/security"; import { withTelemetry } from "../../telemetry"; import { commonFlags } from "../../flags"; import { @@ -35,21 +36,9 @@ import type { Address } from "viem"; export default class AuthLogin extends Command { static description = "Store your private key in OS keyring, or switch active identity"; - 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"], }; @@ -57,117 +46,122 @@ export default class AuthLogin extends Command { async run(): Promise { return withTelemetry(this, async () => { const { flags } = await this.parse(AuthLogin); - const isNonInteractive = !!flags["private-key"]; const environment = flags.environment as string; const identities = getIdentities(); // One or more identities — show selector if (identities.length > 0) { - if (isNonInteractive) { - if (!flags.force) { - this.error( - "A private key already exists. Use --force to replace it.", - ); - } - } else { - const activeAddress = getActiveIdentityAddress(environment); - const choices = [ - ...identities.map((id) => ({ - name: formatIdentity(id) + (id.address.toLowerCase() === activeAddress?.toLowerCase() ? " ✓ active" : ""), - value: id.address, - })), - { name: "─── Replace signing key ───", value: "__key__" }, - ]; - - const selected = await select({ - message: `Select active identity for ${environment}:`, - choices, - }); + const activeAddress = getActiveIdentityAddress(environment); + const choices = [ + ...identities.map((id) => ({ + name: formatIdentity(id) + (id.address.toLowerCase() === activeAddress?.toLowerCase() ? " ✓ active" : ""), + value: id.address, + })), + { name: "─── Replace signing key ───", value: "__key__" }, + ]; + + const selected = await select({ + message: `Select active identity for ${environment}:`, + choices, + }); - if (selected !== "__key__") { - setActiveIdentity(environment, selected); - const id = identities.find((i) => i.address.toLowerCase() === selected.toLowerCase())!; - this.log(`\n✓ Active identity: ${formatIdentity(id)}`); - return; - } + if (selected !== "__key__") { + setActiveIdentity(environment, selected); + const id = identities.find((i) => i.address.toLowerCase() === selected.toLowerCase())!; + this.log(`\n✓ Active identity: ${formatIdentity(id)}`); + return; + } - // User chose to replace signing key — warn before proceeding - displayWarning([ - "WARNING: Replacing the signing key will remove all current identities.", - "Contract identities (Safe, Timelock) tied to the old key will be cleared.", - ]); - this.log(""); + // Show existing key for backup before replacing + const existing = await getPrivateKeyWithSource({ privateKey: undefined }); + if (existing) { + const existingAddress = getAddressFromPrivateKey(existing.key); + const backupContent = ` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Your existing signing key is shown below. +Back it up before it is replaced. + +Address: ${existingAddress} +Private key: ${existing.key} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Press 'q' to exit and continue... +`; + await showPrivateKey(backupContent); + } - const confirmReplace = await confirm({ - message: "Replace current signing key?", - default: false, - }); + // User chose to replace signing key — warn before proceeding + displayWarning([ + "A signing key already exists.", + "Replacing it will clear all current identities.", + "Make sure you have backed up your existing key.", + ]); + this.log(""); + + const confirmReplace = await confirm({ + message: "Replace current signing key?", + default: false, + }); - if (!confirmReplace) { - this.log("\nCancelled."); - return; - } + 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:"); + // 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(""); + } - // 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) { + // Create choices for selection + 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}`); - } + // Retrieve the actual private key + 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."); } @@ -177,16 +171,14 @@ export default class AuthLogin extends Command { 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 @@ -194,7 +186,6 @@ export default class AuthLogin extends Command { 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."); // Switching signing key — wipe all identities (they belonged to the previous EOA) @@ -232,16 +223,47 @@ export default class AuthLogin extends Command { this.log(`(Timelock scan skipped — chain not reachable)`); } + // Discover Safes where this EOA is an owner via Safe Transaction Service + try { + const safeServiceUrl = + environment === "mainnet-alpha" + ? "https://safe-transaction-mainnet.safe.global" + : "https://safe-transaction-sepolia.safe.global"; + const res = await fetch(`${safeServiceUrl}/api/v1/owners/${address}/safes/`); + if (res.ok) { + const data = await res.json() as { safes: string[] }; + const safes = data.safes ?? []; + if (safes.length > 0) { + this.log(`\nFound ${safes.length} Safe(s) where this EOA is an owner:`); + for (const safe of safes) { + const alreadyKnown = getIdentities().some( + (id) => id.address.toLowerCase() === safe.toLowerCase(), + ); + if (alreadyKnown) { + this.log(` ${safe} (already in identities)`); + } else { + const addIt = await confirm({ message: `Add Safe ${safe} to your identities?`, default: true }); + if (addIt) { + addIdentity({ type: "safe", address: safe, environment }); + this.log(`✓ Safe added to identities`); + } + } + } + } + } + } catch { + // Safe Transaction Service not reachable — skip + } + // Ask if user wants to delete the legacy key (only if save was successful) 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) { + if (confirmDelete) { const deleted = await deleteLegacyPrivateKey( selectedKey.environment, selectedKey.source, From c230f0ae9df9e99e6949e697f2a25aa6cc313d0f Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Mon, 23 Mar 2026 09:46:12 -0700 Subject: [PATCH 10/41] fix: read scheduled Release from chain event instead of rebuilding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit executeGovernedUpgrade previously re-ran the full Docker build pipeline to reconstruct the Release for on-chain hash verification. This would fail with ReleaseMismatch on any non-deterministic build. The Release is already emitted in full in the AppUpgradeScheduled event. Add getScheduledRelease() to fetch and decode it from logs by matching on readyAt, then pass it directly to executeUpgrade — no rebuild needed. Also simplifies ExecuteGovernedUpgradeOptions and the execute CLI command, removing all build flags (dockerfile, image-ref, env-file, instance-type, log-visibility, resource-usage-monitoring) that are no longer required. --- .../commands/compute/app/upgrade/execute.ts | 98 ++----------------- .../sdk/src/client/common/contract/caller.ts | 66 ++++++++++++- .../src/client/modules/compute/app/upgrade.ts | 50 +++++----- 3 files changed, 91 insertions(+), 123 deletions(-) diff --git a/packages/cli/src/commands/compute/app/upgrade/execute.ts b/packages/cli/src/commands/compute/app/upgrade/execute.ts index 868c7745..486c83f2 100644 --- a/packages/cli/src/commands/compute/app/upgrade/execute.ts +++ b/packages/cli/src/commands/compute/app/upgrade/execute.ts @@ -1,24 +1,11 @@ -import { Command, Args, Flags } from "@oclif/core"; +import { Command, Args } from "@oclif/core"; import { getEnvironmentConfig, isMainnet } from "@layr-labs/ecloud-sdk"; import { withTelemetry } from "../../../../telemetry"; import { commonFlags } from "../../../../flags"; import { createComputeClient } from "../../../../client"; import { createViemClients } from "../../../../utils/viemClients"; -import { - getDockerfileInteractive, - getImageReferenceInteractive, - getEnvFileInteractive, - getInstanceTypeInteractive, - getLogSettingsInteractive, - getResourceUsageMonitoringInteractive, - getOrPromptAppID, - LogVisibility, - ResourceUsageMonitoring, - confirm, -} from "../../../../utils/prompts"; +import { getOrPromptAppID, confirm } from "../../../../utils/prompts"; import chalk from "chalk"; -import { UserApiClient } from "@layr-labs/ecloud-sdk"; -import { getClientId } from "../../../../utils/version"; import { executeGovernedUpgrade } from "@layr-labs/ecloud-sdk"; import { setLinkedAppForDirectory } from "../../../../utils/globalConfig"; import { getDashboardUrl } from "../../../../utils/dashboard"; @@ -33,42 +20,7 @@ export default class AppUpgradeExecute extends Command { }), }; - static flags = { - ...commonFlags, - dockerfile: Flags.string({ - required: false, - description: "Path to Dockerfile (must match what was used in schedule)", - env: "ECLOUD_DOCKERFILE_PATH", - }), - "image-ref": Flags.string({ - required: false, - description: "Image reference (must match what was used in schedule)", - env: "ECLOUD_IMAGE_REF", - }), - "env-file": Flags.string({ - required: false, - description: 'Environment file (must match what was used in schedule)', - default: ".env", - env: "ECLOUD_ENVFILE_PATH", - }), - "log-visibility": Flags.string({ - required: false, - description: "Log visibility setting: public, private, or off", - options: ["public", "private", "off"], - env: "ECLOUD_LOG_VISIBILITY", - }), - "instance-type": Flags.string({ - required: false, - description: "Machine instance type", - env: "ECLOUD_INSTANCE_TYPE", - }), - "resource-usage-monitoring": Flags.string({ - required: false, - description: "Resource usage monitoring: enable or disable", - options: ["enable", "disable"], - env: "ECLOUD_RESOURCE_USAGE_MONITORING", - }), - }; + static flags = { ...commonFlags }; async run() { return withTelemetry(this, async () => { @@ -80,7 +32,6 @@ export default class AppUpgradeExecute extends Command { const rpcUrl = flags["rpc-url"] || environmentConfig.defaultRPCURL; const privateKey = flags["private-key"]!; - // Resolve app ID const appID = await getOrPromptAppID({ appID: args["app-id"], environment, @@ -108,31 +59,7 @@ export default class AppUpgradeExecute extends Command { this.error(`Upgrade is not ready yet. Executable after ${chalk.bold(readyDate)} (${remaining}s remaining).`); } - this.log(chalk.cyan(`\nScheduled upgrade is ready. Proceeding with execution...`)); - this.log(chalk.yellow("Note: build inputs must exactly match what was used in 'upgrade schedule'.")); - - // Collect the same build inputs used during scheduling - const dockerfilePath = await getDockerfileInteractive(flags.dockerfile); - const buildFromDockerfile = dockerfilePath !== ""; - const imageRef = await getImageReferenceInteractive(flags["image-ref"], buildFromDockerfile); - const envFilePath = await getEnvFileInteractive(flags["env-file"]); - - const { publicClient, walletClient } = createViemClients({ privateKey, rpcUrl, environment }); - let currentInstanceType = ""; - try { - const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { clientId: getClientId() }); - const infos = await userApiClient.getInfos([appID], 1); - if (infos.length > 0) currentInstanceType = infos[0].machineType || ""; - } catch {} - - const availableTypes = await fetchAvailableInstanceTypes(environmentConfig, walletClient, publicClient); - const instanceType = await getInstanceTypeInteractive(flags["instance-type"], currentInstanceType, availableTypes); - - const logSettings = await getLogSettingsInteractive(flags["log-visibility"] as LogVisibility | undefined); - const resourceUsageMonitoring = await getResourceUsageMonitoringInteractive( - flags["resource-usage-monitoring"] as ResourceUsageMonitoring | undefined, - ); - const logVisibility = logSettings.publicLogs ? "public" : logSettings.logRedirect ? "private" : "off"; + this.log(chalk.cyan(`\nScheduled upgrade is ready. Fetching release from chain...`)); if (isMainnet(environmentConfig)) { const confirmed = await confirm("Execute the scheduled upgrade?"); @@ -142,18 +69,14 @@ export default class AppUpgradeExecute extends Command { } } + const { walletClient, publicClient } = createViemClients({ privateKey, rpcUrl, environment }); + const res = await executeGovernedUpgrade( { appId: appID, walletClient, publicClient, environment, - dockerfilePath, - imageRef, - envFilePath, - instanceType, - logVisibility: logVisibility as LogVisibility, - resourceUsageMonitoring: resourceUsageMonitoring as ResourceUsageMonitoring, skipTelemetry: true, }, ); @@ -172,12 +95,3 @@ export default class AppUpgradeExecute extends Command { }); } } - -async function fetchAvailableInstanceTypes(environmentConfig: any, walletClient: any, publicClient: any) { - try { - const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { clientId: getClientId() }); - const skuList = await userApiClient.getSKUs(); - if (skuList.skus.length > 0) return skuList.skus; - } catch {} - return [{ sku: "g1-standard-4t", description: "4 vCPUs, 16 GB memory, TDX" }]; -} diff --git a/packages/sdk/src/client/common/contract/caller.ts b/packages/sdk/src/client/common/contract/caller.ts index af236504..fac732ad 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, hexToBytes } from "viem"; import type { WalletClient, PublicClient } from "viem"; import { @@ -1295,6 +1295,60 @@ export async function getPendingAppUpgrade( return { releaseHash: result.releaseHash, readyAt: result.readyAt }; } +/** + * Fetch the scheduled Release struct from chain logs. + * + * The contract only stores keccak256(abi.encode(release)) on-chain, but the full + * Release is emitted in the AppUpgradeScheduled event. We find the event whose + * readyAt matches the pending upgrade and decode the Release from it — no rebuild needed. + */ +export async function getScheduledRelease( + publicClient: PublicClient, + environmentConfig: EnvironmentConfig, + appID: Address, +): Promise { + const pending = await getPendingAppUpgrade(publicClient, environmentConfig, appID); + if (pending.readyAt === 0n) { + throw new Error("no upgrade is scheduled for this app"); + } + + const eventAbi = (AppControllerABI as any[]).find( + (e) => e.type === "event" && e.name === "AppUpgradeScheduled", + ); + + const logs = await publicClient.getLogs({ + address: environmentConfig.appControllerAddress as Address, + event: eventAbi, + args: { app: appID }, + fromBlock: "earliest", + }); + + // scheduleUpgrade overwrites any previous pending upgrade, so match on readyAt + const match = [...logs].reverse().find((log) => { + const { readyAt } = log.args as any; + return BigInt(readyAt) === pending.readyAt; + }); + + if (!match) { + throw new Error( + "AppUpgradeScheduled event not found for this app — the node may not have full history", + ); + } + + const { release } = match.args as any; + return { + rmsRelease: { + artifacts: release.rmsRelease.artifacts.map((a: any) => ({ + digest: hexToBytes(a.digest), + registry: a.registry, + })), + upgradeByTime: Number(release.rmsRelease.upgradeByTime), + }, + publicEnv: hexToBytes(release.publicEnv), + encryptedEnv: hexToBytes(release.encryptedEnv), + }; +} + /** * Options for transferring app ownership */ @@ -1746,7 +1800,14 @@ export async function getSafeTimelockFactoryAddress( }) as Promise
; } -/** Canonical salt used for all factory deployments — ensures deterministic addresses */ +/** + * 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 { @@ -1787,7 +1848,6 @@ export async function deploySafe( } // Parse SafeDeployed event to get the deployed address - const safeDeployedTopic = "0x" + Buffer.from("SafeDeployed(address,address,address[],uint256,bytes32)").toString("hex"); // Use the second indexed topic (safe address) from the log const log = receipt.logs.find( (l) => l.address.toLowerCase() === factoryAddress.toLowerCase(), diff --git a/packages/sdk/src/client/modules/compute/app/upgrade.ts b/packages/sdk/src/client/modules/compute/app/upgrade.ts index e2c98c5f..ab0acb56 100644 --- a/packages/sdk/src/client/modules/compute/app/upgrade.ts +++ b/packages/sdk/src/client/modules/compute/app/upgrade.ts @@ -26,6 +26,7 @@ import { executeUpgradeBatch, scheduleAppUpgrade, executeGovernedUpgrade as executeGovernedUpgradeOnChain, + getScheduledRelease, getPendingAppUpgrade, type GasEstimate, type PendingUpgrade, @@ -41,6 +42,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"; @@ -628,15 +630,23 @@ export async function scheduleUpgrade( } /** - * Options for executeGovernedUpgrade + * Options for executeGovernedUpgrade. + * No build inputs are needed — the Release is fetched from the AppUpgradeScheduled event. */ -export type ExecuteGovernedUpgradeOptions = Omit & { gas?: GasEstimate }; +export interface ExecuteGovernedUpgradeOptions { + appId: string | Address; + walletClient: WalletClient; + publicClient: PublicClient; + environment?: string; + gas?: GasEstimate; + skipTelemetry?: boolean; +} /** * Execute a previously scheduled upgrade for a governed app. * - * Runs the full build pipeline with the same inputs as scheduleUpgrade to reconstruct - * the Release, then calls executeUpgrade on-chain (verifying hash match). + * Reads the Release directly from the AppUpgradeScheduled event on-chain instead of + * rebuilding — no Docker or build inputs required. */ export async function executeGovernedUpgrade( options: ExecuteGovernedUpgradeOptions, @@ -655,9 +665,7 @@ export async function executeGovernedUpgrade( logger, ); - const appID = validateUpgradeOptions(options as SDKUpgradeOptions); - const { logRedirect } = validateLogVisibility(options.logVisibility); - const resourceUsageAllow = validateResourceUsageMonitoring(options.resourceUsageMonitoring); + const appID = validateAppID(options.appId); // Check that a scheduled upgrade exists and is ready const pending = await getPendingAppUpgrade(preflightCtx.publicClient, preflightCtx.environmentConfig, appID); @@ -670,26 +678,11 @@ export async function executeGovernedUpgrade( throw new Error(`upgrade is not ready yet — ${remaining}s remaining`); } - logger.debug("Checking Docker..."); - await ensureDockerIsRunning(); - - const dockerfilePath = options.dockerfilePath || ""; - const imageRef = options.imageRef || ""; - const envFilePath = options.envFilePath || ""; - - logger.info("Preparing release (must match scheduled release)..."); - const { release, finalImageRef } = await prepareRelease( - { - dockerfilePath, - imageRef, - envFilePath, - logRedirect, - resourceUsageAllow, - instanceType: options.instanceType, - environmentConfig: preflightCtx.environmentConfig, - appId: appID, - }, - logger, + logger.info("Fetching scheduled release from chain..."); + const release = await getScheduledRelease( + preflightCtx.publicClient, + preflightCtx.environmentConfig, + appID, ); logger.info("Executing scheduled upgrade on-chain..."); @@ -716,7 +709,8 @@ export async function executeGovernedUpgrade( logger, ); - return { appId: appID, imageRef: finalImageRef, txHash }; + const imageRef = release.rmsRelease.artifacts[0]?.registry ?? ""; + return { appId: appID, imageRef, txHash }; }, ); } From 2cd50adb69d100a7dd178857be1b570cb323688b Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Mon, 23 Mar 2026 10:08:47 -0700 Subject: [PATCH 11/41] test: add unit tests for getScheduledRelease --- packages/sdk/package.json | 1 + .../src/client/common/contract/caller.test.ts | 159 ++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 packages/sdk/src/client/common/contract/caller.test.ts diff --git a/packages/sdk/package.json b/packages/sdk/package.json index a4f4749c..e8a8aaef 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/contract/caller.test.ts b/packages/sdk/src/client/common/contract/caller.test.ts new file mode 100644 index 00000000..04f0e125 --- /dev/null +++ b/packages/sdk/src/client/common/contract/caller.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { PublicClient } from "viem"; +import type { EnvironmentConfig } from "../types"; + +// ---- constants used across tests ---- + +const APP_ID = "0x000000000000000000000000000000000000aaaa" as `0x${string}`; +const CONTROLLER = "0x000000000000000000000000000000000000cccc"; + +const READY_AT = 1_700_000_000n; +const RELEASE_HASH = "0x" + "ab".repeat(32) as `0x${string}`; + +const MOCK_DIGEST = ("0x" + "ab".repeat(32)) as `0x${string}`; +const MOCK_PUBLIC_ENV = ("0x" + "cd".repeat(8)) as `0x${string}`; +const MOCK_ENCRYPTED_ENV = ("0x" + "ef".repeat(16)) as `0x${string}`; +const MOCK_REGISTRY = "registry.example.com/app:abc123"; +const MOCK_UPGRADE_BY_TIME = 9_999_999; + +const ENV_CONFIG = { + appControllerAddress: CONTROLLER, +} as unknown as EnvironmentConfig; + +function makeMockLog(readyAt = READY_AT) { + return { + args: { + app: APP_ID, + readyAt, + release: { + rmsRelease: { + artifacts: [{ digest: MOCK_DIGEST, registry: MOCK_REGISTRY }], + upgradeByTime: MOCK_UPGRADE_BY_TIME, + }, + publicEnv: MOCK_PUBLIC_ENV, + encryptedEnv: MOCK_ENCRYPTED_ENV, + }, + }, + }; +} + +function makePublicClient({ + readyAt = READY_AT, + logs = [makeMockLog()], +}: { + readyAt?: bigint; + logs?: ReturnType[]; +} = {}): PublicClient { + return { + readContract: vi.fn().mockResolvedValue({ releaseHash: RELEASE_HASH, readyAt }), + getLogs: vi.fn().mockResolvedValue(logs), + } as unknown as PublicClient; +} + +// Lazy import so mocks are set up before the module loads +let getScheduledRelease: typeof import("./caller").getScheduledRelease; + +beforeEach(async () => { + ({ getScheduledRelease } = await import("./caller")); +}); + +// ---- tests ---- + +describe("getScheduledRelease", () => { + it("returns a correctly typed Release matching the pending readyAt", async () => { + const client = makePublicClient(); + + const release = await getScheduledRelease(client, ENV_CONFIG, APP_ID); + + expect(release.rmsRelease.artifacts).toHaveLength(1); + expect(release.rmsRelease.artifacts[0].registry).toBe(MOCK_REGISTRY); + expect(release.rmsRelease.upgradeByTime).toBe(MOCK_UPGRADE_BY_TIME); + + // digest: bytes32 hex → 32-byte Uint8Array + expect(release.rmsRelease.artifacts[0].digest).toBeInstanceOf(Uint8Array); + expect(release.rmsRelease.artifacts[0].digest).toHaveLength(32); + + // publicEnv / encryptedEnv: bytes hex → Uint8Array + expect(release.publicEnv).toBeInstanceOf(Uint8Array); + expect(release.publicEnv).toHaveLength(8); + expect(release.encryptedEnv).toBeInstanceOf(Uint8Array); + expect(release.encryptedEnv).toHaveLength(16); + }); + + it("throws when no upgrade is pending (readyAt === 0n)", async () => { + const client = makePublicClient({ readyAt: 0n }); + + await expect(getScheduledRelease(client, ENV_CONFIG, APP_ID)).rejects.toThrow( + "no upgrade is scheduled for this app", + ); + expect(client.getLogs).not.toHaveBeenCalled(); + }); + + it("throws when logs contain no event matching the pending readyAt", async () => { + const client = makePublicClient({ logs: [makeMockLog(READY_AT + 1n)] }); + + await expect(getScheduledRelease(client, ENV_CONFIG, APP_ID)).rejects.toThrow( + "AppUpgradeScheduled event not found", + ); + }); + + it("throws when logs are empty", async () => { + const client = makePublicClient({ logs: [] }); + + await expect(getScheduledRelease(client, ENV_CONFIG, APP_ID)).rejects.toThrow( + "AppUpgradeScheduled event not found", + ); + }); + + it("returns the most recent matching event when multiple logs exist", async () => { + const OLD_REGISTRY = "registry.example.com/app:old"; + const NEW_REGISTRY = "registry.example.com/app:new"; + + const olderLog = { + ...makeMockLog(READY_AT), + args: { + ...makeMockLog(READY_AT).args, + release: { + ...makeMockLog(READY_AT).args.release, + rmsRelease: { + artifacts: [{ digest: MOCK_DIGEST, registry: OLD_REGISTRY }], + upgradeByTime: MOCK_UPGRADE_BY_TIME, + }, + }, + }, + }; + const newerLog = { + ...makeMockLog(READY_AT), + args: { + ...makeMockLog(READY_AT).args, + release: { + ...makeMockLog(READY_AT).args.release, + rmsRelease: { + artifacts: [{ digest: MOCK_DIGEST, registry: NEW_REGISTRY }], + upgradeByTime: MOCK_UPGRADE_BY_TIME, + }, + }, + }, + }; + + // getLogs returns chronological order; getScheduledRelease searches newest-first + const client = makePublicClient({ logs: [olderLog, newerLog] }); + + const release = await getScheduledRelease(client, ENV_CONFIG, APP_ID); + expect(release.rmsRelease.artifacts[0].registry).toBe(NEW_REGISTRY); + }); + + it("queries getLogs filtered to the app controller address and app", async () => { + const client = makePublicClient(); + + await getScheduledRelease(client, ENV_CONFIG, APP_ID); + + expect(client.getLogs).toHaveBeenCalledWith( + expect.objectContaining({ + address: CONTROLLER, + args: expect.objectContaining({ app: APP_ID }), + fromBlock: "earliest", + }), + ); + }); +}); From 55cf774c15cc75490e8463b95cd9fb85bc8b6027 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Mon, 23 Mar 2026 13:08:41 -0700 Subject: [PATCH 12/41] feat: add ecloud SDK and CLI support for timelocked governance operations Add schedule/execute CLI commands and SDK methods for the three AppController operations that require Timelock routing when an app is timelocked: terminateApp, transferOwnership, and grantTeamRole(ADMIN). - TimelockController ABI: add schedule, execute, hashOperation, getTimestamp - caller.ts: add scheduleTimelockOp/executeTimelockOp primitives, per-op helpers (terminate, transferOwnership, grantTeamAdmin), and readyAt helpers - AppModule: expose all new methods including getTimelockTerminateReadyAt, getTimelockTransferOwnershipReadyAt, getTimelockGrantAdminReadyAt - CLI: app terminate schedule/execute, app ownership schedule-transfer/ execute-transfer, team grant-admin schedule/execute --- .../compute/app/ownership/execute-transfer.ts | 86 +++++ .../app/ownership/schedule-transfer.ts | 98 ++++++ .../commands/compute/app/terminate/execute.ts | 78 +++++ .../compute/app/terminate/schedule.ts | 90 +++++ .../compute/team/grant-admin/execute.ts | 89 +++++ .../compute/team/grant-admin/schedule.ts | 101 ++++++ .../common/abis/TimelockController.json | 47 +++ .../sdk/src/client/common/contract/caller.ts | 313 ++++++++++++++++++ .../src/client/modules/compute/app/index.ts | 120 +++++++ 9 files changed, 1022 insertions(+) create mode 100644 packages/cli/src/commands/compute/app/ownership/execute-transfer.ts create mode 100644 packages/cli/src/commands/compute/app/ownership/schedule-transfer.ts create mode 100644 packages/cli/src/commands/compute/app/terminate/execute.ts create mode 100644 packages/cli/src/commands/compute/app/terminate/schedule.ts create mode 100644 packages/cli/src/commands/compute/team/grant-admin/execute.ts create mode 100644 packages/cli/src/commands/compute/team/grant-admin/schedule.ts diff --git a/packages/cli/src/commands/compute/app/ownership/execute-transfer.ts b/packages/cli/src/commands/compute/app/ownership/execute-transfer.ts new file mode 100644 index 00000000..857296f6 --- /dev/null +++ b/packages/cli/src/commands/compute/app/ownership/execute-transfer.ts @@ -0,0 +1,86 @@ +import { Command, Args, Flags } from "@oclif/core"; +import { getEnvironmentConfig, isMainnet } from "@layr-labs/ecloud-sdk"; +import { withTelemetry } from "../../../../telemetry"; +import { commonFlags } from "../../../../flags"; +import { createComputeClient } from "../../../../client"; +import { getOrPromptAppID, confirm } from "../../../../utils/prompts"; +import chalk from "chalk"; +import { isAddress } from "viem"; + +export default class AppOwnershipExecuteTransfer extends Command { + static description = "Execute a previously scheduled ownership transfer for a timelocked app once the delay has elapsed"; + + static args = { + "app-id": Args.string({ description: "App ID or name", required: false }), + }; + + static flags = { + ...commonFlags, + timelock: Flags.string({ + required: true, + description: "Timelock contract address that owns the app", + env: "ECLOUD_TIMELOCK_ADDRESS", + }), + to: Flags.string({ + required: true, + description: "New owner address (must match the scheduled transfer)", + env: "ECLOUD_NEW_OWNER", + }), + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(AppOwnershipExecuteTransfer); + 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 timelockAddress = flags.timelock; + if (!isAddress(timelockAddress)) this.error(`Invalid timelock address: ${timelockAddress}`); + + const newOwner = flags.to; + if (!isAddress(newOwner)) this.error(`Invalid new owner address: ${newOwner}`); + + const appId = await getOrPromptAppID({ appID: args["app-id"], environment, privateKey, rpcUrl, action: "execute transfer ownership" }); + + const timelocked = await compute.app.isTimelocked(appId); + if (!timelocked) { + this.error("This app is not timelocked. Use 'ecloud compute app ownership transfer' for direct ownership transfer."); + } + + const readyAt = await compute.app.getTimelockTransferOwnershipReadyAt(appId, timelockAddress, newOwner); + + if (readyAt === 0n) { + this.error("No ownership transfer is scheduled for this app. Run 'ecloud compute app ownership schedule-transfer' first."); + } + + const now = BigInt(Math.floor(Date.now() / 1000)); + if (now < readyAt) { + const remaining = readyAt - now; + const readyDate = new Date(Number(readyAt) * 1000).toLocaleString(); + this.error(`Transfer is not ready yet. Executable after ${chalk.bold(readyDate)} (${remaining}s remaining).`); + } + + const readyDate = new Date(Number(readyAt) * 1000).toLocaleString(); + this.log(`\nApp: ${chalk.bold(appId)}`); + this.log(`Timelock: ${chalk.bold(timelockAddress)}`); + this.log(`New owner: ${chalk.bold(newOwner)}`); + this.log(`Scheduled: ${chalk.bold(readyDate)}`); + + if (isMainnet(environmentConfig)) { + const confirmed = await confirm("Execute ownership transfer?"); + if (!confirmed) { + this.log(`\n${chalk.gray("Execution cancelled")}`); + return; + } + } + + const { tx } = await compute.app.executeTimelockTransferOwnership(appId, timelockAddress, newOwner, {}); + + this.log(`\n✅ ${chalk.green(`Ownership transferred successfully (tx: ${tx})`)}`); + }); + } +} diff --git a/packages/cli/src/commands/compute/app/ownership/schedule-transfer.ts b/packages/cli/src/commands/compute/app/ownership/schedule-transfer.ts new file mode 100644 index 00000000..622a20b8 --- /dev/null +++ b/packages/cli/src/commands/compute/app/ownership/schedule-transfer.ts @@ -0,0 +1,98 @@ +import { Command, Args, Flags } from "@oclif/core"; +import { getEnvironmentConfig, isMainnet } from "@layr-labs/ecloud-sdk"; +import { withTelemetry } from "../../../../telemetry"; +import { commonFlags } from "../../../../flags"; +import { createComputeClient } from "../../../../client"; +import { getOrPromptAppID, confirm } from "../../../../utils/prompts"; +import chalk from "chalk"; +import { isAddress } from "viem"; + +function parseDurationToSeconds(input: string): bigint { + const match = input.match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)?$/i); + if (!match) throw new Error(`Invalid duration "${input}". Use format: 30s, 5m, 2h, 1d`); + const value = parseFloat(match[1]); + const unit = (match[2] || "s").toLowerCase(); + const multipliers: Record = { s: 1, m: 60, h: 3600, d: 86400 }; + return BigInt(Math.ceil(value * multipliers[unit])); +} + +export default class AppOwnershipScheduleTransfer extends Command { + static description = "Schedule an ownership transfer for a timelocked app through its Timelock"; + + static args = { + "app-id": Args.string({ description: "App ID or name", required: false }), + }; + + static flags = { + ...commonFlags, + timelock: Flags.string({ + required: true, + description: "Timelock contract address that owns the app", + env: "ECLOUD_TIMELOCK_ADDRESS", + }), + to: Flags.string({ + required: true, + description: "New owner address", + env: "ECLOUD_NEW_OWNER", + }), + after: Flags.string({ + required: true, + description: "Delay before transfer can execute (e.g. 30s, 5m, 2h, 1d)", + env: "ECLOUD_OP_DELAY", + }), + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(AppOwnershipScheduleTransfer); + 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 timelockAddress = flags.timelock; + if (!isAddress(timelockAddress)) this.error(`Invalid timelock address: ${timelockAddress}`); + + const newOwner = flags.to; + if (!isAddress(newOwner)) this.error(`Invalid new owner address: ${newOwner}`); + + let delaySeconds: bigint; + try { + delaySeconds = parseDurationToSeconds(flags.after); + } catch (e: any) { + this.error(e.message); + } + + const appId = await getOrPromptAppID({ appID: args["app-id"], environment, privateKey, rpcUrl, action: "schedule transfer ownership" }); + + const timelocked = await compute.app.isTimelocked(appId); + if (!timelocked) { + this.error("This app is not timelocked. Use 'ecloud compute app ownership transfer' for direct ownership transfer."); + } + + const readyAt = Math.floor(Date.now() / 1000) + Number(delaySeconds); + const readyDate = new Date(readyAt * 1000).toLocaleString(); + + this.log(`\nApp: ${chalk.bold(appId)}`); + this.log(`Timelock: ${chalk.bold(timelockAddress)}`); + this.log(`New owner: ${chalk.bold(newOwner)}`); + this.log(`Delay: ${chalk.bold(flags.after)} (executable after ${chalk.bold(readyDate)})`); + + if (isMainnet(environmentConfig)) { + const confirmed = await confirm("Schedule this ownership transfer?"); + if (!confirmed) { + this.log(`\n${chalk.gray("Scheduling cancelled")}`); + return; + } + } + + const { tx } = await compute.app.scheduleTimelockTransferOwnership(appId, timelockAddress, newOwner, delaySeconds, {}); + + this.log(`\n✅ ${chalk.green(`Ownership transfer scheduled (tx: ${tx})`)}`); + this.log(chalk.cyan(`\nExecutable after: ${chalk.bold(readyDate)}`)); + this.log(chalk.cyan(`Run to execute: ecloud compute app ownership execute-transfer --app=${appId} --timelock=${timelockAddress} --to=${newOwner}`)); + }); + } +} diff --git a/packages/cli/src/commands/compute/app/terminate/execute.ts b/packages/cli/src/commands/compute/app/terminate/execute.ts new file mode 100644 index 00000000..854668fc --- /dev/null +++ b/packages/cli/src/commands/compute/app/terminate/execute.ts @@ -0,0 +1,78 @@ +import { Command, Args, Flags } from "@oclif/core"; +import { getEnvironmentConfig, isMainnet } from "@layr-labs/ecloud-sdk"; +import { withTelemetry } from "../../../../telemetry"; +import { commonFlags } from "../../../../flags"; +import { createComputeClient } from "../../../../client"; +import { getOrPromptAppID, confirm } from "../../../../utils/prompts"; +import chalk from "chalk"; +import { isAddress } from "viem"; + +export default class AppTerminateExecute extends Command { + static description = "Execute a previously scheduled termination for a timelocked app once the delay has elapsed"; + + static args = { + "app-id": Args.string({ description: "App ID or name", required: false }), + }; + + static flags = { + ...commonFlags, + timelock: Flags.string({ + required: true, + description: "Timelock contract address that owns the app", + env: "ECLOUD_TIMELOCK_ADDRESS", + }), + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(AppTerminateExecute); + 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 timelockAddress = flags.timelock; + if (!isAddress(timelockAddress)) this.error(`Invalid timelock address: ${timelockAddress}`); + + const appId = await getOrPromptAppID({ appID: args["app-id"], environment, privateKey, rpcUrl, action: "execute terminate" }); + + const timelocked = await compute.app.isTimelocked(appId); + if (!timelocked) { + this.error("This app is not timelocked. Use 'ecloud compute app terminate' for direct termination."); + } + + const readyAt = await compute.app.getTimelockTerminateReadyAt(appId, timelockAddress); + + if (readyAt === 0n) { + this.error("No termination is scheduled for this app. Run 'ecloud compute app terminate schedule' first."); + } + + const now = BigInt(Math.floor(Date.now() / 1000)); + if (now < readyAt) { + const remaining = readyAt - now; + const readyDate = new Date(Number(readyAt) * 1000).toLocaleString(); + this.error(`Termination is not ready yet. Executable after ${chalk.bold(readyDate)} (${remaining}s remaining).`); + } + + const readyDate = new Date(Number(readyAt) * 1000).toLocaleString(); + this.log(`\nApp: ${chalk.bold(appId)}`); + this.log(`Timelock: ${chalk.bold(timelockAddress)}`); + this.log(`Scheduled: ${chalk.bold(readyDate)}`); + this.log(chalk.red("\n⚠️ This will permanently destroy the app.")); + + if (isMainnet(environmentConfig)) { + const confirmed = await confirm("Execute termination?"); + if (!confirmed) { + this.log(`\n${chalk.gray("Execution cancelled")}`); + return; + } + } + + const { tx } = await compute.app.executeTimelockTerminate(appId, timelockAddress, {}); + + this.log(`\n✅ ${chalk.green(`App terminated successfully (tx: ${tx})`)}`); + }); + } +} diff --git a/packages/cli/src/commands/compute/app/terminate/schedule.ts b/packages/cli/src/commands/compute/app/terminate/schedule.ts new file mode 100644 index 00000000..1473268b --- /dev/null +++ b/packages/cli/src/commands/compute/app/terminate/schedule.ts @@ -0,0 +1,90 @@ +import { Command, Args, Flags } from "@oclif/core"; +import { getEnvironmentConfig, isMainnet } from "@layr-labs/ecloud-sdk"; +import { withTelemetry } from "../../../../telemetry"; +import { commonFlags } from "../../../../flags"; +import { createComputeClient } from "../../../../client"; +import { getOrPromptAppID, confirm } from "../../../../utils/prompts"; +import chalk from "chalk"; +import { isAddress } from "viem"; + +function parseDurationToSeconds(input: string): bigint { + const match = input.match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)?$/i); + if (!match) throw new Error(`Invalid duration "${input}". Use format: 30s, 5m, 2h, 1d`); + const value = parseFloat(match[1]); + const unit = (match[2] || "s").toLowerCase(); + const multipliers: Record = { s: 1, m: 60, h: 3600, d: 86400 }; + return BigInt(Math.ceil(value * multipliers[unit])); +} + +export default class AppTerminateSchedule extends Command { + static description = "Schedule termination of a timelocked app through its Timelock"; + + static args = { + "app-id": Args.string({ description: "App ID or name", required: false }), + }; + + static flags = { + ...commonFlags, + timelock: Flags.string({ + required: true, + description: "Timelock contract address that owns the app", + env: "ECLOUD_TIMELOCK_ADDRESS", + }), + after: Flags.string({ + required: true, + description: "Delay before termination can execute (e.g. 30s, 5m, 2h, 1d)", + env: "ECLOUD_OP_DELAY", + }), + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(AppTerminateSchedule); + 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 timelockAddress = flags.timelock; + if (!isAddress(timelockAddress)) this.error(`Invalid timelock address: ${timelockAddress}`); + + let delaySeconds: bigint; + try { + delaySeconds = parseDurationToSeconds(flags.after); + } catch (e: any) { + this.error(e.message); + } + + const appId = await getOrPromptAppID({ appID: args["app-id"], environment, privateKey, rpcUrl, action: "schedule terminate" }); + + const timelocked = await compute.app.isTimelocked(appId); + if (!timelocked) { + this.error("This app is not timelocked. Use 'ecloud compute app terminate' for direct termination."); + } + + const readyAt = Math.floor(Date.now() / 1000) + Number(delaySeconds); + const readyDate = new Date(readyAt * 1000).toLocaleString(); + + this.log(`\nApp: ${chalk.bold(appId)}`); + this.log(`Timelock: ${chalk.bold(timelockAddress)}`); + this.log(`Delay: ${chalk.bold(flags.after)} (executable after ${chalk.bold(readyDate)})`); + this.log(chalk.red("\n⚠️ This will permanently destroy the app once executed.")); + + if (isMainnet(environmentConfig)) { + const confirmed = await confirm("Schedule this termination?"); + if (!confirmed) { + this.log(`\n${chalk.gray("Scheduling cancelled")}`); + return; + } + } + + const { tx } = await compute.app.scheduleTimelockTerminate(appId, timelockAddress, delaySeconds, {}); + + this.log(`\n✅ ${chalk.green(`Termination scheduled (tx: ${tx})`)}`); + this.log(chalk.cyan(`\nExecutable after: ${chalk.bold(readyDate)}`)); + this.log(chalk.cyan(`Run to execute: ecloud compute app terminate execute --app=${appId} --timelock=${timelockAddress}`)); + }); + } +} diff --git a/packages/cli/src/commands/compute/team/grant-admin/execute.ts b/packages/cli/src/commands/compute/team/grant-admin/execute.ts new file mode 100644 index 00000000..691858e1 --- /dev/null +++ b/packages/cli/src/commands/compute/team/grant-admin/execute.ts @@ -0,0 +1,89 @@ +import { Command, Args, Flags } from "@oclif/core"; +import { getEnvironmentConfig, isMainnet } from "@layr-labs/ecloud-sdk"; +import { withTelemetry } from "../../../../telemetry"; +import { commonFlags } from "../../../../flags"; +import { createComputeClient } from "../../../../client"; +import { getOrPromptAppID, confirm } from "../../../../utils/prompts"; +import chalk from "chalk"; +import { isAddress } from "viem"; + +export default class TeamGrantAdminExecute extends Command { + static description = "Execute a previously scheduled grantTeamRole(ADMIN) operation once the Timelock delay has elapsed"; + + static args = { + address: Args.string({ + description: "Address to grant the ADMIN role to (must match the scheduled operation)", + required: true, + }), + }; + + static flags = { + ...commonFlags, + app: Flags.string({ + required: false, + description: "App ID (used to look up the team owner)", + env: "ECLOUD_APP_ID", + }), + timelock: Flags.string({ + required: true, + description: "Timelock contract address that owns the app", + env: "ECLOUD_TIMELOCK_ADDRESS", + }), + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(TeamGrantAdminExecute); + 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 account = args.address; + if (!isAddress(account)) this.error(`Invalid address: ${account}`); + + const timelockAddress = flags.timelock; + if (!isAddress(timelockAddress)) this.error(`Invalid timelock address: ${timelockAddress}`); + + const appId = await getOrPromptAppID({ appID: flags.app, environment, privateKey, rpcUrl, action: "execute grant ADMIN" }); + + const timelocked = await compute.app.isTimelocked(appId); + if (!timelocked) { + this.error("This app is not timelocked. Use 'ecloud compute team grant' for direct role grants."); + } + + const readyAt = await compute.app.getTimelockGrantAdminReadyAt(appId, timelockAddress, account); + + if (readyAt === 0n) { + this.error("No ADMIN grant is scheduled for this address. Run 'ecloud compute team grant-admin schedule' first."); + } + + const now = BigInt(Math.floor(Date.now() / 1000)); + if (now < readyAt) { + const remaining = readyAt - now; + const readyDate = new Date(Number(readyAt) * 1000).toLocaleString(); + this.error(`ADMIN grant is not ready yet. Executable after ${chalk.bold(readyDate)} (${remaining}s remaining).`); + } + + const readyDate = new Date(Number(readyAt) * 1000).toLocaleString(); + this.log(`\nApp: ${chalk.bold(appId)}`); + this.log(`Timelock: ${chalk.bold(timelockAddress)}`); + this.log(`Grant: ${chalk.bold("ADMIN")} → ${chalk.bold(account)}`); + this.log(`Scheduled: ${chalk.bold(readyDate)}`); + + if (isMainnet(environmentConfig)) { + const confirmed = await confirm("Execute ADMIN grant?"); + if (!confirmed) { + this.log(`\n${chalk.gray("Execution cancelled")}`); + return; + } + } + + const { tx } = await compute.app.executeTimelockGrantAdmin(appId, timelockAddress, account, {}); + + this.log(`\n✅ ${chalk.green(`ADMIN role granted to ${account} (tx: ${tx})`)}`); + }); + } +} diff --git a/packages/cli/src/commands/compute/team/grant-admin/schedule.ts b/packages/cli/src/commands/compute/team/grant-admin/schedule.ts new file mode 100644 index 00000000..2c6a5fea --- /dev/null +++ b/packages/cli/src/commands/compute/team/grant-admin/schedule.ts @@ -0,0 +1,101 @@ +import { Command, Args, Flags } from "@oclif/core"; +import { getEnvironmentConfig, isMainnet } from "@layr-labs/ecloud-sdk"; +import { withTelemetry } from "../../../../telemetry"; +import { commonFlags } from "../../../../flags"; +import { createComputeClient } from "../../../../client"; +import { getOrPromptAppID, confirm } from "../../../../utils/prompts"; +import chalk from "chalk"; +import { isAddress } from "viem"; + +function parseDurationToSeconds(input: string): bigint { + const match = input.match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)?$/i); + if (!match) throw new Error(`Invalid duration "${input}". Use format: 30s, 5m, 2h, 1d`); + const value = parseFloat(match[1]); + const unit = (match[2] || "s").toLowerCase(); + const multipliers: Record = { s: 1, m: 60, h: 3600, d: 86400 }; + return BigInt(Math.ceil(value * multipliers[unit])); +} + +export default class TeamGrantAdminSchedule extends Command { + static description = "Schedule a grantTeamRole(ADMIN) operation for a timelocked app through its Timelock"; + + static args = { + address: Args.string({ + description: "Address to grant the ADMIN role to", + required: true, + }), + }; + + static flags = { + ...commonFlags, + app: Flags.string({ + required: false, + description: "App ID (used to look up the team owner)", + env: "ECLOUD_APP_ID", + }), + timelock: Flags.string({ + required: true, + description: "Timelock contract address that owns the app", + env: "ECLOUD_TIMELOCK_ADDRESS", + }), + after: Flags.string({ + required: true, + description: "Delay before operation can execute (e.g. 30s, 5m, 2h, 1d)", + env: "ECLOUD_OP_DELAY", + }), + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(TeamGrantAdminSchedule); + 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 account = args.address; + if (!isAddress(account)) this.error(`Invalid address: ${account}`); + + const timelockAddress = flags.timelock; + if (!isAddress(timelockAddress)) this.error(`Invalid timelock address: ${timelockAddress}`); + + let delaySeconds: bigint; + try { + delaySeconds = parseDurationToSeconds(flags.after); + } catch (e: any) { + this.error(e.message); + } + + const appId = await getOrPromptAppID({ appID: flags.app, environment, privateKey, rpcUrl, action: "schedule grant ADMIN" }); + + const timelocked = await compute.app.isTimelocked(appId); + if (!timelocked) { + this.error("This app is not timelocked. Use 'ecloud compute team grant' for direct role grants."); + } + + const readyAt = Math.floor(Date.now() / 1000) + Number(delaySeconds); + const readyDate = new Date(readyAt * 1000).toLocaleString(); + + this.log(`\nApp: ${chalk.bold(appId)}`); + this.log(`Timelock: ${chalk.bold(timelockAddress)}`); + this.log(`Grant: ${chalk.bold("ADMIN")} → ${chalk.bold(account)}`); + this.log(`Delay: ${chalk.bold(flags.after)} (executable after ${chalk.bold(readyDate)})`); + + if (isMainnet(environmentConfig)) { + const confirmed = await confirm("Schedule this ADMIN grant?"); + if (!confirmed) { + this.log(`\n${chalk.gray("Scheduling cancelled")}`); + return; + } + } + + const { tx } = await compute.app.scheduleTimelockGrantAdmin(appId, timelockAddress, account, delaySeconds, {}); + + this.log(`\n✅ ${chalk.green(`ADMIN grant scheduled (tx: ${tx})`)}`); + this.log(chalk.cyan(`\nExecutable after: ${chalk.bold(readyDate)}`)); + this.log(chalk.cyan(`Run to execute: ecloud compute team grant-admin execute ${account} --app=${appId} --timelock=${timelockAddress}`)); + }); + } +} diff --git a/packages/sdk/src/client/common/abis/TimelockController.json b/packages/sdk/src/client/common/abis/TimelockController.json index 81509efb..660e11f8 100644 --- a/packages/sdk/src/client/common/abis/TimelockController.json +++ b/packages/sdk/src/client/common/abis/TimelockController.json @@ -15,5 +15,52 @@ ], "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" } ] diff --git a/packages/sdk/src/client/common/contract/caller.ts b/packages/sdk/src/client/common/contract/caller.ts index fac732ad..8ecea4ae 100644 --- a/packages/sdk/src/client/common/contract/caller.ts +++ b/packages/sdk/src/client/common/contract/caller.ts @@ -1924,6 +1924,319 @@ export interface DiscoveredTimelock { * address, then checks isTimelock() to see if it has been deployed. * Returns null if no Timelock exists for this EOA. */ +// ─── Timelocked operations via TimelockController ──────────────────────────── +// +// transferOwnership, terminateApp, and grantTeamRole(ADMIN) are restricted to +// msg.sender == owner when an app is timelocked. The Timelock IS the owner, so +// these operations must be enqueued through TimelockController.schedule() and +// then sent via TimelockController.execute() after the delay elapses. +// +// We use predecessor=0 and salt=0 so the operation hash is deterministic from +// (target, calldata) alone — consistent with how upgrades are queued. + +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; +} + +/** + * Return the scheduled timestamp for a terminateApp operation queued through a Timelock. + */ +export async function getTimelockTerminateTimestamp( + publicClient: PublicClient, + timelockAddress: Address, + appControllerAddress: Address, + appID: Address, +): Promise { + const calldata = encodeFunctionData({ abi: AppControllerABI, functionName: "terminateApp", args: [appID] }); + return getTimelockOpTimestamp(publicClient, timelockAddress, appControllerAddress, calldata); +} + +/** + * Return the scheduled timestamp for a transferOwnership operation queued through a Timelock. + */ +export async function getTimelockTransferOwnershipTimestamp( + publicClient: PublicClient, + timelockAddress: Address, + appControllerAddress: Address, + appID: Address, + newOwner: Address, +): Promise { + const calldata = encodeFunctionData({ abi: AppControllerABI, functionName: "transferOwnership", args: [appID, newOwner] }); + return getTimelockOpTimestamp(publicClient, timelockAddress, appControllerAddress, calldata); +} + +/** + * Return the scheduled timestamp for a grantTeamRole(ADMIN) operation queued through a Timelock. + */ +export async function getTimelockGrantAdminTimestamp( + publicClient: PublicClient, + timelockAddress: Address, + appControllerAddress: Address, + team: Address, + account: Address, +): Promise { + const calldata = encodeFunctionData({ abi: AppControllerABI, functionName: "grantTeamRole", args: [team, TeamRole.ADMIN, account] }); + return getTimelockOpTimestamp(publicClient, timelockAddress, appControllerAddress, calldata); +} + +// ─── Per-operation helpers ──────────────────────────────────────────────────── + +export interface ScheduleTimelockTransferOwnershipOptions { + walletClient: WalletClient; + publicClient: PublicClient; + environmentConfig: EnvironmentConfig; + timelockAddress: Address; + appID: Address; + newOwner: Address; + delaySeconds: bigint; + gas?: GasEstimate; +} + +export async function scheduleTimelockTransferOwnership( + options: ScheduleTimelockTransferOwnershipOptions, + logger: Logger = noopLogger, +): Promise { + const { appID, newOwner, delaySeconds, timelockAddress, ...base } = options; + const calldata = encodeFunctionData({ + abi: AppControllerABI, + functionName: "transferOwnership", + args: [appID, newOwner], + }); + return scheduleTimelockOp({ ...base, timelockAddress, calldata, delaySeconds }, logger); +} + +export interface ExecuteTimelockTransferOwnershipOptions { + walletClient: WalletClient; + publicClient: PublicClient; + environmentConfig: EnvironmentConfig; + timelockAddress: Address; + appID: Address; + newOwner: Address; + gas?: GasEstimate; +} + +export async function executeTimelockTransferOwnership( + options: ExecuteTimelockTransferOwnershipOptions, + logger: Logger = noopLogger, +): Promise { + const { appID, newOwner, timelockAddress, ...base } = options; + const calldata = encodeFunctionData({ + abi: AppControllerABI, + functionName: "transferOwnership", + args: [appID, newOwner], + }); + return executeTimelockOp({ ...base, timelockAddress, calldata }, logger); +} + +export interface ScheduleTimelockTerminateAppOptions { + walletClient: WalletClient; + publicClient: PublicClient; + environmentConfig: EnvironmentConfig; + timelockAddress: Address; + appID: Address; + delaySeconds: bigint; + gas?: GasEstimate; +} + +export async function scheduleTimelockTerminateApp( + options: ScheduleTimelockTerminateAppOptions, + logger: Logger = noopLogger, +): Promise { + const { appID, delaySeconds, timelockAddress, ...base } = options; + const calldata = encodeFunctionData({ + abi: AppControllerABI, + functionName: "terminateApp", + args: [appID], + }); + return scheduleTimelockOp({ ...base, timelockAddress, calldata, delaySeconds }, logger); +} + +export interface ExecuteTimelockTerminateAppOptions { + walletClient: WalletClient; + publicClient: PublicClient; + environmentConfig: EnvironmentConfig; + timelockAddress: Address; + appID: Address; + gas?: GasEstimate; +} + +export async function executeTimelockTerminateApp( + options: ExecuteTimelockTerminateAppOptions, + logger: Logger = noopLogger, +): Promise { + const { appID, timelockAddress, ...base } = options; + const calldata = encodeFunctionData({ + abi: AppControllerABI, + functionName: "terminateApp", + args: [appID], + }); + return executeTimelockOp({ ...base, timelockAddress, calldata }, logger); +} + +export interface ScheduleTimelockGrantTeamAdminOptions { + walletClient: WalletClient; + publicClient: PublicClient; + environmentConfig: EnvironmentConfig; + timelockAddress: Address; + team: Address; + account: Address; + delaySeconds: bigint; + gas?: GasEstimate; +} + +export async function scheduleTimelockGrantTeamAdmin( + options: ScheduleTimelockGrantTeamAdminOptions, + logger: Logger = noopLogger, +): Promise { + const { team, account, delaySeconds, timelockAddress, ...base } = options; + const calldata = encodeFunctionData({ + abi: AppControllerABI, + functionName: "grantTeamRole", + args: [team, TeamRole.ADMIN, account], + }); + return scheduleTimelockOp({ ...base, timelockAddress, calldata, delaySeconds }, logger); +} + +export interface ExecuteTimelockGrantTeamAdminOptions { + walletClient: WalletClient; + publicClient: PublicClient; + environmentConfig: EnvironmentConfig; + timelockAddress: Address; + team: Address; + account: Address; + gas?: GasEstimate; +} + +export async function executeTimelockGrantTeamAdmin( + options: ExecuteTimelockGrantTeamAdminOptions, + logger: Logger = noopLogger, +): Promise { + const { team, account, timelockAddress, ...base } = options; + const calldata = encodeFunctionData({ + abi: AppControllerABI, + functionName: "grantTeamRole", + args: [team, TeamRole.ADMIN, account], + }); + return executeTimelockOp({ ...base, timelockAddress, calldata }, logger); +} + export async function discoverTimelockForEOA( publicClient: PublicClient, environmentConfig: EnvironmentConfig, diff --git a/packages/sdk/src/client/modules/compute/app/index.ts b/packages/sdk/src/client/modules/compute/app/index.ts index 6b5499d6..a4caa372 100644 --- a/packages/sdk/src/client/modules/compute/app/index.ts +++ b/packages/sdk/src/client/modules/compute/app/index.ts @@ -44,6 +44,16 @@ import { revokeTeamRole as revokeTeamRoleCaller, getTeamRoleMembers as getTeamRoleMembersCaller, getAppOwner, + getTimelockOpTimestamp, + getTimelockTerminateTimestamp, + getTimelockTransferOwnershipTimestamp, + getTimelockGrantAdminTimestamp, + scheduleTimelockTransferOwnership, + executeTimelockTransferOwnership, + scheduleTimelockTerminateApp, + executeTimelockTerminateApp, + scheduleTimelockGrantTeamAdmin, + executeTimelockGrantTeamAdmin, TeamRole, type GasEstimate, type AppConfig, @@ -203,6 +213,19 @@ export interface AppModule { 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; + + // Timelocked operations — routed through TimelockController.schedule/execute + // Use these when the app owner is a Timelock (isTimelocked() === true). + getTimelockOpReadyAt: (timelockAddress: Address, appControllerAddress: Address, calldata: Hex) => Promise; + getTimelockTerminateReadyAt: (appId: AppId, timelockAddress: Address) => Promise; + getTimelockTransferOwnershipReadyAt: (appId: AppId, timelockAddress: Address, newOwner: Address) => Promise; + getTimelockGrantAdminReadyAt: (appId: AppId, timelockAddress: Address, account: Address) => Promise; + scheduleTimelockTransferOwnership: (appId: AppId, timelockAddress: Address, newOwner: Address, delaySeconds: bigint, opts?: { gas?: GasEstimate }) => Promise<{ tx: Hex }>; + executeTimelockTransferOwnership: (appId: AppId, timelockAddress: Address, newOwner: Address, opts?: { gas?: GasEstimate }) => Promise<{ tx: Hex }>; + scheduleTimelockTerminate: (appId: AppId, timelockAddress: Address, delaySeconds: bigint, opts?: { gas?: GasEstimate }) => Promise<{ tx: Hex }>; + executeTimelockTerminate: (appId: AppId, timelockAddress: Address, opts?: { gas?: GasEstimate }) => Promise<{ tx: Hex }>; + scheduleTimelockGrantAdmin: (appId: AppId, timelockAddress: Address, account: Address, delaySeconds: bigint, opts?: { gas?: GasEstimate }) => Promise<{ tx: Hex }>; + executeTimelockGrantAdmin: (appId: AppId, timelockAddress: Address, account: Address, opts?: { gas?: GasEstimate }) => Promise<{ tx: Hex }>; } export interface AppModuleConfig { @@ -765,5 +788,102 @@ export function createAppModule(ctx: AppModuleConfig): AppModule { const team = await getAppOwner(publicClient, environment, appId as Address); return getTeamRoleMembersCaller(publicClient, environment, team, role); }, + + async getTimelockOpReadyAt(timelockAddress, appControllerAddress, calldata) { + return getTimelockOpTimestamp(publicClient, timelockAddress, appControllerAddress, calldata); + }, + + async getTimelockTerminateReadyAt(appId, timelockAddress) { + return getTimelockTerminateTimestamp(publicClient, timelockAddress, environment.appControllerAddress as Address, appId as Address); + }, + + async getTimelockTransferOwnershipReadyAt(appId, timelockAddress, newOwner) { + return getTimelockTransferOwnershipTimestamp(publicClient, timelockAddress, environment.appControllerAddress as Address, appId as Address, newOwner); + }, + + async getTimelockGrantAdminReadyAt(appId, timelockAddress, account) { + const team = await getAppOwner(publicClient, environment, appId as Address); + return getTimelockGrantAdminTimestamp(publicClient, timelockAddress, environment.appControllerAddress as Address, team, account as Address); + }, + + async scheduleTimelockTransferOwnership(appId, timelockAddress, newOwner, delaySeconds, opts) { + return withSDKTelemetry( + { functionName: "scheduleTimelockTransferOwnership", skipTelemetry, properties: { environment: ctx.environment } }, + async () => { + const tx = await scheduleTimelockTransferOwnership( + { walletClient, publicClient, environmentConfig: environment, timelockAddress, appID: appId as Address, newOwner, delaySeconds, gas: opts?.gas }, + logger, + ); + return { tx }; + }, + ); + }, + + async executeTimelockTransferOwnership(appId, timelockAddress, newOwner, opts) { + return withSDKTelemetry( + { functionName: "executeTimelockTransferOwnership", skipTelemetry, properties: { environment: ctx.environment } }, + async () => { + const tx = await executeTimelockTransferOwnership( + { walletClient, publicClient, environmentConfig: environment, timelockAddress, appID: appId as Address, newOwner, gas: opts?.gas }, + logger, + ); + return { tx }; + }, + ); + }, + + async scheduleTimelockTerminate(appId, timelockAddress, delaySeconds, opts) { + return withSDKTelemetry( + { functionName: "scheduleTimelockTerminate", skipTelemetry, properties: { environment: ctx.environment } }, + async () => { + const tx = await scheduleTimelockTerminateApp( + { walletClient, publicClient, environmentConfig: environment, timelockAddress, appID: appId as Address, delaySeconds, gas: opts?.gas }, + logger, + ); + return { tx }; + }, + ); + }, + + async executeTimelockTerminate(appId, timelockAddress, opts) { + return withSDKTelemetry( + { functionName: "executeTimelockTerminate", skipTelemetry, properties: { environment: ctx.environment } }, + async () => { + const tx = await executeTimelockTerminateApp( + { walletClient, publicClient, environmentConfig: environment, timelockAddress, appID: appId as Address, gas: opts?.gas }, + logger, + ); + return { tx }; + }, + ); + }, + + async scheduleTimelockGrantAdmin(appId, timelockAddress, account, delaySeconds, opts) { + return withSDKTelemetry( + { functionName: "scheduleTimelockGrantAdmin", skipTelemetry, properties: { environment: ctx.environment } }, + async () => { + const team = await getAppOwner(publicClient, environment, appId as Address); + const tx = await scheduleTimelockGrantTeamAdmin( + { walletClient, publicClient, environmentConfig: environment, timelockAddress, team, account: account as Address, delaySeconds, gas: opts?.gas }, + logger, + ); + return { tx }; + }, + ); + }, + + async executeTimelockGrantAdmin(appId, timelockAddress, account, opts) { + return withSDKTelemetry( + { functionName: "executeTimelockGrantAdmin", skipTelemetry, properties: { environment: ctx.environment } }, + async () => { + const team = await getAppOwner(publicClient, environment, appId as Address); + const tx = await executeTimelockGrantTeamAdmin( + { walletClient, publicClient, environmentConfig: environment, timelockAddress, team, account: account as Address, gas: opts?.gas }, + logger, + ); + return { tx }; + }, + ); + }, }; } From a2d92c3b440813d391528b1016ada852e7c29c09 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Tue, 24 Mar 2026 11:42:26 -0700 Subject: [PATCH 13/41] docs: update identity command matrix with new governance commands and consistent style - Add new commands: ownership schedule/execute-transfer, terminate schedule/execute, upgrade cancel, team grant-admin schedule/execute - Rename sections to command-group style (compute app, compute team, etc.) - Shorten command names in table rows (remove ecloud compute app prefix) - Replace emoji cell values with plain text (no permission, direct, yes, visible) - Abbreviate column headers to TL(EOA) / TL(Safe) - Update legend to document new cell value vocabulary --- docs/identity-command-matrix.md | 85 +++++++++++++++++++-------------- 1 file changed, 48 insertions(+), 37 deletions(-) diff --git a/docs/identity-command-matrix.md b/docs/identity-command-matrix.md index 027613d8..c64590d0 100644 --- a/docs/identity-command-matrix.md +++ b/docs/identity-command-matrix.md @@ -68,78 +68,89 @@ graph TD --- +**Column abbreviations:** `TL(EOA)` = Timelock with EOA proposer · `TL(Safe)` = Timelock with Safe proposer + **Legend:** - `direct` — CLI signs and submits immediately, no extra steps +- `direct (after delay)` — CLI signs and submits; delay must have elapsed since `schedule` - `Safe propose` — CLI proposes tx to Safe; threshold of signers must approve at app.safe.global -- `schedule + execute` — two-step timelocked flow; delay must elapse between steps -- `schedule + execute + Safe propose` — same two-step flow, but each step also requires Safe approval -- `❌ error` — command is blocked; CLI shows a descriptive error with the correct alternative +- `Safe propose (after delay)` — same as `Safe propose`, but delay must have elapsed since `schedule` +- `no permission` — command is blocked; CLI shows a descriptive error with the correct alternative +- `yes` — available / shown - `—` — not applicable / not shown --- ## Auth -| Command | EOA | Timelock(EOA) | Safe | Timelock(Safe) | PAUSER | DEVELOPER | +| Command | EOA | TL(EOA) | Safe | TL(Safe) | PAUSER | DEVELOPER | |---|---|---|---|---|---|---| | `ecloud auth login` | select identity | select identity | select identity | select identity | select identity | select identity | | `ecloud auth new` | create EOA key | create Timelock | create Safe | create Timelock | — | — | --- -## App lifecycle +## compute app -| Command | EOA | Timelock(EOA) | Safe | Timelock(Safe) | PAUSER | DEVELOPER | +| Command | EOA | TL(EOA) | Safe | TL(Safe) | PAUSER | DEVELOPER | |---|---|---|---|---|---|---| -| `ecloud compute app deploy` | direct | direct | Safe propose | Safe propose | ❌ no permission | ❌ no permission | -| `ecloud compute app upgrade` | direct | ❌ TimelockRequired | Safe propose | ❌ TimelockRequired | ❌ no permission | ❌ no permission | -| `ecloud compute app start` | direct | direct | Safe propose | Safe propose | ❌ no permission | ❌ no permission | -| `ecloud compute app stop` | direct | direct | Safe propose | Safe propose | direct | ❌ no permission | -| `ecloud compute app terminate` | direct | direct | Safe propose | Safe propose | ❌ no permission | ❌ no permission | +| `deploy` | direct | direct | Safe propose | Safe propose | no permission | no permission | +| `upgrade` | direct | no permission | Safe propose | no permission | no permission | no permission | +| `start` | direct | direct | Safe propose | Safe propose | no permission | no permission | +| `stop` | direct | direct | Safe propose | Safe propose | direct | no permission | +| `terminate` | direct | direct | Safe propose | Safe propose | no permission | no permission | +| `terminate schedule` | no permission | direct | no permission | Safe propose | no permission | no permission | +| `terminate execute` | no permission | direct (after delay) | no permission | Safe propose (after delay) | no permission | no permission | --- -## App metadata & observability +## compute app metadata & observability -| Command | EOA | Timelock(EOA) | Safe | Timelock(Safe) | PAUSER | DEVELOPER | +| Command | EOA | TL(EOA) | Safe | TL(Safe) | PAUSER | DEVELOPER | |---|---|---|---|---|---|---| -| `ecloud compute app profile set` | direct | direct | direct | direct | ❌ no permission | direct | -| `ecloud compute app info` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| `ecloud compute app logs` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| `ecloud compute app list` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| `ecloud compute app releases` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `profile set` | direct | direct | direct | direct | no permission | direct | +| `info` | yes | yes | yes | yes | yes | yes | +| `logs` | yes | yes | yes | yes | yes | yes | +| `list` | yes | yes | yes | yes | yes | yes | +| `releases` | yes | yes | yes | yes | yes | yes | --- -## Timelocked upgrade flow +## compute app upgrade -Only available when identity is `Timelock(EOA)` or `Timelock(Safe)`. Blocked for all other identities. +Only available when identity is `TL(EOA)` or `TL(Safe)`. Blocked for all other identities. -| Command | EOA | Timelock(EOA) | Safe | Timelock(Safe) | PAUSER | DEVELOPER | +| Command | EOA | TL(EOA) | Safe | TL(Safe) | PAUSER | DEVELOPER | |---|---|---|---|---|---|---| -| `ecloud compute app upgrade schedule --after=` | ❌ not timelocked | ✅ schedules | ❌ not timelocked | ✅ schedules + Safe propose | ❌ no permission | ❌ no permission | -| `ecloud compute app upgrade execute ` | ❌ not timelocked | ✅ executes after delay | ❌ not timelocked | ✅ executes + Safe propose | ❌ no permission | ❌ no permission | -| `ecloud demo fastforward` | — | ✅ skips delay | — | ✅ skips delay | — | — | +| `upgrade schedule` | no permission | direct | no permission | Safe propose | no permission | no permission | +| `upgrade execute` | no permission | direct (after delay) | no permission | Safe propose (after delay) | no permission | no permission | +| `upgrade cancel` | no permission | direct | no permission | Safe propose | no permission | no permission | +| `demo fastforward` | — | skips delay | — | skips delay | — | — | --- -## App ownership +## compute app ownership -| Command | EOA | Timelock(EOA) | Safe | Timelock(Safe) | PAUSER | DEVELOPER | +| Command | EOA | TL(EOA) | Safe | TL(Safe) | PAUSER | DEVELOPER | |---|---|---|---|---|---|---| -| `ecloud compute app ownership transfer --to=` | direct | direct | Safe propose | Safe propose | ❌ no permission | ❌ no permission | +| `ownership transfer` | direct | direct | Safe propose | Safe propose | no permission | no permission | +| `ownership schedule-transfer` | no permission | direct | no permission | Safe propose | no permission | no permission | +| `ownership execute-transfer` | no permission | direct (after delay) | no permission | Safe propose (after delay) | no permission | no permission | > Transferring to a Timelock address automatically enables timelocked mode on the app. +> `schedule-transfer` / `execute-transfer` are only available when the app is already timelocked. --- -## Team roles +## compute team -| Command | EOA | Timelock(EOA) | Safe | Timelock(Safe) | PAUSER | DEVELOPER | +| Command | EOA | TL(EOA) | Safe | TL(Safe) | PAUSER | DEVELOPER | |---|---|---|---|---|---|---| -| `ecloud compute team grant ` | direct | direct | Safe propose | Safe propose | ❌ no permission | ❌ no permission | -| `ecloud compute team revoke ` | direct | direct | Safe propose | Safe propose | ❌ no permission | ❌ no permission | -| `ecloud compute team list` | — | — | ✅ shows roles | ✅ shows roles | — | — | +| `team grant` (PAUSER/DEVELOPER) | direct | direct | Safe propose | Safe propose | no permission | no permission | +| `team revoke` (PAUSER/DEVELOPER) | direct | direct | Safe propose | Safe propose | no permission | no permission | +| `team list` | — | — | visible | visible | — | — | +| `team grant-admin schedule` | no permission | no permission | no permission | Safe propose | no permission | no permission | +| `team grant-admin execute` | no permission | no permission | no permission | Safe propose (after delay) | no permission | no permission | > Team roles (ADMIN, PAUSER, DEVELOPER) are only shown in `ecloud compute app info` and `ecloud compute team list` when the app owner is a Safe or Timelock(Safe). > ADMIN is the Safe or Timelock address — never an individual EOA in a Safe-governed app. @@ -162,13 +173,13 @@ The contract does not hard-enforce this convention today — it is an operationa --- -## App info +## compute app info -| Field shown | EOA | Timelock(EOA) | Safe | Timelock(Safe) | PAUSER | DEVELOPER | +| Field shown | EOA | TL(EOA) | Safe | TL(Safe) | PAUSER | DEVELOPER | |---|---|---|---|---|---|---| -| Owner | ✅ | ✅ with delay label | ✅ | ✅ with delay + Safe label | ✅ | ✅ | -| Status / Image / Instance | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Team Roles section | — | — | ✅ | ✅ | — | — | +| Owner | yes | yes (delay label) | yes | yes (delay + Safe label) | yes | yes | +| Status / Image / Instance | yes | yes | yes | yes | yes | yes | +| Team Roles section | — | — | yes | yes | — | — | --- From 402428ce5b38909a0a9c086eafd2ab01c6e5286b Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 9 Apr 2026 17:33:56 -0700 Subject: [PATCH 14/41] refactor: split auth into keyring management + identity management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keyring commands (signing key): - auth generate — generate new key + store in keyring (simplified, no Safe/Timelock) - auth login — import existing key + store + discover identities on-chain - auth logout — remove key from keyring + clear all identities Identity commands (new): - auth identity new — create Safe or Timelock (moved from auth generate) - auth identity list — show all stored identities - auth identity select — switch active identity per environment Changes: - auth generate no longer offers Safe/Timelock creation - auth login no longer has identity selector (use auth identity select) - auth logout now clears identities (prevents orphaned state) - Added removeIdentity() to globalConfig.ts - Added auth-flow-map.md with complete state transition table, decision trees for every command, and command tree with key requirements --- docs/auth-flow-map.md | 448 ++++++++++++++++++ packages/cli/src/commands/auth/generate.ts | 398 ++-------------- .../cli/src/commands/auth/identity/list.ts | 53 +++ .../cli/src/commands/auth/identity/new.ts | 262 ++++++++++ .../cli/src/commands/auth/identity/select.ts | 59 +++ packages/cli/src/commands/auth/login.ts | 112 +---- packages/cli/src/commands/auth/logout.ts | 8 +- packages/cli/src/utils/globalConfig.ts | 11 + 8 files changed, 912 insertions(+), 439 deletions(-) create mode 100644 docs/auth-flow-map.md create mode 100644 packages/cli/src/commands/auth/identity/list.ts create mode 100644 packages/cli/src/commands/auth/identity/new.ts create mode 100644 packages/cli/src/commands/auth/identity/select.ts diff --git a/docs/auth-flow-map.md b/docs/auth-flow-map.md new file mode 100644 index 00000000..e3111c57 --- /dev/null +++ b/docs/auth-flow-map.md @@ -0,0 +1,448 @@ +# Auth Flow Map + +Complete map of all authentication and identity transitions in the ecloud CLI. + +## Storage Layers + +Two independent storage systems: + +| Layer | Location | What it stores | +|---|---|---| +| **Keyring** | OS keyring (macOS Keychain / Linux Secret Service) | One private key — the master signing credential | +| **Config** | `~/.config/ecloud/config.yaml` | Identities (EOA, Safe, Timelock) + active identity per environment | + +## Concepts + +- **Signing key** — one private key stored in OS keyring. Master credential. All identities are controlled by this key. +- **Identity** — an on-chain address the signing key can operate from: EOA, Safe, or Timelock. +- **Active identity** — the identity used for commands in a given environment. One per environment. +- **Roles** (PAUSER, DEVELOPER) — permissions on a specific app, not identity types. Checked at the app level, not the identity level. + +## State Transitions + +Every auth command and exactly what it changes: + +| Command | Keyring | Config (identities) | Config (active) | +|---|---|---|---| +| `auth generate` (store=yes) | writes new key | replaces all with EOA | sets EOA active for all envs | +| `auth generate` (store=no) | no change | no change | no change | +| `auth login` | writes imported key | replaces all with EOA + discovered | sets EOA active | +| `auth logout` | deletes key | clears all | clears all | +| `auth identity new` (Safe) | no change | adds Safe | sets Safe active | +| `auth identity new` (Timelock) | no change | adds Timelock | sets Timelock active | +| `auth identity list` | no change | no change | no change | +| `auth identity select` | no change | no change | sets selected active | + +## Commands + +| Command | Purpose | +|---|---| +| `ecloud auth generate` | Generate a new private key and store in OS keyring | +| `ecloud auth login` | Import an existing private key into OS keyring | +| `ecloud auth logout` | Remove signing key and all identities | +| `ecloud auth whoami` | Show signing key, identities, and active identity | +| `ecloud auth identity new` | Create a new identity (Safe or Timelock) | +| `ecloud auth identity list` | Show all stored identities | +| `ecloud auth identity select` | Switch active identity for an environment | + +--- + +## `ecloud auth generate` + +Generate a new private key. Optionally store in OS keyring. + +``` +ecloud auth generate +│ +├── ? Store this key in your OS keyring? (Y/n) +│ +├── No +│ │ +│ ├── Generate new key +│ ├── Show key in pager (address + private key) +│ ├── "Key not stored in keyring." +│ └── END — key exists only in user's memory/clipboard +│ +└── Yes + │ + ├── Check: does a signing key already exist in keyring? + │ + ├── No existing key + │ │ + │ ├── Generate new key + │ ├── Show key in pager + │ ├── Store in keyring + │ ├── Replace all identities with new EOA + │ ├── Set active identity for all environments + │ └── END — ✓ new EOA identity active + │ + └── Existing key found + │ + ├── ⚠ Warning: "A signing key already exists." + │ "Address: 0x..." + │ "Replacing it will clear all current identities." + │ + ├── ? Replace existing key? (y/N) + │ + ├── No → "Cancelled." → END + │ + └── Yes + │ + ├── Generate new key + ├── Show key in pager + ├── Store in keyring (replaces old) + ├── Replace all identities with new EOA + ├── Set active identity for all environments + └── END — ✓ new EOA identity active, old key gone +``` + +--- + +## `ecloud auth login` + +Import an existing private key. Discovers associated Timelocks and Safes on-chain. + +``` +ecloud auth login +│ +├── Check: does a signing key already exist in keyring? +│ +├── Existing key found +│ │ +│ ├── ⚠ Warning: "A signing key already exists." +│ │ "Address: 0x..." +│ │ "Replacing it will clear all current identities." +│ │ +│ ├── ? Replace current signing key? (y/N) +│ ├── No → "Cancelled." → END +│ └── Yes → (continue to key import below) +│ +├── No existing key → (continue to key import below) +│ +├── Check for legacy eigenx-cli keys +│ │ +│ ├── Found legacy keys +│ │ ├── Display them +│ │ ├── ? Import one? → Yes → select which → retrieve key +│ │ └── No → prompt for manual entry +│ │ +│ └── No legacy keys → prompt for manual entry +│ +├── ? Enter your private key: ******** +│ +├── Validate key format +├── Show derived address +├── ? Store in OS keyring? → No → "Cancelled." → END +│ → Yes ↓ +│ +├── Store key in keyring +├── Replace all identities with new EOA +├── Set active identity +│ +├── Discover identities on-chain: +│ │ +│ ├── Scan for Timelock (deterministic address via CREATE2) +│ │ ├── Found → ? Add to identities? → Yes/No +│ │ └── Not found → "No Timelock found" +│ │ +│ └── Scan Safe Transaction Service for Safes owned by this EOA +│ ├── Found N Safes → for each: ? Add to identities? → Yes/No +│ └── None found → (skip) +│ +├── If legacy key was imported: +│ ├── ? Delete legacy key from eigenx-cli? → Yes/No +│ +└── END — ✓ key stored, identities discovered +``` + +--- + +## `ecloud auth logout` + +Removes signing key from OS keyring and clears all identities. + +``` +ecloud auth logout +│ +├── Check: key in keyring? +│ ├── No → "No key found. Nothing to remove." → END +│ └── Yes ↓ +│ +├── "Found stored key: Address: 0x..." +│ +├── ? Remove private key from keyring? (y/N) +│ ├── No → "Cancelled." → END +│ └── Yes ↓ +│ +├── Remove key from keyring +├── Clear all identities +├── Clear all active identity selections +│ +└── END — ✓ clean slate +``` + +--- + +## `ecloud auth whoami` + +Read-only — displays current state. + +``` +ecloud auth whoami --environment + +Signing key: 0xABC...DEF (stored credentials) + or +Signing key: none (run: ecloud auth generate) + +Identities (): + ● EOA 0xABC...DEF ← active + ○ Safe 0x123...456 + ○ Timelock 0x789... (24h delay, via Safe 0x123...) + +Run 'ecloud auth identity select' to switch active identity. +``` + +--- + +## `ecloud auth identity new` + +Create a new identity. Requires a signing key in the keyring. + +``` +ecloud auth identity new +│ +├── Check: signing key in keyring? +│ └── No → error: "Run 'ecloud auth generate' or 'ecloud auth login' first." → END +│ +├── ? What type of identity? +│ > Gnosis Safe (multi-sig) +│ Timelock (for existing EOA or Safe) +│ +├── Safe +│ │ +│ ├── "Signing key 0x... will be included as an owner." +│ ├── ? Additional owner addresses: (comma-separated) +│ ├── ? Threshold: (e.g., 2 of 3) +│ ├── ? Add timelock delay? (y/N) +│ │ +│ ├── No timelock +│ │ ├── Deploy Safe via factory (on-chain tx) +│ │ ├── Add Safe identity to config +│ │ ├── Set active identity → Safe +│ │ └── END — ✓ Safe identity active +│ │ +│ └── Yes timelock +│ ├── ? Minimum delay: (e.g., "24h", "7d") +│ ├── Deploy Safe + Timelock via factory (on-chain txs) +│ ├── Add Timelock(Safe) identity to config +│ ├── Set active identity → Timelock(Safe) +│ └── END — ✓ Timelock(Safe) identity active +│ +└── Timelock + │ + ├── ? Is the proposer/executor an EOA or a Safe? + │ + ├── EOA + │ ├── Check: canonical Timelock exists on-chain? + │ │ ├── Yes + in config → "Already in identities." → ? Set active? → END + │ │ ├── Yes + not in config → ? Add to identities? → END + │ │ └── No → deploy new Timelock ↓ + │ ├── ? Minimum delay: (e.g., "24h") + │ ├── Deploy Timelock via factory (on-chain tx) + │ ├── Add Timelock(EOA) identity to config + │ ├── Set active identity → Timelock(EOA) + │ └── END — ✓ Timelock(EOA) identity active + │ + └── Safe + ├── ? Safe address: 0x... + ├── ? Minimum delay: (e.g., "24h") + ├── Deploy Timelock via factory (on-chain tx) + ├── Add Timelock(Safe) identity to config + ├── Set active identity → Timelock(Safe) + └── END — ✓ Timelock(Safe) identity active +``` + +--- + +## `ecloud auth identity list` + +Read-only — shows all stored identities. + +``` +ecloud auth identity list --environment + +Identities (): + ● EOA 0xABC...DEF ← active + ○ Safe 0x123...456 + ○ Timelock 0x789... (24h delay, via Safe 0x123...) +``` + +--- + +## `ecloud auth identity select` + +Switch active identity for an environment. + +``` +ecloud auth identity select --environment +│ +├── No identities → "Run 'ecloud auth identity new' to create one." → END +│ +├── ? Select active identity for : +│ ● EOA 0xABC... ✓ active +│ ○ Safe 0xDEF... +│ ○ Timelock 0x123... (24h delay) +│ +├── Selected → set as active +└── END — ✓ Active identity: +``` + +--- + +## Identity Transitions + +How an account evolves from simple to secure: + +``` + auth generate → EOA (signing key) + │ + ┌────────────┼────────────┐ + │ │ │ + ▼ ▼ ▼ + identity new → identity new → identity new → + Timelock(EOA) Safe Safe + Timelock + │ + identity new → + Timelock(Safe) +``` + +| From | To | Command | +|---|---|---| +| Nothing | EOA | `auth generate` or `auth login` | +| EOA | Timelock(EOA) | `auth identity new` → Timelock → EOA proposer | +| EOA | Safe | `auth identity new` → Safe | +| EOA | Timelock(Safe) | `auth identity new` → Safe → "Add timelock? Yes" | +| Safe | Timelock(Safe) | `auth identity new` → Timelock → Safe proposer | +| Any | Switch active | `auth identity select` | +| Any | Clean slate | `auth logout` | + +--- + +## App Ownership Transitions + +Separate from identity — this is about who owns the app on-chain: + +| App owner | Upgrade flow | Command | +|---|---|---| +| EOA | direct | `ecloud compute app upgrade` | +| Safe | Safe propose → approved | `ecloud compute app upgrade` | +| Timelock(EOA) | schedule → wait → execute | `upgrade schedule` + `upgrade execute` | +| Timelock(Safe) | Safe propose → schedule → wait → Safe propose → execute | `upgrade schedule` + `upgrade execute` | + +Transfer app ownership: +``` +ecloud compute app ownership transfer --to= +``` + +--- + +## Roles (PAUSER / DEVELOPER) + +Roles are **app-level permissions**, not identity types. They are granted by the app owner (or admin) to specific EOA addresses. + +| Role | What it can do | How it's granted | +|---|---|---| +| ADMIN | All operations | Implicitly the app owner (Safe or Timelock address) | +| PAUSER | Stop the app (direct, no approval needed) | `ecloud compute team grant
` | +| DEVELOPER | Read-only + profile set | `ecloud compute team grant
` | + +Roles are checked at command execution time via on-chain `getTeamRoleMembers()`. They are **not** stored in the identity config. + +`ecloud compute app info` can show your role on the app (future enhancement — not implemented yet). + +--- + +## Command Tree + +``` +ecloud +├── auth +│ ├── generate — no key (generates + stores) +│ ├── login — no key (imports + stores + discovers) +│ ├── logout — no key (removes key + clears identities) +│ ├── whoami — no key (reads keyring + config) +│ └── identity +│ ├── new — KEY: write (deploys Safe/Timelock) +│ ├── list — no key (reads config) +│ └── select — no key (writes config) +│ +├── compute +│ ├── app +│ │ ├── create — KEY: write +│ │ ├── deploy — KEY: write (identity determines: direct / Safe propose) +│ │ ├── upgrade — KEY: write (blocked for Timelock — use schedule/execute) +│ │ ├── start — KEY: write (identity determines flow) +│ │ ├── stop — KEY: write (PAUSER can stop directly) +│ │ ├── terminate — KEY: write (identity determines flow) +│ │ ├── info — KEY: read (address only) +│ │ ├── list — KEY: read (address only) +│ │ ├── releases — KEY: read (address only) +│ │ ├── logs — KEY: read (address only) +│ │ ├── profile set — KEY: write (DEVELOPER can set profile) +│ │ ├── configure tls — KEY: write +│ │ ├── upgrade +│ │ │ ├── schedule — KEY: write (Timelock schedule) +│ │ │ ├── execute — KEY: write (after delay) +│ │ │ └── cancel — KEY: write +│ │ ├── terminate +│ │ │ ├── schedule — KEY: write (Timelock schedule) +│ │ │ └── execute — KEY: write (after delay) +│ │ └── ownership +│ │ ├── transfer — KEY: write +│ │ ├── schedule-transfer — KEY: write (Timelock schedule) +│ │ └── execute-transfer — KEY: write (after delay) +│ │ +│ ├── build +│ │ ├── submit — KEY: read (address only) +│ │ ├── status — KEY: read (address only) +│ │ ├── logs — KEY: read (address only) +│ │ ├── list — KEY: read (address only) +│ │ ├── info — KEY: read (address only) +│ │ └── verify — KEY: read (address only) +│ │ +│ ├── team +│ │ ├── grant — KEY: write +│ │ ├── revoke — KEY: write +│ │ ├── list — KEY: read +│ │ └── grant-admin +│ │ ├── schedule — KEY: write (Timelock(Safe) only) +│ │ └── execute — KEY: write (after delay) +│ │ +│ ├── environment +│ │ ├── list — no key +│ │ ├── set — no key +│ │ └── show — no key +│ │ +│ └── undelegate — KEY: write +│ +├── billing +│ ├── subscribe — KEY: write +│ ├── cancel — KEY: write +│ ├── status — KEY: read +│ └── top-up — KEY: write +│ +├── telemetry +│ ├── enable — no key +│ ├── disable — no key +│ └── status — no key +│ +├── upgrade — no key +└── version — no key +``` + +**Key types:** +- **KEY: write** — private key signs on-chain transactions. Active identity determines the flow (direct / Safe propose / Timelock schedule). +- **KEY: read** — private key used only to derive address for filtering. Could be replaced by active identity address in the future. +- **no key** — works without credentials. + +See `docs/identity-command-matrix.md` for the full command × identity permission matrix. diff --git a/packages/cli/src/commands/auth/generate.ts b/packages/cli/src/commands/auth/generate.ts index 2fa526d7..d729c9cf 100644 --- a/packages/cli/src/commands/auth/generate.ts +++ b/packages/cli/src/commands/auth/generate.ts @@ -1,54 +1,27 @@ /** * Auth Generate Command * - * Create a new identity: EOA private key, Gnosis Safe, or Timelock (wrapping EOA or Safe). + * 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, select, input } from "@inquirer/prompts"; +import { confirm } from "@inquirer/prompts"; import { generateNewPrivateKey, storePrivateKey, keyExists, getPrivateKeyWithSource, getAddressFromPrivateKey, - getEnvironmentConfig, - deploySafe, - deployTimelock, - discoverTimelockForEOA, - type DeploySafeOptions, - type DeployTimelockOptions, } from "@layr-labs/ecloud-sdk"; import { showPrivateKey, displayWarning } from "../../utils/security"; import { withTelemetry } from "../../telemetry"; -import { commonFlags, validateCommonFlags } from "../../flags"; -import { createViemClients } from "../../utils/viemClients"; -import { addIdentity, setActiveIdentity, replaceAllIdentities, getIdentities } from "../../utils/globalConfig"; -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".`); - 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]); -} - -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), - }; -} +import { replaceAllIdentities, setActiveIdentity } from "../../utils/globalConfig"; export default class AuthGenerate extends Command { - static description = "Create a new identity: EOA private key, Gnosis Safe, or Timelock"; + 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 %>", @@ -56,9 +29,8 @@ export default class AuthGenerate extends Command { ]; static flags = { - ...commonFlags, store: Flags.boolean({ - description: "Automatically store EOA key in OS keyring", + description: "Automatically store in OS keyring (skip prompt)", default: false, }), }; @@ -67,148 +39,36 @@ export default class AuthGenerate extends Command { return withTelemetry(this, async () => { const { flags } = await this.parse(AuthGenerate); - const kind = await select({ - message: "What would you like to create?", - choices: [ - { name: "EOA (new private key)", value: "eoa" }, - { name: "Gnosis Safe", value: "safe" }, - { name: "Timelock (for existing EOA or Safe)", value: "timelock" }, - ], - }); - - this.log(""); - - if (kind === "eoa") { - await this._runEOA(flags); - } else if (kind === "safe") { - await this._runSafe(flags); - } else { - await this._runTimelock(flags); - } - }); - } - - private async _runEOA(flags: any): Promise { - const { privateKey, address } = generateNewPrivateKey(); - - const content = ` -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -A new private key was generated for you. - -IMPORTANT: You MUST backup this key now. - It will never be shown again. - -Address: ${address} -Private key: ${privateKey} - -⚠️ SECURITY WARNING: - • Anyone with this key can control your account - • Never share it or commit it to version control - • Store it in a secure password manager -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -Press 'q' to exit and continue... -`; - - const displayed = await showPrivateKey(content); - if (!displayed) { - this.log("Key generation cancelled."); - return; - } - - let shouldStore = flags.store; - if (!shouldStore) { - shouldStore = await confirm({ message: "Store this key in your OS keyring?", default: true }); - } - - if (shouldStore) { - const exists = await keyExists(); - if (exists) { - // Show existing key so user can back it up before it's replaced - const existing = await getPrivateKeyWithSource({ privateKey: undefined }); - if (existing) { - const existingAddress = getAddressFromPrivateKey(existing.key); - const backupContent = ` -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -Your existing signing key is shown below. -Back it up before it is replaced. - -Address: ${existingAddress} -Private key: ${existing.key} -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -Press 'q' to exit and continue... -`; - await showPrivateKey(backupContent); - } - - displayWarning([ - "A signing key already exists.", - "Replacing it will clear all current identities.", - "Make sure you have backed up your existing key.", - ]); - const confirmReplace = await confirm({ message: "Replace existing key?", default: false }); - if (!confirmReplace) { - this.log("\nCancelled."); - return; - } - } - try { - await storePrivateKey(privateKey); - // New signing key — wipe all identities (they belonged to the previous EOA) - 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."); - } catch (err: any) { - this.error(`Failed to store key: ${err.message}`); + let shouldStore = flags.store; + if (!shouldStore) { + shouldStore = await confirm({ message: "Store this key in your OS keyring?", default: true }); } - } else { - this.log("\nKey not stored in keyring."); - this.log("Remember to save the key shown above in a secure location."); - } - } - - private async _runSafe(flags: any): Promise { - // Ensure a signing key exists — generate one if needed - let signingKey: string | undefined = flags["private-key"] as string | undefined; - - if (!signingKey) { - const exists = await keyExists(); - if (exists) { - const existing = await getPrivateKeyWithSource({ privateKey: undefined }); - if (existing) { - const existingAddress = getAddressFromPrivateKey(existing.key); - const backupContent = ` -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -Your existing signing key is shown below. -Back it up before it is replaced. - -Address: ${existingAddress} -Private key: ${existing.key} -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -Press 'q' to exit and continue... -`; - await showPrivateKey(backupContent); - } - - displayWarning([ - "A signing key already exists.", - "Replacing it will clear all current identities.", - "Make sure you have backed up your existing key.", - ]); - const confirmReplace = await confirm({ message: "Generate new signing key?", default: false }); - if (!confirmReplace) { - this.log("\nCancelled."); - return; + // 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(); + const content = ` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ A new private key was generated for you. @@ -227,192 +87,32 @@ Private key: ${privateKey} Press 'q' to exit and continue... `; + const displayed = await showPrivateKey(content); if (!displayed) { - this.log("Cancelled."); + this.log("Key generation cancelled."); return; } - await storePrivateKey(privateKey); - 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("You can now use ecloud commands without --private-key flag.\n"); - signingKey = privateKey; - } - - 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" }); - } - 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 } = await deploySafe( - { walletClient, publicClient, environmentConfig, owners, threshold } as DeploySafeOptions, - logger, - ); - 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 }); - 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 === 0n) { - this.error(`Account ${signerAddress} has no ETH. Fund it before deploying.`); - } - - const proposerKind = await select({ - message: "Is the proposer/executor an EOA or a Safe?", - choices: [ - { name: "EOA (signing key)", value: "eoa" }, - { name: "Gnosis Safe (multi-sig)", value: "safe" }, - ], - }); - - const proposer: Address = proposerKind === "eoa" - ? signerAddress - : await input({ - message: "Safe address:", - validate: (v) => (v.trim().startsWith("0x") ? true : "Must be a 0x address"), - }) as Address; - - // Check if a canonical Timelock already exists for this EOA - if (proposerKind === "eoa") { - const existing = await discoverTimelockForEOA(publicClient, environmentConfig, proposer); - if (existing) { - const delayHours = Number(existing.minDelay) / 3600; - const delayLabel = delayHours >= 24 ? `${delayHours / 24}d` : `${delayHours}h`; - const alreadyInConfig = getIdentities().some( - (id) => id.address.toLowerCase() === existing.address.toLowerCase(), - ); - if (alreadyInConfig) { - this.log(`\nTimelock ${existing.address} is already in your identities.`); - const activate = await confirm({ message: "Set it as active identity?", default: true }); - if (activate) { - setActiveIdentity(flags.environment, existing.address); - this.log(`✓ Active identity set to Timelock ${existing.address}`); - } - } else { - this.log(`\nA Timelock already exists for this EOA: ${existing.address} (${delayLabel} delay)`); - const addIt = await confirm({ message: "Add it to your identities?", default: true }); - if (addIt) { - addIdentity({ type: "timelock", address: existing.address, delay: delayLabel, environment: flags.environment }); - setActiveIdentity(flags.environment, existing.address); - this.log(`✓ Timelock added and set as active identity`); + if (shouldStore) { + 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}`); } - return; + } else { + this.log("\nKey not stored in keyring."); + this.log("Remember to save the key shown above in a secure location."); } - } - - const delayStr = await input({ - message: 'Minimum delay (e.g., "24h", "7d"):', - default: "24h", }); - 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], - } 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/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..e8d4213f --- /dev/null +++ b/packages/cli/src/commands/auth/identity/new.ts @@ -0,0 +1,262 @@ +/** + * 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, + discoverTimelockForEOA, + 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 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".`); + 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]); +} + +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" }); + } + 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 } = await deploySafe( + { walletClient, publicClient, environmentConfig, owners, threshold } as DeploySafeOptions, + logger, + ); + 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 }); + 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.`); + } + + const proposerKind = await select({ + message: "Is the proposer/executor an EOA or a Safe?", + choices: [ + { name: "EOA (signing key)", value: "eoa" }, + { name: "Gnosis Safe (multi-sig)", value: "safe" }, + ], + }); + + const proposer: Address = proposerKind === "eoa" + ? signerAddress + : await input({ + message: "Safe address:", + validate: (v) => (v.trim().startsWith("0x") ? true : "Must be a 0x address"), + }) as Address; + + // Check if a canonical Timelock already exists for this EOA + if (proposerKind === "eoa") { + const existing = await discoverTimelockForEOA(publicClient, environmentConfig, proposer); + if (existing) { + const delayHours = Number(existing.minDelay) / 3600; + const delayLabel = delayHours >= 24 ? `${delayHours / 24}d` : `${delayHours}h`; + const alreadyInConfig = getIdentities().some( + (id) => id.address.toLowerCase() === existing.address.toLowerCase(), + ); + if (alreadyInConfig) { + this.log(`\nTimelock ${existing.address} is already in your identities.`); + const activate = await confirm({ message: "Set it as active identity?", default: true }); + if (activate) { + setActiveIdentity(flags.environment, existing.address); + this.log(`✓ Active identity set to Timelock ${existing.address}`); + } + } else { + this.log(`\nA Timelock already exists for this EOA: ${existing.address} (${delayLabel} delay)`); + const addIt = await confirm({ message: "Add it to your identities?", default: true }); + if (addIt) { + addIdentity({ type: "timelock", address: existing.address, delay: delayLabel, environment: flags.environment }); + setActiveIdentity(flags.environment, existing.address); + this.log(`✓ Timelock added and set as active identity`); + } + } + return; + } + } + + const delayStr = await input({ + message: 'Minimum delay (e.g., "24h", "7d"):', + default: "24h", + }); + 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], + } 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 67481f2b..ab2d78d6 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -1,7 +1,8 @@ /** * 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 } from "@oclif/core"; @@ -19,7 +20,7 @@ import { discoverTimelockForEOA, type LegacyKey, } from "@layr-labs/ecloud-sdk"; -import { getHiddenInput, displayWarning, showPrivateKey } from "../../utils/security"; +import { getHiddenInput, displayWarning } from "../../utils/security"; import { withTelemetry } from "../../telemetry"; import { commonFlags } from "../../flags"; import { @@ -27,14 +28,12 @@ import { addIdentity, replaceAllIdentities, setActiveIdentity, - getActiveIdentityAddress, - formatIdentity, } 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, or switch active identity"; + static description = "Import an existing private key into OS keyring"; static examples = ["<%= config.bin %> <%= command.id %>"]; @@ -48,62 +47,20 @@ export default class AuthLogin extends Command { const { flags } = await this.parse(AuthLogin); const environment = flags.environment as string; - const identities = getIdentities(); - - // One or more identities — show selector - if (identities.length > 0) { - const activeAddress = getActiveIdentityAddress(environment); - const choices = [ - ...identities.map((id) => ({ - name: formatIdentity(id) + (id.address.toLowerCase() === activeAddress?.toLowerCase() ? " ✓ active" : ""), - value: id.address, - })), - { name: "─── Replace signing key ───", value: "__key__" }, - ]; - - const selected = await select({ - message: `Select active identity for ${environment}:`, - choices, - }); - - if (selected !== "__key__") { - setActiveIdentity(environment, selected); - const id = identities.find((i) => i.address.toLowerCase() === selected.toLowerCase())!; - this.log(`\n✓ Active identity: ${formatIdentity(id)}`); - return; - } - - // Show existing key for backup before replacing + // Check for existing key + const exists = await keyExists(); + if (exists) { const existing = await getPrivateKeyWithSource({ privateKey: undefined }); if (existing) { const existingAddress = getAddressFromPrivateKey(existing.key); - const backupContent = ` -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -Your existing signing key is shown below. -Back it up before it is replaced. - -Address: ${existingAddress} -Private key: ${existing.key} -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -Press 'q' to exit and continue... -`; - await showPrivateKey(backupContent); + displayWarning([ + "A signing key already exists.", + `Address: ${existingAddress}`, + "", + "Replacing it will clear all current identities.", + ]); } - - // User chose to replace signing key — warn before proceeding - displayWarning([ - "A signing key already exists.", - "Replacing it will clear all current identities.", - "Make sure you have backed up your existing key.", - ]); - this.log(""); - - const confirmReplace = await confirm({ - message: "Replace current signing key?", - default: false, - }); - + const confirmReplace = await confirm({ message: "Replace current signing key?", default: false }); if (!confirmReplace) { this.log("\nCancelled."); return; @@ -119,7 +76,6 @@ Press 'q' to exit and continue... this.log("\nFound legacy keys from eigenx-cli:"); this.log(""); - // Display legacy keys for (const key of legacyKeys) { this.log(` Address: ${key.address}`); this.log(` Environment: ${key.environment}`); @@ -133,7 +89,6 @@ Press 'q' to exit and continue... }); if (importLegacy) { - // Create choices for selection const choices = legacyKeys.map((key) => ({ name: `${key.address} (${key.environment} - ${key.source})`, value: key, @@ -144,7 +99,6 @@ Press 'q' to exit and continue... choices, }); - // Retrieve the actual private key privateKey = await getLegacyPrivateKey(selectedKey.environment, selectedKey.source); if (!privateKey) { @@ -158,7 +112,6 @@ Press 'q' to exit and continue... // If no legacy key was selected, prompt for private key input if (!privateKey) { privateKey = await getHiddenInput("Enter your private key:"); - privateKey = privateKey.trim(); } @@ -166,9 +119,7 @@ Press 'q' to exit and continue... this.error("Invalid private key format. Please check and try again."); } - // Derive address for confirmation const address = getAddressFromPrivateKey(privateKey); - this.log(`\nAddress: ${address}`); const confirmStore = await confirm({ @@ -181,19 +132,17 @@ Press 'q' to exit and continue... return; } - // Store in keyring try { await storePrivateKey(privateKey); this.log("\n✓ Private key stored in OS keyring"); this.log(`✓ Address: ${address}`); - this.log("You can now use ecloud commands without --private-key flag."); - // Switching signing key — wipe all identities (they belonged to the previous EOA) + // New signing key — wipe old identities, set EOA as default replaceAllIdentities([{ type: "eoa", address }]); setActiveIdentity(environment, address); - // Discover canonical Timelock on-chain for this EOA - this.log(`\nScanning chain for Timelock associated with ${address}...`); + // Discover Timelock on-chain + this.log(`\nScanning chain for associated identities...`); try { const publicClient = createPublicClientOnly({ environment, rpcUrl: flags["rpc-url"] }); const environmentConfig = getEnvironmentConfig(environment); @@ -223,7 +172,7 @@ Press 'q' to exit and continue... this.log(`(Timelock scan skipped — chain not reachable)`); } - // Discover Safes where this EOA is an owner via Safe Transaction Service + // Discover Safes try { const safeServiceUrl = environment === "mainnet-alpha" @@ -255,7 +204,7 @@ Press 'q' to exit and continue... // Safe Transaction Service not reachable — skip } - // Ask if user wants to delete the legacy key (only if save was successful) + // Clean up legacy key if imported if (selectedKey) { this.log(""); const confirmDelete = await confirm({ @@ -264,28 +213,17 @@ Press 'q' to exit and continue... }); if (confirmDelete) { - const deleted = await deleteLegacyPrivateKey( - selectedKey.environment, - selectedKey.source, - ); - + 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/utils/globalConfig.ts b/packages/cli/src/utils/globalConfig.ts index e69d58aa..412f27f1 100644 --- a/packages/cli/src/utils/globalConfig.ts +++ b/packages/cli/src/utils/globalConfig.ts @@ -453,6 +453,17 @@ export function replaceAllIdentities(identities: StoredIdentity[]): void { 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) */ From 021997b874d859d6d2adbaca2c6d22aa9e153f4b Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 9 Apr 2026 17:42:22 -0700 Subject: [PATCH 15/41] docs: add identity limits and examples to auth flow map --- docs/auth-flow-map.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/auth-flow-map.md b/docs/auth-flow-map.md index e3111c57..e89a9f3e 100644 --- a/docs/auth-flow-map.md +++ b/docs/auth-flow-map.md @@ -18,6 +18,26 @@ Two independent storage systems: - **Active identity** — the identity used for commands in a given environment. One per environment. - **Roles** (PAUSER, DEVELOPER) — permissions on a specific app, not identity types. Checked at the app level, not the identity level. +## Identity Limits + +| Identity type | How many per signing key | Why | +|---|---|---| +| **EOA** | 1 | The signing key's own address. Created automatically on `auth generate` or `auth login`. | +| **Safe** | Unlimited | Each `auth identity new → Safe` deploys a new Safe contract. Different Safes can have different owners, thresholds, and purposes. | +| **Timelock(EOA)** | 1 | Address is deterministic via CREATE2 (`CANONICAL_SALT`). Re-running discovers the existing one instead of deploying. | +| **Timelock(Safe)** | 1 per Safe | Each Safe can have its own Timelock. Different Safes can have different delay periods. | + +Example: +``` +Identities: + ● EOA 0xABC... ← your signing key + ○ Safe 0x111... (2/3 — you + partner A + B) ← multi-sig for production + ○ Safe 0x222... (1/1 — just you) ← single-owner for testing + ○ Timelock 0x333... (24h delay, wraps EOA) ← only one per EOA + ○ Timelock 0x444... (24h delay, wraps Safe 0x111) ← one per Safe + ○ Timelock 0x555... (7d delay, wraps Safe 0x222) ← different delay +``` + ## State Transitions Every auth command and exactly what it changes: From 57ae77566ea7a2e13e6ded6a3ef941525e8bd14b Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 9 Apr 2026 17:50:54 -0700 Subject: [PATCH 16/41] feat: discover Timelock for both EOA and Safe proposers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename discoverTimelockForEOA to discoverTimelock — the underlying calculateTimelockAddress works with any address (EOA or Safe) + salt. Now when creating a Timelock via auth identity new, the CLI checks for existing Timelocks for both EOA and Safe proposers before deploying. This prevents duplicate deployments since addresses are deterministic via CREATE2 with CANONICAL_SALT. --- .../cli/src/commands/auth/identity/new.ts | 52 +++++++++---------- packages/cli/src/commands/auth/login.ts | 4 +- .../sdk/src/client/common/contract/caller.ts | 9 ++-- packages/sdk/src/client/index.ts | 1 + 4 files changed, 35 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/commands/auth/identity/new.ts b/packages/cli/src/commands/auth/identity/new.ts index e8d4213f..c13f4d2e 100644 --- a/packages/cli/src/commands/auth/identity/new.ts +++ b/packages/cli/src/commands/auth/identity/new.ts @@ -14,7 +14,7 @@ import { getEnvironmentConfig, deploySafe, deployTimelock, - discoverTimelockForEOA, + discoverTimelock, type DeploySafeOptions, type DeployTimelockOptions, } from "@layr-labs/ecloud-sdk"; @@ -194,33 +194,33 @@ export default class AuthIdentityNew extends Command { validate: (v) => (v.trim().startsWith("0x") ? true : "Must be a 0x address"), }) as Address; - // Check if a canonical Timelock already exists for this EOA - if (proposerKind === "eoa") { - const existing = await discoverTimelockForEOA(publicClient, environmentConfig, proposer); - if (existing) { - const delayHours = Number(existing.minDelay) / 3600; - const delayLabel = delayHours >= 24 ? `${delayHours / 24}d` : `${delayHours}h`; - const alreadyInConfig = getIdentities().some( - (id) => id.address.toLowerCase() === existing.address.toLowerCase(), - ); - if (alreadyInConfig) { - this.log(`\nTimelock ${existing.address} is already in your identities.`); - const activate = await confirm({ message: "Set it as active identity?", default: true }); - if (activate) { - setActiveIdentity(flags.environment, existing.address); - this.log(`✓ Active identity set to Timelock ${existing.address}`); - } - } else { - this.log(`\nA Timelock already exists for this EOA: ${existing.address} (${delayLabel} delay)`); - const addIt = await confirm({ message: "Add it to your identities?", default: true }); - if (addIt) { - addIdentity({ type: "timelock", address: existing.address, delay: delayLabel, environment: flags.environment }); - setActiveIdentity(flags.environment, existing.address); - this.log(`✓ Timelock added and set as active identity`); - } + // Check if a canonical Timelock already exists for this proposer (EOA or Safe) + const existing = await discoverTimelock(publicClient, environmentConfig, proposer); + if (existing) { + const delayHours = Number(existing.minDelay) / 3600; + const delayLabel = delayHours >= 24 ? `${delayHours / 24}d` : `${delayHours}h`; + const proposerLabel = proposerKind === "eoa" ? "EOA" : "Safe"; + const alreadyInConfig = getIdentities().some( + (id) => id.address.toLowerCase() === existing.address.toLowerCase(), + ); + if (alreadyInConfig) { + this.log(`\nTimelock ${existing.address} is already in your identities.`); + const activate = await confirm({ message: "Set it as active identity?", default: true }); + if (activate) { + setActiveIdentity(flags.environment, existing.address); + this.log(`✓ Active identity set to Timelock ${existing.address}`); + } + } else { + this.log(`\nA Timelock already exists for this ${proposerLabel}: ${existing.address} (${delayLabel} delay)`); + const addIt = await confirm({ message: "Add it to your identities?", default: true }); + if (addIt) { + const isSafe = proposerKind === "safe"; + addIdentity({ type: "timelock", address: existing.address, delay: delayLabel, safeAddress: isSafe ? proposer : undefined, environment: flags.environment }); + setActiveIdentity(flags.environment, existing.address); + this.log(`✓ Timelock added and set as active identity`); } - return; } + return; } const delayStr = await input({ diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index ab2d78d6..98b63c30 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -17,7 +17,7 @@ import { getLegacyPrivateKey, deleteLegacyPrivateKey, getEnvironmentConfig, - discoverTimelockForEOA, + discoverTimelock, type LegacyKey, } from "@layr-labs/ecloud-sdk"; import { getHiddenInput, displayWarning } from "../../utils/security"; @@ -146,7 +146,7 @@ export default class AuthLogin extends Command { try { const publicClient = createPublicClientOnly({ environment, rpcUrl: flags["rpc-url"] }); const environmentConfig = getEnvironmentConfig(environment); - const found = await discoverTimelockForEOA(publicClient, environmentConfig, address as Address); + const found = await discoverTimelock(publicClient, environmentConfig, address as Address); if (found) { const delayHours = Number(found.minDelay) / 3600; diff --git a/packages/sdk/src/client/common/contract/caller.ts b/packages/sdk/src/client/common/contract/caller.ts index 8ecea4ae..58cabba9 100644 --- a/packages/sdk/src/client/common/contract/caller.ts +++ b/packages/sdk/src/client/common/contract/caller.ts @@ -2237,10 +2237,10 @@ export async function executeTimelockGrantTeamAdmin( return executeTimelockOp({ ...base, timelockAddress, calldata }, logger); } -export async function discoverTimelockForEOA( +export async function discoverTimelock( publicClient: PublicClient, environmentConfig: EnvironmentConfig, - eoaAddress: Address, + proposerAddress: Address, ): Promise { const factoryAddress = await getSafeTimelockFactoryAddress(publicClient, environmentConfig); @@ -2248,7 +2248,7 @@ export async function discoverTimelockForEOA( address: factoryAddress, abi: SafeTimelockFactoryABI, functionName: "calculateTimelockAddress", - args: [eoaAddress, CANONICAL_SALT], + args: [proposerAddress, CANONICAL_SALT], }) as Address; const exists = await publicClient.readContract({ @@ -2269,3 +2269,6 @@ export async function discoverTimelockForEOA( return { address: timelockAddress, minDelay }; } + +/** @deprecated Use discoverTimelock instead */ +export const discoverTimelockForEOA = discoverTimelock; diff --git a/packages/sdk/src/client/index.ts b/packages/sdk/src/client/index.ts index e1af09d6..57efde5b 100644 --- a/packages/sdk/src/client/index.ts +++ b/packages/sdk/src/client/index.ts @@ -126,6 +126,7 @@ export { getSafeTimelockFactoryAddress, deploySafe, deployTimelock, + discoverTimelock, discoverTimelockForEOA, CANONICAL_SALT, type DeploySafeOptions, From 1ce68f8d9d4f2d1a1cef80ee47e432c9776a4402 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 9 Apr 2026 17:54:29 -0700 Subject: [PATCH 17/41] docs: clarify Timelock CREATE2 determinism for both EOA and Safe --- docs/auth-flow-map.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/auth-flow-map.md b/docs/auth-flow-map.md index e89a9f3e..6b6d478e 100644 --- a/docs/auth-flow-map.md +++ b/docs/auth-flow-map.md @@ -25,7 +25,9 @@ Two independent storage systems: | **EOA** | 1 | The signing key's own address. Created automatically on `auth generate` or `auth login`. | | **Safe** | Unlimited | Each `auth identity new → Safe` deploys a new Safe contract. Different Safes can have different owners, thresholds, and purposes. | | **Timelock(EOA)** | 1 | Address is deterministic via CREATE2 (`CANONICAL_SALT`). Re-running discovers the existing one instead of deploying. | -| **Timelock(Safe)** | 1 per Safe | Each Safe can have its own Timelock. Different Safes can have different delay periods. | +| **Timelock(Safe)** | 1 per Safe | Address is deterministic via CREATE2 (Safe address + `CANONICAL_SALT`). Re-running discovers the existing one. Each Safe can have its own Timelock with its own delay period. | + +All Timelock addresses are deterministic — computed by `SafeTimelockFactory.calculateTimelockAddress(proposer, salt)` using CREATE2. The CLI checks for existing Timelocks before deploying, for both EOA and Safe proposers. Example: ``` From 395e93076f16b6a2baa2801cfe7983b72b901aa8 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 9 Apr 2026 20:19:50 -0700 Subject: [PATCH 18/41] feat: compute app list groups apps by identity, no private key required MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit List now queries apps across all identity addresses from config (EOA, Safe, Timelock), grouped by owner with active identity marked. Falls back to signing key address if no identities configured. No private key needed for reads — uses addresses from config. --- docs/auth-flow-map.md | 32 +- packages/cli/src/commands/compute/app/list.ts | 307 +++++++++--------- 2 files changed, 174 insertions(+), 165 deletions(-) diff --git a/docs/auth-flow-map.md b/docs/auth-flow-map.md index 6b6d478e..edeb535a 100644 --- a/docs/auth-flow-map.md +++ b/docs/auth-flow-map.md @@ -406,10 +406,10 @@ ecloud │ │ ├── start — KEY: write (identity determines flow) │ │ ├── stop — KEY: write (PAUSER can stop directly) │ │ ├── terminate — KEY: write (identity determines flow) -│ │ ├── info — KEY: read (address only) -│ │ ├── list — KEY: read (address only) -│ │ ├── releases — KEY: read (address only) -│ │ ├── logs — KEY: read (address only) +│ │ ├── info — no key (takes app ID as argument) +│ │ ├── list — no key (queries all identity addresses from config) +│ │ ├── releases — no key (takes app ID as argument) +│ │ ├── logs — no key (takes app ID as argument) │ │ ├── profile set — KEY: write (DEVELOPER can set profile) │ │ ├── configure tls — KEY: write │ │ ├── upgrade @@ -464,7 +464,27 @@ ecloud **Key types:** - **KEY: write** — private key signs on-chain transactions. Active identity determines the flow (direct / Safe propose / Timelock schedule). -- **KEY: read** — private key used only to derive address for filtering. Could be replaced by active identity address in the future. -- **no key** — works without credentials. +- **KEY: read** — private key used only to derive address for filtering. +- **no key** — works without credentials. Read commands use identity addresses from config. + +### `compute app list` — grouped by identity + +`list` queries apps across all identities in config, grouped by owner: + +``` +ecloud compute app list + +EOA 0xABC...DEF ← active + myapp running docker.io/myapp:v2 + worker running docker.io/worker:v1 + +Safe 0x111...456 + production running docker.io/prod:v3 + +Timelock 0x333...789 (24h delay, wraps Safe 0x111) + staging stopped docker.io/staging:v1 +``` + +No private key required — uses identity addresses from config. Falls back to signing key address if no identities are configured. See `docs/identity-command-matrix.md` for the full command × identity permission matrix. diff --git a/packages/cli/src/commands/compute/app/list.ts b/packages/cli/src/commands/compute/app/list.ts index 7c55160e..d580ba00 100644 --- a/packages/cli/src/commands/compute/app/list.ts +++ b/packages/cli/src/commands/compute/app/list.ts @@ -5,9 +5,10 @@ import { getAppLatestReleaseBlockNumbers, getBlockTimestamps, UserApiClient, + getPrivateKeyWithSource, + getAddressFromPrivateKey, } from "@layr-labs/ecloud-sdk"; -import { commonFlags, validateCommonFlags } from "../../../flags"; -import { privateKeyToAccount } from "viem/accounts"; +import { commonFlags } from "../../../flags"; import { Address, Hex } from "viem"; import { getAppName } from "../../../utils/appNames"; import { @@ -17,9 +18,10 @@ import { } from "../../../utils/prompts"; import { getAppInfosChunked } from "../../../utils/appResolver"; import { formatAppDisplay, printAppDisplay } from "../../../utils/format"; -import { createViemClients } from "../../../utils/viemClients"; +import { createPublicClientOnly, 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 +35,186 @@ 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 - const validatedFlags = await validateCommonFlags(flags); - - // Get validated values from flags - const environment = validatedFlags.environment; + const environment = flags.environment as string; 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; - - // Create viem clients and UserAPI client - const { publicClient, walletClient } = createViemClients({ - privateKey, - rpcUrl, - environment, - }); - - if (flags.verbose) { - this.log(`Fetching apps for developer: ${developerAddr}`); + const rpcUrl = flags["rpc-url"] || environmentConfig.defaultRPCURL; + + // Collect addresses to query — from identities if available, else from signing key + const identities = getIdentities(); + let addressesToQuery: { address: Address; label: string }[] = []; + + if (identities.length > 0) { + for (const id of identities) { + addressesToQuery.push({ + address: id.address as Address, + label: formatIdentity(id), + }); + } + } else { + // Fallback: derive address from signing key + const keyResult = await getPrivateKeyWithSource({ privateKey: flags["private-key"] }); + if (!keyResult) { + this.error("No signing key found. Run 'ecloud auth generate' or 'ecloud auth login' first."); + } + const address = getAddressFromPrivateKey(keyResult.key); + addressesToQuery.push({ + address: address as Address, + label: `${address.slice(0, 6)}...${address.slice(-4)} (EOA)`, + }); } - // List apps from contract - const result = await getAllAppsByDeveloper(publicClient, environmentConfig, developerAddr); + // Create clients — use publicClient only for reads, walletClient only if we have a key + const keyResult = await getPrivateKeyWithSource({ privateKey: flags["private-key"] }); + let publicClient: any; + let walletClient: any; - if (result.apps.length === 0) { - this.log(`\nNo apps found for developer ${developerAddr}`); - return; + if (keyResult) { + const clients = createViemClients({ + privateKey: keyResult.key, + rpcUrl, + environment, + }); + publicClient = clients.publicClient; + walletClient = clients.walletClient; + } else { + publicClient = createPublicClientOnly({ environment, rpcUrl }); } - // Filter out terminated apps unless --all flag is used - const filteredApps: Address[] = []; - const filteredConfigs: { status: number }[] = []; + const activeAddress = getActiveIdentityAddress(environment); + let totalApps = 0; - 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); - } + console.log(); - 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)`, - ); - } - return; - } + for (const { address, label } of addressesToQuery) { + const result = await getAllAppsByDeveloper(publicClient, environmentConfig, address); - // Create UserAPI client - const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { - clientId: getClientId(), - }); + // Filter out terminated unless --all + const filteredApps: Address[] = []; + const filteredConfigs: { status: number }[] = []; - // 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}`); + for (let i = 0; i < result.apps.length; i++) { + const config = result.appConfigs[i]; + if (!flags.all && config.status === ContractAppStatusTerminated) { + continue; } - 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 app items with all data for sorting - interface AppDisplayItem { - appAddr: Address; - apiInfo: (typeof appInfos)[0] | undefined; - appName: string; - status: string; - releaseTimestamp: number | undefined; - } - - const appItems: AppDisplayItem[] = []; - for (let i = 0; i < filteredApps.length; i++) { - const appAddr = filteredApps[i]; - const config = filteredConfigs[i]; - - 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; + filteredApps.push(result.apps[i]); + filteredConfigs.push(config); + } - const status = apiInfo?.status || getContractStatusString(config.status); + if (filteredApps.length === 0) continue; - const releaseBlockNumber = releaseBlockNumbers.get(appAddr); - const releaseTimestamp = releaseBlockNumber - ? blockTimestamps.get(releaseBlockNumber) - : undefined; + totalApps += filteredApps.length; - appItems.push({ appAddr, apiInfo, appName, status, releaseTimestamp }); - } + // Print identity header + const isActive = address.toLowerCase() === activeAddress?.toLowerCase(); + const activeMarker = isActive ? chalk.green(" ← active") : ""; + this.log(chalk.bold(`${label}${activeMarker}`)); + console.log(); - // 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); + // Create UserAPI client if we have a wallet + let userApiClient: UserApiClient | null = null; + if (walletClient) { + userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { + clientId: getClientId(), + }); + } - if (aPriority !== bPriority) { - return aPriority - bPriority; + // Fetch app info and release data + const [appInfos, releaseBlockNumbers] = await Promise.all([ + userApiClient + ? getAppInfosChunked(userApiClient, filteredApps, 1).catch(() => []) + : Promise.resolve([]), + getAppLatestReleaseBlockNumbers(publicClient, environmentConfig, filteredApps).catch( + () => new Map(), + ) as Promise>, + ]); + + const blockNumbers = Array.from(releaseBlockNumbers.values()).filter((n) => n > 0); + const blockTimestamps = + blockNumbers.length > 0 + ? await getBlockTimestamps(publicClient, blockNumbers).catch( + () => 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)}`); + + 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)`)); + } }); } } From 05ebcf055b29e69acb61fd900f2a4c4bb1cb7f69 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 9 Apr 2026 21:22:05 -0700 Subject: [PATCH 19/41] feat: compute app list with identity grouping and API auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit List queries apps across all identity addresses (EOA, Safe, Timelock), grouped by owner with active identity marked. Uses private key for API authentication — backend resolves Safe/Timelock ownership on-chain. Update auth-flow-map with backend ownership resolution flow. --- docs/auth-flow-map.md | 31 ++++-- packages/cli/src/commands/compute/app/list.ts | 98 +++++++++---------- 2 files changed, 72 insertions(+), 57 deletions(-) diff --git a/docs/auth-flow-map.md b/docs/auth-flow-map.md index edeb535a..6c31c6bd 100644 --- a/docs/auth-flow-map.md +++ b/docs/auth-flow-map.md @@ -406,10 +406,10 @@ ecloud │ │ ├── start — KEY: write (identity determines flow) │ │ ├── stop — KEY: write (PAUSER can stop directly) │ │ ├── terminate — KEY: write (identity determines flow) -│ │ ├── info — no key (takes app ID as argument) -│ │ ├── list — no key (queries all identity addresses from config) -│ │ ├── releases — no key (takes app ID as argument) -│ │ ├── logs — no key (takes app ID as argument) +│ │ ├── info — KEY: read (API auth via EOA signature, backend resolves Safe/Timelock) +│ │ ├── list — KEY: read (queries all identity addresses, grouped by owner) +│ │ ├── releases — KEY: read (API auth via EOA signature) +│ │ ├── logs — KEY: read (API auth via EOA signature) │ │ ├── profile set — KEY: write (DEVELOPER can set profile) │ │ ├── configure tls — KEY: write │ │ ├── upgrade @@ -464,12 +464,12 @@ ecloud **Key types:** - **KEY: write** — private key signs on-chain transactions. Active identity determines the flow (direct / Safe propose / Timelock schedule). -- **KEY: read** — private key used only to derive address for filtering. -- **no key** — works without credentials. Read commands use identity addresses from config. +- **KEY: read** — private key signs API requests (backend verifies EOA signature and resolves Safe/Timelock ownership on-chain). +- **no key** — works without credentials. ### `compute app list` — grouped by identity -`list` queries apps across all identities in config, grouped by owner: +`list` queries apps across all identity addresses, grouped by owner: ``` ecloud compute app list @@ -485,6 +485,21 @@ Timelock 0x333...789 (24h delay, wraps Safe 0x111) staging stopped docker.io/staging:v1 ``` -No private key required — uses identity addresses from config. Falls back to signing key address if no identities are configured. +Requires private key for API authentication. The backend resolves Safe/Timelock ownership on-chain — EOA signature is sufficient to access apps owned by Safes and Timelocks the EOA controls. + +### Backend ownership resolution + +When the API receives an EOA-signed request for an app owned by a Safe or Timelock, it resolves the ownership chain on-chain: + +``` +1. caller == app owner? → grant access +2. app owner is Safe → caller in Safe.getOwners()? → grant access +3. app owner is Timelock → caller is proposer? → grant access +4. app owner is Timelock(Safe) → proposer is Safe + → caller in Safe.getOwners()? → grant access +5. caller has team role (ADMIN/PAUSER/DEVELOPER)? → grant access +``` + +All checks are on-chain reads — no extra headers needed from the CLI. See `docs/identity-command-matrix.md` for the full command × identity permission matrix. diff --git a/packages/cli/src/commands/compute/app/list.ts b/packages/cli/src/commands/compute/app/list.ts index d580ba00..56c0528d 100644 --- a/packages/cli/src/commands/compute/app/list.ts +++ b/packages/cli/src/commands/compute/app/list.ts @@ -5,10 +5,9 @@ import { getAppLatestReleaseBlockNumbers, getBlockTimestamps, UserApiClient, - getPrivateKeyWithSource, - getAddressFromPrivateKey, } from "@layr-labs/ecloud-sdk"; -import { commonFlags } from "../../../flags"; +import { commonFlags, validateCommonFlags } from "../../../flags"; +import { privateKeyToAccount } from "viem/accounts"; import { Address, Hex } from "viem"; import { getAppName } from "../../../utils/appNames"; import { @@ -18,7 +17,7 @@ import { } from "../../../utils/prompts"; import { getAppInfosChunked } from "../../../utils/appResolver"; import { formatAppDisplay, printAppDisplay } from "../../../utils/format"; -import { createPublicClientOnly, createViemClients } from "../../../utils/viemClients"; +import { createViemClients } from "../../../utils/viemClients"; import { getDashboardUrl } from "../../../utils/dashboard"; import { getClientId } from "../../../utils/version"; import { getIdentities, getActiveIdentityAddress, formatIdentity } from "../../../utils/globalConfig"; @@ -41,13 +40,30 @@ export default class AppList extends Command { return withTelemetry(this, async () => { const { flags } = await this.parse(AppList); - const environment = flags.environment as string; + // Validate flags — private key is required for API authentication + const validatedFlags = await validateCommonFlags(flags); + + const environment = validatedFlags.environment; const environmentConfig = getEnvironmentConfig(environment); - const rpcUrl = flags["rpc-url"] || environmentConfig.defaultRPCURL; + const rpcUrl = validatedFlags["rpc-url"] || environmentConfig.defaultRPCURL; + const privateKey = validatedFlags["private-key"]!; + + const account = privateKeyToAccount(privateKey as Hex); + const eoaAddress = account.address; + + const { publicClient, walletClient } = createViemClients({ + privateKey, + rpcUrl, + environment, + }); + + const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { + clientId: getClientId(), + }); - // Collect addresses to query — from identities if available, else from signing key + // Collect addresses to query — EOA + all identity addresses const identities = getIdentities(); - let addressesToQuery: { address: Address; label: string }[] = []; + const addressesToQuery: { address: Address; label: string }[] = []; if (identities.length > 0) { for (const id of identities) { @@ -57,41 +73,20 @@ export default class AppList extends Command { }); } } else { - // Fallback: derive address from signing key - const keyResult = await getPrivateKeyWithSource({ privateKey: flags["private-key"] }); - if (!keyResult) { - this.error("No signing key found. Run 'ecloud auth generate' or 'ecloud auth login' first."); - } - const address = getAddressFromPrivateKey(keyResult.key); + // No identities configured — query just the EOA addressesToQuery.push({ - address: address as Address, - label: `${address.slice(0, 6)}...${address.slice(-4)} (EOA)`, + address: eoaAddress, + label: `${eoaAddress.slice(0, 6)}...${eoaAddress.slice(-4)} (EOA)`, }); } - // Create clients — use publicClient only for reads, walletClient only if we have a key - const keyResult = await getPrivateKeyWithSource({ privateKey: flags["private-key"] }); - let publicClient: any; - let walletClient: any; - - if (keyResult) { - const clients = createViemClients({ - privateKey: keyResult.key, - rpcUrl, - environment, - }); - publicClient = clients.publicClient; - walletClient = clients.walletClient; - } else { - publicClient = createPublicClientOnly({ environment, rpcUrl }); - } - const activeAddress = getActiveIdentityAddress(environment); let totalApps = 0; console.log(); for (const { address, label } of addressesToQuery) { + // Query apps owned by this address from blockchain const result = await getAllAppsByDeveloper(publicClient, environmentConfig, address); // Filter out terminated unless --all @@ -117,30 +112,35 @@ export default class AppList extends Command { this.log(chalk.bold(`${label}${activeMarker}`)); console.log(); - // Create UserAPI client if we have a wallet - let userApiClient: UserApiClient | null = null; - if (walletClient) { - userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { - clientId: getClientId(), - }); - } - - // Fetch app info and release data + // 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([ - userApiClient - ? getAppInfosChunked(userApiClient, filteredApps, 1).catch(() => []) - : Promise.resolve([]), + 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( - () => new Map(), + (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( - () => new Map(), - ) + ? 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 From 543f657911abf4907add32ab12cf1b8a37c46ff3 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Mon, 13 Apr 2026 12:42:18 -0700 Subject: [PATCH 20/41] feat: identity-aware transaction routing for start/stop/terminate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New SDK modules: - safe.ts: Safe Transaction Service proposal (read nonce, sign tx hash, post to Safe API) - identity-router.ts: sendWithIdentity() routes transactions based on active identity type (EOA direct / Safe propose / Timelock schedule) CLI changes: - identityTransaction.ts: shared utilities for identity context display and routing - start, stop, terminate: show active identity before sending, route through identity router when identity is Safe or Timelock EOA behavior unchanged — existing code path preserved. Safe/Timelock paths: propose to Safe Transaction Service or schedule on Timelock via the identity router. --- .../cli/src/commands/compute/app/start.ts | 52 ++-- packages/cli/src/commands/compute/app/stop.ts | 49 +++- .../cli/src/commands/compute/app/terminate.ts | 48 ++-- packages/cli/src/utils/identityTransaction.ts | 113 +++++++++ .../client/common/contract/identity-router.ts | 223 ++++++++++++++++++ .../sdk/src/client/common/contract/safe.ts | 160 +++++++++++++ packages/sdk/src/client/index.ts | 16 ++ 7 files changed, 612 insertions(+), 49 deletions(-) create mode 100644 packages/cli/src/utils/identityTransaction.ts create mode 100644 packages/sdk/src/client/common/contract/identity-router.ts create mode 100644 packages/sdk/src/client/common/contract/safe.ts diff --git a/packages/cli/src/commands/compute/app/start.ts b/packages/cli/src/commands/compute/app/start.ts index 78d0ddf8..4aaa6c23 100644 --- a/packages/cli/src/commands/compute/app/start.ts +++ b/packages/cli/src/commands/compute/app/start.ts @@ -10,8 +10,10 @@ 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 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)"; @@ -36,17 +38,11 @@ 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; - - // Get private key for gas estimation const privateKey = flags["private-key"] || (await getPrivateKeyInteractive(environment)); - // Resolve app ID (prompt if not provided) const appId = await getOrPromptAppID({ appID: args["app-id"], environment: flags["environment"]!, @@ -55,14 +51,14 @@ 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, @@ -71,7 +67,6 @@ export default class AppLifecycleStart extends Command { data: callData, }); - // 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`)); @@ -80,25 +75,40 @@ export default class AppLifecycleStart extends Command { 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 confirmed = await confirm(`This will cost up to ${finalTx.maxCostEth} ETH. 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, + }); + + 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..8b051e4a 100644 --- a/packages/cli/src/commands/compute/app/stop.ts +++ b/packages/cli/src/commands/compute/app/stop.ts @@ -10,8 +10,10 @@ 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 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)"; @@ -55,15 +57,20 @@ 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); + + // Estimate gas cost const estimate = await estimateTransactionGas({ publicClient, from: address, @@ -91,14 +98,36 @@ export default class AppLifecycleStop extends Command { } } - 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, + }); + + 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..c3ce74a7 100644 --- a/packages/cli/src/commands/compute/app/terminate.ts +++ b/packages/cli/src/commands/compute/app/terminate.ts @@ -10,8 +10,10 @@ 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 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"; @@ -37,17 +39,11 @@ 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; - - // Get private key for gas estimation const privateKey = flags["private-key"] || (await getPrivateKeyInteractive(environment)); - // Resolve app ID (prompt if not provided) const appId = await getOrPromptAppID({ appID: args["app-id"], environment: flags["environment"]!, @@ -56,14 +52,14 @@ 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, @@ -72,7 +68,6 @@ export default class AppLifecycleTerminate extends Command { data: callData, }); - // 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`)); @@ -81,7 +76,6 @@ export default class AppLifecycleTerminate extends Command { this.log(chalk.yellow(`Nonce override active — nonce: ${finalTx.nonce}`)); } - // Ask for confirmation unless forced if (!flags.force) { const costInfo = isMainnet(environmentConfig) ? ` (cost: up to ${finalTx.maxCostEth} ETH)` @@ -93,14 +87,32 @@ 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, + }); + + 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/utils/identityTransaction.ts b/packages/cli/src/utils/identityTransaction.ts new file mode 100644 index 00000000..169baabc --- /dev/null +++ b/packages/cli/src/utils/identityTransaction.ts @@ -0,0 +1,113 @@ +/** + * 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; +}): 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, + }); +} + +/** + * 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/sdk/src/client/common/contract/identity-router.ts b/packages/sdk/src/client/common/contract/identity-router.ts new file mode 100644 index 00000000..5af9c904 --- /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; +} + +/** + * 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 delaySeconds = parseDelayToSeconds(identity.delay); + const delayLabel = identity.delay || "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, run:`, + ` ecloud compute app upgrade execute `, + ]; + + 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, run:`, + ` ecloud compute app upgrade execute `, + ]; + } +} 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/index.ts b/packages/sdk/src/client/index.ts index 57efde5b..08e44bec 100644 --- a/packages/sdk/src/client/index.ts +++ b/packages/sdk/src/client/index.ts @@ -134,6 +134,22 @@ export { type DiscoveredTimelock, } 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, From a8510ac713329e5060e4a63930fc6b12fd058acd Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Wed, 15 Apr 2026 13:38:37 -0700 Subject: [PATCH 21/41] refactor: remove per-operation timelock commands, add identity routing to upgrade/transfer/grant --- .../compute/app/ownership/execute-transfer.ts | 86 ---- .../app/ownership/schedule-transfer.ts | 98 ---- .../compute/app/ownership/transfer.ts | 82 +++- .../commands/compute/app/terminate/execute.ts | 78 --- .../compute/app/terminate/schedule.ts | 90 ---- .../cli/src/commands/compute/app/upgrade.ts | 84 ++-- .../commands/compute/app/upgrade/cancel.ts | 65 --- .../commands/compute/app/upgrade/execute.ts | 97 ---- .../commands/compute/app/upgrade/schedule.ts | 198 -------- .../compute/team/grant-admin/execute.ts | 89 ---- .../compute/team/grant-admin/schedule.ts | 101 ---- .../cli/src/commands/compute/team/grant.ts | 107 ++++- .../src/client/common/abis/AppController.json | 438 +---------------- .../src/client/common/contract/caller.test.ts | 163 +------ .../sdk/src/client/common/contract/caller.ts | 443 +----------------- .../client/common/contract/identity-router.ts | 6 +- packages/sdk/src/client/index.ts | 15 +- .../src/client/modules/compute/app/index.ts | 240 +--------- .../src/client/modules/compute/app/upgrade.ts | 176 ------- .../sdk/src/client/modules/compute/index.ts | 2 +- 20 files changed, 248 insertions(+), 2410 deletions(-) delete mode 100644 packages/cli/src/commands/compute/app/ownership/execute-transfer.ts delete mode 100644 packages/cli/src/commands/compute/app/ownership/schedule-transfer.ts delete mode 100644 packages/cli/src/commands/compute/app/terminate/execute.ts delete mode 100644 packages/cli/src/commands/compute/app/terminate/schedule.ts delete mode 100644 packages/cli/src/commands/compute/app/upgrade/cancel.ts delete mode 100644 packages/cli/src/commands/compute/app/upgrade/execute.ts delete mode 100644 packages/cli/src/commands/compute/app/upgrade/schedule.ts delete mode 100644 packages/cli/src/commands/compute/team/grant-admin/execute.ts delete mode 100644 packages/cli/src/commands/compute/team/grant-admin/schedule.ts diff --git a/packages/cli/src/commands/compute/app/ownership/execute-transfer.ts b/packages/cli/src/commands/compute/app/ownership/execute-transfer.ts deleted file mode 100644 index 857296f6..00000000 --- a/packages/cli/src/commands/compute/app/ownership/execute-transfer.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Command, Args, Flags } from "@oclif/core"; -import { getEnvironmentConfig, isMainnet } from "@layr-labs/ecloud-sdk"; -import { withTelemetry } from "../../../../telemetry"; -import { commonFlags } from "../../../../flags"; -import { createComputeClient } from "../../../../client"; -import { getOrPromptAppID, confirm } from "../../../../utils/prompts"; -import chalk from "chalk"; -import { isAddress } from "viem"; - -export default class AppOwnershipExecuteTransfer extends Command { - static description = "Execute a previously scheduled ownership transfer for a timelocked app once the delay has elapsed"; - - static args = { - "app-id": Args.string({ description: "App ID or name", required: false }), - }; - - static flags = { - ...commonFlags, - timelock: Flags.string({ - required: true, - description: "Timelock contract address that owns the app", - env: "ECLOUD_TIMELOCK_ADDRESS", - }), - to: Flags.string({ - required: true, - description: "New owner address (must match the scheduled transfer)", - env: "ECLOUD_NEW_OWNER", - }), - }; - - async run() { - return withTelemetry(this, async () => { - const { args, flags } = await this.parse(AppOwnershipExecuteTransfer); - 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 timelockAddress = flags.timelock; - if (!isAddress(timelockAddress)) this.error(`Invalid timelock address: ${timelockAddress}`); - - const newOwner = flags.to; - if (!isAddress(newOwner)) this.error(`Invalid new owner address: ${newOwner}`); - - const appId = await getOrPromptAppID({ appID: args["app-id"], environment, privateKey, rpcUrl, action: "execute transfer ownership" }); - - const timelocked = await compute.app.isTimelocked(appId); - if (!timelocked) { - this.error("This app is not timelocked. Use 'ecloud compute app ownership transfer' for direct ownership transfer."); - } - - const readyAt = await compute.app.getTimelockTransferOwnershipReadyAt(appId, timelockAddress, newOwner); - - if (readyAt === 0n) { - this.error("No ownership transfer is scheduled for this app. Run 'ecloud compute app ownership schedule-transfer' first."); - } - - const now = BigInt(Math.floor(Date.now() / 1000)); - if (now < readyAt) { - const remaining = readyAt - now; - const readyDate = new Date(Number(readyAt) * 1000).toLocaleString(); - this.error(`Transfer is not ready yet. Executable after ${chalk.bold(readyDate)} (${remaining}s remaining).`); - } - - const readyDate = new Date(Number(readyAt) * 1000).toLocaleString(); - this.log(`\nApp: ${chalk.bold(appId)}`); - this.log(`Timelock: ${chalk.bold(timelockAddress)}`); - this.log(`New owner: ${chalk.bold(newOwner)}`); - this.log(`Scheduled: ${chalk.bold(readyDate)}`); - - if (isMainnet(environmentConfig)) { - const confirmed = await confirm("Execute ownership transfer?"); - if (!confirmed) { - this.log(`\n${chalk.gray("Execution cancelled")}`); - return; - } - } - - const { tx } = await compute.app.executeTimelockTransferOwnership(appId, timelockAddress, newOwner, {}); - - this.log(`\n✅ ${chalk.green(`Ownership transferred successfully (tx: ${tx})`)}`); - }); - } -} diff --git a/packages/cli/src/commands/compute/app/ownership/schedule-transfer.ts b/packages/cli/src/commands/compute/app/ownership/schedule-transfer.ts deleted file mode 100644 index 622a20b8..00000000 --- a/packages/cli/src/commands/compute/app/ownership/schedule-transfer.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { Command, Args, Flags } from "@oclif/core"; -import { getEnvironmentConfig, isMainnet } from "@layr-labs/ecloud-sdk"; -import { withTelemetry } from "../../../../telemetry"; -import { commonFlags } from "../../../../flags"; -import { createComputeClient } from "../../../../client"; -import { getOrPromptAppID, confirm } from "../../../../utils/prompts"; -import chalk from "chalk"; -import { isAddress } from "viem"; - -function parseDurationToSeconds(input: string): bigint { - const match = input.match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)?$/i); - if (!match) throw new Error(`Invalid duration "${input}". Use format: 30s, 5m, 2h, 1d`); - const value = parseFloat(match[1]); - const unit = (match[2] || "s").toLowerCase(); - const multipliers: Record = { s: 1, m: 60, h: 3600, d: 86400 }; - return BigInt(Math.ceil(value * multipliers[unit])); -} - -export default class AppOwnershipScheduleTransfer extends Command { - static description = "Schedule an ownership transfer for a timelocked app through its Timelock"; - - static args = { - "app-id": Args.string({ description: "App ID or name", required: false }), - }; - - static flags = { - ...commonFlags, - timelock: Flags.string({ - required: true, - description: "Timelock contract address that owns the app", - env: "ECLOUD_TIMELOCK_ADDRESS", - }), - to: Flags.string({ - required: true, - description: "New owner address", - env: "ECLOUD_NEW_OWNER", - }), - after: Flags.string({ - required: true, - description: "Delay before transfer can execute (e.g. 30s, 5m, 2h, 1d)", - env: "ECLOUD_OP_DELAY", - }), - }; - - async run() { - return withTelemetry(this, async () => { - const { args, flags } = await this.parse(AppOwnershipScheduleTransfer); - 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 timelockAddress = flags.timelock; - if (!isAddress(timelockAddress)) this.error(`Invalid timelock address: ${timelockAddress}`); - - const newOwner = flags.to; - if (!isAddress(newOwner)) this.error(`Invalid new owner address: ${newOwner}`); - - let delaySeconds: bigint; - try { - delaySeconds = parseDurationToSeconds(flags.after); - } catch (e: any) { - this.error(e.message); - } - - const appId = await getOrPromptAppID({ appID: args["app-id"], environment, privateKey, rpcUrl, action: "schedule transfer ownership" }); - - const timelocked = await compute.app.isTimelocked(appId); - if (!timelocked) { - this.error("This app is not timelocked. Use 'ecloud compute app ownership transfer' for direct ownership transfer."); - } - - const readyAt = Math.floor(Date.now() / 1000) + Number(delaySeconds); - const readyDate = new Date(readyAt * 1000).toLocaleString(); - - this.log(`\nApp: ${chalk.bold(appId)}`); - this.log(`Timelock: ${chalk.bold(timelockAddress)}`); - this.log(`New owner: ${chalk.bold(newOwner)}`); - this.log(`Delay: ${chalk.bold(flags.after)} (executable after ${chalk.bold(readyDate)})`); - - if (isMainnet(environmentConfig)) { - const confirmed = await confirm("Schedule this ownership transfer?"); - if (!confirmed) { - this.log(`\n${chalk.gray("Scheduling cancelled")}`); - return; - } - } - - const { tx } = await compute.app.scheduleTimelockTransferOwnership(appId, timelockAddress, newOwner, delaySeconds, {}); - - this.log(`\n✅ ${chalk.green(`Ownership transfer scheduled (tx: ${tx})`)}`); - this.log(chalk.cyan(`\nExecutable after: ${chalk.bold(readyDate)}`)); - this.log(chalk.cyan(`Run to execute: ecloud compute app ownership execute-transfer --app=${appId} --timelock=${timelockAddress} --to=${newOwner}`)); - }); - } -} diff --git a/packages/cli/src/commands/compute/app/ownership/transfer.ts b/packages/cli/src/commands/compute/app/ownership/transfer.ts index e7ef6371..7fef9feb 100644 --- a/packages/cli/src/commands/compute/app/ownership/transfer.ts +++ b/packages/cli/src/commands/compute/app/ownership/transfer.ts @@ -1,10 +1,18 @@ import { Command, Args, Flags } from "@oclif/core"; -import { getEnvironmentConfig, isMainnet } from "@layr-labs/ecloud-sdk"; +import { + getEnvironmentConfig, + isMainnet, + estimateTransactionGas, + encodeTransferOwnershipData, +} from "@layr-labs/ecloud-sdk"; import { withTelemetry } from "../../../../telemetry"; -import { commonFlags } from "../../../../flags"; +import { commonFlags, applyTxOverrides } from "../../../../flags"; import { createComputeClient } from "../../../../client"; -import { getOrPromptAppID, confirm } from "../../../../utils/prompts"; +import { getOrPromptAppID, getPrivateKeyInteractive, confirm } from "../../../../utils/prompts"; +import { createViemClients } from "../../../../utils/viemClients"; +import { printIdentityContext, executeWithIdentity, printTransactionResult } from "../../../../utils/identityTransaction"; import { isAddress } from "viem"; +import type { Address } from "viem"; import chalk from "chalk"; export default class AppOwnershipTransfer extends Command { @@ -24,6 +32,10 @@ export default class AppOwnershipTransfer extends Command { 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, + }), }; async run() { @@ -34,7 +46,7 @@ export default class AppOwnershipTransfer extends Command { const environment = flags.environment; const environmentConfig = getEnvironmentConfig(environment); const rpcUrl = flags["rpc-url"] || environmentConfig.defaultRPCURL; - const privateKey = flags["private-key"]!; + const privateKey = flags["private-key"] || (await getPrivateKeyInteractive(environment)); const appId = await getOrPromptAppID({ appID: args["app-id"], @@ -49,16 +61,34 @@ export default class AppOwnershipTransfer extends Command { this.error(`Invalid address: ${newOwner}`); } - // Check current timelocked state - const timelocked = await compute.app.isTimelocked(appId); + 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)}`); - if (!timelocked) { - this.log(chalk.yellow("\nNote: if the new owner is a Timelock deployed by SafeTimelockFactory, timelocked mode will be enabled automatically.")); + + const callData = encodeTransferOwnershipData(appId, newOwner as Address); + const estimate = await estimateTransactionGas({ + publicClient, + from: address, + to: environmentConfig.appControllerAddress, + data: callData, + }); + + const finalTx = await applyTxOverrides(estimate, flags, { publicClient, address }); + 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)) { + 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")}`); @@ -66,16 +96,34 @@ export default class AppOwnershipTransfer extends Command { } } - const res = await compute.app.transferOwnership(appId, newOwner); + 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})`)}`); - 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, + }); - // 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. Upgrades now require:")); - this.log(chalk.cyan(" ecloud compute app upgrade schedule --app= --after=")); - this.log(chalk.cyan(" ecloud compute app upgrade execute --app=")); + 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/terminate/execute.ts b/packages/cli/src/commands/compute/app/terminate/execute.ts deleted file mode 100644 index 854668fc..00000000 --- a/packages/cli/src/commands/compute/app/terminate/execute.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { Command, Args, Flags } from "@oclif/core"; -import { getEnvironmentConfig, isMainnet } from "@layr-labs/ecloud-sdk"; -import { withTelemetry } from "../../../../telemetry"; -import { commonFlags } from "../../../../flags"; -import { createComputeClient } from "../../../../client"; -import { getOrPromptAppID, confirm } from "../../../../utils/prompts"; -import chalk from "chalk"; -import { isAddress } from "viem"; - -export default class AppTerminateExecute extends Command { - static description = "Execute a previously scheduled termination for a timelocked app once the delay has elapsed"; - - static args = { - "app-id": Args.string({ description: "App ID or name", required: false }), - }; - - static flags = { - ...commonFlags, - timelock: Flags.string({ - required: true, - description: "Timelock contract address that owns the app", - env: "ECLOUD_TIMELOCK_ADDRESS", - }), - }; - - async run() { - return withTelemetry(this, async () => { - const { args, flags } = await this.parse(AppTerminateExecute); - 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 timelockAddress = flags.timelock; - if (!isAddress(timelockAddress)) this.error(`Invalid timelock address: ${timelockAddress}`); - - const appId = await getOrPromptAppID({ appID: args["app-id"], environment, privateKey, rpcUrl, action: "execute terminate" }); - - const timelocked = await compute.app.isTimelocked(appId); - if (!timelocked) { - this.error("This app is not timelocked. Use 'ecloud compute app terminate' for direct termination."); - } - - const readyAt = await compute.app.getTimelockTerminateReadyAt(appId, timelockAddress); - - if (readyAt === 0n) { - this.error("No termination is scheduled for this app. Run 'ecloud compute app terminate schedule' first."); - } - - const now = BigInt(Math.floor(Date.now() / 1000)); - if (now < readyAt) { - const remaining = readyAt - now; - const readyDate = new Date(Number(readyAt) * 1000).toLocaleString(); - this.error(`Termination is not ready yet. Executable after ${chalk.bold(readyDate)} (${remaining}s remaining).`); - } - - const readyDate = new Date(Number(readyAt) * 1000).toLocaleString(); - this.log(`\nApp: ${chalk.bold(appId)}`); - this.log(`Timelock: ${chalk.bold(timelockAddress)}`); - this.log(`Scheduled: ${chalk.bold(readyDate)}`); - this.log(chalk.red("\n⚠️ This will permanently destroy the app.")); - - if (isMainnet(environmentConfig)) { - const confirmed = await confirm("Execute termination?"); - if (!confirmed) { - this.log(`\n${chalk.gray("Execution cancelled")}`); - return; - } - } - - const { tx } = await compute.app.executeTimelockTerminate(appId, timelockAddress, {}); - - this.log(`\n✅ ${chalk.green(`App terminated successfully (tx: ${tx})`)}`); - }); - } -} diff --git a/packages/cli/src/commands/compute/app/terminate/schedule.ts b/packages/cli/src/commands/compute/app/terminate/schedule.ts deleted file mode 100644 index 1473268b..00000000 --- a/packages/cli/src/commands/compute/app/terminate/schedule.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Command, Args, Flags } from "@oclif/core"; -import { getEnvironmentConfig, isMainnet } from "@layr-labs/ecloud-sdk"; -import { withTelemetry } from "../../../../telemetry"; -import { commonFlags } from "../../../../flags"; -import { createComputeClient } from "../../../../client"; -import { getOrPromptAppID, confirm } from "../../../../utils/prompts"; -import chalk from "chalk"; -import { isAddress } from "viem"; - -function parseDurationToSeconds(input: string): bigint { - const match = input.match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)?$/i); - if (!match) throw new Error(`Invalid duration "${input}". Use format: 30s, 5m, 2h, 1d`); - const value = parseFloat(match[1]); - const unit = (match[2] || "s").toLowerCase(); - const multipliers: Record = { s: 1, m: 60, h: 3600, d: 86400 }; - return BigInt(Math.ceil(value * multipliers[unit])); -} - -export default class AppTerminateSchedule extends Command { - static description = "Schedule termination of a timelocked app through its Timelock"; - - static args = { - "app-id": Args.string({ description: "App ID or name", required: false }), - }; - - static flags = { - ...commonFlags, - timelock: Flags.string({ - required: true, - description: "Timelock contract address that owns the app", - env: "ECLOUD_TIMELOCK_ADDRESS", - }), - after: Flags.string({ - required: true, - description: "Delay before termination can execute (e.g. 30s, 5m, 2h, 1d)", - env: "ECLOUD_OP_DELAY", - }), - }; - - async run() { - return withTelemetry(this, async () => { - const { args, flags } = await this.parse(AppTerminateSchedule); - 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 timelockAddress = flags.timelock; - if (!isAddress(timelockAddress)) this.error(`Invalid timelock address: ${timelockAddress}`); - - let delaySeconds: bigint; - try { - delaySeconds = parseDurationToSeconds(flags.after); - } catch (e: any) { - this.error(e.message); - } - - const appId = await getOrPromptAppID({ appID: args["app-id"], environment, privateKey, rpcUrl, action: "schedule terminate" }); - - const timelocked = await compute.app.isTimelocked(appId); - if (!timelocked) { - this.error("This app is not timelocked. Use 'ecloud compute app terminate' for direct termination."); - } - - const readyAt = Math.floor(Date.now() / 1000) + Number(delaySeconds); - const readyDate = new Date(readyAt * 1000).toLocaleString(); - - this.log(`\nApp: ${chalk.bold(appId)}`); - this.log(`Timelock: ${chalk.bold(timelockAddress)}`); - this.log(`Delay: ${chalk.bold(flags.after)} (executable after ${chalk.bold(readyDate)})`); - this.log(chalk.red("\n⚠️ This will permanently destroy the app once executed.")); - - if (isMainnet(environmentConfig)) { - const confirmed = await confirm("Schedule this termination?"); - if (!confirmed) { - this.log(`\n${chalk.gray("Scheduling cancelled")}`); - return; - } - } - - const { tx } = await compute.app.scheduleTimelockTerminate(appId, timelockAddress, delaySeconds, {}); - - this.log(`\n✅ ${chalk.green(`Termination scheduled (tx: ${tx})`)}`); - this.log(chalk.cyan(`\nExecutable after: ${chalk.bold(readyDate)}`)); - this.log(chalk.cyan(`Run to execute: ecloud compute app terminate execute --app=${appId} --timelock=${timelockAddress}`)); - }); - } -} diff --git a/packages/cli/src/commands/compute/app/upgrade.ts b/packages/cli/src/commands/compute/app/upgrade.ts index 542cd407..34c0e526 100644 --- a/packages/cli/src/commands/compute/app/upgrade.ts +++ b/packages/cli/src/commands/compute/app/upgrade.ts @@ -4,6 +4,8 @@ import { withTelemetry } from "../../../telemetry"; import { commonFlags, applyTxOverrides } from "../../../flags"; import { createBuildClient, createComputeClient } from "../../../client"; import { createViemClients } from "../../../utils/viemClients"; +import { printIdentityContext, executeWithIdentity, printTransactionResult } from "../../../utils/identityTransaction"; +import type { Address } from "viem"; import { getDockerfileInteractive, getImageReferenceInteractive, @@ -146,16 +148,13 @@ export default class AppUpgrade extends Command { action: "upgrade", }); - // Check timelocked mode — timelocked apps cannot be directly upgraded - const timelocked = await compute.app.isTimelocked(appID); - if (timelocked) { - this.error( - `App ${appID} is timelocked (Timelock owner).\n` + - `Use the two-step timelocked flow instead:\n` + - ` ecloud compute app upgrade schedule --app=${appID} --after=\n` + - ` ecloud compute app upgrade execute --app=${appID}`, - ); - } + // 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; @@ -314,11 +313,6 @@ 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( @@ -388,7 +382,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(chalk.yellow(`\nGas override active — max fee: ${flags["max-fee-per-gas"] || "estimated"} gwei, priority fee: ${flags["max-priority-fee"] || "estimated"} gwei`)); @@ -398,7 +392,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`)}`); @@ -406,26 +400,50 @@ export default class AppUpgrade extends Command { } } - // 11. Execute the upgrade - const res = await compute.app.executeUpgrade(prepared, finalTx); + if (identity.type === "eoa") { + // 11a. EOA: execute the EIP-7702 batch directly + const res = await compute.app.executeUpgrade(prepared, finalTx); - // 12. Watch until upgrade completes - await compute.app.watchUpgrade(res.appId); + // 12. Watch until upgrade completes + await compute.app.watchUpgrade(res.appId); - 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}`); - } + 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}`); + } - this.log( - `\n✅ ${chalk.green(`App upgraded successfully ${chalk.bold(`(id: ${res.appId}, image: ${res.imageRef})`)}`)}`, - ); + this.log( + `\n✅ ${chalk.green(`App upgraded successfully ${chalk.bold(`(id: ${res.appId}, image: ${res.imageRef})`)}`)}`, + ); - // Show dashboard link - const dashboardUrl = getDashboardUrl(environment, res.appId); - this.log(`\n${chalk.gray("View your app:")} ${chalk.blue.underline(dashboardUrl)}`); + // Show dashboard link + const dashboardUrl = getDashboardUrl(environment, res.appId); + this.log(`\n${chalk.gray("View your app:")} ${chalk.blue.underline(dashboardUrl)}`); + } 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, + }); + + 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/app/upgrade/cancel.ts b/packages/cli/src/commands/compute/app/upgrade/cancel.ts deleted file mode 100644 index cd30b333..00000000 --- a/packages/cli/src/commands/compute/app/upgrade/cancel.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Command, Args } from "@oclif/core"; -import { getEnvironmentConfig, isMainnet } from "@layr-labs/ecloud-sdk"; -import { withTelemetry } from "../../../../telemetry"; -import { commonFlags } from "../../../../flags"; -import { createComputeClient } from "../../../../client"; -import { getOrPromptAppID, confirm } from "../../../../utils/prompts"; -import chalk from "chalk"; - -export default class AppUpgradeCancel extends Command { - static description = "Cancel a pending scheduled upgrade for a timelocked app"; - - static args = { - "app-id": Args.string({ - description: "App ID", - required: false, - }), - }; - - static flags = { ...commonFlags }; - - async run() { - return withTelemetry(this, async () => { - const { args, flags } = await this.parse(AppUpgradeCancel); - 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: args["app-id"], - environment, - privateKey, - rpcUrl, - action: "cancel upgrade", - }); - - const timelocked = await compute.app.isTimelocked(appID); - if (!timelocked) { - this.error("This app is not timelocked. Only timelocked apps have scheduled upgrades."); - } - - const pending = await compute.app.getPendingUpgrade(appID); - if (pending.readyAt === 0n) { - this.error("No upgrade is scheduled for this app."); - } - - const readyDate = new Date(Number(pending.readyAt) * 1000).toLocaleString(); - this.log(`\nApp: ${chalk.bold(appID)}`); - this.log(`Scheduled: ${chalk.bold(readyDate)}`); - - if (isMainnet(environmentConfig)) { - const confirmed = await confirm("Cancel this scheduled upgrade?"); - if (!confirmed) { - this.log(`\n${chalk.gray("Cancellation aborted")}`); - return; - } - } - - const res = await compute.app.cancelUpgrade(appID); - this.log(`\n✅ ${chalk.green(`Scheduled upgrade cancelled (tx: ${res.tx})`)}`); - }); - } -} diff --git a/packages/cli/src/commands/compute/app/upgrade/execute.ts b/packages/cli/src/commands/compute/app/upgrade/execute.ts deleted file mode 100644 index 486c83f2..00000000 --- a/packages/cli/src/commands/compute/app/upgrade/execute.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Command, Args } from "@oclif/core"; -import { getEnvironmentConfig, isMainnet } from "@layr-labs/ecloud-sdk"; -import { withTelemetry } from "../../../../telemetry"; -import { commonFlags } from "../../../../flags"; -import { createComputeClient } from "../../../../client"; -import { createViemClients } from "../../../../utils/viemClients"; -import { getOrPromptAppID, confirm } from "../../../../utils/prompts"; -import chalk from "chalk"; -import { executeGovernedUpgrade } from "@layr-labs/ecloud-sdk"; -import { setLinkedAppForDirectory } from "../../../../utils/globalConfig"; -import { getDashboardUrl } from "../../../../utils/dashboard"; - -export default class AppUpgradeExecute extends Command { - static description = "Execute a previously scheduled upgrade for a timelocked app once the delay has elapsed"; - - static args = { - "app-id": Args.string({ - description: "App ID or name", - required: false, - }), - }; - - static flags = { ...commonFlags }; - - async run() { - return withTelemetry(this, async () => { - const { args, flags } = await this.parse(AppUpgradeExecute); - 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: args["app-id"], - environment, - privateKey, - rpcUrl, - action: "execute upgrade", - }); - - // Verify timelocked mode - const timelocked = await compute.app.isTimelocked(appID); - if (!timelocked) { - this.error("This app is not timelocked. Use 'ecloud compute app upgrade' for direct upgrades."); - } - - // Check scheduled upgrade status - const pending = await compute.app.getPendingUpgrade(appID); - if (pending.readyAt === 0n) { - this.error("No upgrade is scheduled for this app. Run 'ecloud compute app upgrade schedule' first."); - } - - const now = BigInt(Math.floor(Date.now() / 1000)); - if (now < pending.readyAt) { - const remaining = pending.readyAt - now; - const readyDate = new Date(Number(pending.readyAt) * 1000).toLocaleString(); - this.error(`Upgrade is not ready yet. Executable after ${chalk.bold(readyDate)} (${remaining}s remaining).`); - } - - this.log(chalk.cyan(`\nScheduled upgrade is ready. Fetching release from chain...`)); - - if (isMainnet(environmentConfig)) { - const confirmed = await confirm("Execute the scheduled upgrade?"); - if (!confirmed) { - this.log(`\n${chalk.gray("Execution cancelled")}`); - return; - } - } - - const { walletClient, publicClient } = createViemClients({ privateKey, rpcUrl, environment }); - - const res = await executeGovernedUpgrade( - { - appId: appID, - walletClient, - publicClient, - environment, - skipTelemetry: true, - }, - ); - - try { - const cwd = process.env.INIT_CWD || process.cwd(); - setLinkedAppForDirectory(environment, cwd, res.appId); - } catch {} - - this.log( - `\n✅ ${chalk.green(`App upgraded successfully ${chalk.bold(`(id: ${res.appId}, image: ${res.imageRef})`)}`)}`, - ); - - const dashboardUrl = getDashboardUrl(environment, res.appId); - this.log(`\n${chalk.gray("View your app:")} ${chalk.blue.underline(dashboardUrl)}`); - }); - } -} diff --git a/packages/cli/src/commands/compute/app/upgrade/schedule.ts b/packages/cli/src/commands/compute/app/upgrade/schedule.ts deleted file mode 100644 index 43f696b0..00000000 --- a/packages/cli/src/commands/compute/app/upgrade/schedule.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { Command, Args, Flags } from "@oclif/core"; -import { getEnvironmentConfig, isMainnet } from "@layr-labs/ecloud-sdk"; -import { withTelemetry } from "../../../../telemetry"; -import { commonFlags } from "../../../../flags"; -import { createComputeClient } from "../../../../client"; -import { createViemClients } from "../../../../utils/viemClients"; -import { - getDockerfileInteractive, - getImageReferenceInteractive, - getEnvFileInteractive, - getInstanceTypeInteractive, - getLogSettingsInteractive, - getResourceUsageMonitoringInteractive, - getOrPromptAppID, - LogVisibility, - ResourceUsageMonitoring, - confirm, -} from "../../../../utils/prompts"; -import chalk from "chalk"; -import { UserApiClient } from "@layr-labs/ecloud-sdk"; -import { getClientId } from "../../../../utils/version"; -import { scheduleUpgrade } from "@layr-labs/ecloud-sdk"; - -/** - * Parse a human-readable duration string into seconds. - * Supported: 30s, 5m, 2h, 1d - */ -function parseDurationToSeconds(input: string): bigint { - const match = input.match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)?$/i); - if (!match) { - throw new Error(`Invalid duration "${input}". Use format: 30s, 5m, 2h, 1d`); - } - const value = parseFloat(match[1]); - const unit = (match[2] || "s").toLowerCase(); - const multipliers: Record = { s: 1, m: 60, h: 3600, d: 86400 }; - return BigInt(Math.ceil(value * multipliers[unit])); -} - -export default class AppUpgradeSchedule extends Command { - static description = "Schedule an upgrade for a timelocked app. The upgrade becomes executable after the specified delay."; - - static args = { - "app-id": Args.string({ - description: "App ID or name to upgrade", - required: false, - }), - }; - - static flags = { - ...commonFlags, - after: Flags.string({ - required: true, - description: "Delay before upgrade can execute (e.g. 30s, 5m, 2h, 1d)", - env: "ECLOUD_UPGRADE_DELAY", - }), - dockerfile: Flags.string({ - required: false, - description: "Path to Dockerfile", - env: "ECLOUD_DOCKERFILE_PATH", - }), - "image-ref": Flags.string({ - required: false, - description: "Image reference pointing to registry", - env: "ECLOUD_IMAGE_REF", - }), - "env-file": Flags.string({ - required: false, - description: 'Environment file to use (default: ".env")', - default: ".env", - env: "ECLOUD_ENVFILE_PATH", - }), - "log-visibility": Flags.string({ - required: false, - description: "Log visibility setting: public, private, or off", - options: ["public", "private", "off"], - env: "ECLOUD_LOG_VISIBILITY", - }), - "instance-type": Flags.string({ - required: false, - description: "Machine instance type", - env: "ECLOUD_INSTANCE_TYPE", - }), - "resource-usage-monitoring": Flags.string({ - required: false, - description: "Resource usage monitoring: enable or disable", - options: ["enable", "disable"], - env: "ECLOUD_RESOURCE_USAGE_MONITORING", - }), - }; - - async run() { - return withTelemetry(this, async () => { - const { args, flags } = await this.parse(AppUpgradeSchedule); - 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"]!; - - // Parse delay - let delaySeconds: bigint; - try { - delaySeconds = parseDurationToSeconds(flags.after); - } catch (e: any) { - this.error(e.message); - } - - // Resolve app ID - const appID = await getOrPromptAppID({ - appID: args["app-id"], - environment, - privateKey, - rpcUrl, - action: "schedule upgrade", - }); - - // Verify timelocked mode - const timelocked = await compute.app.isTimelocked(appID); - if (!timelocked) { - this.error( - "This app is not timelocked. Use 'ecloud compute app upgrade' for direct upgrades, or transfer ownership to a Timelock first.", - ); - } - - // Collect build inputs - const dockerfilePath = await getDockerfileInteractive(flags.dockerfile); - const buildFromDockerfile = dockerfilePath !== ""; - const imageRef = await getImageReferenceInteractive(flags["image-ref"], buildFromDockerfile); - const envFilePath = await getEnvFileInteractive(flags["env-file"]); - - // Instance type - const { publicClient, walletClient } = createViemClients({ privateKey, rpcUrl, environment }); - let currentInstanceType = ""; - try { - const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { clientId: getClientId() }); - const infos = await userApiClient.getInfos([appID], 1); - if (infos.length > 0) currentInstanceType = infos[0].machineType || ""; - } catch { /* best-effort */ } - - const availableTypes = await fetchAvailableInstanceTypes(environmentConfig, walletClient, publicClient); - const instanceType = await getInstanceTypeInteractive(flags["instance-type"], currentInstanceType, availableTypes); - - const logSettings = await getLogSettingsInteractive(flags["log-visibility"] as LogVisibility | undefined); - const resourceUsageMonitoring = await getResourceUsageMonitoringInteractive( - flags["resource-usage-monitoring"] as ResourceUsageMonitoring | undefined, - ); - const logVisibility = logSettings.publicLogs ? "public" : logSettings.logRedirect ? "private" : "off"; - - const readyAt = Math.floor(Date.now() / 1000) + Number(delaySeconds); - const readyDate = new Date(readyAt * 1000).toLocaleString(); - - this.log(`\nApp: ${chalk.bold(appID)}`); - this.log(`Delay: ${chalk.bold(flags.after)} (executable after ${chalk.bold(readyDate)})`); - this.log(`Image: ${chalk.bold(imageRef || dockerfilePath)}`); - - if (isMainnet(environmentConfig)) { - const confirmed = await confirm("Schedule this upgrade?"); - if (!confirmed) { - this.log(`\n${chalk.gray("Upgrade scheduling cancelled")}`); - return; - } - } - - const res = await scheduleUpgrade( - { - appId: appID, - walletClient, - publicClient, - environment, - dockerfilePath, - imageRef, - envFilePath, - instanceType, - logVisibility: logVisibility as LogVisibility, - resourceUsageMonitoring: resourceUsageMonitoring as ResourceUsageMonitoring, - delaySeconds, - skipTelemetry: true, - }, - ); - - this.log( - `\n✅ ${chalk.green(`Upgrade scheduled (tx: ${res.txHash})`)}`, - ); - this.log(chalk.cyan(`\nExecutable after: ${chalk.bold(readyDate)}`)); - this.log(chalk.cyan(`Run to execute: ecloud compute app upgrade execute --app=${appID}`)); - }); - } -} - -async function fetchAvailableInstanceTypes(environmentConfig: any, walletClient: any, publicClient: any) { - try { - const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { clientId: getClientId() }); - const skuList = await userApiClient.getSKUs(); - if (skuList.skus.length > 0) return skuList.skus; - } catch {} - return [{ sku: "g1-standard-4t", description: "4 vCPUs, 16 GB memory, TDX" }]; -} diff --git a/packages/cli/src/commands/compute/team/grant-admin/execute.ts b/packages/cli/src/commands/compute/team/grant-admin/execute.ts deleted file mode 100644 index 691858e1..00000000 --- a/packages/cli/src/commands/compute/team/grant-admin/execute.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Command, Args, Flags } from "@oclif/core"; -import { getEnvironmentConfig, isMainnet } from "@layr-labs/ecloud-sdk"; -import { withTelemetry } from "../../../../telemetry"; -import { commonFlags } from "../../../../flags"; -import { createComputeClient } from "../../../../client"; -import { getOrPromptAppID, confirm } from "../../../../utils/prompts"; -import chalk from "chalk"; -import { isAddress } from "viem"; - -export default class TeamGrantAdminExecute extends Command { - static description = "Execute a previously scheduled grantTeamRole(ADMIN) operation once the Timelock delay has elapsed"; - - static args = { - address: Args.string({ - description: "Address to grant the ADMIN role to (must match the scheduled operation)", - required: true, - }), - }; - - static flags = { - ...commonFlags, - app: Flags.string({ - required: false, - description: "App ID (used to look up the team owner)", - env: "ECLOUD_APP_ID", - }), - timelock: Flags.string({ - required: true, - description: "Timelock contract address that owns the app", - env: "ECLOUD_TIMELOCK_ADDRESS", - }), - }; - - async run() { - return withTelemetry(this, async () => { - const { args, flags } = await this.parse(TeamGrantAdminExecute); - 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 account = args.address; - if (!isAddress(account)) this.error(`Invalid address: ${account}`); - - const timelockAddress = flags.timelock; - if (!isAddress(timelockAddress)) this.error(`Invalid timelock address: ${timelockAddress}`); - - const appId = await getOrPromptAppID({ appID: flags.app, environment, privateKey, rpcUrl, action: "execute grant ADMIN" }); - - const timelocked = await compute.app.isTimelocked(appId); - if (!timelocked) { - this.error("This app is not timelocked. Use 'ecloud compute team grant' for direct role grants."); - } - - const readyAt = await compute.app.getTimelockGrantAdminReadyAt(appId, timelockAddress, account); - - if (readyAt === 0n) { - this.error("No ADMIN grant is scheduled for this address. Run 'ecloud compute team grant-admin schedule' first."); - } - - const now = BigInt(Math.floor(Date.now() / 1000)); - if (now < readyAt) { - const remaining = readyAt - now; - const readyDate = new Date(Number(readyAt) * 1000).toLocaleString(); - this.error(`ADMIN grant is not ready yet. Executable after ${chalk.bold(readyDate)} (${remaining}s remaining).`); - } - - const readyDate = new Date(Number(readyAt) * 1000).toLocaleString(); - this.log(`\nApp: ${chalk.bold(appId)}`); - this.log(`Timelock: ${chalk.bold(timelockAddress)}`); - this.log(`Grant: ${chalk.bold("ADMIN")} → ${chalk.bold(account)}`); - this.log(`Scheduled: ${chalk.bold(readyDate)}`); - - if (isMainnet(environmentConfig)) { - const confirmed = await confirm("Execute ADMIN grant?"); - if (!confirmed) { - this.log(`\n${chalk.gray("Execution cancelled")}`); - return; - } - } - - const { tx } = await compute.app.executeTimelockGrantAdmin(appId, timelockAddress, account, {}); - - this.log(`\n✅ ${chalk.green(`ADMIN role granted to ${account} (tx: ${tx})`)}`); - }); - } -} diff --git a/packages/cli/src/commands/compute/team/grant-admin/schedule.ts b/packages/cli/src/commands/compute/team/grant-admin/schedule.ts deleted file mode 100644 index 2c6a5fea..00000000 --- a/packages/cli/src/commands/compute/team/grant-admin/schedule.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { Command, Args, Flags } from "@oclif/core"; -import { getEnvironmentConfig, isMainnet } from "@layr-labs/ecloud-sdk"; -import { withTelemetry } from "../../../../telemetry"; -import { commonFlags } from "../../../../flags"; -import { createComputeClient } from "../../../../client"; -import { getOrPromptAppID, confirm } from "../../../../utils/prompts"; -import chalk from "chalk"; -import { isAddress } from "viem"; - -function parseDurationToSeconds(input: string): bigint { - const match = input.match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)?$/i); - if (!match) throw new Error(`Invalid duration "${input}". Use format: 30s, 5m, 2h, 1d`); - const value = parseFloat(match[1]); - const unit = (match[2] || "s").toLowerCase(); - const multipliers: Record = { s: 1, m: 60, h: 3600, d: 86400 }; - return BigInt(Math.ceil(value * multipliers[unit])); -} - -export default class TeamGrantAdminSchedule extends Command { - static description = "Schedule a grantTeamRole(ADMIN) operation for a timelocked app through its Timelock"; - - static args = { - address: Args.string({ - description: "Address to grant the ADMIN role to", - required: true, - }), - }; - - static flags = { - ...commonFlags, - app: Flags.string({ - required: false, - description: "App ID (used to look up the team owner)", - env: "ECLOUD_APP_ID", - }), - timelock: Flags.string({ - required: true, - description: "Timelock contract address that owns the app", - env: "ECLOUD_TIMELOCK_ADDRESS", - }), - after: Flags.string({ - required: true, - description: "Delay before operation can execute (e.g. 30s, 5m, 2h, 1d)", - env: "ECLOUD_OP_DELAY", - }), - }; - - async run() { - return withTelemetry(this, async () => { - const { args, flags } = await this.parse(TeamGrantAdminSchedule); - 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 account = args.address; - if (!isAddress(account)) this.error(`Invalid address: ${account}`); - - const timelockAddress = flags.timelock; - if (!isAddress(timelockAddress)) this.error(`Invalid timelock address: ${timelockAddress}`); - - let delaySeconds: bigint; - try { - delaySeconds = parseDurationToSeconds(flags.after); - } catch (e: any) { - this.error(e.message); - } - - const appId = await getOrPromptAppID({ appID: flags.app, environment, privateKey, rpcUrl, action: "schedule grant ADMIN" }); - - const timelocked = await compute.app.isTimelocked(appId); - if (!timelocked) { - this.error("This app is not timelocked. Use 'ecloud compute team grant' for direct role grants."); - } - - const readyAt = Math.floor(Date.now() / 1000) + Number(delaySeconds); - const readyDate = new Date(readyAt * 1000).toLocaleString(); - - this.log(`\nApp: ${chalk.bold(appId)}`); - this.log(`Timelock: ${chalk.bold(timelockAddress)}`); - this.log(`Grant: ${chalk.bold("ADMIN")} → ${chalk.bold(account)}`); - this.log(`Delay: ${chalk.bold(flags.after)} (executable after ${chalk.bold(readyDate)})`); - - if (isMainnet(environmentConfig)) { - const confirmed = await confirm("Schedule this ADMIN grant?"); - if (!confirmed) { - this.log(`\n${chalk.gray("Scheduling cancelled")}`); - return; - } - } - - const { tx } = await compute.app.scheduleTimelockGrantAdmin(appId, timelockAddress, account, delaySeconds, {}); - - this.log(`\n✅ ${chalk.green(`ADMIN grant scheduled (tx: ${tx})`)}`); - this.log(chalk.cyan(`\nExecutable after: ${chalk.bold(readyDate)}`)); - this.log(chalk.cyan(`Run to execute: ecloud compute team grant-admin execute ${account} --app=${appId} --timelock=${timelockAddress}`)); - }); - } -} diff --git a/packages/cli/src/commands/compute/team/grant.ts b/packages/cli/src/commands/compute/team/grant.ts index 94033e88..24e02e6f 100644 --- a/packages/cli/src/commands/compute/team/grant.ts +++ b/packages/cli/src/commands/compute/team/grant.ts @@ -1,17 +1,27 @@ import { Command, Args, Flags } from "@oclif/core"; -import { getEnvironmentConfig, isMainnet, TeamRole } from "@layr-labs/ecloud-sdk"; +import { + getEnvironmentConfig, + isMainnet, + TeamRole, + estimateTransactionGas, + encodeGrantTeamRoleData, + getAppOwner, +} from "@layr-labs/ecloud-sdk"; import { withTelemetry } from "../../../telemetry"; -import { commonFlags } from "../../../flags"; +import { commonFlags, applyTxOverrides } from "../../../flags"; import { createComputeClient } from "../../../client"; -import { getOrPromptAppID, confirm } from "../../../utils/prompts"; +import { getOrPromptAppID, getPrivateKeyInteractive, confirm } from "../../../utils/prompts"; +import { createViemClients } from "../../../utils/viemClients"; +import { printIdentityContext, executeWithIdentity, printTransactionResult } from "../../../utils/identityTransaction"; import { isAddress } from "viem"; +import type { Address } from "viem"; import chalk from "chalk"; -const ROLE_CHOICES = ["PAUSER", "DEVELOPER"] as const; +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 (PAUSER or DEVELOPER) to an address for an app's team"; + static description = "Grant a team role (ADMIN, PAUSER, or DEVELOPER) to an address for an app's team"; static args = { address: Args.string({ @@ -29,21 +39,24 @@ export default class TeamGrant extends Command { }), role: Flags.string({ required: true, - description: "Role to grant: PAUSER or DEVELOPER", + 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, + }), }; async run() { return withTelemetry(this, async () => { const { args, flags } = await this.parse(TeamGrant); - 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 privateKey = flags["private-key"] || (await getPrivateKeyInteractive(environment)); const account = args.address; if (!isAddress(account)) { @@ -59,20 +72,82 @@ export default class TeamGrant extends Command { }); const role = TeamRole[flags.role as RoleChoice]; + const isAdminRole = flags.role === "ADMIN"; this.log(`\nApp: ${chalk.bold(appID)}`); this.log(`Grant: ${chalk.bold(flags.role)} → ${chalk.bold(account)}`); - if (isMainnet(environmentConfig)) { - const confirmed = await confirm("Grant this role?"); - if (!confirmed) { - this.log(`\n${chalk.gray("Cancelled")}`); - return; + if (isAdminRole) { + // ADMIN is a sensitive op — route through identity + const { publicClient, walletClient, address } = createViemClients({ + privateKey, + rpcUrl, + environment, + }); + + const identity = printIdentityContext(environment, address, this.log.bind(this)); + + if (identity.type !== "eoa") { + this.log(chalk.yellow(`\nNote: ADMIN role grant will be routed through ${identity.type}.`)); + } + + if ((isMainnet(environmentConfig) || identity.type !== "eoa") && !flags.force) { + const confirmed = await confirm("Grant this ADMIN role?"); + if (!confirmed) { + this.log(`\n${chalk.gray("Cancelled")}`); + return; + } } - } - const res = await compute.app.grantTeamRole(appID, role, account); - this.log(`\n✅ ${chalk.green(`${flags.role} role granted to ${account} (tx: ${res.tx})`)}`); + 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 { + // Look up the team address (owner) for the app + const team = await getAppOwner(publicClient, environmentConfig, appID as Address); + const callData = encodeGrantTeamRoleData(team, role, account as Address); + const estimate = await estimateTransactionGas({ + publicClient, + from: address, + to: environmentConfig.appControllerAddress, + data: callData, + }); + const finalTx = await applyTxOverrides(estimate, flags, { publicClient, address }); + + const result = await executeWithIdentity({ + environment, + eoaAddress: address, + walletClient, + publicClient, + environmentConfig, + to: environmentConfig.appControllerAddress as Address, + data: callData, + pendingMessage: `Granting ADMIN role to ${account}...`, + txDescription: "GrantTeamRole", + gas: finalTx, + }); + + this.log(""); + printTransactionResult(result, this.log.bind(this)); + if (result.type === "direct") { + this.log(`\n✅ ${chalk.green(`${flags.role} role granted to ${account}`)}`); + } + } + } else { + // PAUSER / DEVELOPER — direct grant (not a sensitive op) + if (isMainnet(environmentConfig) && !flags.force) { + const confirmed = await confirm("Grant this role?"); + if (!confirmed) { + this.log(`\n${chalk.gray("Cancelled")}`); + return; + } + } + + 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})`)}`); + } }); } } diff --git a/packages/sdk/src/client/common/abis/AppController.json b/packages/sdk/src/client/common/abis/AppController.json index fdf07872..8bb61548 100644 --- a/packages/sdk/src/client/common/abis/AppController.json +++ b/packages/sdk/src/client/common/abis/AppController.json @@ -127,19 +127,6 @@ ], "stateMutability": "view" }, - { - "type": "function", - "name": "cancelUpgrade", - "inputs": [ - { - "name": "app", - "type": "address", - "internalType": "contract IApp" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, { "type": "function", "name": "computeAVSRegistrar", @@ -301,71 +288,6 @@ ], "stateMutability": "nonpayable" }, - { - "type": "function", - "name": "createAppWithIsolatedBilling", - "inputs": [ - { - "name": "salt", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "release", - "type": "tuple", - "internalType": "structIAppController.Release", - "components": [ - { - "name": "rmsRelease", - "type": "tuple", - "internalType": "structIReleaseManagerTypes.Release", - "components": [ - { - "name": "artifacts", - "type": "tuple[]", - "internalType": "structIReleaseManagerTypes.Artifact[]", - "components": [ - { - "name": "digest", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "registry", - "type": "string", - "internalType": "string" - } - ] - }, - { - "name": "upgradeByTime", - "type": "uint32", - "internalType": "uint32" - } - ] - }, - { - "name": "publicEnv", - "type": "bytes", - "internalType": "bytes" - }, - { - "name": "encryptedEnv", - "type": "bytes", - "internalType": "bytes" - } - ] - } - ], - "outputs": [ - { - "name": "app", - "type": "address", - "internalType": "contractIApp" - } - ], - "stateMutability": "nonpayable" - }, { "type": "function", "name": "domainSeparator", @@ -379,71 +301,6 @@ ], "stateMutability": "view" }, - { - "type": "function", - "name": "executeUpgrade", - "inputs": [ - { - "name": "app", - "type": "address", - "internalType": "contract IApp" - }, - { - "name": "release", - "type": "tuple", - "internalType": "struct IAppController.Release", - "components": [ - { - "name": "rmsRelease", - "type": "tuple", - "internalType": "struct IReleaseManagerTypes.Release", - "components": [ - { - "name": "artifacts", - "type": "tuple[]", - "internalType": "struct IReleaseManagerTypes.Artifact[]", - "components": [ - { - "name": "digest", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "registry", - "type": "string", - "internalType": "string" - } - ] - }, - { - "name": "upgradeByTime", - "type": "uint32", - "internalType": "uint32" - } - ] - }, - { - "name": "publicEnv", - "type": "bytes", - "internalType": "bytes" - }, - { - "name": "encryptedEnv", - "type": "bytes", - "internalType": "bytes" - } - ] - } - ], - "outputs": [ - { - "name": "", - "type": "uint256", - "internalType": "uint256" - } - ], - "stateMutability": "nonpayable" - }, { "type": "function", "name": "getActiveAppCount", @@ -463,44 +320,6 @@ ], "stateMutability": "view" }, - { - "type": "function", - "name": "getBillingType", - "inputs": [ - { - "name": "app", - "type": "address", - "internalType": "address" - } - ], - "outputs": [ - { - "name": "", - "type": "uint8", - "internalType": "uint8" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "getAppCreator", - "inputs": [ - { - "name": "app", - "type": "address", - "internalType": "contractIApp" - } - ], - "outputs": [ - { - "name": "", - "type": "address", - "internalType": "address" - } - ], - "stateMutability": "view" - }, { "type": "function", "name": "getAppLatestReleaseBlockNumber", @@ -652,62 +471,6 @@ ], "stateMutability": "view" }, - { - "type": "function", - "name": "getAppsByBillingAccount", - "inputs": [ - { - "name": "account", - "type": "address", - "internalType": "address" - }, - { - "name": "offset", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "limit", - "type": "uint256", - "internalType": "uint256" - } - ], - "outputs": [ - { - "name": "apps", - "type": "address[]", - "internalType": "contractIApp[]" - }, - { - "name": "appConfigsMem", - "type": "tuple[]", - "internalType": "structIAppController.AppConfig[]", - "components": [ - { - "name": "creator", - "type": "address", - "internalType": "address" - }, - { - "name": "operatorSetId", - "type": "uint32", - "internalType": "uint32" - }, - { - "name": "latestReleaseBlockNumber", - "type": "uint32", - "internalType": "uint32" - }, - { - "name": "status", - "type": "uint8", - "internalType": "enumIAppController.AppStatus" - } - ] - } - ], - "stateMutability": "view" - }, { "type": "function", "name": "getAppsByCreator", @@ -895,37 +658,6 @@ ], "stateMutability": "view" }, - { - "type": "function", - "name": "getPendingUpgrade", - "inputs": [ - { - "name": "app", - "type": "address", - "internalType": "contract IApp" - } - ], - "outputs": [ - { - "name": "", - "type": "tuple", - "internalType": "struct IAppController.PendingUpgrade", - "components": [ - { - "name": "releaseHash", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "readyAt", - "type": "uint256", - "internalType": "uint256" - } - ] - } - ], - "stateMutability": "view" - }, { "type": "function", "name": "getRoleAdmin", @@ -1327,70 +1059,6 @@ ], "stateMutability": "view" }, - { - "type": "function", - "name": "scheduleUpgrade", - "inputs": [ - { - "name": "app", - "type": "address", - "internalType": "contract IApp" - }, - { - "name": "release", - "type": "tuple", - "internalType": "struct IAppController.Release", - "components": [ - { - "name": "rmsRelease", - "type": "tuple", - "internalType": "struct IReleaseManagerTypes.Release", - "components": [ - { - "name": "artifacts", - "type": "tuple[]", - "internalType": "struct IReleaseManagerTypes.Artifact[]", - "components": [ - { - "name": "digest", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "registry", - "type": "string", - "internalType": "string" - } - ] - }, - { - "name": "upgradeByTime", - "type": "uint32", - "internalType": "uint32" - } - ] - }, - { - "name": "publicEnv", - "type": "bytes", - "internalType": "bytes" - }, - { - "name": "encryptedEnv", - "type": "bytes", - "internalType": "bytes" - } - ] - }, - { - "name": "delay", - "type": "uint256", - "internalType": "uint256" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, { "type": "function", "name": "setMaxActiveAppsPerUser", @@ -1759,85 +1427,6 @@ ], "anonymous": false }, - { - "type": "event", - "name": "AppUpgradeCancelled", - "inputs": [ - { - "name": "app", - "type": "address", - "indexed": true, - "internalType": "contract IApp" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "AppUpgradeScheduled", - "inputs": [ - { - "name": "app", - "type": "address", - "indexed": true, - "internalType": "contract IApp" - }, - { - "name": "readyAt", - "type": "uint256", - "indexed": false, - "internalType": "uint256" - }, - { - "name": "release", - "type": "tuple", - "indexed": false, - "internalType": "struct IAppController.Release", - "components": [ - { - "name": "rmsRelease", - "type": "tuple", - "internalType": "struct IReleaseManagerTypes.Release", - "components": [ - { - "name": "artifacts", - "type": "tuple[]", - "internalType": "struct IReleaseManagerTypes.Artifact[]", - "components": [ - { - "name": "digest", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "registry", - "type": "string", - "internalType": "string" - } - ] - }, - { - "name": "upgradeByTime", - "type": "uint32", - "internalType": "uint32" - } - ] - }, - { - "name": "publicEnv", - "type": "bytes", - "internalType": "bytes" - }, - { - "name": "encryptedEnv", - "type": "bytes", - "internalType": "bytes" - } - ] - } - ], - "anonymous": false - }, { "type": "event", "name": "AppUpgraded", @@ -2084,21 +1673,6 @@ "name": "MoreThanOneArtifact", "inputs": [] }, - { - "type": "error", - "name": "NoScheduledUpgrade", - "inputs": [] - }, - { - "type": "error", - "name": "NotTimelocked", - "inputs": [] - }, - { - "type": "error", - "name": "ReleaseMismatch", - "inputs": [] - }, { "type": "error", "name": "SignatureExpired", @@ -2114,15 +1688,5 @@ "internalType": "string" } ] - }, - { - "type": "error", - "name": "TimelockRequired", - "inputs": [] - }, - { - "type": "error", - "name": "UpgradeNotReady", - "inputs": [] } -] \ No newline at end of file +] diff --git a/packages/sdk/src/client/common/contract/caller.test.ts b/packages/sdk/src/client/common/contract/caller.test.ts index 04f0e125..828882a1 100644 --- a/packages/sdk/src/client/common/contract/caller.test.ts +++ b/packages/sdk/src/client/common/contract/caller.test.ts @@ -1,159 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { PublicClient } from "viem"; -import type { EnvironmentConfig } from "../types"; - -// ---- constants used across tests ---- - -const APP_ID = "0x000000000000000000000000000000000000aaaa" as `0x${string}`; -const CONTROLLER = "0x000000000000000000000000000000000000cccc"; - -const READY_AT = 1_700_000_000n; -const RELEASE_HASH = "0x" + "ab".repeat(32) as `0x${string}`; - -const MOCK_DIGEST = ("0x" + "ab".repeat(32)) as `0x${string}`; -const MOCK_PUBLIC_ENV = ("0x" + "cd".repeat(8)) as `0x${string}`; -const MOCK_ENCRYPTED_ENV = ("0x" + "ef".repeat(16)) as `0x${string}`; -const MOCK_REGISTRY = "registry.example.com/app:abc123"; -const MOCK_UPGRADE_BY_TIME = 9_999_999; - -const ENV_CONFIG = { - appControllerAddress: CONTROLLER, -} as unknown as EnvironmentConfig; - -function makeMockLog(readyAt = READY_AT) { - return { - args: { - app: APP_ID, - readyAt, - release: { - rmsRelease: { - artifacts: [{ digest: MOCK_DIGEST, registry: MOCK_REGISTRY }], - upgradeByTime: MOCK_UPGRADE_BY_TIME, - }, - publicEnv: MOCK_PUBLIC_ENV, - encryptedEnv: MOCK_ENCRYPTED_ENV, - }, - }, - }; -} - -function makePublicClient({ - readyAt = READY_AT, - logs = [makeMockLog()], -}: { - readyAt?: bigint; - logs?: ReturnType[]; -} = {}): PublicClient { - return { - readContract: vi.fn().mockResolvedValue({ releaseHash: RELEASE_HASH, readyAt }), - getLogs: vi.fn().mockResolvedValue(logs), - } as unknown as PublicClient; -} - -// Lazy import so mocks are set up before the module loads -let getScheduledRelease: typeof import("./caller").getScheduledRelease; - -beforeEach(async () => { - ({ getScheduledRelease } = await import("./caller")); -}); - -// ---- tests ---- - -describe("getScheduledRelease", () => { - it("returns a correctly typed Release matching the pending readyAt", async () => { - const client = makePublicClient(); - - const release = await getScheduledRelease(client, ENV_CONFIG, APP_ID); - - expect(release.rmsRelease.artifacts).toHaveLength(1); - expect(release.rmsRelease.artifacts[0].registry).toBe(MOCK_REGISTRY); - expect(release.rmsRelease.upgradeByTime).toBe(MOCK_UPGRADE_BY_TIME); - - // digest: bytes32 hex → 32-byte Uint8Array - expect(release.rmsRelease.artifacts[0].digest).toBeInstanceOf(Uint8Array); - expect(release.rmsRelease.artifacts[0].digest).toHaveLength(32); - - // publicEnv / encryptedEnv: bytes hex → Uint8Array - expect(release.publicEnv).toBeInstanceOf(Uint8Array); - expect(release.publicEnv).toHaveLength(8); - expect(release.encryptedEnv).toBeInstanceOf(Uint8Array); - expect(release.encryptedEnv).toHaveLength(16); - }); - - it("throws when no upgrade is pending (readyAt === 0n)", async () => { - const client = makePublicClient({ readyAt: 0n }); - - await expect(getScheduledRelease(client, ENV_CONFIG, APP_ID)).rejects.toThrow( - "no upgrade is scheduled for this app", - ); - expect(client.getLogs).not.toHaveBeenCalled(); - }); - - it("throws when logs contain no event matching the pending readyAt", async () => { - const client = makePublicClient({ logs: [makeMockLog(READY_AT + 1n)] }); - - await expect(getScheduledRelease(client, ENV_CONFIG, APP_ID)).rejects.toThrow( - "AppUpgradeScheduled event not found", - ); - }); - - it("throws when logs are empty", async () => { - const client = makePublicClient({ logs: [] }); - - await expect(getScheduledRelease(client, ENV_CONFIG, APP_ID)).rejects.toThrow( - "AppUpgradeScheduled event not found", - ); - }); - - it("returns the most recent matching event when multiple logs exist", async () => { - const OLD_REGISTRY = "registry.example.com/app:old"; - const NEW_REGISTRY = "registry.example.com/app:new"; - - const olderLog = { - ...makeMockLog(READY_AT), - args: { - ...makeMockLog(READY_AT).args, - release: { - ...makeMockLog(READY_AT).args.release, - rmsRelease: { - artifacts: [{ digest: MOCK_DIGEST, registry: OLD_REGISTRY }], - upgradeByTime: MOCK_UPGRADE_BY_TIME, - }, - }, - }, - }; - const newerLog = { - ...makeMockLog(READY_AT), - args: { - ...makeMockLog(READY_AT).args, - release: { - ...makeMockLog(READY_AT).args.release, - rmsRelease: { - artifacts: [{ digest: MOCK_DIGEST, registry: NEW_REGISTRY }], - upgradeByTime: MOCK_UPGRADE_BY_TIME, - }, - }, - }, - }; - - // getLogs returns chronological order; getScheduledRelease searches newest-first - const client = makePublicClient({ logs: [olderLog, newerLog] }); - - const release = await getScheduledRelease(client, ENV_CONFIG, APP_ID); - expect(release.rmsRelease.artifacts[0].registry).toBe(NEW_REGISTRY); - }); - - it("queries getLogs filtered to the app controller address and app", async () => { - const client = makePublicClient(); - - await getScheduledRelease(client, ENV_CONFIG, APP_ID); - - expect(client.getLogs).toHaveBeenCalledWith( - expect.objectContaining({ - address: CONTROLLER, - args: expect.objectContaining({ app: APP_ID }), - fromBlock: "earliest", - }), - ); - }); -}); +// 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 58cabba9..4b7e893c 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, hexToBytes } from "viem"; +import { Address, Hex, encodeFunctionData, decodeErrorResult, bytesToHex } from "viem"; import type { WalletClient, PublicClient } from "viem"; import { @@ -740,14 +740,6 @@ export async function prepareUpgradeBatch( needsPermissionChange, } = options; - // 0. Check timelocked — timelocked apps cannot use direct upgradeApp() - const timelocked = await getAppTimelocked(publicClient, environmentConfig, appID); - if (timelocked) { - throw new Error( - "this app is timelocked — use 'ecloud compute app upgrade schedule' and 'ecloud compute app upgrade execute' instead of a direct upgrade", - ); - } - // 1. Pack upgrade app call // Convert Release Uint8Array values to hex strings for viem const releaseForViem = { @@ -1012,18 +1004,6 @@ function formatAppControllerError(decoded: { return new Error("invalid release metadata URI provided"); case "InvalidShortString": return new Error("invalid short string format"); - case "TimelockRequired": - return new Error( - "this app is timelocked — use 'ecloud compute app upgrade schedule' and 'ecloud compute app upgrade execute' instead of a direct upgrade", - ); - case "NotTimelocked": - return new Error("this operation requires a timelocked app — transfer ownership to a Timelock first"); - case "UpgradeNotReady": - return new Error("the scheduled upgrade delay has not elapsed yet"); - case "NoScheduledUpgrade": - return new Error("no upgrade is scheduled for this app"); - case "ReleaseMismatch": - return new Error("the provided release does not match the scheduled upgrade"); default: return new Error(`contract error: ${errorName}`); } @@ -1255,7 +1235,7 @@ export async function getBlockTimestamps( } /** - * Get whether an app is timelocked (requires scheduleUpgrade + executeUpgrade) + * Get whether an app is timelocked (owner is a Timelock — sensitive ops go through Timelock.schedule → execute) */ export async function getAppTimelocked( publicClient: PublicClient, @@ -1272,83 +1252,6 @@ export async function getAppTimelocked( return timelocked as boolean; } -/** - * Get the pending upgrade for a governed app - */ -export interface PendingUpgrade { - releaseHash: Hex; - readyAt: bigint; -} - -export async function getPendingAppUpgrade( - publicClient: PublicClient, - environmentConfig: EnvironmentConfig, - appID: Address, -): Promise { - const result = (await publicClient.readContract({ - address: environmentConfig.appControllerAddress as Address, - abi: AppControllerABI, - functionName: "getPendingUpgrade", - args: [appID], - })) as { releaseHash: Hex; readyAt: bigint }; - - return { releaseHash: result.releaseHash, readyAt: result.readyAt }; -} - -/** - * Fetch the scheduled Release struct from chain logs. - * - * The contract only stores keccak256(abi.encode(release)) on-chain, but the full - * Release is emitted in the AppUpgradeScheduled event. We find the event whose - * readyAt matches the pending upgrade and decode the Release from it — no rebuild needed. - */ -export async function getScheduledRelease( - publicClient: PublicClient, - environmentConfig: EnvironmentConfig, - appID: Address, -): Promise { - const pending = await getPendingAppUpgrade(publicClient, environmentConfig, appID); - if (pending.readyAt === 0n) { - throw new Error("no upgrade is scheduled for this app"); - } - - const eventAbi = (AppControllerABI as any[]).find( - (e) => e.type === "event" && e.name === "AppUpgradeScheduled", - ); - - const logs = await publicClient.getLogs({ - address: environmentConfig.appControllerAddress as Address, - event: eventAbi, - args: { app: appID }, - fromBlock: "earliest", - }); - - // scheduleUpgrade overwrites any previous pending upgrade, so match on readyAt - const match = [...logs].reverse().find((log) => { - const { readyAt } = log.args as any; - return BigInt(readyAt) === pending.readyAt; - }); - - if (!match) { - throw new Error( - "AppUpgradeScheduled event not found for this app — the node may not have full history", - ); - } - - const { release } = match.args as any; - return { - rmsRelease: { - artifacts: release.rmsRelease.artifacts.map((a: any) => ({ - digest: hexToBytes(a.digest), - registry: a.registry, - })), - upgradeByTime: Number(release.rmsRelease.upgradeByTime), - }, - publicEnv: hexToBytes(release.publicEnv), - encryptedEnv: hexToBytes(release.encryptedEnv), - }; -} - /** * Options for transferring app ownership */ @@ -1392,156 +1295,6 @@ export async function transferAppOwnership( ); } -/** - * Options for scheduling a governed upgrade - */ -export interface ScheduleUpgradeOptions { - walletClient: WalletClient; - publicClient: PublicClient; - environmentConfig: EnvironmentConfig; - appID: Address; - release: Release; - /** Delay in seconds before the upgrade can be executed */ - delaySeconds: bigint; - gas?: GasEstimate; -} - -/** - * Schedule an upgrade for a governed app (Safe/Timelock owner). - * Emits AppUpgradeScheduled; controller takes no action until executeGovernedUpgrade is called. - */ -export async function scheduleAppUpgrade( - options: ScheduleUpgradeOptions, - logger: Logger = noopLogger, -): Promise { - const { walletClient, publicClient, environmentConfig, appID, release, delaySeconds, gas } = options; - - const releaseForViem = { - rmsRelease: { - artifacts: release.rmsRelease.artifacts.map((artifact) => ({ - digest: `0x${bytesToHex(artifact.digest).slice(2).padStart(64, "0")}` as Hex, - registry: artifact.registry, - })), - upgradeByTime: release.rmsRelease.upgradeByTime, - }, - publicEnv: bytesToHex(release.publicEnv) as Hex, - encryptedEnv: bytesToHex(release.encryptedEnv) as Hex, - }; - - const data = encodeFunctionData({ - abi: AppControllerABI, - functionName: "scheduleUpgrade", - args: [appID, releaseForViem, delaySeconds], - }); - - return sendAndWaitForTransaction( - { - walletClient, - publicClient, - environmentConfig, - to: environmentConfig.appControllerAddress as Address, - data, - pendingMessage: `Scheduling upgrade for app ${appID} (delay: ${delaySeconds}s)...`, - txDescription: "ScheduleUpgrade", - gas, - }, - logger, - ); -} - -/** - * Options for executing a scheduled governed upgrade - */ -export interface ExecuteGovernedUpgradeOptions { - walletClient: WalletClient; - publicClient: PublicClient; - environmentConfig: EnvironmentConfig; - appID: Address; - release: Release; - gas?: GasEstimate; -} - -/** - * Execute a previously scheduled upgrade for a governed app. - * The release must match the hash committed in scheduleUpgrade. - */ -export async function executeGovernedUpgrade( - options: ExecuteGovernedUpgradeOptions, - logger: Logger = noopLogger, -): Promise { - const { walletClient, publicClient, environmentConfig, appID, release, gas } = options; - - const releaseForViem = { - rmsRelease: { - artifacts: release.rmsRelease.artifacts.map((artifact) => ({ - digest: `0x${bytesToHex(artifact.digest).slice(2).padStart(64, "0")}` as Hex, - registry: artifact.registry, - })), - upgradeByTime: release.rmsRelease.upgradeByTime, - }, - publicEnv: bytesToHex(release.publicEnv) as Hex, - encryptedEnv: bytesToHex(release.encryptedEnv) as Hex, - }; - - const data = encodeFunctionData({ - abi: AppControllerABI, - functionName: "executeUpgrade", - args: [appID, releaseForViem], - }); - - return sendAndWaitForTransaction( - { - walletClient, - publicClient, - environmentConfig, - to: environmentConfig.appControllerAddress as Address, - data, - pendingMessage: `Executing scheduled upgrade for app ${appID}...`, - txDescription: "ExecuteUpgrade", - gas, - }, - logger, - ); -} - -/** - * Cancel a pending scheduled upgrade for a timelocked app. - */ -export interface CancelUpgradeOptions { - walletClient: WalletClient; - publicClient: PublicClient; - environmentConfig: EnvironmentConfig; - appID: Address; - gas?: GasEstimate; -} - -export async function cancelAppUpgrade( - options: CancelUpgradeOptions, - logger: Logger = noopLogger, -): Promise { - const { walletClient, publicClient, environmentConfig, appID, gas } = options; - - const data = encodeFunctionData({ - abi: AppControllerABI, - functionName: "cancelUpgrade", - args: [appID], - }); - - return sendAndWaitForTransaction( - { - walletClient, - publicClient, - environmentConfig, - to: environmentConfig.appControllerAddress as Address, - data, - pendingMessage: `Cancelling scheduled upgrade for app ${appID}...`, - txDescription: "CancelUpgrade", - gas, - }, - logger, - ); -} - /** * Team role enum matching the contract's TeamRole enum. */ @@ -1926,13 +1679,13 @@ export interface DiscoveredTimelock { */ // ─── Timelocked operations via TimelockController ──────────────────────────── // -// transferOwnership, terminateApp, and grantTeamRole(ADMIN) are restricted to -// msg.sender == owner when an app is timelocked. The Timelock IS the owner, so -// these operations must be enqueued through TimelockController.schedule() and -// then sent via TimelockController.execute() after the delay elapses. +// 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 — consistent with how upgrades are queued. +// (target, calldata) alone. const ZERO_BYTES32 = "0x0000000000000000000000000000000000000000000000000000000000000000" as Hex; @@ -2055,188 +1808,6 @@ export async function getTimelockOpTimestamp( })) as bigint; } -/** - * Return the scheduled timestamp for a terminateApp operation queued through a Timelock. - */ -export async function getTimelockTerminateTimestamp( - publicClient: PublicClient, - timelockAddress: Address, - appControllerAddress: Address, - appID: Address, -): Promise { - const calldata = encodeFunctionData({ abi: AppControllerABI, functionName: "terminateApp", args: [appID] }); - return getTimelockOpTimestamp(publicClient, timelockAddress, appControllerAddress, calldata); -} - -/** - * Return the scheduled timestamp for a transferOwnership operation queued through a Timelock. - */ -export async function getTimelockTransferOwnershipTimestamp( - publicClient: PublicClient, - timelockAddress: Address, - appControllerAddress: Address, - appID: Address, - newOwner: Address, -): Promise { - const calldata = encodeFunctionData({ abi: AppControllerABI, functionName: "transferOwnership", args: [appID, newOwner] }); - return getTimelockOpTimestamp(publicClient, timelockAddress, appControllerAddress, calldata); -} - -/** - * Return the scheduled timestamp for a grantTeamRole(ADMIN) operation queued through a Timelock. - */ -export async function getTimelockGrantAdminTimestamp( - publicClient: PublicClient, - timelockAddress: Address, - appControllerAddress: Address, - team: Address, - account: Address, -): Promise { - const calldata = encodeFunctionData({ abi: AppControllerABI, functionName: "grantTeamRole", args: [team, TeamRole.ADMIN, account] }); - return getTimelockOpTimestamp(publicClient, timelockAddress, appControllerAddress, calldata); -} - -// ─── Per-operation helpers ──────────────────────────────────────────────────── - -export interface ScheduleTimelockTransferOwnershipOptions { - walletClient: WalletClient; - publicClient: PublicClient; - environmentConfig: EnvironmentConfig; - timelockAddress: Address; - appID: Address; - newOwner: Address; - delaySeconds: bigint; - gas?: GasEstimate; -} - -export async function scheduleTimelockTransferOwnership( - options: ScheduleTimelockTransferOwnershipOptions, - logger: Logger = noopLogger, -): Promise { - const { appID, newOwner, delaySeconds, timelockAddress, ...base } = options; - const calldata = encodeFunctionData({ - abi: AppControllerABI, - functionName: "transferOwnership", - args: [appID, newOwner], - }); - return scheduleTimelockOp({ ...base, timelockAddress, calldata, delaySeconds }, logger); -} - -export interface ExecuteTimelockTransferOwnershipOptions { - walletClient: WalletClient; - publicClient: PublicClient; - environmentConfig: EnvironmentConfig; - timelockAddress: Address; - appID: Address; - newOwner: Address; - gas?: GasEstimate; -} - -export async function executeTimelockTransferOwnership( - options: ExecuteTimelockTransferOwnershipOptions, - logger: Logger = noopLogger, -): Promise { - const { appID, newOwner, timelockAddress, ...base } = options; - const calldata = encodeFunctionData({ - abi: AppControllerABI, - functionName: "transferOwnership", - args: [appID, newOwner], - }); - return executeTimelockOp({ ...base, timelockAddress, calldata }, logger); -} - -export interface ScheduleTimelockTerminateAppOptions { - walletClient: WalletClient; - publicClient: PublicClient; - environmentConfig: EnvironmentConfig; - timelockAddress: Address; - appID: Address; - delaySeconds: bigint; - gas?: GasEstimate; -} - -export async function scheduleTimelockTerminateApp( - options: ScheduleTimelockTerminateAppOptions, - logger: Logger = noopLogger, -): Promise { - const { appID, delaySeconds, timelockAddress, ...base } = options; - const calldata = encodeFunctionData({ - abi: AppControllerABI, - functionName: "terminateApp", - args: [appID], - }); - return scheduleTimelockOp({ ...base, timelockAddress, calldata, delaySeconds }, logger); -} - -export interface ExecuteTimelockTerminateAppOptions { - walletClient: WalletClient; - publicClient: PublicClient; - environmentConfig: EnvironmentConfig; - timelockAddress: Address; - appID: Address; - gas?: GasEstimate; -} - -export async function executeTimelockTerminateApp( - options: ExecuteTimelockTerminateAppOptions, - logger: Logger = noopLogger, -): Promise { - const { appID, timelockAddress, ...base } = options; - const calldata = encodeFunctionData({ - abi: AppControllerABI, - functionName: "terminateApp", - args: [appID], - }); - return executeTimelockOp({ ...base, timelockAddress, calldata }, logger); -} - -export interface ScheduleTimelockGrantTeamAdminOptions { - walletClient: WalletClient; - publicClient: PublicClient; - environmentConfig: EnvironmentConfig; - timelockAddress: Address; - team: Address; - account: Address; - delaySeconds: bigint; - gas?: GasEstimate; -} - -export async function scheduleTimelockGrantTeamAdmin( - options: ScheduleTimelockGrantTeamAdminOptions, - logger: Logger = noopLogger, -): Promise { - const { team, account, delaySeconds, timelockAddress, ...base } = options; - const calldata = encodeFunctionData({ - abi: AppControllerABI, - functionName: "grantTeamRole", - args: [team, TeamRole.ADMIN, account], - }); - return scheduleTimelockOp({ ...base, timelockAddress, calldata, delaySeconds }, logger); -} - -export interface ExecuteTimelockGrantTeamAdminOptions { - walletClient: WalletClient; - publicClient: PublicClient; - environmentConfig: EnvironmentConfig; - timelockAddress: Address; - team: Address; - account: Address; - gas?: GasEstimate; -} - -export async function executeTimelockGrantTeamAdmin( - options: ExecuteTimelockGrantTeamAdminOptions, - logger: Logger = noopLogger, -): Promise { - const { team, account, timelockAddress, ...base } = options; - const calldata = encodeFunctionData({ - abi: AppControllerABI, - functionName: "grantTeamRole", - args: [team, TeamRole.ADMIN, account], - }); - return executeTimelockOp({ ...base, timelockAddress, calldata }, logger); -} - export async function discoverTimelock( publicClient: PublicClient, environmentConfig: EnvironmentConfig, diff --git a/packages/sdk/src/client/common/contract/identity-router.ts b/packages/sdk/src/client/common/contract/identity-router.ts index 5af9c904..f4a89cd2 100644 --- a/packages/sdk/src/client/common/contract/identity-router.ts +++ b/packages/sdk/src/client/common/contract/identity-router.ts @@ -203,8 +203,7 @@ export function formatTransactionResult(result: TransactionResult): string[] { ` Tx: ${result.txHash}`, ` Delay: ${result.delayLabel}`, ``, - ` After the delay elapses, run:`, - ` ecloud compute app upgrade execute `, + ` After the delay elapses, execute the queued operation on the Timelock.`, ]; case "safe-proposal-for-timelock": @@ -216,8 +215,7 @@ export function formatTransactionResult(result: TransactionResult): string[] { ` Step 1: Approve the schedule at:`, ` ${result.proposal.safeUrl}`, ``, - ` Step 2: After Safe approval + ${result.delayLabel} delay, run:`, - ` ecloud compute app upgrade execute `, + ` Step 2: After Safe approval + ${result.delayLabel} delay, execute the queued operation on the Timelock.`, ]; } } diff --git a/packages/sdk/src/client/index.ts b/packages/sdk/src/client/index.ts index 08e44bec..f7127e41 100644 --- a/packages/sdk/src/client/index.ts +++ b/packages/sdk/src/client/index.ts @@ -46,12 +46,7 @@ export { prepareUpgradeFromVerifiableBuild, executeUpgrade, watchUpgrade, - scheduleUpgrade, - executeGovernedUpgrade, type PrepareUpgradeResult, - type GovernedUpgradeResult, - type ScheduleUpgradeOptions, - type ExecuteGovernedUpgradeOptions, } from "./modules/compute/app/upgrade"; // Export compute module for standalone use @@ -62,6 +57,8 @@ export { encodeStartAppData, encodeStopAppData, encodeTerminateAppData, + encodeTransferOwnershipData, + encodeGrantTeamRoleData, } from "./modules/compute"; export { createBillingModule, @@ -104,11 +101,7 @@ export { getAppsByBillingAccount, calculateAppID, getAppTimelocked, - getPendingAppUpgrade, transferAppOwnership, - scheduleAppUpgrade, - executeGovernedUpgrade as executeGovernedUpgradeOnChain, - cancelAppUpgrade, grantTeamRole, revokeTeamRole, getTeamRoleMembers, @@ -116,11 +109,7 @@ export { TeamRole, type GasEstimate, type EstimateGasOptions, - type PendingUpgrade, type TransferOwnershipOptions, - type ScheduleUpgradeOptions as ScheduleUpgradeCallerOptions, - type ExecuteGovernedUpgradeOptions as ExecuteGovernedUpgradeCallerOptions, - type CancelUpgradeOptions, type GrantTeamRoleOptions, type RevokeTeamRoleOptions, getSafeTimelockFactoryAddress, diff --git a/packages/sdk/src/client/modules/compute/app/index.ts b/packages/sdk/src/client/modules/compute/app/index.ts index a4caa372..dba43376 100644 --- a/packages/sdk/src/client/modules/compute/app/index.ts +++ b/packages/sdk/src/client/modules/compute/app/index.ts @@ -35,29 +35,14 @@ import { getBillingType, getAppsByBillingAccount, getAppTimelocked, - getPendingAppUpgrade, transferAppOwnership, - scheduleAppUpgrade, - executeGovernedUpgrade, - cancelAppUpgrade, grantTeamRole as grantTeamRoleCaller, revokeTeamRole as revokeTeamRoleCaller, getTeamRoleMembers as getTeamRoleMembersCaller, getAppOwner, - getTimelockOpTimestamp, - getTimelockTerminateTimestamp, - getTimelockTransferOwnershipTimestamp, - getTimelockGrantAdminTimestamp, - scheduleTimelockTransferOwnership, - executeTimelockTransferOwnership, - scheduleTimelockTerminateApp, - executeTimelockTerminateApp, - scheduleTimelockGrantTeamAdmin, - executeTimelockGrantTeamAdmin, TeamRole, type GasEstimate, type AppConfig, - type PendingUpgrade, } from "../../../common/contract/caller"; import { withSDKTelemetry } from "../../../common/telemetry/wrapper"; import { UserApiClient } from "../../../common/utils/userapi"; @@ -85,6 +70,8 @@ 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)", ]); /** @@ -120,6 +107,28 @@ 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], + }); +} + export interface AppModule { // Project creation create: (opts: CreateAppOpts) => Promise; @@ -192,40 +201,12 @@ export interface AppModule { // Governance isTimelocked: (appId: AppId) => Promise; - getPendingUpgrade: (appId: AppId) => Promise; transferOwnership: (appId: AppId, newOwner: Address, opts?: { gas?: GasEstimate }) => Promise<{ tx: Hex }>; - scheduleUpgrade: ( - appId: AppId, - release: import("../../../common/types").Release, - delaySeconds: bigint, - opts?: { gas?: GasEstimate }, - ) => Promise<{ tx: Hex }>; - executeGovernedUpgrade: ( - appId: AppId, - release: import("../../../common/types").Release, - opts?: { gas?: GasEstimate }, - ) => Promise<{ tx: Hex }>; - - // Cancel scheduled upgrade - cancelUpgrade: (appId: AppId, 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; - - // Timelocked operations — routed through TimelockController.schedule/execute - // Use these when the app owner is a Timelock (isTimelocked() === true). - getTimelockOpReadyAt: (timelockAddress: Address, appControllerAddress: Address, calldata: Hex) => Promise; - getTimelockTerminateReadyAt: (appId: AppId, timelockAddress: Address) => Promise; - getTimelockTransferOwnershipReadyAt: (appId: AppId, timelockAddress: Address, newOwner: Address) => Promise; - getTimelockGrantAdminReadyAt: (appId: AppId, timelockAddress: Address, account: Address) => Promise; - scheduleTimelockTransferOwnership: (appId: AppId, timelockAddress: Address, newOwner: Address, delaySeconds: bigint, opts?: { gas?: GasEstimate }) => Promise<{ tx: Hex }>; - executeTimelockTransferOwnership: (appId: AppId, timelockAddress: Address, newOwner: Address, opts?: { gas?: GasEstimate }) => Promise<{ tx: Hex }>; - scheduleTimelockTerminate: (appId: AppId, timelockAddress: Address, delaySeconds: bigint, opts?: { gas?: GasEstimate }) => Promise<{ tx: Hex }>; - executeTimelockTerminate: (appId: AppId, timelockAddress: Address, opts?: { gas?: GasEstimate }) => Promise<{ tx: Hex }>; - scheduleTimelockGrantAdmin: (appId: AppId, timelockAddress: Address, account: Address, delaySeconds: bigint, opts?: { gas?: GasEstimate }) => Promise<{ tx: Hex }>; - executeTimelockGrantAdmin: (appId: AppId, timelockAddress: Address, account: Address, opts?: { gas?: GasEstimate }) => Promise<{ tx: Hex }>; } export interface AppModuleConfig { @@ -632,10 +613,6 @@ export function createAppModule(ctx: AppModuleConfig): AppModule { return getAppTimelocked(publicClient, environment, appId as Address); }, - async getPendingUpgrade(appId) { - return getPendingAppUpgrade(publicClient, environment, appId as Address); - }, - async transferOwnership(appId, newOwner, opts) { return withSDKTelemetry( { @@ -660,78 +637,6 @@ export function createAppModule(ctx: AppModuleConfig): AppModule { ); }, - async scheduleUpgrade(appId, release, delaySeconds, opts) { - return withSDKTelemetry( - { - functionName: "scheduleUpgrade", - skipTelemetry, - properties: { environment: ctx.environment }, - }, - async () => { - const tx = await scheduleAppUpgrade( - { - walletClient, - publicClient, - environmentConfig: environment, - appID: appId as Address, - release, - delaySeconds, - gas: opts?.gas, - }, - logger, - ); - return { tx }; - }, - ); - }, - - async executeGovernedUpgrade(appId, release, opts) { - return withSDKTelemetry( - { - functionName: "executeGovernedUpgrade", - skipTelemetry, - properties: { environment: ctx.environment }, - }, - async () => { - const tx = await executeGovernedUpgrade( - { - walletClient, - publicClient, - environmentConfig: environment, - appID: appId as Address, - release, - gas: opts?.gas, - }, - logger, - ); - return { tx }; - }, - ); - }, - - async cancelUpgrade(appId, opts) { - return withSDKTelemetry( - { - functionName: "cancelUpgrade", - skipTelemetry, - properties: { environment: ctx.environment }, - }, - async () => { - const tx = await cancelAppUpgrade( - { - walletClient, - publicClient, - environmentConfig: environment, - appID: appId as Address, - gas: opts?.gas, - }, - logger, - ); - return { tx }; - }, - ); - }, - async grantTeamRole(appId, role, account, opts) { return withSDKTelemetry( { @@ -788,102 +693,5 @@ export function createAppModule(ctx: AppModuleConfig): AppModule { const team = await getAppOwner(publicClient, environment, appId as Address); return getTeamRoleMembersCaller(publicClient, environment, team, role); }, - - async getTimelockOpReadyAt(timelockAddress, appControllerAddress, calldata) { - return getTimelockOpTimestamp(publicClient, timelockAddress, appControllerAddress, calldata); - }, - - async getTimelockTerminateReadyAt(appId, timelockAddress) { - return getTimelockTerminateTimestamp(publicClient, timelockAddress, environment.appControllerAddress as Address, appId as Address); - }, - - async getTimelockTransferOwnershipReadyAt(appId, timelockAddress, newOwner) { - return getTimelockTransferOwnershipTimestamp(publicClient, timelockAddress, environment.appControllerAddress as Address, appId as Address, newOwner); - }, - - async getTimelockGrantAdminReadyAt(appId, timelockAddress, account) { - const team = await getAppOwner(publicClient, environment, appId as Address); - return getTimelockGrantAdminTimestamp(publicClient, timelockAddress, environment.appControllerAddress as Address, team, account as Address); - }, - - async scheduleTimelockTransferOwnership(appId, timelockAddress, newOwner, delaySeconds, opts) { - return withSDKTelemetry( - { functionName: "scheduleTimelockTransferOwnership", skipTelemetry, properties: { environment: ctx.environment } }, - async () => { - const tx = await scheduleTimelockTransferOwnership( - { walletClient, publicClient, environmentConfig: environment, timelockAddress, appID: appId as Address, newOwner, delaySeconds, gas: opts?.gas }, - logger, - ); - return { tx }; - }, - ); - }, - - async executeTimelockTransferOwnership(appId, timelockAddress, newOwner, opts) { - return withSDKTelemetry( - { functionName: "executeTimelockTransferOwnership", skipTelemetry, properties: { environment: ctx.environment } }, - async () => { - const tx = await executeTimelockTransferOwnership( - { walletClient, publicClient, environmentConfig: environment, timelockAddress, appID: appId as Address, newOwner, gas: opts?.gas }, - logger, - ); - return { tx }; - }, - ); - }, - - async scheduleTimelockTerminate(appId, timelockAddress, delaySeconds, opts) { - return withSDKTelemetry( - { functionName: "scheduleTimelockTerminate", skipTelemetry, properties: { environment: ctx.environment } }, - async () => { - const tx = await scheduleTimelockTerminateApp( - { walletClient, publicClient, environmentConfig: environment, timelockAddress, appID: appId as Address, delaySeconds, gas: opts?.gas }, - logger, - ); - return { tx }; - }, - ); - }, - - async executeTimelockTerminate(appId, timelockAddress, opts) { - return withSDKTelemetry( - { functionName: "executeTimelockTerminate", skipTelemetry, properties: { environment: ctx.environment } }, - async () => { - const tx = await executeTimelockTerminateApp( - { walletClient, publicClient, environmentConfig: environment, timelockAddress, appID: appId as Address, gas: opts?.gas }, - logger, - ); - return { tx }; - }, - ); - }, - - async scheduleTimelockGrantAdmin(appId, timelockAddress, account, delaySeconds, opts) { - return withSDKTelemetry( - { functionName: "scheduleTimelockGrantAdmin", skipTelemetry, properties: { environment: ctx.environment } }, - async () => { - const team = await getAppOwner(publicClient, environment, appId as Address); - const tx = await scheduleTimelockGrantTeamAdmin( - { walletClient, publicClient, environmentConfig: environment, timelockAddress, team, account: account as Address, delaySeconds, gas: opts?.gas }, - logger, - ); - return { tx }; - }, - ); - }, - - async executeTimelockGrantAdmin(appId, timelockAddress, account, opts) { - return withSDKTelemetry( - { functionName: "executeTimelockGrantAdmin", skipTelemetry, properties: { environment: ctx.environment } }, - async () => { - const team = await getAppOwner(publicClient, environment, appId as Address); - const tx = await executeTimelockGrantTeamAdmin( - { walletClient, publicClient, environmentConfig: environment, timelockAddress, team, account: account as Address, gas: opts?.gas }, - logger, - ); - return { tx }; - }, - ); - }, }; } diff --git a/packages/sdk/src/client/modules/compute/app/upgrade.ts b/packages/sdk/src/client/modules/compute/app/upgrade.ts index ab0acb56..3855f8ee 100644 --- a/packages/sdk/src/client/modules/compute/app/upgrade.ts +++ b/packages/sdk/src/client/modules/compute/app/upgrade.ts @@ -24,12 +24,7 @@ import { upgradeApp, prepareUpgradeBatch, executeUpgradeBatch, - scheduleAppUpgrade, - executeGovernedUpgrade as executeGovernedUpgradeOnChain, - getScheduledRelease, - getPendingAppUpgrade, type GasEstimate, - type PendingUpgrade, } from "../../../common/contract/caller"; import { estimateBatchGas, createAuthorizationList } from "../../../common/contract/eip7702"; import { watchUntilUpgradeComplete } from "../../../common/contract/watcher"; @@ -543,177 +538,6 @@ export async function executeUpgrade(options: ExecuteUpgradeOptions): Promise { - /** Delay in seconds before the upgrade can be executed */ - delaySeconds: bigint; - gas?: GasEstimate; -} - -/** - * Schedule an upgrade for a governed app (requires prior scheduleUpgrade + executeUpgrade flow). - * - * Runs the full build pipeline, then calls scheduleUpgrade on-chain instead of upgradeApp. - * The controller does NOT act on this event — call executeGovernedUpgrade after the delay. - */ -export async function scheduleUpgrade( - options: ScheduleUpgradeOptions, - logger: Logger = defaultLogger, -): Promise { - return withSDKTelemetry( - { - functionName: "scheduleUpgrade", - skipTelemetry: options.skipTelemetry, - properties: { environment: options.environment || "sepolia" }, - }, - async () => { - logger.debug("Performing preflight checks..."); - const preflightCtx = await doPreflightChecks( - { walletClient: options.walletClient, publicClient: options.publicClient, environment: options.environment }, - logger, - ); - - const appID = validateUpgradeOptions(options as SDKUpgradeOptions); - const { logRedirect } = validateLogVisibility(options.logVisibility); - const resourceUsageAllow = validateResourceUsageMonitoring(options.resourceUsageMonitoring); - - logger.debug("Checking Docker..."); - await ensureDockerIsRunning(); - - const dockerfilePath = options.dockerfilePath || ""; - const imageRef = options.imageRef || ""; - const envFilePath = options.envFilePath || ""; - - logger.info("Preparing release..."); - const { release, finalImageRef } = await prepareRelease( - { - dockerfilePath, - imageRef, - envFilePath, - logRedirect, - resourceUsageAllow, - instanceType: options.instanceType, - environmentConfig: preflightCtx.environmentConfig, - appId: appID, - }, - logger, - ); - - logger.info("Scheduling upgrade on-chain..."); - const txHash = await scheduleAppUpgrade( - { - walletClient: preflightCtx.walletClient, - publicClient: preflightCtx.publicClient, - environmentConfig: preflightCtx.environmentConfig, - appID, - release, - delaySeconds: options.delaySeconds, - gas: options.gas, - }, - logger, - ); - - return { appId: appID, imageRef: finalImageRef, txHash }; - }, - ); -} - -/** - * Options for executeGovernedUpgrade. - * No build inputs are needed — the Release is fetched from the AppUpgradeScheduled event. - */ -export interface ExecuteGovernedUpgradeOptions { - appId: string | Address; - walletClient: WalletClient; - publicClient: PublicClient; - environment?: string; - gas?: GasEstimate; - skipTelemetry?: boolean; -} - -/** - * Execute a previously scheduled upgrade for a governed app. - * - * Reads the Release directly from the AppUpgradeScheduled event on-chain instead of - * rebuilding — no Docker or build inputs required. - */ -export async function executeGovernedUpgrade( - options: ExecuteGovernedUpgradeOptions, - logger: Logger = defaultLogger, -): Promise { - return withSDKTelemetry( - { - functionName: "executeGovernedUpgrade", - skipTelemetry: options.skipTelemetry, - properties: { environment: options.environment || "sepolia" }, - }, - async () => { - logger.debug("Performing preflight checks..."); - const preflightCtx = await doPreflightChecks( - { walletClient: options.walletClient, publicClient: options.publicClient, environment: options.environment }, - logger, - ); - - const appID = validateAppID(options.appId); - - // Check that a scheduled upgrade exists and is ready - const pending = await getPendingAppUpgrade(preflightCtx.publicClient, preflightCtx.environmentConfig, appID); - if (pending.readyAt === 0n) { - throw new Error("no upgrade is scheduled for this app"); - } - const now = BigInt(Math.floor(Date.now() / 1000)); - if (now < pending.readyAt) { - const remaining = pending.readyAt - now; - throw new Error(`upgrade is not ready yet — ${remaining}s remaining`); - } - - logger.info("Fetching scheduled release from chain..."); - const release = await getScheduledRelease( - preflightCtx.publicClient, - preflightCtx.environmentConfig, - appID, - ); - - logger.info("Executing scheduled upgrade on-chain..."); - const txHash = await executeGovernedUpgradeOnChain( - { - walletClient: preflightCtx.walletClient, - publicClient: preflightCtx.publicClient, - environmentConfig: preflightCtx.environmentConfig, - appID, - release, - gas: options.gas, - }, - logger, - ); - - logger.info("Waiting for upgrade to complete..."); - await watchUntilUpgradeComplete( - { - walletClient: preflightCtx.walletClient, - publicClient: preflightCtx.publicClient, - environmentConfig: preflightCtx.environmentConfig, - appId: appID, - }, - logger, - ); - - const imageRef = release.rmsRelease.artifacts[0]?.registry ?? ""; - return { appId: appID, imageRef, txHash }; - }, - ); -} /** * Watch an upgrade until it completes diff --git a/packages/sdk/src/client/modules/compute/index.ts b/packages/sdk/src/client/modules/compute/index.ts index 8c87ccc6..2bef3571 100644 --- a/packages/sdk/src/client/modules/compute/index.ts +++ b/packages/sdk/src/client/modules/compute/index.ts @@ -28,4 +28,4 @@ export function createComputeModule(config: ComputeModuleConfig): ComputeModule export { createAppModule, type AppModule, type AppModuleConfig } from "./app"; // Re-export app module utilities -export { encodeStartAppData, encodeStopAppData, encodeTerminateAppData } from "./app"; +export { encodeStartAppData, encodeStopAppData, encodeTerminateAppData, encodeTransferOwnershipData, encodeGrantTeamRoleData } from "./app"; From 237fe9ca89e9b90e64a40bf2c4bb165ba82fccf9 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Wed, 15 Apr 2026 14:18:35 -0700 Subject: [PATCH 22/41] feat: use factory deployer registry for identity recovery on login --- packages/cli/src/commands/auth/login.ts | 78 ++-- .../common/abis/SafeTimelockFactory.json | 395 ++++++++++++++++-- .../sdk/src/client/common/contract/caller.ts | 36 ++ packages/sdk/src/client/index.ts | 2 + 4 files changed, 422 insertions(+), 89 deletions(-) diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 98b63c30..0e56bf3e 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -17,7 +17,8 @@ import { getLegacyPrivateKey, deleteLegacyPrivateKey, getEnvironmentConfig, - discoverTimelock, + getTimelocksByDeployer, + getSafesByDeployer, type LegacyKey, } from "@layr-labs/ecloud-sdk"; import { getHiddenInput, displayWarning } from "../../utils/security"; @@ -141,67 +142,54 @@ export default class AuthLogin extends Command { replaceAllIdentities([{ type: "eoa", address }]); setActiveIdentity(environment, address); - // Discover Timelock on-chain + // Discover all Safes and Timelocks deployed by this EOA via SafeTimelockFactory this.log(`\nScanning chain for associated identities...`); try { const publicClient = createPublicClientOnly({ environment, rpcUrl: flags["rpc-url"] }); const environmentConfig = getEnvironmentConfig(environment); - const found = await discoverTimelock(publicClient, environmentConfig, address as Address); - if (found) { - const delayHours = Number(found.minDelay) / 3600; - const delayLabel = delayHours >= 24 ? `${delayHours / 24}d` : `${delayHours}h`; - this.log(`Found Timelock: ${found.address} (${delayLabel} delay)`); + const [timelocks, safes] = await Promise.all([ + getTimelocksByDeployer(publicClient, environmentConfig, address as Address), + getSafesByDeployer(publicClient, environmentConfig, address as Address), + ]); + + if (safes.length === 0 && timelocks.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() === found.address.toLowerCase(), + (id) => id.address.toLowerCase() === safe.toLowerCase(), ); - if (!alreadyKnown) { - const addIt = await confirm({ message: "Add this Timelock to your identities?", default: true }); + 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) { - addIdentity({ type: "timelock", address: found.address, delay: delayLabel, environment }); - this.log(`✓ Timelock added to identities`); + addIdentity({ type: "safe", address: safe, environment }); + this.log(`✓ Safe added to identities`); } - } else { - this.log(`✓ Timelock already in your identities`); } - } else { - this.log(`No Timelock found for this EOA on ${environment}`); } - } catch { - this.log(`(Timelock scan skipped — chain not reachable)`); - } - // Discover Safes - try { - const safeServiceUrl = - environment === "mainnet-alpha" - ? "https://safe-transaction-mainnet.safe.global" - : "https://safe-transaction-sepolia.safe.global"; - const res = await fetch(`${safeServiceUrl}/api/v1/owners/${address}/safes/`); - if (res.ok) { - const data = await res.json() as { safes: string[] }; - const safes = data.safes ?? []; - if (safes.length > 0) { - this.log(`\nFound ${safes.length} Safe(s) where this EOA is an owner:`); - for (const safe of safes) { - const alreadyKnown = getIdentities().some( - (id) => id.address.toLowerCase() === safe.toLowerCase(), - ); - if (alreadyKnown) { - this.log(` ${safe} (already in identities)`); - } else { - const addIt = await confirm({ message: `Add Safe ${safe} to your identities?`, default: true }); - if (addIt) { - addIdentity({ type: "safe", address: safe, environment }); - this.log(`✓ Safe added to identities`); - } - } + for (const timelock of timelocks) { + 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) { + addIdentity({ type: "timelock", address: timelock, environment }); + this.log(`✓ Timelock added to identities`); } } } } catch { - // Safe Transaction Service not reachable — skip + this.log(`(Identity scan skipped — chain not reachable)`); } // Clean up legacy key if imported diff --git a/packages/sdk/src/client/common/abis/SafeTimelockFactory.json b/packages/sdk/src/client/common/abis/SafeTimelockFactory.json index 7919a28c..b556fe1a 100644 --- a/packages/sdk/src/client/common/abis/SafeTimelockFactory.json +++ b/packages/sdk/src/client/common/abis/SafeTimelockFactory.json @@ -1,93 +1,354 @@ [ + { + "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": "deploySafe", + "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": "owners", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "threshold", + "type": "uint256", + "internalType": "uint256" + } ] }, - { "name": "salt", "type": "bytes32", "internalType": "bytes32" } + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + } ], - "outputs": [{ "name": "safe", "type": "address", "internalType": "address" }], - "stateMutability": "nonpayable" + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" }, { "type": "function", - "name": "deployTimelock", + "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.TimelockConfig", + "internalType": "struct ISafeTimelockFactory.SafeConfig", "components": [ - { "name": "minDelay", "type": "uint256", "internalType": "uint256" }, - { "name": "proposers", "type": "address[]", "internalType": "address[]" }, - { "name": "executors", "type": "address[]", "internalType": "address[]" } + { + "name": "owners", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "threshold", + "type": "uint256", + "internalType": "uint256" + } ] }, - { "name": "salt", "type": "bytes32", "internalType": "bytes32" } + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "safe", + "type": "address", + "internalType": "address" + } ], - "outputs": [{ "name": "timelock", "type": "address", "internalType": "address" }], "stateMutability": "nonpayable" }, { "type": "function", - "name": "calculateSafeAddress", + "name": "deployTimelock", "inputs": [ - { "name": "deployer", "type": "address", "internalType": "address" }, { "name": "config", "type": "tuple", - "internalType": "struct ISafeTimelockFactory.SafeConfig", + "internalType": "struct ISafeTimelockFactory.TimelockConfig", "components": [ - { "name": "owners", "type": "address[]", "internalType": "address[]" }, - { "name": "threshold", "type": "uint256", "internalType": "uint256" } + { + "name": "minDelay", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "proposers", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "executors", + "type": "address[]", + "internalType": "address[]" + } ] }, - { "name": "salt", "type": "bytes32", "internalType": "bytes32" } + { + "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[]" + } ], - "outputs": [{ "name": "", "type": "address", "internalType": "address" }], "stateMutability": "view" }, { "type": "function", - "name": "calculateTimelockAddress", + "name": "getTimelocksByDeployer", "inputs": [ - { "name": "deployer", "type": "address", "internalType": "address" }, - { "name": "salt", "type": "bytes32", "internalType": "bytes32" } + { + "name": "deployer", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "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" }], + "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" }], + "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" } + { + "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 }, @@ -95,17 +356,63 @@ "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" } + { + "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": [] } -] + { + "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/contract/caller.ts b/packages/sdk/src/client/common/contract/caller.ts index 4b7e893c..bc000976 100644 --- a/packages/sdk/src/client/common/contract/caller.ts +++ b/packages/sdk/src/client/common/contract/caller.ts @@ -1843,3 +1843,39 @@ export async function discoverTimelock( /** @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[]; +} diff --git a/packages/sdk/src/client/index.ts b/packages/sdk/src/client/index.ts index f7127e41..6d2d1a64 100644 --- a/packages/sdk/src/client/index.ts +++ b/packages/sdk/src/client/index.ts @@ -117,6 +117,8 @@ export { deployTimelock, discoverTimelock, discoverTimelockForEOA, + getTimelocksByDeployer, + getSafesByDeployer, CANONICAL_SALT, type DeploySafeOptions, type DeployTimelockOptions, From 908606c1e8d411259f6e92e065b32199633091e6 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Wed, 15 Apr 2026 14:22:59 -0700 Subject: [PATCH 23/41] fix: discover Safe-deployed Timelocks during login identity scan --- packages/cli/src/commands/auth/login.ts | 41 ++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 0e56bf3e..916c5d45 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -142,18 +142,29 @@ export default class AuthLogin extends Command { replaceAllIdentities([{ type: "eoa", address }]); setActiveIdentity(environment, address); - // Discover all Safes and Timelocks deployed by this EOA via SafeTimelockFactory + // 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); - const [timelocks, safes] = await Promise.all([ - getTimelocksByDeployer(publicClient, environmentConfig, address as Address), + // 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), ]); - if (safes.length === 0 && timelocks.length === 0) { + // 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}`); } @@ -173,7 +184,8 @@ export default class AuthLogin extends Command { } } - for (const timelock of timelocks) { + // 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(), ); @@ -188,6 +200,25 @@ export default class AuthLogin extends Command { } } } + + 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) { + addIdentity({ type: "timelock", address: timelock, safeAddress: safe, environment }); + this.log(`✓ Timelock added to identities`); + } + } + } } catch { this.log(`(Identity scan skipped — chain not reachable)`); } From aab26c61b39655e59f9b92b6facf5d48be8e493e Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 16 Apr 2026 10:12:37 -0700 Subject: [PATCH 24/41] fix: use factory registry for identity discovery, fix whoami hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth identity new: replace discoverTimelock (single canonical-salt lookup) with getTimelocksByDeployer (full factory registry scan), supporting multiple timelocks per proposer - auth whoami: fix misleading hints — 'auth new' → 'auth gen'/'auth login', 'auth login' to switch → 'auth identity select' - environment.ts: update sepolia-dev AppController address to latest deploy Co-Authored-By: Claude Sonnet 4.6 --- package.json | 28 +++++----- .../cli/src/commands/auth/identity/new.ts | 56 ++++++++++++------- packages/cli/src/commands/auth/whoami.ts | 4 +- .../src/client/common/config/environment.ts | 2 +- 4 files changed, 53 insertions(+), 37 deletions(-) diff --git a/package.json b/package.json index ef0dfcf9..c03d1323 100644 --- a/package.json +++ b/package.json @@ -29,19 +29,17 @@ "prettier": { "printWidth": 100 }, - "pnpm": { - "overrides": { - "minimatch@3": "3.1.5", - "minimatch@5": "5.1.9", - "minimatch@9": "9.0.9", - "rollup": "4.60.1", - "ajv": "6.14.0", - "flatted": "3.4.2", - "brace-expansion@1": "1.1.13", - "brace-expansion@2": "2.0.3", - "picomatch": "4.0.4", - "diff": "4.0.4" - } - }, - "packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c" + "packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c", + "overrides": { + "minimatch@3": "3.1.5", + "minimatch@5": "5.1.9", + "minimatch@9": "9.0.9", + "rollup": "4.60.1", + "ajv": "6.14.0", + "flatted": "3.4.2", + "brace-expansion@1": "1.1.13", + "brace-expansion@2": "2.0.3", + "picomatch": "4.0.4", + "diff": "4.0.4" + } } diff --git a/packages/cli/src/commands/auth/identity/new.ts b/packages/cli/src/commands/auth/identity/new.ts index c13f4d2e..e383730b 100644 --- a/packages/cli/src/commands/auth/identity/new.ts +++ b/packages/cli/src/commands/auth/identity/new.ts @@ -14,7 +14,7 @@ import { getEnvironmentConfig, deploySafe, deployTimelock, - discoverTimelock, + getTimelocksByDeployer, type DeploySafeOptions, type DeployTimelockOptions, } from "@layr-labs/ecloud-sdk"; @@ -194,30 +194,48 @@ export default class AuthIdentityNew extends Command { validate: (v) => (v.trim().startsWith("0x") ? true : "Must be a 0x address"), }) as Address; - // Check if a canonical Timelock already exists for this proposer (EOA or Safe) - const existing = await discoverTimelock(publicClient, environmentConfig, proposer); - if (existing) { - const delayHours = Number(existing.minDelay) / 3600; - const delayLabel = delayHours >= 24 ? `${delayHours / 24}d` : `${delayHours}h`; + // Find all Timelocks deployed by this proposer via the factory registry + const existingTimelocks = await getTimelocksByDeployer(publicClient, environmentConfig, proposer); + if (existingTimelocks.length > 0) { const proposerLabel = proposerKind === "eoa" ? "EOA" : "Safe"; - const alreadyInConfig = getIdentities().some( - (id) => id.address.toLowerCase() === existing.address.toLowerCase(), - ); - if (alreadyInConfig) { - this.log(`\nTimelock ${existing.address} is already in your identities.`); - const activate = await confirm({ message: "Set it as active identity?", default: true }); + 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 — just offer to switch active + this.log(`\nAll Timelocks for this ${proposerLabel} are already in your identities:`); + for (const addr of knownTimelocks) this.log(` ${addr}`); + const activate = await confirm({ message: "Set one as active identity?", default: true }); if (activate) { - setActiveIdentity(flags.environment, existing.address); - this.log(`✓ Active identity set to Timelock ${existing.address}`); + const chosen = existingTimelocks.length === 1 + ? existingTimelocks[0] + : (await select({ + message: "Which Timelock?", + choices: existingTimelocks.map((a) => ({ name: a, value: a })), + })); + setActiveIdentity(flags.environment, chosen); + this.log(`✓ Active identity set to Timelock ${chosen}`); } } else { - this.log(`\nA Timelock already exists for this ${proposerLabel}: ${existing.address} (${delayLabel} delay)`); - const addIt = await confirm({ message: "Add it to your identities?", default: true }); + 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"; - addIdentity({ type: "timelock", address: existing.address, delay: delayLabel, safeAddress: isSafe ? proposer : undefined, environment: flags.environment }); - setActiveIdentity(flags.environment, existing.address); - this.log(`✓ Timelock added and set as active identity`); + 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}`); } } return; diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts index 88cfacd9..81d1cce5 100644 --- a/packages/cli/src/commands/auth/whoami.ts +++ b/packages/cli/src/commands/auth/whoami.ts @@ -46,7 +46,7 @@ export default class AuthWhoami extends Command { if (identities.length === 0) { this.log("Identities: none"); this.log(""); - this.log("Run 'ecloud auth new' to create an identity."); + this.log("Run 'ecloud auth gen' to generate a new key, or 'ecloud auth login' to import an existing one."); return; } @@ -67,7 +67,7 @@ export default class AuthWhoami extends Command { if (!activeAddress) { this.log("No active identity. Run 'ecloud auth login' to select one."); } else { - this.log("Run 'ecloud auth login' to switch active identity."); + this.log("Run 'ecloud auth identity select' to switch active identity."); } }); } diff --git a/packages/sdk/src/client/common/config/environment.ts b/packages/sdk/src/client/common/config/environment.ts index 5fdfe9a6..caee00d5 100644 --- a/packages/sdk/src/client/common/config/environment.ts +++ b/packages/sdk/src/client/common/config/environment.ts @@ -39,7 +39,7 @@ const ENVIRONMENTS: Record> = { "sepolia-dev": { name: "sepolia", build: "dev", - appControllerAddress: "0xa86DC1C47cb2518327fB4f9A1627F51966c83B92", + appControllerAddress: "0x84639cE873580a5c19DCCb78f92EFAdCC01019e0", permissionControllerAddress: ChainAddresses[SEPOLIA_CHAIN_ID].PermissionController, erc7702DelegatorAddress: CommonAddresses.ERC7702Delegator, kmsServerURL: "http://10.128.0.57:8080", From 5ab224e6efc7b2cb9f546383b272dacd6425b674 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 16 Apr 2026 10:17:27 -0700 Subject: [PATCH 25/41] fix: allow deploying additional timelocks when one already exists After handling existing timelocks, ask whether to deploy another with a different delay instead of always returning early. Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/commands/auth/identity/new.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/auth/identity/new.ts b/packages/cli/src/commands/auth/identity/new.ts index e383730b..16c82d4a 100644 --- a/packages/cli/src/commands/auth/identity/new.ts +++ b/packages/cli/src/commands/auth/identity/new.ts @@ -238,7 +238,9 @@ export default class AuthIdentityNew extends Command { this.log(`✓ Timelock${newTimelocks.length > 1 ? "s" : ""} added and active set to ${chosen}`); } } - return; + + const deployAnother = await confirm({ message: "Deploy an additional Timelock with a different delay?", default: false }); + if (!deployAnother) return; } const delayStr = await input({ From 85666b11b476ac9474d9fecb295d058ceb81f7bb Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 16 Apr 2026 10:25:45 -0700 Subject: [PATCH 26/41] feat: support random salt for deploying multiple timelocks from same EOA Adds optional `salt` parameter to `DeployTimelockOptions` in the SDK so callers can override the default CANONICAL_SALT (bytes32(0)). In the CLI, when a user chooses to deploy an additional timelock beyond the first, a cryptographically random 32-byte salt is generated to avoid CREATE2 collision. Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/commands/auth/identity/new.ts | 3 +++ packages/sdk/src/client/common/contract/caller.ts | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/auth/identity/new.ts b/packages/cli/src/commands/auth/identity/new.ts index 16c82d4a..42602090 100644 --- a/packages/cli/src/commands/auth/identity/new.ts +++ b/packages/cli/src/commands/auth/identity/new.ts @@ -196,6 +196,7 @@ export default class AuthIdentityNew extends Command { // Find all Timelocks deployed by this proposer via the factory registry const existingTimelocks = await getTimelocksByDeployer(publicClient, environmentConfig, proposer); + let useRandomSalt = false; if (existingTimelocks.length > 0) { const proposerLabel = proposerKind === "eoa" ? "EOA" : "Safe"; const storedAddresses = new Set(getIdentities().map((id) => id.address.toLowerCase())); @@ -241,6 +242,7 @@ export default class AuthIdentityNew extends Command { const deployAnother = await confirm({ message: "Deploy an additional Timelock with a different delay?", default: false }); if (!deployAnother) return; + useRandomSalt = true; } const delayStr = await input({ @@ -259,6 +261,7 @@ export default class AuthIdentityNew extends Command { minDelay, proposers: [proposer], executors: [proposer], + salt: useRandomSalt ? (`0x${Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex")}` as `0x${string}`) : undefined, } as DeployTimelockOptions, logger, ); diff --git a/packages/sdk/src/client/common/contract/caller.ts b/packages/sdk/src/client/common/contract/caller.ts index bc000976..12bb6b35 100644 --- a/packages/sdk/src/client/common/contract/caller.ts +++ b/packages/sdk/src/client/common/contract/caller.ts @@ -1621,6 +1621,8 @@ export interface DeployTimelockOptions { minDelay: bigint; proposers: Address[]; executors: Address[]; + /** Salt for CREATE2 deployment. Defaults to CANONICAL_SALT (bytes32(0)). */ + salt?: Hex; } /** @@ -1631,7 +1633,7 @@ export async function deployTimelock( logger: Logger = noopLogger, ): Promise<{ tx: Hex; timelock: Address }> { const { walletClient, publicClient, environmentConfig, minDelay, proposers, executors } = options; - const salt = CANONICAL_SALT; + const salt = options.salt ?? CANONICAL_SALT; const factoryAddress = await getSafeTimelockFactoryAddress(publicClient, environmentConfig); const account = walletClient.account!; From 7fa73f148f5cfd7bd019716fa2fb1865061cdf48 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 16 Apr 2026 10:28:47 -0700 Subject: [PATCH 27/41] fix: use delay-derived deterministic salt instead of random Salt is keccak256(abi.encodePacked(minDelay)), so the same delay cannot be deployed twice for the same proposer (CREATE2 reverts), and different delays produce distinct Timelock addresses. Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/commands/auth/identity/new.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/auth/identity/new.ts b/packages/cli/src/commands/auth/identity/new.ts index 42602090..4aca6750 100644 --- a/packages/cli/src/commands/auth/identity/new.ts +++ b/packages/cli/src/commands/auth/identity/new.ts @@ -22,6 +22,7 @@ import { withTelemetry } from "../../../telemetry"; import { commonFlags, validateCommonFlags } from "../../../flags"; import { createViemClients } from "../../../utils/viemClients"; import { addIdentity, setActiveIdentity, getIdentities } from "../../../utils/globalConfig"; +import { keccak256, encodePacked } from "viem"; import type { Address } from "viem"; /** Parse human delay strings like "24h", "7d", "30m" into seconds */ @@ -261,7 +262,7 @@ export default class AuthIdentityNew extends Command { minDelay, proposers: [proposer], executors: [proposer], - salt: useRandomSalt ? (`0x${Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex")}` as `0x${string}`) : undefined, + salt: useRandomSalt ? keccak256(encodePacked(["uint256"], [minDelay])) : undefined, } as DeployTimelockOptions, logger, ); From c2e80f4c1ace10639254e0aff890dc4d95ea5a7a Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 16 Apr 2026 10:31:29 -0700 Subject: [PATCH 28/41] fix: require unit in delay input, validate at prompt Bare numbers like "22" now error (previously silently treated as seconds). Both delay prompts validate with the same regex before accepting, so the user gets inline feedback rather than a thrown error post-confirmation. Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/commands/auth/identity/new.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/auth/identity/new.ts b/packages/cli/src/commands/auth/identity/new.ts index 4aca6750..2a873627 100644 --- a/packages/cli/src/commands/auth/identity/new.ts +++ b/packages/cli/src/commands/auth/identity/new.ts @@ -27,10 +27,10 @@ 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".`); + 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] || "s").toLowerCase(); + const unit = match[2].toLowerCase(); const multipliers: Record = { s: 1, m: 60, h: 3600, d: 86400 }; return BigInt(n * multipliers[unit]); } @@ -122,7 +122,11 @@ export default class AuthIdentityNew extends Command { 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" }); + 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); @@ -249,6 +253,7 @@ export default class AuthIdentityNew extends Command { 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); From 574babba087ef1b3183d35fb57ffd4f4acc98378 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 16 Apr 2026 10:35:30 -0700 Subject: [PATCH 29/41] fix: let runtime BUILD_TYPE env var override build-time constant Previously the build-time compiled value always won, so BUILD_TYPE=dev had no effect when running a prod build. Runtime env var now takes precedence so the dev alias works without rebuilding. Co-Authored-By: Claude Sonnet 4.6 --- packages/sdk/src/client/common/config/environment.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/src/client/common/config/environment.ts b/packages/sdk/src/client/common/config/environment.ts index caee00d5..9f6e504f 100644 --- a/packages/sdk/src/client/common/config/environment.ts +++ b/packages/sdk/src/client/common/config/environment.ts @@ -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"; From 8d28d8a5e3594a163e5f3bf5761f5e1c75665276 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 16 Apr 2026 10:47:51 -0700 Subject: [PATCH 30/41] fix: show delay alongside address in existing timelock list and selector When all timelocks are already stored, display the known delay next to each address so users can distinguish between them without guessing. Co-Authored-By: Claude Sonnet 4.6 --- .../cli/src/commands/auth/identity/new.ts | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/commands/auth/identity/new.ts b/packages/cli/src/commands/auth/identity/new.ts index 2a873627..726e94fd 100644 --- a/packages/cli/src/commands/auth/identity/new.ts +++ b/packages/cli/src/commands/auth/identity/new.ts @@ -211,20 +211,39 @@ export default class AuthIdentityNew extends Command { const knownTimelocks = existingTimelocks.filter((a) => storedAddresses.has(a.toLowerCase())); if (newTimelocks.length === 0) { - // All already in config — just offer to switch active + // 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:`); - for (const addr of knownTimelocks) this.log(` ${addr}`); - const activate = await confirm({ message: "Set one as active identity?", default: true }); - if (activate) { + 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) => ({ name: a, value: a })), + 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 + useRandomSalt = true; } else { this.log(`\nFound ${newTimelocks.length} Timelock${newTimelocks.length > 1 ? "s" : ""} deployed by this ${proposerLabel}:`); for (const addr of newTimelocks) this.log(` ${addr}`); @@ -243,11 +262,10 @@ export default class AuthIdentityNew extends Command { 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; + useRandomSalt = true; } - - const deployAnother = await confirm({ message: "Deploy an additional Timelock with a different delay?", default: false }); - if (!deployAnother) return; - useRandomSalt = true; } const delayStr = await input({ From d5e0a4e398a6ed1c3eecd1fa4647b786263c65aa Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 16 Apr 2026 11:36:43 -0700 Subject: [PATCH 31/41] fix: update sepolia-dev AppController address after redeploy --- packages/sdk/src/client/common/config/environment.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/src/client/common/config/environment.ts b/packages/sdk/src/client/common/config/environment.ts index 9f6e504f..16bcf895 100644 --- a/packages/sdk/src/client/common/config/environment.ts +++ b/packages/sdk/src/client/common/config/environment.ts @@ -39,7 +39,7 @@ const ENVIRONMENTS: Record> = { "sepolia-dev": { name: "sepolia", build: "dev", - appControllerAddress: "0x84639cE873580a5c19DCCb78f92EFAdCC01019e0", + appControllerAddress: "0xf4D36493CE5FFBC5518DC8c0a89C4eBBD2aee009", permissionControllerAddress: ChainAddresses[SEPOLIA_CHAIN_ID].PermissionController, erc7702DelegatorAddress: CommonAddresses.ERC7702Delegator, kmsServerURL: "http://10.128.0.57:8080", From 28489f45756c625ff0d9f4e4eb91cae5b60228e1 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 16 Apr 2026 11:46:30 -0700 Subject: [PATCH 32/41] feat: add auth sync command to rebuild identities from chain --- packages/cli/src/commands/auth/sync.ts | 101 +++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 packages/cli/src/commands/auth/sync.ts diff --git a/packages/cli/src/commands/auth/sync.ts b/packages/cli/src/commands/auth/sync.ts new file mode 100644 index 00000000..12bd3032 --- /dev/null +++ b/packages/cli/src/commands/auth/sync.ts @@ -0,0 +1,101 @@ +/** + * 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 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) { + addIdentity({ type: "safe", address: safe, environment }); + this.log(`✓ Safe: ${safe}`); + } + + for (const timelock of directTimelocks) { + addIdentity({ type: "timelock", address: timelock, delay: "unknown", environment }); + this.log(`✓ Timelock: ${timelock} (via EOA)`); + } + + for (const timelock of safeTimelocks) { + const safe = safes.find((s) => + safeTimelockArrays[safes.indexOf(s)]?.some((t) => t.toLowerCase() === timelock.toLowerCase()), + ); + addIdentity({ type: "timelock", address: timelock, delay: "unknown", safeAddress: safe, environment }); + this.log(`✓ Timelock: ${timelock}${safe ? ` (via Safe ${safe})` : ""}`); + } + + const total = safes.length + directTimelocks.length + safeTimelocks.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.`); + }); + } +} From 35056f92e57cfba71bf0a427bdac6c9ce6abbc75 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 16 Apr 2026 11:49:30 -0700 Subject: [PATCH 33/41] fix: add identity creation hint to whoami output --- packages/cli/src/commands/auth/whoami.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts index 81d1cce5..92442d64 100644 --- a/packages/cli/src/commands/auth/whoami.ts +++ b/packages/cli/src/commands/auth/whoami.ts @@ -67,7 +67,8 @@ export default class AuthWhoami extends Command { if (!activeAddress) { this.log("No active identity. Run 'ecloud auth login' to select one."); } else { - this.log("Run 'ecloud auth identity select' to switch active identity."); + 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."); } }); } From fc1479b387da76092cce25f0aaed2376cc50d5cd Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 16 Apr 2026 12:46:45 -0700 Subject: [PATCH 34/41] =?UTF-8?q?auth:=20identity=20UX=20overhaul=20?= =?UTF-8?q?=E2=80=94=20Safe/Timelock=20governance,=20sync=20command,=20ded?= =?UTF-8?q?up=20ABIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth sync: new command to rescan chain and rebuild identities from stored signing key - auth identity new: proposer selector (EOA + on-chain Safes with threshold/owner info), PROPOSER_ROLE filter for existing timelocks, deterministic salt keccak256(proposer, minDelay) - auth login/sync: read getMinDelay() from chain, store human-readable delay (e.g. "24h") - StoredIdentity: add threshold/owners fields for Safes; formatIdentity shows "Safe 1/1 · 0xabc…xyz" - contractAbis.ts: shared SAFE_ABI, TIMELOCK_ABI, fetchSafeInfo(), fetchTimelockDelay() — eliminates inline ABI duplicates across new.ts, login.ts, sync.ts - format.ts: add formatDelay(seconds: bigint) → human-readable string --- .../cli/src/commands/auth/identity/new.ts | 63 ++++++++++++------- packages/cli/src/commands/auth/login.ts | 14 +++-- packages/cli/src/commands/auth/sync.ts | 14 +++-- packages/cli/src/utils/contractAbis.ts | 45 +++++++++++++ packages/cli/src/utils/format.ts | 13 ++++ packages/cli/src/utils/globalConfig.ts | 15 ++++- 6 files changed, 132 insertions(+), 32 deletions(-) create mode 100644 packages/cli/src/utils/contractAbis.ts diff --git a/packages/cli/src/commands/auth/identity/new.ts b/packages/cli/src/commands/auth/identity/new.ts index 726e94fd..ed434d95 100644 --- a/packages/cli/src/commands/auth/identity/new.ts +++ b/packages/cli/src/commands/auth/identity/new.ts @@ -15,6 +15,7 @@ import { deploySafe, deployTimelock, getTimelocksByDeployer, + getSafesByDeployer, type DeploySafeOptions, type DeployTimelockOptions, } from "@layr-labs/ecloud-sdk"; @@ -22,6 +23,7 @@ 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"; @@ -163,7 +165,7 @@ export default class AuthIdentityNew extends Command { this.log(` Tx: ${tlTx}`); this.log(`\n✓ Active identity set to: Timelock(Safe) ${timelock}`); } else { - addIdentity({ type: "safe", address: safe, environment: flags.environment }); + 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}`); } @@ -184,24 +186,45 @@ export default class AuthIdentityNew extends Command { this.error(`Account ${signerAddress} has no ETH. Fund it before deploying.`); } - const proposerKind = await select({ - message: "Is the proposer/executor an EOA or a Safe?", - choices: [ - { name: "EOA (signing key)", value: "eoa" }, - { name: "Gnosis Safe (multi-sig)", value: "safe" }, - ], - }); - - const proposer: Address = proposerKind === "eoa" - ? signerAddress - : await input({ - message: "Safe address:", - validate: (v) => (v.trim().startsWith("0x") ? true : "Must be a 0x address"), - }) as Address; + // Build proposer choices: EOA + any Safes deployed by this EOA + const knownSafes = await getSafesByDeployer(publicClient, environmentConfig, signerAddress); + + 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}` }; + } + }), + ); - // Find all Timelocks deployed by this proposer via the factory registry - const existingTimelocks = await getTimelocksByDeployer(publicClient, environmentConfig, proposer); - let useRandomSalt = false; + 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())); @@ -243,7 +266,6 @@ export default class AuthIdentityNew extends Command { return; } // action === "deploy": fall through to deploy flow below - useRandomSalt = true; } else { this.log(`\nFound ${newTimelocks.length} Timelock${newTimelocks.length > 1 ? "s" : ""} deployed by this ${proposerLabel}:`); for (const addr of newTimelocks) this.log(` ${addr}`); @@ -264,7 +286,6 @@ export default class AuthIdentityNew extends Command { } const deployAnother = await confirm({ message: "Deploy an additional Timelock with a different delay?", default: false }); if (!deployAnother) return; - useRandomSalt = true; } } @@ -285,7 +306,7 @@ export default class AuthIdentityNew extends Command { minDelay, proposers: [proposer], executors: [proposer], - salt: useRandomSalt ? keccak256(encodePacked(["uint256"], [minDelay])) : undefined, + salt: keccak256(encodePacked(["address", "uint256"], [proposer, minDelay])), } as DeployTimelockOptions, logger, ); diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 916c5d45..bbee669c 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -24,6 +24,7 @@ import { import { getHiddenInput, displayWarning } from "../../utils/security"; import { withTelemetry } from "../../telemetry"; import { commonFlags } from "../../flags"; +import { fetchSafeInfo, fetchTimelockDelay } from "../../utils/contractAbis"; import { getIdentities, addIdentity, @@ -178,7 +179,8 @@ export default class AuthLogin extends Command { this.log(`Found Safe: ${safe}`); const addIt = await confirm({ message: `Add this Safe to your identities?`, default: true }); if (addIt) { - addIdentity({ type: "safe", address: safe, environment }); + const { threshold, owners } = await fetchSafeInfo(publicClient, safe as Address); + addIdentity({ type: "safe", address: safe, environment, threshold, owners }); this.log(`✓ Safe added to identities`); } } @@ -195,8 +197,9 @@ export default class AuthLogin extends Command { this.log(`Found Timelock: ${timelock}`); const addIt = await confirm({ message: `Add this Timelock to your identities?`, default: true }); if (addIt) { - addIdentity({ type: "timelock", address: timelock, environment }); - this.log(`✓ Timelock added to identities`); + const delay = await fetchTimelockDelay(publicClient, timelock as Address); + addIdentity({ type: "timelock", address: timelock, delay, environment }); + this.log(`✓ Timelock added to identities (delay: ${delay})`); } } } @@ -214,8 +217,9 @@ export default class AuthLogin extends Command { 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) { - addIdentity({ type: "timelock", address: timelock, safeAddress: safe, environment }); - this.log(`✓ Timelock added to identities`); + 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})`); } } } diff --git a/packages/cli/src/commands/auth/sync.ts b/packages/cli/src/commands/auth/sync.ts index 12bd3032..923b4c49 100644 --- a/packages/cli/src/commands/auth/sync.ts +++ b/packages/cli/src/commands/auth/sync.ts @@ -22,6 +22,7 @@ import { addIdentity, } from "../../utils/globalConfig"; import { createPublicClientOnly } from "../../utils/viemClients"; +import { fetchSafeInfo, fetchTimelockDelay } from "../../utils/contractAbis"; import type { Address } from "viem"; export default class AuthSync extends Command { @@ -71,21 +72,24 @@ export default class AuthSync extends Command { setActiveIdentity(environment, address); for (const safe of safes) { - addIdentity({ type: "safe", address: safe, environment }); + const { threshold, owners } = await fetchSafeInfo(publicClient, safe as Address); + addIdentity({ type: "safe", address: safe, environment, threshold, owners }); this.log(`✓ Safe: ${safe}`); } for (const timelock of directTimelocks) { - addIdentity({ type: "timelock", address: timelock, delay: "unknown", environment }); - this.log(`✓ Timelock: ${timelock} (via EOA)`); + const delay = await fetchTimelockDelay(publicClient, timelock as Address); + addIdentity({ type: "timelock", address: timelock, delay, environment }); + this.log(`✓ Timelock: ${timelock} (via EOA, delay: ${delay})`); } for (const timelock of safeTimelocks) { const safe = safes.find((s) => safeTimelockArrays[safes.indexOf(s)]?.some((t) => t.toLowerCase() === timelock.toLowerCase()), ); - addIdentity({ type: "timelock", address: timelock, delay: "unknown", safeAddress: safe, environment }); - this.log(`✓ Timelock: ${timelock}${safe ? ` (via Safe ${safe})` : ""}`); + const delay = await fetchTimelockDelay(publicClient, timelock as Address); + addIdentity({ type: "timelock", address: timelock, delay, safeAddress: safe, environment }); + this.log(`✓ Timelock: ${timelock}${safe ? ` (via Safe ${safe})` : ""} (delay: ${delay})`); } const total = safes.length + directTimelocks.length + safeTimelocks.length; diff --git a/packages/cli/src/utils/contractAbis.ts b/packages/cli/src/utils/contractAbis.ts new file mode 100644 index 00000000..4042f58f --- /dev/null +++ b/packages/cli/src/utils/contractAbis.ts @@ -0,0 +1,45 @@ +/** + * 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" }, +] as const; + +/** 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..7397b679 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -175,6 +175,19 @@ 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`; +} + /** * 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 412f27f1..8a0f82c6 100644 --- a/packages/cli/src/utils/globalConfig.ts +++ b/packages/cli/src/utils/globalConfig.ts @@ -32,6 +32,10 @@ export interface StoredIdentity { 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 { @@ -481,7 +485,16 @@ export function clearActiveIdentity(environment: string): void { export function formatIdentity(id: StoredIdentity): string { const short = id.address.slice(0, 6) + "..." + id.address.slice(-4); if (id.type === "eoa") return `${short} (EOA)`; - if (id.type === "safe") return `${short} (Safe${id.environment ? ` · ${id.environment}` : ""})`; + if (id.type === "safe") { + let safeInfo = "Safe"; + if (id.threshold != null && id.owners != null) { + const ownerSummary = id.owners.length <= 3 + ? id.owners.map((o) => `${o.slice(0, 6)}…${o.slice(-4)}`).join(", ") + : `${id.owners.slice(0, 2).map((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 ${id.safeAddress.slice(0, 6)}...${id.safeAddress.slice(-4)}` From 4fef5d68eea02401951ed16d3dc52eda2a33c8ff Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 16 Apr 2026 17:34:59 -0700 Subject: [PATCH 35/41] feat: identity routing for team roles + lifecycle governance fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - team grant/revoke: route all role operations through identity (Safe/Timelock) via encodeGrantTeamRoleData/encodeRevokeTeamRoleData; previously only ADMIN went through identity routing, PAUSER/DEVELOPER were sent directly as EOA - add encodeRevokeTeamRoleData to SDK (ABI, encoder fn, barrel exports) - fix gas estimation crash for Safe/Timelock identities in start/stop/terminate/ transfer: skip estimation when identity is not EOA (msg.sender mismatch causes revert); estimateGas still runs for EOA path - fix getPrivateKeyInteractive(environment) bug in all compute commands — was passing environment string "sepolia-dev" as the private key parameter - remove acceptAdmin from deploy batch — eigenx-contracts emptied App.initialize, 2-step admin handshake no longer needed - whoami: add verbose flag to show full addresses in identity display --- packages/cli/src/commands/auth/whoami.ts | 4 +- .../compute/app/ownership/transfer.ts | 32 ++--- .../cli/src/commands/compute/app/start.ts | 33 +++--- packages/cli/src/commands/compute/app/stop.ts | 39 +++--- .../cli/src/commands/compute/app/terminate.ts | 32 ++--- .../cli/src/commands/compute/team/grant.ts | 111 +++++++----------- .../cli/src/commands/compute/team/revoke.ts | 62 ++++++++-- packages/cli/src/utils/globalConfig.ts | 10 +- .../sdk/src/client/common/contract/caller.ts | 65 ++-------- packages/sdk/src/client/common/types/index.ts | 2 +- packages/sdk/src/client/index.ts | 1 + .../src/client/modules/compute/app/index.ts | 12 ++ .../sdk/src/client/modules/compute/index.ts | 2 +- 13 files changed, 212 insertions(+), 193 deletions(-) diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts index 92442d64..f7773bf1 100644 --- a/packages/cli/src/commands/auth/whoami.ts +++ b/packages/cli/src/commands/auth/whoami.ts @@ -21,12 +21,14 @@ export default class AuthWhoami extends Command { static flags = { environment: commonFlags.environment, + 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; // Signing key status const result = await getPrivateKeyWithSource({ privateKey: undefined }); @@ -55,7 +57,7 @@ export default class AuthWhoami extends Command { const isActive = id.address.toLowerCase() === activeAddress?.toLowerCase(); const marker = isActive ? "●" : "○"; const active = isActive ? " ← active" : ""; - this.log(` ${marker} ${formatIdentity(id)}${active}`); + this.log(` ${marker} ${formatIdentity(id, verbose)}${active}`); } // If active identity is the EOA signing key itself (no contract identity active) diff --git a/packages/cli/src/commands/compute/app/ownership/transfer.ts b/packages/cli/src/commands/compute/app/ownership/transfer.ts index 7fef9feb..24bc9afb 100644 --- a/packages/cli/src/commands/compute/app/ownership/transfer.ts +++ b/packages/cli/src/commands/compute/app/ownership/transfer.ts @@ -46,7 +46,7 @@ export default class AppOwnershipTransfer extends Command { const environment = flags.environment; const environmentConfig = getEnvironmentConfig(environment); const rpcUrl = flags["rpc-url"] || environmentConfig.defaultRPCURL; - const privateKey = flags["private-key"] || (await getPrivateKeyInteractive(environment)); + const privateKey = await getPrivateKeyInteractive(flags["private-key"]); const appId = await getOrPromptAppID({ appID: args["app-id"], @@ -73,19 +73,23 @@ export default class AppOwnershipTransfer extends Command { this.log(`New owner: ${chalk.bold(newOwner)}`); const callData = encodeTransferOwnershipData(appId, newOwner as Address); - const estimate = await estimateTransactionGas({ - publicClient, - from: address, - to: environmentConfig.appControllerAddress, - data: callData, - }); - - const finalTx = await applyTxOverrides(estimate, flags, { publicClient, address }); - 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}`)); + 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) { diff --git a/packages/cli/src/commands/compute/app/start.ts b/packages/cli/src/commands/compute/app/start.ts index 4aaa6c23..3233dc4c 100644 --- a/packages/cli/src/commands/compute/app/start.ts +++ b/packages/cli/src/commands/compute/app/start.ts @@ -41,7 +41,7 @@ export default class AppLifecycleStart extends Command { const environment = flags.environment; const environmentConfig = getEnvironmentConfig(environment); const rpcUrl = flags.rpcUrl || environmentConfig.defaultRPCURL; - const privateKey = flags["private-key"] || (await getPrivateKeyInteractive(environment)); + const privateKey = await getPrivateKeyInteractive(flags["private-key"]); const appId = await getOrPromptAppID({ appID: args["app-id"], @@ -60,23 +60,28 @@ export default class AppLifecycleStart extends Command { 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; - 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}`)); + } } 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; diff --git a/packages/cli/src/commands/compute/app/stop.ts b/packages/cli/src/commands/compute/app/stop.ts index 8b051e4a..4c27a2fa 100644 --- a/packages/cli/src/commands/compute/app/stop.ts +++ b/packages/cli/src/commands/compute/app/stop.ts @@ -46,7 +46,7 @@ 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"]); // Resolve app ID (prompt if not provided) const appId = await getOrPromptAppID({ @@ -70,28 +70,33 @@ export default class AppLifecycleStop extends Command { // Encode the calldata const callData = encodeStopAppData(appId); - // Estimate gas cost - 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; diff --git a/packages/cli/src/commands/compute/app/terminate.ts b/packages/cli/src/commands/compute/app/terminate.ts index c3ce74a7..21588f6b 100644 --- a/packages/cli/src/commands/compute/app/terminate.ts +++ b/packages/cli/src/commands/compute/app/terminate.ts @@ -42,7 +42,7 @@ export default class AppLifecycleTerminate extends Command { const environment = flags.environment; const environmentConfig = getEnvironmentConfig(environment); const rpcUrl = flags.rpcUrl || environmentConfig.defaultRPCURL; - const privateKey = flags["private-key"] || (await getPrivateKeyInteractive(environment)); + const privateKey = await getPrivateKeyInteractive(flags["private-key"]); const appId = await getOrPromptAppID({ appID: args["app-id"], @@ -61,23 +61,27 @@ export default class AppLifecycleTerminate extends Command { 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; - 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}`)); + } } 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}?`); diff --git a/packages/cli/src/commands/compute/team/grant.ts b/packages/cli/src/commands/compute/team/grant.ts index 24e02e6f..c9dfb95a 100644 --- a/packages/cli/src/commands/compute/team/grant.ts +++ b/packages/cli/src/commands/compute/team/grant.ts @@ -56,7 +56,7 @@ export default class TeamGrant extends Command { const environment = flags.environment; const environmentConfig = getEnvironmentConfig(environment); const rpcUrl = flags["rpc-url"] || environmentConfig.defaultRPCURL; - const privateKey = flags["private-key"] || (await getPrivateKeyInteractive(environment)); + const privateKey = await getPrivateKeyInteractive(flags["private-key"]); const account = args.address; if (!isAddress(account)) { @@ -72,81 +72,60 @@ export default class TeamGrant extends Command { }); const role = TeamRole[flags.role as RoleChoice]; - const isAdminRole = flags.role === "ADMIN"; - this.log(`\nApp: ${chalk.bold(appID)}`); this.log(`Grant: ${chalk.bold(flags.role)} → ${chalk.bold(account)}`); - if (isAdminRole) { - // ADMIN is a sensitive op — route through identity - const { publicClient, walletClient, address } = createViemClients({ - privateKey, - rpcUrl, - environment, - }); - - const identity = printIdentityContext(environment, address, this.log.bind(this)); - - if (identity.type !== "eoa") { - this.log(chalk.yellow(`\nNote: ADMIN role grant will be routed through ${identity.type}.`)); - } - - if ((isMainnet(environmentConfig) || identity.type !== "eoa") && !flags.force) { - const confirmed = await confirm("Grant this ADMIN 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 { - // Look up the team address (owner) for the app - const team = await getAppOwner(publicClient, environmentConfig, appID as Address); - const callData = encodeGrantTeamRoleData(team, role, account as Address); - const estimate = await estimateTransactionGas({ - publicClient, - from: address, - to: environmentConfig.appControllerAddress, - data: callData, - }); - const finalTx = await applyTxOverrides(estimate, flags, { publicClient, address }); + const { publicClient, walletClient, address } = createViemClients({ + privateKey, + rpcUrl, + environment, + }); - const result = await executeWithIdentity({ - environment, - eoaAddress: address, - walletClient, - publicClient, - environmentConfig, - to: environmentConfig.appControllerAddress as Address, - data: callData, - pendingMessage: `Granting ADMIN role to ${account}...`, - txDescription: "GrantTeamRole", - gas: finalTx, - }); + const identity = printIdentityContext(environment, address, this.log.bind(this)); - this.log(""); - printTransactionResult(result, this.log.bind(this)); - if (result.type === "direct") { - this.log(`\n✅ ${chalk.green(`${flags.role} role granted to ${account}`)}`); - } - } - } else { - // PAUSER / DEVELOPER — direct grant (not a sensitive op) - if (isMainnet(environmentConfig) && !flags.force) { - const confirmed = await confirm("Grant this role?"); - if (!confirmed) { - this.log(`\n${chalk.gray("Cancelled")}`); - return; - } + 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, + }); + + 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/revoke.ts b/packages/cli/src/commands/compute/team/revoke.ts index 91256bda..ffbf01b5 100644 --- a/packages/cli/src/commands/compute/team/revoke.ts +++ b/packages/cli/src/commands/compute/team/revoke.ts @@ -1,10 +1,19 @@ import { Command, Args, Flags } from "@oclif/core"; -import { getEnvironmentConfig, isMainnet, TeamRole } from "@layr-labs/ecloud-sdk"; +import { + getEnvironmentConfig, + isMainnet, + TeamRole, + encodeRevokeTeamRoleData, + getAppOwner, +} from "@layr-labs/ecloud-sdk"; import { withTelemetry } from "../../../telemetry"; import { commonFlags } from "../../../flags"; import { createComputeClient } from "../../../client"; -import { getOrPromptAppID, confirm } from "../../../utils/prompts"; +import { getOrPromptAppID, getPrivateKeyInteractive, confirm } from "../../../utils/prompts"; +import { createViemClients } from "../../../utils/viemClients"; +import { printIdentityContext, executeWithIdentity, printTransactionResult } from "../../../utils/identityTransaction"; import { isAddress } from "viem"; +import type { Address } from "viem"; import chalk from "chalk"; const ROLE_CHOICES = ["PAUSER", "DEVELOPER"] as const; @@ -33,17 +42,20 @@ export default class TeamRevoke extends Command { options: ROLE_CHOICES as unknown as string[], env: "ECLOUD_TEAM_ROLE", }), + force: Flags.boolean({ + description: "Skip all confirmation prompts", + default: false, + }), }; async run() { return withTelemetry(this, async () => { const { args, flags } = await this.parse(TeamRevoke); - 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 privateKey = await getPrivateKeyInteractive(flags["private-key"]); const account = args.address; if (!isAddress(account)) { @@ -63,16 +75,50 @@ export default class TeamRevoke extends Command { this.log(`\nApp: ${chalk.bold(appID)}`); this.log(`Revoke: ${chalk.bold(flags.role)} from ${chalk.bold(account)}`); - if (isMainnet(environmentConfig)) { - const confirmed = await confirm("Revoke this role?"); + 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; } } - const res = await compute.app.revokeTeamRole(appID, role, account); - this.log(`\n✅ ${chalk.green(`${flags.role} role revoked from ${account} (tx: ${res.tx})`)}`); + 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, + }); + + 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/utils/globalConfig.ts b/packages/cli/src/utils/globalConfig.ts index 8a0f82c6..9f9f65f7 100644 --- a/packages/cli/src/utils/globalConfig.ts +++ b/packages/cli/src/utils/globalConfig.ts @@ -482,22 +482,22 @@ export function clearActiveIdentity(environment: string): void { /** * Format a stored identity for display */ -export function formatIdentity(id: StoredIdentity): string { - const short = id.address.slice(0, 6) + "..." + id.address.slice(-4); +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) => `${o.slice(0, 6)}…${o.slice(-4)}`).join(", ") - : `${id.owners.slice(0, 2).map((o) => `${o.slice(0, 6)}…${o.slice(-4)}`).join(", ")} +${id.owners.length - 2} more`; + ? 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 ${id.safeAddress.slice(0, 6)}...${id.safeAddress.slice(-4)}` + ? `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}` : ""})`; } diff --git a/packages/sdk/src/client/common/contract/caller.ts b/packages/sdk/src/client/common/contract/caller.ts index 12bb6b35..7b9a6c8d 100644 --- a/packages/sdk/src/client/common/contract/caller.ts +++ b/packages/sdk/src/client/common/contract/caller.ts @@ -238,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); @@ -265,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; @@ -284,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, @@ -409,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, @@ -434,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]; @@ -456,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}`); - } - - 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); + // 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); - const setAppointeeExecution = data.executions[2]; + const setAppointeeExecution = data.executions[1]; const setAppointeeHash = await walletClient.sendTransaction({ account, to: setAppointeeExecution.target, @@ -508,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}`); @@ -609,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"); 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/index.ts b/packages/sdk/src/client/index.ts index 6d2d1a64..0109f170 100644 --- a/packages/sdk/src/client/index.ts +++ b/packages/sdk/src/client/index.ts @@ -59,6 +59,7 @@ export { encodeTerminateAppData, encodeTransferOwnershipData, encodeGrantTeamRoleData, + encodeRevokeTeamRoleData, } from "./modules/compute"; export { createBillingModule, diff --git a/packages/sdk/src/client/modules/compute/app/index.ts b/packages/sdk/src/client/modules/compute/app/index.ts index dba43376..b04b3465 100644 --- a/packages/sdk/src/client/modules/compute/app/index.ts +++ b/packages/sdk/src/client/modules/compute/app/index.ts @@ -72,6 +72,7 @@ const CONTROLLER_ABI = parseAbi([ "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)", ]); /** @@ -129,6 +130,17 @@ export function encodeGrantTeamRoleData(team: Address, role: TeamRole, 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; diff --git a/packages/sdk/src/client/modules/compute/index.ts b/packages/sdk/src/client/modules/compute/index.ts index 2bef3571..2e88e9c4 100644 --- a/packages/sdk/src/client/modules/compute/index.ts +++ b/packages/sdk/src/client/modules/compute/index.ts @@ -28,4 +28,4 @@ export function createComputeModule(config: ComputeModuleConfig): ComputeModule export { createAppModule, type AppModule, type AppModuleConfig } from "./app"; // Re-export app module utilities -export { encodeStartAppData, encodeStopAppData, encodeTerminateAppData, encodeTransferOwnershipData, encodeGrantTeamRoleData } from "./app"; +export { encodeStartAppData, encodeStopAppData, encodeTerminateAppData, encodeTransferOwnershipData, encodeGrantTeamRoleData, encodeRevokeTeamRoleData } from "./app"; From 201a547946d2d452541913bf092692250faaa68b Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Thu, 16 Apr 2026 23:00:10 -0700 Subject: [PATCH 36/41] feat: show pending Timelock ops in whoami + update AppController address - whoami fetches pending ops for all Timelock identities via getPendingOperations() (single view call per Timelock, no log scanning, no RPC range limits) - pending ops show description (decoded function name), countdown to executable, and op ID - TimelockController ABI: add getPendingOperations, getPendingOperationIds, events - SDK: getPendingTimelockOps() + PendingTimelockOp type exported - whoami: add --rpc-url flag (reads ECLOUD_RPC_URL env var) - environment.ts: update sepolia-dev AppController to 0x6A56214b79d24469f066AdfD1F28bB929824daCE --- packages/cli/src/commands/auth/whoami.ts | 69 ++++++++++++++++++- .../common/abis/TimelockController.json | 55 +++++++++++++++ .../src/client/common/config/environment.ts | 2 +- .../sdk/src/client/common/contract/caller.ts | 49 ++++++++++++- packages/sdk/src/client/index.ts | 2 + 5 files changed, 173 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts index f7773bf1..74db6c98 100644 --- a/packages/cli/src/commands/auth/whoami.ts +++ b/packages/cli/src/commands/auth/whoami.ts @@ -5,14 +5,24 @@ */ 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 chalk from "chalk"; +import type { Address } from "viem"; export default class AuthWhoami extends Command { static description = "Show stored identities and current authentication status"; @@ -21,6 +31,7 @@ export default class AuthWhoami extends Command { static flags = { environment: commonFlags.environment, + "rpc-url": commonFlags["rpc-url"], verbose: commonFlags.verbose, }; @@ -52,12 +63,55 @@ export default class AuthWhoami extends Command { return; } + // 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: ${op.id.slice(0, 10)}…`); + } + } + } } // If active identity is the EOA signing key itself (no contract identity active) @@ -70,8 +124,19 @@ export default class AuthWhoami extends Command { 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."); + this.log("Run 'ecloud auth identity select' to switch active identity."); } }); } } + +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`; +} diff --git a/packages/sdk/src/client/common/abis/TimelockController.json b/packages/sdk/src/client/common/abis/TimelockController.json index 660e11f8..0cb66a92 100644 --- a/packages/sdk/src/client/common/abis/TimelockController.json +++ b/packages/sdk/src/client/common/abis/TimelockController.json @@ -62,5 +62,60 @@ "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 16bcf895..a4608d6f 100644 --- a/packages/sdk/src/client/common/config/environment.ts +++ b/packages/sdk/src/client/common/config/environment.ts @@ -39,7 +39,7 @@ const ENVIRONMENTS: Record> = { "sepolia-dev": { name: "sepolia", build: "dev", - appControllerAddress: "0xf4D36493CE5FFBC5518DC8c0a89C4eBBD2aee009", + appControllerAddress: "0x6A56214b79d24469f066AdfD1F28bB929824daCE", permissionControllerAddress: ChainAddresses[SEPOLIA_CHAIN_ID].PermissionController, erc7702DelegatorAddress: CommonAddresses.ERC7702Delegator, kmsServerURL: "http://10.128.0.57:8080", diff --git a/packages/sdk/src/client/common/contract/caller.ts b/packages/sdk/src/client/common/contract/caller.ts index 7b9a6c8d..f93584bd 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 { @@ -1842,3 +1842,50 @@ export async function 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 }); + 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/index.ts b/packages/sdk/src/client/index.ts index 0109f170..e8926208 100644 --- a/packages/sdk/src/client/index.ts +++ b/packages/sdk/src/client/index.ts @@ -120,10 +120,12 @@ export { discoverTimelockForEOA, getTimelocksByDeployer, getSafesByDeployer, + getPendingTimelockOps, CANONICAL_SALT, type DeploySafeOptions, type DeployTimelockOptions, type DiscoveredTimelock, + type PendingTimelockOp, } from "./common/contract/caller"; // Safe Transaction Service From 7078024aaf8b0f82a3668dabf2210c050da1e477 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Fri, 17 Apr 2026 14:32:26 -0700 Subject: [PATCH 37/41] fix: resolve Timelock(Safe) identity by checking PROPOSER_ROLE on-chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sync: replace factory deployer lookup with hasRole(PROPOSER_ROLE, safe) check — TimelockControllerImpl uses AccessControl not AccessControlEnumerable so getRoleMember doesn't exist deploySafe: predict address before deploy and skip if already exists to handle re-runs gracefully identity new: surface existing predicted Safe in proposer selector --- .../cli/src/commands/auth/identity/new.ts | 29 ++++++++++++--- packages/cli/src/commands/auth/sync.ts | 36 ++++++++++++------- packages/cli/src/utils/contractAbis.ts | 18 ++++++++++ .../sdk/src/client/common/contract/caller.ts | 18 +++++++++- 4 files changed, 82 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/commands/auth/identity/new.ts b/packages/cli/src/commands/auth/identity/new.ts index ed434d95..2c1c7889 100644 --- a/packages/cli/src/commands/auth/identity/new.ts +++ b/packages/cli/src/commands/auth/identity/new.ts @@ -16,6 +16,8 @@ import { deployTimelock, getTimelocksByDeployer, getSafesByDeployer, + getSafeTimelockFactoryAddress, + CANONICAL_SALT, type DeploySafeOptions, type DeployTimelockOptions, } from "@layr-labs/ecloud-sdk"; @@ -139,12 +141,16 @@ export default class AuthIdentityNew extends Command { this.log(`Deploying Safe (${thresholdRaw} of ${owners.length}) via factory...`); } - const { tx: safeTx, safe } = await deploySafe( + const { tx: safeTx, safe, alreadyExisted } = await deploySafe( { walletClient, publicClient, environmentConfig, owners, threshold } as DeploySafeOptions, logger, ); - this.log(`\n✓ Safe deployed: ${safe} (${thresholdRaw}/${owners.length})`); - this.log(` Tx: ${safeTx}`); + 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); @@ -186,8 +192,21 @@ export default class AuthIdentityNew extends Command { this.error(`Account ${signerAddress} has no ETH. Fund it before deploying.`); } - // Build proposer choices: EOA + any Safes deployed by this EOA - const knownSafes = await getSafesByDeployer(publicClient, environmentConfig, signerAddress); + // 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) => { diff --git a/packages/cli/src/commands/auth/sync.ts b/packages/cli/src/commands/auth/sync.ts index 923b4c49..4fb3c3f6 100644 --- a/packages/cli/src/commands/auth/sync.ts +++ b/packages/cli/src/commands/auth/sync.ts @@ -22,7 +22,7 @@ import { addIdentity, } from "../../utils/globalConfig"; import { createPublicClientOnly } from "../../utils/viemClients"; -import { fetchSafeInfo, fetchTimelockDelay } from "../../utils/contractAbis"; +import { fetchSafeInfo, fetchTimelockDelay, isTimelockProposer } from "../../utils/contractAbis"; import type { Address } from "viem"; export default class AuthSync extends Command { @@ -77,22 +77,32 @@ export default class AuthSync extends Command { this.log(`✓ Safe: ${safe}`); } - for (const timelock of directTimelocks) { - const delay = await fetchTimelockDelay(publicClient, timelock as Address); - addIdentity({ type: "timelock", address: timelock, delay, environment }); - this.log(`✓ Timelock: ${timelock} (via EOA, delay: ${delay})`); - } + // 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 safeTimelocks) { - const safe = safes.find((s) => - safeTimelockArrays[safes.indexOf(s)]?.some((t) => t.toLowerCase() === timelock.toLowerCase()), - ); + for (const timelock of allTimelocks) { const delay = await fetchTimelockDelay(publicClient, timelock as Address); - addIdentity({ type: "timelock", address: timelock, delay, safeAddress: safe, environment }); - this.log(`✓ Timelock: ${timelock}${safe ? ` (via Safe ${safe})` : ""} (delay: ${delay})`); + // 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 + directTimelocks.length + safeTimelocks.length; + const total = safes.length + allTimelocks.length; if (total === 0) { this.log(`No factory-deployed identities found on ${environment}.`); } else { diff --git a/packages/cli/src/utils/contractAbis.ts b/packages/cli/src/utils/contractAbis.ts index 4042f58f..135fc4e7 100644 --- a/packages/cli/src/utils/contractAbis.ts +++ b/packages/cli/src/utils/contractAbis.ts @@ -15,6 +15,24 @@ export const TIMELOCK_ABI = [ { name: "hasRole", type: "function", inputs: [{ type: "bytes32" }, { type: "address" }], outputs: [{ type: "bool" }], stateMutability: "view" }, ] 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, diff --git a/packages/sdk/src/client/common/contract/caller.ts b/packages/sdk/src/client/common/contract/caller.ts index f93584bd..1d8fb318 100644 --- a/packages/sdk/src/client/common/contract/caller.ts +++ b/packages/sdk/src/client/common/contract/caller.ts @@ -1538,7 +1538,7 @@ export interface DeploySafeOptions { export async function deploySafe( options: DeploySafeOptions, logger: Logger = noopLogger, -): Promise<{ tx: Hex; safe: Address }> { +): Promise<{ tx: Hex | null; safe: Address; alreadyExisted?: boolean }> { const { walletClient, publicClient, environmentConfig, owners, threshold } = options; const salt = CANONICAL_SALT; @@ -1546,6 +1546,22 @@ export async function deploySafe( 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", From 803f2eda6da2e9259649dc8b159762d4762e4911 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Mon, 20 Apr 2026 10:59:49 -0700 Subject: [PATCH 38/41] feat: add --execute and --cancel flags for Timelock operations All governance commands (upgrade, stop, start, terminate, transfer, team grant, team revoke) now support --execute and --cancel to complete or abort pending Timelock operations. Pending ops are also shown in app list and app info output. Shared utilities: timelockExecute.ts (execute/cancel handlers), timelockFlags in flags.ts, formatCountdown in format.ts. Also includes describeCalldata improvement to show app address in pending op descriptions. --- packages/cli/src/commands/auth/whoami.ts | 14 +- packages/cli/src/commands/compute/app/info.ts | 32 ++++- packages/cli/src/commands/compute/app/list.ts | 37 ++++- .../compute/app/ownership/transfer.ts | 19 ++- .../cli/src/commands/compute/app/start.ts | 13 +- packages/cli/src/commands/compute/app/stop.ts | 13 +- .../cli/src/commands/compute/app/terminate.ts | 13 +- .../cli/src/commands/compute/app/upgrade.ts | 14 +- .../cli/src/commands/compute/team/grant.ts | 19 ++- .../cli/src/commands/compute/team/revoke.ts | 19 ++- packages/cli/src/flags.ts | 11 ++ packages/cli/src/utils/contractAbis.ts | 2 + packages/cli/src/utils/format.ts | 11 ++ packages/cli/src/utils/timelockExecute.ts | 134 ++++++++++++++++++ .../src/client/common/config/environment.ts | 4 +- .../sdk/src/client/common/contract/caller.ts | 4 + packages/sdk/src/client/index.ts | 1 + 17 files changed, 334 insertions(+), 26 deletions(-) create mode 100644 packages/cli/src/utils/timelockExecute.ts diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts index 74db6c98..ad9d7f8f 100644 --- a/packages/cli/src/commands/auth/whoami.ts +++ b/packages/cli/src/commands/auth/whoami.ts @@ -21,6 +21,7 @@ import { formatIdentity, type StoredIdentity, } from "../../utils/globalConfig"; +import { formatCountdown } from "../../utils/format"; import chalk from "chalk"; import type { Address } from "viem"; @@ -108,7 +109,7 @@ export default class AuthWhoami extends Command { const status = op.ready ? chalk.green("ready to execute") : `executable in ${formatCountdown(op.executableAt - now)}`; - this.log(` ⏳ ${op.description} [${status}] id: ${op.id.slice(0, 10)}…`); + this.log(` ⏳ ${op.description} [${status}] id: ${verbose ? op.id : `${op.id.slice(0, 10)}…`}`); } } } @@ -129,14 +130,3 @@ export default class AuthWhoami extends Command { }); } } - -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`; -} diff --git a/packages/cli/src/commands/compute/app/info.ts b/packages/cli/src/commands/compute/app/info.ts index 511867a5..68cfe8df 100644 --- a/packages/cli/src/commands/compute/app/info.ts +++ b/packages/cli/src/commands/compute/app/info.ts @@ -4,13 +4,16 @@ 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 { Address, type PublicClient } from "viem"; import chalk from "chalk"; @@ -175,6 +178,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 56c0528d..fd6c480c 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,7 +18,7 @@ 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"; @@ -83,6 +85,27 @@ export default class AppList extends Command { 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); + } + } + } catch { + // skip if Timelock doesn't support getPendingOperations + } + } + console.log(); for (const { address, label } of addressesToQuery) { @@ -198,6 +221,18 @@ export default class AppList extends Command { 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(" ──────────────────────────────────────────────────────────────")); } diff --git a/packages/cli/src/commands/compute/app/ownership/transfer.ts b/packages/cli/src/commands/compute/app/ownership/transfer.ts index 24bc9afb..dec42943 100644 --- a/packages/cli/src/commands/compute/app/ownership/transfer.ts +++ b/packages/cli/src/commands/compute/app/ownership/transfer.ts @@ -6,11 +6,12 @@ import { encodeTransferOwnershipData, } from "@layr-labs/ecloud-sdk"; import { withTelemetry } from "../../../../telemetry"; -import { commonFlags, applyTxOverrides } from "../../../../flags"; +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"; @@ -28,7 +29,7 @@ export default class AppOwnershipTransfer extends Command { static flags = { ...commonFlags, to: Flags.string({ - required: true, + required: false, description: "New owner address (Safe or Timelock address enables governance mode)", env: "ECLOUD_NEW_OWNER", }), @@ -36,6 +37,7 @@ export default class AppOwnershipTransfer extends Command { description: "Skip all confirmation prompts", default: false, }), + ...timelockFlags, }; async run() { @@ -48,6 +50,19 @@ export default class AppOwnershipTransfer extends Command { 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, diff --git a/packages/cli/src/commands/compute/app/start.ts b/packages/cli/src/commands/compute/app/start.ts index 3233dc4c..437d7ab4 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, @@ -11,6 +11,7 @@ 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"; @@ -31,6 +32,7 @@ export default class AppLifecycleStart extends Command { description: "Skip all confirmation prompts", default: false, }), + ...timelockFlags, }; async run() { @@ -43,6 +45,15 @@ export default class AppLifecycleStart extends Command { const rpcUrl = flags.rpcUrl || 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; + } + const appId = await getOrPromptAppID({ appID: args["app-id"], environment: flags["environment"]!, diff --git a/packages/cli/src/commands/compute/app/stop.ts b/packages/cli/src/commands/compute/app/stop.ts index 4c27a2fa..a74572df 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, @@ -11,6 +11,7 @@ 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"; @@ -31,6 +32,7 @@ export default class AppLifecycleStop extends Command { description: "Skip all confirmation prompts", default: false, }), + ...timelockFlags, }; async run() { @@ -48,6 +50,15 @@ export default class AppLifecycleStop extends Command { // Get private key for gas estimation 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({ appID: args["app-id"], diff --git a/packages/cli/src/commands/compute/app/terminate.ts b/packages/cli/src/commands/compute/app/terminate.ts index 21588f6b..6709f4e0 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, @@ -11,6 +11,7 @@ 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"; @@ -32,6 +33,7 @@ export default class AppLifecycleTerminate extends Command { description: "Force termination without confirmation", default: false, }), + ...timelockFlags, }; async run() { @@ -44,6 +46,15 @@ export default class AppLifecycleTerminate extends Command { const rpcUrl = flags.rpcUrl || 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; + } + const appId = await getOrPromptAppID({ appID: args["app-id"], environment: flags["environment"]!, diff --git a/packages/cli/src/commands/compute/app/upgrade.ts b/packages/cli/src/commands/compute/app/upgrade.ts index 34c0e526..84092e88 100644 --- a/packages/cli/src/commands/compute/app/upgrade.ts +++ b/packages/cli/src/commands/compute/app/upgrade.ts @@ -1,10 +1,11 @@ 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 type { Address } from "viem"; import { getDockerfileInteractive, @@ -126,6 +127,7 @@ export default class AppUpgrade extends Command { description: "Skip all confirmation prompts", default: false, }), + ...timelockFlags, }; async run() { @@ -139,6 +141,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"], diff --git a/packages/cli/src/commands/compute/team/grant.ts b/packages/cli/src/commands/compute/team/grant.ts index c9dfb95a..76b444f2 100644 --- a/packages/cli/src/commands/compute/team/grant.ts +++ b/packages/cli/src/commands/compute/team/grant.ts @@ -8,11 +8,12 @@ import { getAppOwner, } from "@layr-labs/ecloud-sdk"; import { withTelemetry } from "../../../telemetry"; -import { commonFlags, applyTxOverrides } from "../../../flags"; +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"; @@ -38,7 +39,7 @@ export default class TeamGrant extends Command { env: "ECLOUD_APP_ID", }), role: Flags.string({ - required: true, + required: false, description: "Role to grant: ADMIN, PAUSER, or DEVELOPER", options: ROLE_CHOICES as unknown as string[], env: "ECLOUD_TEAM_ROLE", @@ -47,6 +48,7 @@ export default class TeamGrant extends Command { description: "Skip all confirmation prompts", default: false, }), + ...timelockFlags, }; async run() { @@ -58,6 +60,19 @@ export default class TeamGrant extends Command { 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 (!isAddress(account)) { this.error(`Invalid address: ${account}`); diff --git a/packages/cli/src/commands/compute/team/revoke.ts b/packages/cli/src/commands/compute/team/revoke.ts index ffbf01b5..751ec470 100644 --- a/packages/cli/src/commands/compute/team/revoke.ts +++ b/packages/cli/src/commands/compute/team/revoke.ts @@ -7,11 +7,12 @@ import { getAppOwner, } from "@layr-labs/ecloud-sdk"; import { withTelemetry } from "../../../telemetry"; -import { commonFlags } from "../../../flags"; +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"; @@ -37,7 +38,7 @@ export default class TeamRevoke extends Command { env: "ECLOUD_APP_ID", }), role: Flags.string({ - required: true, + required: false, description: "Role to revoke: PAUSER or DEVELOPER", options: ROLE_CHOICES as unknown as string[], env: "ECLOUD_TEAM_ROLE", @@ -46,6 +47,7 @@ export default class TeamRevoke extends Command { description: "Skip all confirmation prompts", default: false, }), + ...timelockFlags, }; async run() { @@ -57,6 +59,19 @@ export default class TeamRevoke extends Command { 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 (!isAddress(account)) { this.error(`Invalid address: ${account}`); diff --git a/packages/cli/src/flags.ts b/packages/cli/src/flags.ts index a64e7680..554b942e 100644 --- a/packages/cli/src/flags.ts +++ b/packages/cli/src/flags.ts @@ -53,6 +53,17 @@ 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, + }), +}; + /** * 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/contractAbis.ts b/packages/cli/src/utils/contractAbis.ts index 135fc4e7..915230b2 100644 --- a/packages/cli/src/utils/contractAbis.ts +++ b/packages/cli/src/utils/contractAbis.ts @@ -13,6 +13,8 @@ export const SAFE_ABI = [ 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") diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index 7397b679..01f3746e 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -188,6 +188,17 @@ export function formatDelay(seconds: bigint): string { 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/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/src/client/common/config/environment.ts b/packages/sdk/src/client/common/config/environment.ts index a4608d6f..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: "0x6A56214b79d24469f066AdfD1F28bB929824daCE", + 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", }, diff --git a/packages/sdk/src/client/common/contract/caller.ts b/packages/sdk/src/client/common/contract/caller.ts index 1d8fb318..c802bc7b 100644 --- a/packages/sdk/src/client/common/contract/caller.ts +++ b/packages/sdk/src/client/common/contract/caller.ts @@ -1870,6 +1870,10 @@ export interface PendingTimelockOp { function describeCalldata(calldata: Hex): string { try { const decoded = decodeFunctionData({ abi: AppControllerABI, data: calldata }); + const appArg = decoded.args?.[0]; + if (appArg && typeof appArg === "string" && appArg.startsWith("0x")) { + return `${decoded.functionName}(${appArg})`; + } return decoded.functionName; } catch { return "unknown"; diff --git a/packages/sdk/src/client/index.ts b/packages/sdk/src/client/index.ts index e8926208..41dda205 100644 --- a/packages/sdk/src/client/index.ts +++ b/packages/sdk/src/client/index.ts @@ -121,6 +121,7 @@ export { getTimelocksByDeployer, getSafesByDeployer, getPendingTimelockOps, + executeTimelockOp, CANONICAL_SALT, type DeploySafeOptions, type DeployTimelockOptions, From 9a4dbe7e02c53788a36ae77f1b7a4607804de53d Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Mon, 20 Apr 2026 11:41:39 -0700 Subject: [PATCH 39/41] feat: add --delay flag, improve op descriptions, fix arg validation - Add --delay flag to all governance commands (override Timelock delay, must be >= minDelay, defaults to minDelay) - Improve describeCalldata to show all address args in pending op descriptions (e.g. grantTeamRole(team, account)) - Make address arg optional in team grant/revoke when using --execute or --cancel --- .../cli/src/commands/compute/app/ownership/transfer.ts | 1 + packages/cli/src/commands/compute/app/start.ts | 1 + packages/cli/src/commands/compute/app/stop.ts | 1 + packages/cli/src/commands/compute/app/terminate.ts | 1 + packages/cli/src/commands/compute/app/upgrade.ts | 1 + packages/cli/src/commands/compute/team/grant.ts | 6 +++++- packages/cli/src/commands/compute/team/revoke.ts | 6 +++++- packages/cli/src/flags.ts | 4 ++++ packages/cli/src/utils/identityTransaction.ts | 2 ++ packages/sdk/src/client/common/contract/caller.ts | 9 ++++++--- .../sdk/src/client/common/contract/identity-router.ts | 6 ++++-- 11 files changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/commands/compute/app/ownership/transfer.ts b/packages/cli/src/commands/compute/app/ownership/transfer.ts index dec42943..0ee03f75 100644 --- a/packages/cli/src/commands/compute/app/ownership/transfer.ts +++ b/packages/cli/src/commands/compute/app/ownership/transfer.ts @@ -136,6 +136,7 @@ export default class AppOwnershipTransfer extends Command { pendingMessage: `Transferring ownership of app ${appId} to ${newOwner}...`, txDescription: "TransferOwnership", gas: finalTx, + delayOverride: flags.delay, }); this.log(""); diff --git a/packages/cli/src/commands/compute/app/start.ts b/packages/cli/src/commands/compute/app/start.ts index 437d7ab4..ec9ccab6 100644 --- a/packages/cli/src/commands/compute/app/start.ts +++ b/packages/cli/src/commands/compute/app/start.ts @@ -118,6 +118,7 @@ export default class AppLifecycleStart extends Command { pendingMessage: `Starting app ${appId}...`, txDescription: "StartApp", gas: finalTx, + delayOverride: flags.delay, }); this.log(""); diff --git a/packages/cli/src/commands/compute/app/stop.ts b/packages/cli/src/commands/compute/app/stop.ts index a74572df..d5cb983f 100644 --- a/packages/cli/src/commands/compute/app/stop.ts +++ b/packages/cli/src/commands/compute/app/stop.ts @@ -136,6 +136,7 @@ export default class AppLifecycleStop extends Command { pendingMessage: `Stopping app ${appId}...`, txDescription: "StopApp", gas: finalTx, + delayOverride: flags.delay, }); this.log(""); diff --git a/packages/cli/src/commands/compute/app/terminate.ts b/packages/cli/src/commands/compute/app/terminate.ts index 6709f4e0..8baf4fa5 100644 --- a/packages/cli/src/commands/compute/app/terminate.ts +++ b/packages/cli/src/commands/compute/app/terminate.ts @@ -121,6 +121,7 @@ export default class AppLifecycleTerminate extends Command { pendingMessage: `Terminating app ${appId}...`, txDescription: "TerminateApp", gas: finalTx, + delayOverride: flags.delay, }); this.log(""); diff --git a/packages/cli/src/commands/compute/app/upgrade.ts b/packages/cli/src/commands/compute/app/upgrade.ts index 84092e88..ed12c0f3 100644 --- a/packages/cli/src/commands/compute/app/upgrade.ts +++ b/packages/cli/src/commands/compute/app/upgrade.ts @@ -448,6 +448,7 @@ export default class AppUpgrade extends Command { pendingMessage: `Upgrading app ${appID}...`, txDescription: "UpgradeApp", gas: finalTx, + delayOverride: flags.delay, }); this.log(""); diff --git a/packages/cli/src/commands/compute/team/grant.ts b/packages/cli/src/commands/compute/team/grant.ts index 76b444f2..92025514 100644 --- a/packages/cli/src/commands/compute/team/grant.ts +++ b/packages/cli/src/commands/compute/team/grant.ts @@ -27,7 +27,7 @@ export default class TeamGrant extends Command { static args = { address: Args.string({ description: "Address to grant the role to", - required: true, + required: false, }), }; @@ -74,6 +74,9 @@ export default class TeamGrant extends Command { } 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}`); } @@ -134,6 +137,7 @@ export default class TeamGrant extends Command { pendingMessage: `Granting ${flags.role} role to ${account}...`, txDescription: "GrantTeamRole", gas: finalTx, + delayOverride: flags.delay, }); this.log(""); diff --git a/packages/cli/src/commands/compute/team/revoke.ts b/packages/cli/src/commands/compute/team/revoke.ts index 751ec470..e160d626 100644 --- a/packages/cli/src/commands/compute/team/revoke.ts +++ b/packages/cli/src/commands/compute/team/revoke.ts @@ -26,7 +26,7 @@ export default class TeamRevoke extends Command { static args = { address: Args.string({ description: "Address to revoke the role from", - required: true, + required: false, }), }; @@ -73,6 +73,9 @@ export default class TeamRevoke extends Command { } 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}`); } @@ -126,6 +129,7 @@ export default class TeamRevoke extends Command { pendingMessage: `Revoking ${flags.role} role from ${account}...`, txDescription: "RevokeTeamRole", gas: finalTx, + delayOverride: flags.delay, }); this.log(""); diff --git a/packages/cli/src/flags.ts b/packages/cli/src/flags.ts index 554b942e..833acfef 100644 --- a/packages/cli/src/flags.ts +++ b/packages/cli/src/flags.ts @@ -62,6 +62,10 @@ export const timelockFlags = { 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, + }), }; /** diff --git a/packages/cli/src/utils/identityTransaction.ts b/packages/cli/src/utils/identityTransaction.ts index 169baabc..8ace6b79 100644 --- a/packages/cli/src/utils/identityTransaction.ts +++ b/packages/cli/src/utils/identityTransaction.ts @@ -47,6 +47,7 @@ export async function executeWithIdentity(options: { pendingMessage?: string; txDescription?: string; gas?: any; + delayOverride?: string; }): Promise { const identity = getActiveIdentityOrEOA(options.environment, options.eoaAddress); @@ -67,6 +68,7 @@ export async function executeWithIdentity(options: { pendingMessage: options.pendingMessage, txDescription: options.txDescription, gas: options.gas, + delayOverride: options.delayOverride, }); } diff --git a/packages/sdk/src/client/common/contract/caller.ts b/packages/sdk/src/client/common/contract/caller.ts index c802bc7b..16ffb07b 100644 --- a/packages/sdk/src/client/common/contract/caller.ts +++ b/packages/sdk/src/client/common/contract/caller.ts @@ -1870,9 +1870,12 @@ export interface PendingTimelockOp { function describeCalldata(calldata: Hex): string { try { const decoded = decodeFunctionData({ abi: AppControllerABI, data: calldata }); - const appArg = decoded.args?.[0]; - if (appArg && typeof appArg === "string" && appArg.startsWith("0x")) { - return `${decoded.functionName}(${appArg})`; + 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 { diff --git a/packages/sdk/src/client/common/contract/identity-router.ts b/packages/sdk/src/client/common/contract/identity-router.ts index f4a89cd2..cf7c252f 100644 --- a/packages/sdk/src/client/common/contract/identity-router.ts +++ b/packages/sdk/src/client/common/contract/identity-router.ts @@ -48,6 +48,7 @@ export interface IdentityRouterOptions { pendingMessage?: string; txDescription?: string; gas?: GasEstimate; + delayOverride?: string; } /** @@ -123,8 +124,9 @@ export async function sendWithIdentity( case "timelock": { const timelockAddress = identity.address as Address; - const delaySeconds = parseDelayToSeconds(identity.delay); - const delayLabel = identity.delay || "24h"; + const effectiveDelay = options.delayOverride || identity.delay; + const delaySeconds = parseDelayToSeconds(effectiveDelay); + const delayLabel = effectiveDelay || "24h"; // Encode the Timelock.schedule() call const scheduleData = encodeFunctionData({ From 38d2404e57dc0306f365b73dd05f39bfefdde36d Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Wed, 22 Apr 2026 19:48:19 -0700 Subject: [PATCH 40/41] feat: declare active identity via X-eigenx-identity header When an identity (Safe, Timelock, Timelock(Safe)) is active, attach the X-eigenx-identity header to UserAPI requests so the platform evaluates permissions against the declared identity instead of the recovered EOA. The platform falls back to the EOA when the header is absent, so older CLIs continue to work unchanged. SDK changes (userapi.ts): - UserApiClientOptions.identities: addresses to declare in the header. - UserApiClient.setIdentities(): switch identities for subsequent requests (used by app list to iterate per-identity). - addIdentityHeader(): wires X-eigenx-identity into both the standard authenticated path and the multipart profile upload path. CLI helper (apiIdentity.ts): - identityForActiveContext(env): declare the active identity on commands that act on a single app (info, releases, upgrade current-info fetch). - identityForAllContexts(env): declare every non-EOA identity for batch endpoints (appResolver, prompts) so one call covers apps across all identities the caller holds. app list switches identities per loop iteration, since each iteration queries apps for a different identity. --- packages/cli/src/commands/compute/app/info.ts | 2 + packages/cli/src/commands/compute/app/list.ts | 7 ++++ .../cli/src/commands/compute/app/releases.ts | 2 + .../cli/src/commands/compute/app/upgrade.ts | 3 +- packages/cli/src/utils/apiIdentity.ts | 37 +++++++++++++++++++ packages/cli/src/utils/appResolver.ts | 7 +++- packages/cli/src/utils/prompts.ts | 3 +- .../sdk/src/client/common/utils/userapi.ts | 32 ++++++++++++++++ 8 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/utils/apiIdentity.ts diff --git a/packages/cli/src/commands/compute/app/info.ts b/packages/cli/src/commands/compute/app/info.ts index 68cfe8df..90676b9b 100644 --- a/packages/cli/src/commands/compute/app/info.ts +++ b/packages/cli/src/commands/compute/app/info.ts @@ -14,6 +14,7 @@ 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"; @@ -69,6 +70,7 @@ export default class AppInfo extends Command { }); const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { clientId: getClientId(), + identities: identityForActiveContext(environment), }); if (flags.watch) { diff --git a/packages/cli/src/commands/compute/app/list.ts b/packages/cli/src/commands/compute/app/list.ts index fd6c480c..85c2e307 100644 --- a/packages/cli/src/commands/compute/app/list.ts +++ b/packages/cli/src/commands/compute/app/list.ts @@ -109,6 +109,13 @@ export default class AppList extends Command { console.log(); 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], + ); + // Query apps owned by this address from blockchain const result = await getAllAppsByDeveloper(publicClient, environmentConfig, address); 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/upgrade.ts b/packages/cli/src/commands/compute/app/upgrade.ts index ed12c0f3..563207f0 100644 --- a/packages/cli/src/commands/compute/app/upgrade.ts +++ b/packages/cli/src/commands/compute/app/upgrade.ts @@ -6,6 +6,7 @@ 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, @@ -331,7 +332,7 @@ export default class AppUpgrade extends Command { environmentConfig, walletClient, publicClient, - { clientId: getClientId() }, + { clientId: getClientId(), identities: identityForActiveContext(environment) }, ); const infos = await userApiClient.getInfos([appID], 1); if (infos.length > 0) { 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/prompts.ts b/packages/cli/src/utils/prompts.ts index 36d814b8..a2e940f8 100644 --- a/packages/cli/src/utils/prompts.ts +++ b/packages/cli/src/utils/prompts.ts @@ -41,6 +41,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 { @@ -1126,7 +1127,7 @@ async function getAppIDInteractive(options: GetAppIDOptions): Promise
{ environmentConfig, walletClient, publicClient, - { clientId: getClientId() }, + { clientId: getClientId(), identities: identityForAllContexts(environment) }, ); const appInfos = await getAppInfosChunked(userApiClient, apps); 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 */ From a87a7cefc3f2dd511f683d6a348a9f293dac5877 Mon Sep 17 00:00:00 2001 From: Taras Shchybovyk Date: Wed, 22 Apr 2026 20:00:24 -0700 Subject: [PATCH 41/41] docs: remove obsolete governance working notes These three docs were written as iteration notes during feature development. They drifted from the final implementation (flag names, command behavior) and overlap with commit messages and 'ecloud --help' output. Dropping them rather than attempting a full refresh. --- docs/auth-flow-map.md | 505 -------------------------------- docs/governance-commands.md | 201 ------------- docs/identity-command-matrix.md | 271 ----------------- 3 files changed, 977 deletions(-) delete mode 100644 docs/auth-flow-map.md delete mode 100644 docs/governance-commands.md delete mode 100644 docs/identity-command-matrix.md diff --git a/docs/auth-flow-map.md b/docs/auth-flow-map.md deleted file mode 100644 index 6c31c6bd..00000000 --- a/docs/auth-flow-map.md +++ /dev/null @@ -1,505 +0,0 @@ -# Auth Flow Map - -Complete map of all authentication and identity transitions in the ecloud CLI. - -## Storage Layers - -Two independent storage systems: - -| Layer | Location | What it stores | -|---|---|---| -| **Keyring** | OS keyring (macOS Keychain / Linux Secret Service) | One private key — the master signing credential | -| **Config** | `~/.config/ecloud/config.yaml` | Identities (EOA, Safe, Timelock) + active identity per environment | - -## Concepts - -- **Signing key** — one private key stored in OS keyring. Master credential. All identities are controlled by this key. -- **Identity** — an on-chain address the signing key can operate from: EOA, Safe, or Timelock. -- **Active identity** — the identity used for commands in a given environment. One per environment. -- **Roles** (PAUSER, DEVELOPER) — permissions on a specific app, not identity types. Checked at the app level, not the identity level. - -## Identity Limits - -| Identity type | How many per signing key | Why | -|---|---|---| -| **EOA** | 1 | The signing key's own address. Created automatically on `auth generate` or `auth login`. | -| **Safe** | Unlimited | Each `auth identity new → Safe` deploys a new Safe contract. Different Safes can have different owners, thresholds, and purposes. | -| **Timelock(EOA)** | 1 | Address is deterministic via CREATE2 (`CANONICAL_SALT`). Re-running discovers the existing one instead of deploying. | -| **Timelock(Safe)** | 1 per Safe | Address is deterministic via CREATE2 (Safe address + `CANONICAL_SALT`). Re-running discovers the existing one. Each Safe can have its own Timelock with its own delay period. | - -All Timelock addresses are deterministic — computed by `SafeTimelockFactory.calculateTimelockAddress(proposer, salt)` using CREATE2. The CLI checks for existing Timelocks before deploying, for both EOA and Safe proposers. - -Example: -``` -Identities: - ● EOA 0xABC... ← your signing key - ○ Safe 0x111... (2/3 — you + partner A + B) ← multi-sig for production - ○ Safe 0x222... (1/1 — just you) ← single-owner for testing - ○ Timelock 0x333... (24h delay, wraps EOA) ← only one per EOA - ○ Timelock 0x444... (24h delay, wraps Safe 0x111) ← one per Safe - ○ Timelock 0x555... (7d delay, wraps Safe 0x222) ← different delay -``` - -## State Transitions - -Every auth command and exactly what it changes: - -| Command | Keyring | Config (identities) | Config (active) | -|---|---|---|---| -| `auth generate` (store=yes) | writes new key | replaces all with EOA | sets EOA active for all envs | -| `auth generate` (store=no) | no change | no change | no change | -| `auth login` | writes imported key | replaces all with EOA + discovered | sets EOA active | -| `auth logout` | deletes key | clears all | clears all | -| `auth identity new` (Safe) | no change | adds Safe | sets Safe active | -| `auth identity new` (Timelock) | no change | adds Timelock | sets Timelock active | -| `auth identity list` | no change | no change | no change | -| `auth identity select` | no change | no change | sets selected active | - -## Commands - -| Command | Purpose | -|---|---| -| `ecloud auth generate` | Generate a new private key and store in OS keyring | -| `ecloud auth login` | Import an existing private key into OS keyring | -| `ecloud auth logout` | Remove signing key and all identities | -| `ecloud auth whoami` | Show signing key, identities, and active identity | -| `ecloud auth identity new` | Create a new identity (Safe or Timelock) | -| `ecloud auth identity list` | Show all stored identities | -| `ecloud auth identity select` | Switch active identity for an environment | - ---- - -## `ecloud auth generate` - -Generate a new private key. Optionally store in OS keyring. - -``` -ecloud auth generate -│ -├── ? Store this key in your OS keyring? (Y/n) -│ -├── No -│ │ -│ ├── Generate new key -│ ├── Show key in pager (address + private key) -│ ├── "Key not stored in keyring." -│ └── END — key exists only in user's memory/clipboard -│ -└── Yes - │ - ├── Check: does a signing key already exist in keyring? - │ - ├── No existing key - │ │ - │ ├── Generate new key - │ ├── Show key in pager - │ ├── Store in keyring - │ ├── Replace all identities with new EOA - │ ├── Set active identity for all environments - │ └── END — ✓ new EOA identity active - │ - └── Existing key found - │ - ├── ⚠ Warning: "A signing key already exists." - │ "Address: 0x..." - │ "Replacing it will clear all current identities." - │ - ├── ? Replace existing key? (y/N) - │ - ├── No → "Cancelled." → END - │ - └── Yes - │ - ├── Generate new key - ├── Show key in pager - ├── Store in keyring (replaces old) - ├── Replace all identities with new EOA - ├── Set active identity for all environments - └── END — ✓ new EOA identity active, old key gone -``` - ---- - -## `ecloud auth login` - -Import an existing private key. Discovers associated Timelocks and Safes on-chain. - -``` -ecloud auth login -│ -├── Check: does a signing key already exist in keyring? -│ -├── Existing key found -│ │ -│ ├── ⚠ Warning: "A signing key already exists." -│ │ "Address: 0x..." -│ │ "Replacing it will clear all current identities." -│ │ -│ ├── ? Replace current signing key? (y/N) -│ ├── No → "Cancelled." → END -│ └── Yes → (continue to key import below) -│ -├── No existing key → (continue to key import below) -│ -├── Check for legacy eigenx-cli keys -│ │ -│ ├── Found legacy keys -│ │ ├── Display them -│ │ ├── ? Import one? → Yes → select which → retrieve key -│ │ └── No → prompt for manual entry -│ │ -│ └── No legacy keys → prompt for manual entry -│ -├── ? Enter your private key: ******** -│ -├── Validate key format -├── Show derived address -├── ? Store in OS keyring? → No → "Cancelled." → END -│ → Yes ↓ -│ -├── Store key in keyring -├── Replace all identities with new EOA -├── Set active identity -│ -├── Discover identities on-chain: -│ │ -│ ├── Scan for Timelock (deterministic address via CREATE2) -│ │ ├── Found → ? Add to identities? → Yes/No -│ │ └── Not found → "No Timelock found" -│ │ -│ └── Scan Safe Transaction Service for Safes owned by this EOA -│ ├── Found N Safes → for each: ? Add to identities? → Yes/No -│ └── None found → (skip) -│ -├── If legacy key was imported: -│ ├── ? Delete legacy key from eigenx-cli? → Yes/No -│ -└── END — ✓ key stored, identities discovered -``` - ---- - -## `ecloud auth logout` - -Removes signing key from OS keyring and clears all identities. - -``` -ecloud auth logout -│ -├── Check: key in keyring? -│ ├── No → "No key found. Nothing to remove." → END -│ └── Yes ↓ -│ -├── "Found stored key: Address: 0x..." -│ -├── ? Remove private key from keyring? (y/N) -│ ├── No → "Cancelled." → END -│ └── Yes ↓ -│ -├── Remove key from keyring -├── Clear all identities -├── Clear all active identity selections -│ -└── END — ✓ clean slate -``` - ---- - -## `ecloud auth whoami` - -Read-only — displays current state. - -``` -ecloud auth whoami --environment - -Signing key: 0xABC...DEF (stored credentials) - or -Signing key: none (run: ecloud auth generate) - -Identities (): - ● EOA 0xABC...DEF ← active - ○ Safe 0x123...456 - ○ Timelock 0x789... (24h delay, via Safe 0x123...) - -Run 'ecloud auth identity select' to switch active identity. -``` - ---- - -## `ecloud auth identity new` - -Create a new identity. Requires a signing key in the keyring. - -``` -ecloud auth identity new -│ -├── Check: signing key in keyring? -│ └── No → error: "Run 'ecloud auth generate' or 'ecloud auth login' first." → END -│ -├── ? What type of identity? -│ > Gnosis Safe (multi-sig) -│ Timelock (for existing EOA or Safe) -│ -├── Safe -│ │ -│ ├── "Signing key 0x... will be included as an owner." -│ ├── ? Additional owner addresses: (comma-separated) -│ ├── ? Threshold: (e.g., 2 of 3) -│ ├── ? Add timelock delay? (y/N) -│ │ -│ ├── No timelock -│ │ ├── Deploy Safe via factory (on-chain tx) -│ │ ├── Add Safe identity to config -│ │ ├── Set active identity → Safe -│ │ └── END — ✓ Safe identity active -│ │ -│ └── Yes timelock -│ ├── ? Minimum delay: (e.g., "24h", "7d") -│ ├── Deploy Safe + Timelock via factory (on-chain txs) -│ ├── Add Timelock(Safe) identity to config -│ ├── Set active identity → Timelock(Safe) -│ └── END — ✓ Timelock(Safe) identity active -│ -└── Timelock - │ - ├── ? Is the proposer/executor an EOA or a Safe? - │ - ├── EOA - │ ├── Check: canonical Timelock exists on-chain? - │ │ ├── Yes + in config → "Already in identities." → ? Set active? → END - │ │ ├── Yes + not in config → ? Add to identities? → END - │ │ └── No → deploy new Timelock ↓ - │ ├── ? Minimum delay: (e.g., "24h") - │ ├── Deploy Timelock via factory (on-chain tx) - │ ├── Add Timelock(EOA) identity to config - │ ├── Set active identity → Timelock(EOA) - │ └── END — ✓ Timelock(EOA) identity active - │ - └── Safe - ├── ? Safe address: 0x... - ├── ? Minimum delay: (e.g., "24h") - ├── Deploy Timelock via factory (on-chain tx) - ├── Add Timelock(Safe) identity to config - ├── Set active identity → Timelock(Safe) - └── END — ✓ Timelock(Safe) identity active -``` - ---- - -## `ecloud auth identity list` - -Read-only — shows all stored identities. - -``` -ecloud auth identity list --environment - -Identities (): - ● EOA 0xABC...DEF ← active - ○ Safe 0x123...456 - ○ Timelock 0x789... (24h delay, via Safe 0x123...) -``` - ---- - -## `ecloud auth identity select` - -Switch active identity for an environment. - -``` -ecloud auth identity select --environment -│ -├── No identities → "Run 'ecloud auth identity new' to create one." → END -│ -├── ? Select active identity for : -│ ● EOA 0xABC... ✓ active -│ ○ Safe 0xDEF... -│ ○ Timelock 0x123... (24h delay) -│ -├── Selected → set as active -└── END — ✓ Active identity: -``` - ---- - -## Identity Transitions - -How an account evolves from simple to secure: - -``` - auth generate → EOA (signing key) - │ - ┌────────────┼────────────┐ - │ │ │ - ▼ ▼ ▼ - identity new → identity new → identity new → - Timelock(EOA) Safe Safe + Timelock - │ - identity new → - Timelock(Safe) -``` - -| From | To | Command | -|---|---|---| -| Nothing | EOA | `auth generate` or `auth login` | -| EOA | Timelock(EOA) | `auth identity new` → Timelock → EOA proposer | -| EOA | Safe | `auth identity new` → Safe | -| EOA | Timelock(Safe) | `auth identity new` → Safe → "Add timelock? Yes" | -| Safe | Timelock(Safe) | `auth identity new` → Timelock → Safe proposer | -| Any | Switch active | `auth identity select` | -| Any | Clean slate | `auth logout` | - ---- - -## App Ownership Transitions - -Separate from identity — this is about who owns the app on-chain: - -| App owner | Upgrade flow | Command | -|---|---|---| -| EOA | direct | `ecloud compute app upgrade` | -| Safe | Safe propose → approved | `ecloud compute app upgrade` | -| Timelock(EOA) | schedule → wait → execute | `upgrade schedule` + `upgrade execute` | -| Timelock(Safe) | Safe propose → schedule → wait → Safe propose → execute | `upgrade schedule` + `upgrade execute` | - -Transfer app ownership: -``` -ecloud compute app ownership transfer --to= -``` - ---- - -## Roles (PAUSER / DEVELOPER) - -Roles are **app-level permissions**, not identity types. They are granted by the app owner (or admin) to specific EOA addresses. - -| Role | What it can do | How it's granted | -|---|---|---| -| ADMIN | All operations | Implicitly the app owner (Safe or Timelock address) | -| PAUSER | Stop the app (direct, no approval needed) | `ecloud compute team grant
` | -| DEVELOPER | Read-only + profile set | `ecloud compute team grant
` | - -Roles are checked at command execution time via on-chain `getTeamRoleMembers()`. They are **not** stored in the identity config. - -`ecloud compute app info` can show your role on the app (future enhancement — not implemented yet). - ---- - -## Command Tree - -``` -ecloud -├── auth -│ ├── generate — no key (generates + stores) -│ ├── login — no key (imports + stores + discovers) -│ ├── logout — no key (removes key + clears identities) -│ ├── whoami — no key (reads keyring + config) -│ └── identity -│ ├── new — KEY: write (deploys Safe/Timelock) -│ ├── list — no key (reads config) -│ └── select — no key (writes config) -│ -├── compute -│ ├── app -│ │ ├── create — KEY: write -│ │ ├── deploy — KEY: write (identity determines: direct / Safe propose) -│ │ ├── upgrade — KEY: write (blocked for Timelock — use schedule/execute) -│ │ ├── start — KEY: write (identity determines flow) -│ │ ├── stop — KEY: write (PAUSER can stop directly) -│ │ ├── terminate — KEY: write (identity determines flow) -│ │ ├── info — KEY: read (API auth via EOA signature, backend resolves Safe/Timelock) -│ │ ├── list — KEY: read (queries all identity addresses, grouped by owner) -│ │ ├── releases — KEY: read (API auth via EOA signature) -│ │ ├── logs — KEY: read (API auth via EOA signature) -│ │ ├── profile set — KEY: write (DEVELOPER can set profile) -│ │ ├── configure tls — KEY: write -│ │ ├── upgrade -│ │ │ ├── schedule — KEY: write (Timelock schedule) -│ │ │ ├── execute — KEY: write (after delay) -│ │ │ └── cancel — KEY: write -│ │ ├── terminate -│ │ │ ├── schedule — KEY: write (Timelock schedule) -│ │ │ └── execute — KEY: write (after delay) -│ │ └── ownership -│ │ ├── transfer — KEY: write -│ │ ├── schedule-transfer — KEY: write (Timelock schedule) -│ │ └── execute-transfer — KEY: write (after delay) -│ │ -│ ├── build -│ │ ├── submit — KEY: read (address only) -│ │ ├── status — KEY: read (address only) -│ │ ├── logs — KEY: read (address only) -│ │ ├── list — KEY: read (address only) -│ │ ├── info — KEY: read (address only) -│ │ └── verify — KEY: read (address only) -│ │ -│ ├── team -│ │ ├── grant — KEY: write -│ │ ├── revoke — KEY: write -│ │ ├── list — KEY: read -│ │ └── grant-admin -│ │ ├── schedule — KEY: write (Timelock(Safe) only) -│ │ └── execute — KEY: write (after delay) -│ │ -│ ├── environment -│ │ ├── list — no key -│ │ ├── set — no key -│ │ └── show — no key -│ │ -│ └── undelegate — KEY: write -│ -├── billing -│ ├── subscribe — KEY: write -│ ├── cancel — KEY: write -│ ├── status — KEY: read -│ └── top-up — KEY: write -│ -├── telemetry -│ ├── enable — no key -│ ├── disable — no key -│ └── status — no key -│ -├── upgrade — no key -└── version — no key -``` - -**Key types:** -- **KEY: write** — private key signs on-chain transactions. Active identity determines the flow (direct / Safe propose / Timelock schedule). -- **KEY: read** — private key signs API requests (backend verifies EOA signature and resolves Safe/Timelock ownership on-chain). -- **no key** — works without credentials. - -### `compute app list` — grouped by identity - -`list` queries apps across all identity addresses, grouped by owner: - -``` -ecloud compute app list - -EOA 0xABC...DEF ← active - myapp running docker.io/myapp:v2 - worker running docker.io/worker:v1 - -Safe 0x111...456 - production running docker.io/prod:v3 - -Timelock 0x333...789 (24h delay, wraps Safe 0x111) - staging stopped docker.io/staging:v1 -``` - -Requires private key for API authentication. The backend resolves Safe/Timelock ownership on-chain — EOA signature is sufficient to access apps owned by Safes and Timelocks the EOA controls. - -### Backend ownership resolution - -When the API receives an EOA-signed request for an app owned by a Safe or Timelock, it resolves the ownership chain on-chain: - -``` -1. caller == app owner? → grant access -2. app owner is Safe → caller in Safe.getOwners()? → grant access -3. app owner is Timelock → caller is proposer? → grant access -4. app owner is Timelock(Safe) → proposer is Safe - → caller in Safe.getOwners()? → grant access -5. caller has team role (ADMIN/PAUSER/DEVELOPER)? → grant access -``` - -All checks are on-chain reads — no extra headers needed from the CLI. - -See `docs/identity-command-matrix.md` for the full command × identity permission matrix. diff --git a/docs/governance-commands.md b/docs/governance-commands.md deleted file mode 100644 index 5d9ebcc2..00000000 --- a/docs/governance-commands.md +++ /dev/null @@ -1,201 +0,0 @@ -# Timelocked Upgrade Commands - -EigenCloud supports two upgrade flows depending on who owns the app: - -- **EOA or Safe owner** — direct upgrade via `upgradeApp`, controller acts immediately (Safe handles threshold approval externally) -- **Timelock owner** — two-step flow: schedule → wait → execute - -Timelocked mode is set automatically when ownership is transferred to a Timelock deployed by `SafeTimelockFactory`. - ---- - -## Commands - -### `ecloud compute app ownership transfer` - -Transfer ownership of an app to a new address. - -``` -ecloud compute app ownership transfer --app= --to=
-``` - -| Flag | Required | Description | -|------|----------|-------------| -| `--app` | yes | App ID or name | -| `--to` | yes | New owner address | - -If `--to` is a Timelock deployed by `SafeTimelockFactory`, **timelocked mode is enabled automatically** and direct upgrades are blocked. Transferring to a Safe or EOA does not enable timelocked mode. - -**Examples:** - -```sh -# Transfer to another EOA — no governance change -ecloud compute app ownership transfer \ - --app=0xAbc...123 \ - --to=0xDef...456 - -# Transfer to a Timelock — timelocked mode enabled -ecloud compute app ownership transfer \ - --app=0xAbc...123 \ - --to=0xTimelock...789 -``` - ---- - -### `ecloud compute app upgrade schedule` - -Schedule an upgrade for a timelocked app. Builds the image and commits a hash on-chain. The controller takes no action until `execute` is called after the delay. - -``` -ecloud compute app upgrade schedule --app= --after= [build flags] -``` - -| Flag | Required | Description | -|------|----------|-------------| -| `--app` | yes | App ID or name | -| `--after` | yes | Delay before upgrade can execute: `30s`, `5m`, `2h`, `1d` | -| `--image-ref` | no | Image reference pointing to registry | -| `--dockerfile` | no | Path to Dockerfile (alternative to `--image-ref`) | -| `--env-file` | no | Environment file (default: `.env`) | -| `--instance-type` | no | Machine instance type | -| `--log-visibility` | no | `public`, `private`, or `off` | -| `--resource-usage-monitoring` | no | `enable` or `disable` | - -**Example:** - -```sh -ecloud compute app upgrade schedule \ - --app=0xAbc...123 \ - --after=2h \ - --image-ref=myrepo/myapp:v2 \ - --env-file=.env.prod \ - --instance-type=g1-standard-4t \ - --log-visibility=public -``` - -``` -App: 0xAbc...123 -Delay: 2h (executable after 3/7/2026, 4:00:00 PM) -Image: myrepo/myapp:v2 - -✅ Upgrade scheduled (tx: 0x...) - -Executable after: 3/7/2026, 4:00:00 PM -Run to execute: ecloud compute app upgrade execute --app=0xAbc...123 -``` - -The `AppUpgradeScheduled` event is emitted on-chain. Multi-sig participants can review the pending upgrade during the delay window. - ---- - -### `ecloud compute app upgrade execute` - -Execute a previously scheduled upgrade once the delay has elapsed. Must be called with the **same build inputs** used in `schedule` — the release is reconstructed and its hash is verified against the on-chain commitment. - -``` -ecloud compute app upgrade execute --app= [same build flags as schedule] -``` - -| Flag | Required | Description | -|------|----------|-------------| -| `--app` | yes | App ID or name | -| `--image-ref` | no | Must match what was used in `schedule` | -| `--dockerfile` | no | Must match what was used in `schedule` | -| `--env-file` | no | Must match what was used in `schedule` | -| `--instance-type` | no | Must match what was used in `schedule` | -| `--log-visibility` | no | Must match what was used in `schedule` | -| `--resource-usage-monitoring` | no | Must match what was used in `schedule` | - -**Example:** - -```sh -ecloud compute app upgrade execute \ - --app=0xAbc...123 \ - --image-ref=myrepo/myapp:v2 \ - --env-file=.env.prod \ - --instance-type=g1-standard-4t \ - --log-visibility=public -``` - -``` -Scheduled upgrade is ready. Proceeding with execution... -Note: build inputs must exactly match what was used in 'upgrade schedule'. - -✅ App upgraded successfully (id: 0xAbc...123, image: myrepo/myapp:v2) - -View your app: https://app.eigencloud.xyz/apps/0xAbc...123 -``` - -**Error cases:** - -``` -# Delay not elapsed -✗ Upgrade is not ready yet. Executable after 3/7/2026, 4:00:00 PM (6847s remaining). - -# No scheduled upgrade -✗ No upgrade is scheduled for this app. Run 'ecloud compute app upgrade schedule' first. - -# Release mismatch (wrong inputs) -✗ contract error: ReleaseMismatch -``` - ---- - -### `ecloud compute app upgrade` (unchanged for EOA apps) - -Direct upgrade — unchanged behavior for non-governed apps. - -``` -ecloud compute app upgrade --app= [build flags] -``` - -If called on a timelocked app: - -``` -✗ App 0xAbc...123 is timelocked (Timelock owner). - Use the two-step timelocked flow instead: - ecloud compute app upgrade schedule --app=0xAbc...123 --after= - ecloud compute app upgrade execute --app=0xAbc...123 -``` - ---- - -## Flow summary - -``` -EOA or Safe-owned app -────────────────────── -ecloud compute app upgrade - └─ upgradeApp() on-chain - └─ AppUpgraded event → controller acts immediately - (Safe handles multi-sig threshold externally before calling this) - -Timelock-owned app -─────────────────── -ecloud compute app upgrade schedule --after=2h - └─ scheduleUpgrade() on-chain - └─ AppUpgradeScheduled event (no controller action) - └─ [2h delay — participants can review or cancel] - -ecloud compute app upgrade execute - └─ executeUpgrade() on-chain (verifies hash, checks delay) - └─ AppUpgraded event → controller acts -``` - ---- - -## Ownership transfer flow - -``` -ecloud compute app ownership transfer --app= --to= - └─ transferOwnership() on-chain - └─ SafeTimelockFactory.isTimelock(newOwner) → true → timelocked = true - └─ AppOwnershipTransferred event - └─ direct upgradeApp() now blocked for this app - -ecloud compute app ownership transfer --app= --to= - └─ transferOwnership() on-chain - └─ SafeTimelockFactory.isTimelock(newOwner) → false → timelocked = false - └─ AppOwnershipTransferred event - └─ direct upgradeApp() still available (Safe handles threshold externally) -``` diff --git a/docs/identity-command-matrix.md b/docs/identity-command-matrix.md deleted file mode 100644 index c64590d0..00000000 --- a/docs/identity-command-matrix.md +++ /dev/null @@ -1,271 +0,0 @@ -# Identity × Command Matrix - -## Running the demo - -The CLI ships with a stateful demo mode that simulates all governance flows without hitting real contracts. - -**Setup:** -```bash -# from the ecloud repo root -alias ecloud="node packages/cli/bin/run.js" - -cd packages/cli && npm run build -``` - -**Start demo mode** (no flags needed — demo is active by default): -```bash -ecloud auth login -``` - -Demo state is stored in `/tmp/ecloud-demo-state.json` and persists across commands. To reset: -```bash -rm /tmp/ecloud-demo-state.json -``` - ---- - -Behaviour of each CLI command per identity type. - -**Identity types:** -- **EOA** — plain wallet, signs directly -- **Timelock(EOA)** — Timelock contract with an EOA as proposer/executor -- **Safe** — Gnosis Safe (multi-sig threshold) -- **Timelock(Safe)** — Timelock contract with a Safe as proposer/executor -- **PAUSER** — EOA (or Safe) granted PAUSER role by an ADMIN; can stop apps only -- **DEVELOPER** — EOA granted DEVELOPER role by an ADMIN; read-only + metadata ops - -## Identity migration - -How accounts can be created and upgraded to stronger security models. - -```mermaid -graph TD - A["ecloud auth new → EOA"] -->|"ecloud auth new\n→ Timelock (EOA proposer)"| B["Timelock(EOA)"] - A -->|"ecloud auth new → Safe"| C["Safe"] - C -->|"ecloud auth new\n→ Timelock (Safe proposer)"| D["Timelock(Safe)"] - C -->|"ecloud auth new → Safe\n→ Add timelock delay? yes"| D -``` - -**App ownership migration** — transfer the app to a stronger owner: - -```mermaid -graph TD - E["App owned by EOA"] - -->|"ecloud compute app ownership transfer --to=<safe-addr>"| F["App owned by Safe"] - -->|"ecloud compute app ownership transfer --to=<timelock-addr>"| G["App owned by Timelock(Safe)"] - - F -. "upgrades require Safe propose" .-> F - G -. "upgrades require schedule + execute + Safe propose" .-> G -``` - -**Upgrade behaviour changes with each step:** - -| App owner | Upgrade command | Flow | -|---|---|---| -| EOA | `ecloud compute app upgrade` | direct | -| Safe | `ecloud compute app upgrade` | Safe propose → approved | -| Timelock(Safe) | `ecloud compute app upgrade schedule` + `execute` | Safe propose → delay → Safe propose | - ---- - -**Column abbreviations:** `TL(EOA)` = Timelock with EOA proposer · `TL(Safe)` = Timelock with Safe proposer - -**Legend:** -- `direct` — CLI signs and submits immediately, no extra steps -- `direct (after delay)` — CLI signs and submits; delay must have elapsed since `schedule` -- `Safe propose` — CLI proposes tx to Safe; threshold of signers must approve at app.safe.global -- `Safe propose (after delay)` — same as `Safe propose`, but delay must have elapsed since `schedule` -- `no permission` — command is blocked; CLI shows a descriptive error with the correct alternative -- `yes` — available / shown -- `—` — not applicable / not shown - ---- - -## Auth - -| Command | EOA | TL(EOA) | Safe | TL(Safe) | PAUSER | DEVELOPER | -|---|---|---|---|---|---|---| -| `ecloud auth login` | select identity | select identity | select identity | select identity | select identity | select identity | -| `ecloud auth new` | create EOA key | create Timelock | create Safe | create Timelock | — | — | - ---- - -## compute app - -| Command | EOA | TL(EOA) | Safe | TL(Safe) | PAUSER | DEVELOPER | -|---|---|---|---|---|---|---| -| `deploy` | direct | direct | Safe propose | Safe propose | no permission | no permission | -| `upgrade` | direct | no permission | Safe propose | no permission | no permission | no permission | -| `start` | direct | direct | Safe propose | Safe propose | no permission | no permission | -| `stop` | direct | direct | Safe propose | Safe propose | direct | no permission | -| `terminate` | direct | direct | Safe propose | Safe propose | no permission | no permission | -| `terminate schedule` | no permission | direct | no permission | Safe propose | no permission | no permission | -| `terminate execute` | no permission | direct (after delay) | no permission | Safe propose (after delay) | no permission | no permission | - ---- - -## compute app metadata & observability - -| Command | EOA | TL(EOA) | Safe | TL(Safe) | PAUSER | DEVELOPER | -|---|---|---|---|---|---|---| -| `profile set` | direct | direct | direct | direct | no permission | direct | -| `info` | yes | yes | yes | yes | yes | yes | -| `logs` | yes | yes | yes | yes | yes | yes | -| `list` | yes | yes | yes | yes | yes | yes | -| `releases` | yes | yes | yes | yes | yes | yes | - ---- - -## compute app upgrade - -Only available when identity is `TL(EOA)` or `TL(Safe)`. Blocked for all other identities. - -| Command | EOA | TL(EOA) | Safe | TL(Safe) | PAUSER | DEVELOPER | -|---|---|---|---|---|---|---| -| `upgrade schedule` | no permission | direct | no permission | Safe propose | no permission | no permission | -| `upgrade execute` | no permission | direct (after delay) | no permission | Safe propose (after delay) | no permission | no permission | -| `upgrade cancel` | no permission | direct | no permission | Safe propose | no permission | no permission | -| `demo fastforward` | — | skips delay | — | skips delay | — | — | - ---- - -## compute app ownership - -| Command | EOA | TL(EOA) | Safe | TL(Safe) | PAUSER | DEVELOPER | -|---|---|---|---|---|---|---| -| `ownership transfer` | direct | direct | Safe propose | Safe propose | no permission | no permission | -| `ownership schedule-transfer` | no permission | direct | no permission | Safe propose | no permission | no permission | -| `ownership execute-transfer` | no permission | direct (after delay) | no permission | Safe propose (after delay) | no permission | no permission | - -> Transferring to a Timelock address automatically enables timelocked mode on the app. -> `schedule-transfer` / `execute-transfer` are only available when the app is already timelocked. - ---- - -## compute team - -| Command | EOA | TL(EOA) | Safe | TL(Safe) | PAUSER | DEVELOPER | -|---|---|---|---|---|---|---| -| `team grant` (PAUSER/DEVELOPER) | direct | direct | Safe propose | Safe propose | no permission | no permission | -| `team revoke` (PAUSER/DEVELOPER) | direct | direct | Safe propose | Safe propose | no permission | no permission | -| `team list` | — | — | visible | visible | — | — | -| `team grant-admin schedule` | no permission | no permission | no permission | Safe propose | no permission | no permission | -| `team grant-admin execute` | no permission | no permission | no permission | Safe propose (after delay) | no permission | no permission | - -> Team roles (ADMIN, PAUSER, DEVELOPER) are only shown in `ecloud compute app info` and `ecloud compute team list` when the app owner is a Safe or Timelock(Safe). -> ADMIN is the Safe or Timelock address — never an individual EOA in a Safe-governed app. - -#### Why you should never grant ADMIN to an EOA in a Safe-governed app - -AppController's admin check is purely role-based: it verifies `msg.sender` holds `keccak256(owner, ADMIN)`. It does **not** enforce that the caller went through Safe's threshold signing. - -This means: if you grant ADMIN to an EOA, that EOA can call `upgradeApp`, `terminateApp`, `startApp`, etc. **directly** — bypassing the Safe entirely. The entire point of Safe ownership (threshold approval, no single point of failure) is defeated. - -**The correct model for Safe-owned apps:** - -| Role | Holder | How ops are authorized | -|---|---|---| -| ADMIN | Safe (or Timelock) only | Requires Safe threshold signature | -| PAUSER | Individual EOA | Direct — intentional, for emergency stop without delay | -| DEVELOPER | Individual EOA | Direct — limited to metadata and observability | - -The contract does not hard-enforce this convention today — it is an operational rule. Granting ADMIN to an EOA is technically possible but breaks the security model. Since granting any role requires being ADMIN, and Safe is the sole ADMIN, any such grant would itself require Safe approval — making it a deliberate, visible act rather than an accident. - ---- - -## compute app info - -| Field shown | EOA | TL(EOA) | Safe | TL(Safe) | PAUSER | DEVELOPER | -|---|---|---|---|---|---|---| -| Owner | yes | yes (delay label) | yes | yes (delay + Safe label) | yes | yes | -| Status / Image / Instance | yes | yes | yes | yes | yes | yes | -| Team Roles section | — | — | yes | yes | — | — | - ---- - -## Full upgrade flows by identity - -### EOA -``` -ecloud compute app deploy --image-ref myrepo/myapp:v1 -ecloud compute app upgrade --image-ref myrepo/myapp:v2 -``` - -### Timelock(EOA) -``` -ecloud compute app deploy --image-ref myrepo/myapp:v1 -ecloud compute app upgrade schedule --after=24h -# wait for delay (or: ecloud demo fastforward) -ecloud compute app upgrade execute -``` - -### Safe -``` -ecloud compute app deploy → Safe propose → approved → done -ecloud compute app upgrade → Safe propose → approved → done -ecloud compute team grant → Safe propose → approved → done -ecloud compute app stop → Safe propose → approved → done -``` - -### Timelock(Safe) -``` -ecloud compute app deploy → Safe propose → approved → done -ecloud compute app upgrade schedule --after=24h → Safe propose → approved → scheduled -# wait for delay (or: ecloud demo fastforward) -ecloud compute app upgrade execute → Safe propose → approved → done -ecloud compute team grant → Safe propose → approved → done -ecloud compute app stop → Safe propose → approved → done -``` - -### Safe → Timelock(Safe) transition (adding upgrade delay) -``` -# 1. Start as Safe, deploy the app -ecloud auth login → select 3/5 Safe -ecloud compute app deploy --image-ref myrepo/myapp:v1 - → Safe propose → approved → done - -# 2. Transfer ownership to a Timelock (adds upgrade delay on top of Safe) -ecloud compute app ownership transfer --to= - → Safe propose → approved → done - → Timelocked mode enabled - -# 3. Switch identity to the Timelock -ecloud auth login → select Timelock (24h delay) via 2/3 Safe - -# 4. Direct upgrade is now blocked -ecloud compute app upgrade → ❌ TimelockRequired - → use: ecloud compute app upgrade schedule --after= - → ecloud compute app upgrade execute - -# 5. Use the two-step timelocked flow -ecloud compute app upgrade schedule --after=24h - → Safe propose → approved → scheduled -# wait for delay (or: ecloud demo fastforward) -ecloud compute app upgrade execute → Safe propose → approved → done -``` - ---- - -### PAUSER role (granted by Safe) -``` -# Admin grants PAUSER role to 0x5678... -ecloud compute team grant 0x5678567856785678567856785678567856785678 → Safe propose → approved → done - -# PAUSER acts directly, no Safe needed -ecloud auth login → select PAUSER identity (0x5678...5678) -ecloud compute app stop → direct -``` - -### DEVELOPER role (granted by Admin) -``` -# Admin grants DEVELOPER role to 0x9999... -ecloud compute team grant 0x9999... → (direct | Safe propose) → done - -# DEVELOPER can view info, update metadata, view logs; cannot perform admin ops -ecloud auth login → select DEVELOPER identity (0x9999...) -ecloud compute app info → shows status, image, instance type -ecloud compute app logs → stream app logs -ecloud compute app profile set → update name, website, description, image -ecloud compute app upgrade → ❌ no permission -ecloud compute app stop → ❌ no permission -```