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