diff --git a/docs/superpowers/plans/2026-04-23-top-up-credit-card.md b/docs/superpowers/plans/2026-04-23-top-up-credit-card.md new file mode 100644 index 00000000..0f391169 --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-top-up-credit-card.md @@ -0,0 +1,770 @@ +# Top-Up Credit Card Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add credit card payment support to `ecloud billing top-up` alongside the existing USDC on-chain flow. + +**Architecture:** Two new SDK methods (`getPaymentMethods`, `purchaseCredits`) on the existing `BillingApiClient` call the `/v1/payment-methods` and `/v1/credits/purchase` endpoints using the same EIP-712 auth. The CLI `top-up` command gets a `--method` flag and branches into the existing USDC path or a new credit card path with card-on-file detection. + +**Tech Stack:** TypeScript, oclif (CLI framework), viem (wallet), vitest (tests), `open` package (browser), `@inquirer/prompts` (interactive prompts) + +**Spec:** `docs/superpowers/specs/2026-04-23-top-up-credit-card-design.md` + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|----------------| +| `packages/sdk/src/client/common/types/index.ts` | Modify | Add `PaymentMethod`, `PaymentMethodsResponse`, `CreditPurchaseResponse` types | +| `packages/sdk/src/client/common/utils/billingapi.ts` | Modify | Add `getPaymentMethods()` and `purchaseCredits()` methods to `BillingApiClient` | +| `packages/sdk/src/client/modules/billing/index.ts` | Modify | Expose new methods on `BillingModule` interface and wire them in `createBillingModule` | +| `packages/cli/src/commands/billing/top-up.ts` | Modify | Add `--method` flag, payment method selection prompt, credit card purchase flow | +| `packages/cli/src/commands/billing/__tests__/top-up.test.ts` | Modify | Add credit card flow test cases | + +--- + +## Task 1: Add new types to SDK + +**Files:** +- Modify: `packages/sdk/src/client/common/types/index.ts:420-425` (after `SubscriptionOpts`, before `BillingEnvironmentConfig`) + +- [ ] **Step 1: Add the new type definitions** + +Insert after the `SubscriptionOpts` interface (line 420) and before the `BillingEnvironmentConfig` interface (line 422): + +```typescript +export interface PaymentMethod { + id: string; + stripePaymentMethodId: string; + createdAt: string; +} + +export interface PaymentMethodsResponse { + paymentMethods: PaymentMethod[]; +} + +export interface CreditPurchaseResponse { + purchaseId?: string; + checkoutSessionId?: string; + checkoutUrl?: string; + amountCents: string; +} +``` + +- [ ] **Step 2: Verify types compile** + +Run: `npx tsc --noEmit -p packages/sdk/tsconfig.json` +Expected: Clean exit, no errors. + +- [ ] **Step 3: Commit** + +```bash +git add packages/sdk/src/client/common/types/index.ts +git commit -m "feat(sdk): add PaymentMethod and CreditPurchaseResponse types" +``` + +--- + +## Task 2: Add `getPaymentMethods()` and `purchaseCredits()` to `BillingApiClient` + +**Files:** +- Modify: `packages/sdk/src/client/common/utils/billingapi.ts:176-179` (after `cancelSubscription`, before the Internal Methods section) + +- [ ] **Step 1: Add the import for new types** + +In `billingapi.ts`, add `PaymentMethodsResponse` and `CreditPurchaseResponse` to the existing import from `"../types"`: + +```typescript +import { + ProductID, + CreateSubscriptionOptions, + CreateSubscriptionResponse, + GetSubscriptionOptions, + ProductSubscriptionResponse, + PaymentMethodsResponse, + CreditPurchaseResponse, +} from "../types"; +``` + +- [ ] **Step 2: Add `getPaymentMethods()` method** + +Insert after `cancelSubscription` (line 178) and before the `// Internal Methods` comment (line 181): + +```typescript + async getPaymentMethods(): Promise { + const endpoint = `${this.config.billingApiServerURL}/v1/payment-methods`; + const resp = await this.makeAuthenticatedRequest(endpoint, "GET", "compute"); + return resp.json(); + } + + async purchaseCredits( + amountCents: number, + paymentMethodId?: string, + ): Promise { + const endpoint = `${this.config.billingApiServerURL}/v1/credits/purchase`; + const body: Record = { amountCents }; + if (paymentMethodId) { + body.paymentMethodId = paymentMethodId; + } + const resp = await this.makeAuthenticatedRequest(endpoint, "POST", "compute", body); + return resp.json(); + } +``` + +- [ ] **Step 3: Verify types compile** + +Run: `npx tsc --noEmit -p packages/sdk/tsconfig.json` +Expected: Clean exit, no errors. + +- [ ] **Step 4: Commit** + +```bash +git add packages/sdk/src/client/common/utils/billingapi.ts +git commit -m "feat(sdk): add getPaymentMethods and purchaseCredits to BillingApiClient" +``` + +--- + +## Task 3: Expose new methods on `BillingModule` + +**Files:** +- Modify: `packages/sdk/src/client/modules/billing/index.ts:47-56` (BillingModule interface) and `~90` (module object in `createBillingModule`) + +- [ ] **Step 1: Add imports for new types** + +Add `PaymentMethodsResponse` and `CreditPurchaseResponse` to the import from `"../../common/types"`: + +```typescript +import type { + ProductID, + SubscriptionOpts, + SubscribeResponse, + CancelResponse, + ProductSubscriptionResponse, + PaymentMethodsResponse, + CreditPurchaseResponse, +} from "../../common/types"; +``` + +- [ ] **Step 2: Extend the `BillingModule` interface** + +Add these two methods to the `BillingModule` interface (after the `topUp` method, before the closing brace): + +```typescript + getPaymentMethods: () => Promise; + purchaseCredits: (amountCents: number, paymentMethodId?: string) => Promise; +``` + +- [ ] **Step 3: Wire the methods in `createBillingModule`** + +Inside the `const module: BillingModule = { ... }` object, after the `cancel` method definition (around line 283), add: + +```typescript + async getPaymentMethods() { + return billingApi.getPaymentMethods(); + }, + + async purchaseCredits(amountCents: number, paymentMethodId?: string) { + return billingApi.purchaseCredits(amountCents, paymentMethodId); + }, +``` + +- [ ] **Step 4: Verify types compile** + +Run: `npx tsc --noEmit -p packages/sdk/tsconfig.json` +Expected: Clean exit, no errors. + +- [ ] **Step 5: Commit** + +```bash +git add packages/sdk/src/client/modules/billing/index.ts +git commit -m "feat(sdk): expose getPaymentMethods and purchaseCredits on BillingModule" +``` + +--- + +## Task 4: Add credit card flow to `top-up.ts` CLI command + +**Files:** +- Modify: `packages/cli/src/commands/billing/top-up.ts` + +- [ ] **Step 1: Update imports** + +Replace the existing imports at the top of the file: + +```typescript +import { Command, Flags } from "@oclif/core"; +import { createBillingClient } from "../../client"; +import { commonFlags } from "../../flags"; +import { type Address, formatUnits } from "viem"; +import chalk from "chalk"; +import { input, select, confirm } from "@inquirer/prompts"; +import open from "open"; +import { withTelemetry } from "../../telemetry"; +``` + +Note: `select` and `confirm` are added from `@inquirer/prompts`; `open` is added for opening checkout URLs in browser. + +- [ ] **Step 2: Update command description and add `--method` flag** + +Update the static properties on the class: + +```typescript +export default class BillingTopUp extends Command { + static description = "Purchase EigenCompute credits with USDC or credit card"; + + static examples = [ + "<%= config.bin %> billing top-up", + "<%= config.bin %> billing top-up --method usdc --amount 50", + "<%= config.bin %> billing top-up --method card --amount 25", + ]; + + static flags = { + ...commonFlags, + method: Flags.string({ + required: false, + description: "Payment method: usdc (on-chain) or card (credit card)", + options: ["usdc", "card"], + }), + amount: Flags.string({ + required: false, + description: "Amount to spend (USDC for on-chain, whole dollars for card)", + }), + account: Flags.string({ + required: false, + description: "Target account address for purchaseCreditsFor (defaults to your wallet)", + }), + product: Flags.string({ + required: false, + description: "Product ID", + default: "compute", + options: ["compute"], + env: "ECLOUD_PRODUCT_ID", + }), + }; +``` + +- [ ] **Step 3: Update the `run()` method — payment method selection and branching** + +Replace the entire `run()` method body. The structure is: + +1. Create billing client, show wallet info and current credits (unchanged). +2. Select payment method (prompt or flag). +3. Branch to USDC path or credit card path. + +```typescript + async run() { + return withTelemetry(this, async () => { + const { flags } = await this.parse(BillingTopUp); + + const billing = await createBillingClient(flags); + const walletAddress = billing.address; + const targetAccount = (flags.account as Address) ?? walletAddress; + + this.log(`\n${chalk.bold("Purchase EigenCompute credits")}`); + this.log(`${chalk.gray("─".repeat(45))}`); + this.log(`\n ${chalk.bold("Wallet:")} ${walletAddress}`); + if (targetAccount !== walletAddress) { + this.log(` ${chalk.bold("Target:")} ${targetAccount}`); + } + + // Show current credit balance + let baselineTotal: number | undefined; + try { + const status = await billing.getStatus({ + productId: flags.product as "compute", + }); + const remaining = status.remainingCredits ?? 0; + const applied = status.creditsApplied ?? 0; + baselineTotal = remaining + applied; + this.log(` ${chalk.bold("Credits:")} ${chalk.cyan(`$${remaining.toFixed(2)}`)}`); + } catch { + this.debug("Could not fetch current credit balance"); + } + + // Select payment method + const method = + flags.method ?? + (await select({ + message: "How would you like to pay?", + choices: [ + { value: "card", name: "Credit card" }, + { value: "usdc", name: "USDC (on-chain)" }, + ], + })); + + if (method === "usdc") { + await this.handleUsdc(billing, flags, walletAddress, targetAccount, baselineTotal); + } else { + await this.handleCard(billing, flags, baselineTotal); + } + }); + } +``` + +- [ ] **Step 4: Extract USDC path into `handleUsdc` method** + +Add this private method. This is the existing USDC flow extracted with no logic changes: + +```typescript + private async handleUsdc( + billing: Awaited>, + flags: Record, + walletAddress: Address, + targetAccount: Address, + baselineTotal: number | undefined, + ) { + const onChainState = await billing.getTopUpInfo(); + const { usdcBalance, minimumPurchase } = onChainState; + + const balanceFormatted = formatUnits(usdcBalance, 6); + this.log(` ${chalk.bold("USDC:")} ${balanceFormatted} USDC`); + + if (usdcBalance === BigInt(0)) { + this.log(`\n${chalk.yellow(" No USDC in wallet.")}`); + this.log(` Send USDC on Sepolia to: ${chalk.cyan(walletAddress)}`); + this.log(` Then re-run: ${chalk.cyan("ecloud billing top-up")}\n`); + return; + } + + const minimumFormatted = formatUnits(minimumPurchase, 6); + const amountStr = + flags.amount ?? + (await input({ + message: `How much USDC to spend on credits? (minimum: ${minimumFormatted})`, + validate: (val) => { + const n = parseFloat(val); + if (isNaN(n) || n <= 0) return "Enter a positive number"; + const raw = BigInt(Math.round(n * 1e6)); + if (raw < minimumPurchase) + return `Minimum purchase is ${minimumFormatted} USDC`; + if (raw > usdcBalance) + return `Insufficient balance. You have ${balanceFormatted} USDC`; + return true; + }, + })); + + const amountFloat = parseFloat(amountStr); + const amountRaw = BigInt(Math.round(amountFloat * 1e6)); + + if (amountRaw < minimumPurchase) { + this.error(`Minimum purchase is ${minimumFormatted} USDC`); + } + if (amountRaw > usdcBalance) { + this.error( + `Insufficient USDC balance. You have ${balanceFormatted} USDC but requested ${amountFloat.toFixed(2)}`, + ); + } + + this.log(`\n Purchasing ${chalk.bold(`$${amountFloat.toFixed(2)}`)} in credits...`); + + const { txHash } = await billing.topUp({ + amount: amountRaw, + account: targetAccount, + }); + this.log(` ${chalk.green("✓")} Transaction confirmed: ${txHash}`); + + await this.pollForCredits(billing, flags, baselineTotal, amountFloat); + } +``` + +- [ ] **Step 5: Add `handleCard` method** + +```typescript + private async handleCard( + billing: Awaited>, + flags: Record, + baselineTotal: number | undefined, + ) { + const MINIMUM_DOLLARS = 5; + + // Prompt for amount + const amountStr = + flags.amount ?? + (await input({ + message: `How many dollars of credits to purchase? (minimum: $${MINIMUM_DOLLARS})`, + validate: (val) => { + const n = parseInt(val, 10); + if (isNaN(n) || n <= 0) return "Enter a positive whole number"; + if (n.toString() !== val.trim()) return "Enter a whole dollar amount (no cents)"; + if (n < MINIMUM_DOLLARS) return `Minimum purchase is $${MINIMUM_DOLLARS}`; + return true; + }, + })); + + const dollars = parseInt(amountStr, 10); + if (isNaN(dollars) || dollars < MINIMUM_DOLLARS) { + this.error(`Minimum purchase is $${MINIMUM_DOLLARS}`); + } + const amountCents = dollars * 100; + + // Check for existing payment methods + const { paymentMethods } = await billing.getPaymentMethods(); + + let useExistingCard = false; + let paymentMethodId: string | undefined; + + if (paymentMethods.length > 0) { + const card = paymentMethods[0]; + const lastFour = card.stripePaymentMethodId.slice(-4); + useExistingCard = await confirm({ + message: `Use card on file (ending in ${lastFour})?`, + default: true, + }); + if (useExistingCard) { + paymentMethodId = card.id; + } + } + + this.log(`\n Purchasing ${chalk.bold(`$${dollars}`)} in credits...`); + + const result = await billing.purchaseCredits(amountCents, paymentMethodId); + + if (result.checkoutUrl) { + this.log(`\n ${chalk.cyan(result.checkoutUrl)}`); + this.log(chalk.gray(" Opening checkout in browser...")); + await open(result.checkoutUrl); + } else { + this.log(` ${chalk.green("✓")} Payment submitted`); + } + + await this.pollForCredits(billing, flags, baselineTotal, dollars); + } +``` + +- [ ] **Step 6: Extract shared polling into `pollForCredits` method** + +```typescript + private async pollForCredits( + billing: Awaited>, + flags: Record, + baselineTotal: number | undefined, + amountPurchased: number, + ) { + this.log(chalk.gray("\n Waiting for credits to appear...")); + const startTime = Date.now(); + while (Date.now() - startTime < POLL_TIMEOUT_MS) { + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + try { + const status = await billing.getStatus({ + productId: flags.product as "compute", + }); + const remaining = status.remainingCredits ?? 0; + const applied = status.creditsApplied ?? 0; + const currentTotal = remaining + applied; + this.debug( + `Poll: remaining=${remaining}, applied=${applied}, total=${currentTotal}, baseline=${baselineTotal}`, + ); + if (baselineTotal === undefined || currentTotal > baselineTotal) { + const creditsAdded = + baselineTotal !== undefined ? currentTotal - baselineTotal : undefined; + this.log( + `\n ${chalk.green("✓")} Credits received: ${chalk.cyan(`$${(creditsAdded ?? amountPurchased).toFixed(2)}`)}`, + ); + if (remaining > 0) { + this.log(` Remaining balance: ${chalk.cyan(`$${remaining.toFixed(2)}`)}`); + } + this.log(); + return; + } + } catch { + this.debug("Error polling for credit balance"); + } + } + + this.log( + `\n ${chalk.yellow("⚠")} Credits haven't appeared yet. This can take a few minutes.`, + ); + this.log(` ${chalk.gray("Check your balance:")} ecloud billing status\n`); + } +``` + +- [ ] **Step 7: Verify types compile** + +Run: `npx tsc --noEmit -p packages/cli/tsconfig.json` +Expected: Clean exit, no errors. + +- [ ] **Step 8: Commit** + +```bash +git add packages/cli/src/commands/billing/top-up.ts +git commit -m "feat(cli): add credit card payment flow to billing top-up" +``` + +--- + +## Task 5: Update tests for credit card flow + +**Files:** +- Modify: `packages/cli/src/commands/billing/__tests__/top-up.test.ts` + +- [ ] **Step 1: Update mocks to include new imports** + +Replace the mock setup at the top of the file. The `@inquirer/prompts` mock needs `select` and `confirm` added; `open` needs to be mocked: + +```typescript +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("../../../client", () => ({ + createBillingClient: vi.fn(), +})); + +vi.mock("../../../telemetry", () => ({ + withTelemetry: vi.fn((_cmd: unknown, fn: () => Promise) => fn()), +})); + +vi.mock("@inquirer/prompts", () => ({ + input: vi.fn(), + select: vi.fn(), + confirm: vi.fn(), +})); + +vi.mock("open", () => ({ + default: vi.fn(), +})); + +import BillingTopUp from "../top-up"; +import { createBillingClient } from "../../../client"; +import { input, select, confirm } from "@inquirer/prompts"; +``` + +- [ ] **Step 2: Update `mockBilling` in `beforeEach` to include new methods** + +```typescript + let mockBilling: { + address: string; + getStatus: ReturnType; + getTopUpInfo: ReturnType; + topUp: ReturnType; + getPaymentMethods: ReturnType; + purchaseCredits: ReturnType; + }; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + logOutput = []; + mockBilling = { + address: WALLET_ADDRESS, + getStatus: vi.fn(), + getTopUpInfo: vi.fn(), + topUp: vi.fn(), + getPaymentMethods: vi.fn(), + purchaseCredits: vi.fn(), + }; + (createBillingClient as ReturnType).mockResolvedValue(mockBilling); + + (input as ReturnType).mockResolvedValue("50"); + }); +``` + +- [ ] **Step 3: Update existing USDC tests** + +The existing tests need to set `flags.method: "usdc"` since the flow now branches on method. Update the `createCommand` helper: + +```typescript + function createCommand(flags: Record = {}) { + const cmd = new BillingTopUp([], {} as any); + cmd.parse = vi.fn().mockResolvedValue({ + flags: { + product: "compute", + "private-key": "0xdeadbeef", + environment: "sepolia-dev", + ...flags, + }, + }); + cmd.log = vi.fn((...args: string[]) => logOutput.push(args.join(" "))); + cmd.debug = vi.fn(); + cmd.error = vi.fn((msg: string) => { + throw new Error(msg); + }) as any; + return cmd; + } +``` + +For each existing test that passes `amount` as a flag, also add `method: "usdc"`. For example, the "happy path" test becomes: + +```typescript + const cmd = createCommand({ amount: "50", method: "usdc" }); +``` + +Apply this change to all existing tests: +- "happy path: sufficient balance, purchase succeeds" → `{ amount: "50", method: "usdc" }` +- "zero USDC balance: exits with fund wallet message" → `{ amount: "50", method: "usdc" }` +- "below minimum purchase: shows error" → `{ amount: "5", method: "usdc" }` +- "--account flag: passes different address to topUp" → `{ amount: "50", method: "usdc", account: targetAccount }` +- "billing API poll timeout: shows timeout message" → `{ amount: "50", method: "usdc" }` +- "uses --amount flag when provided (skips prompt)" → `{ amount: "100", method: "usdc" }` +- "does not fail if status check errors" → `{ amount: "50", method: "usdc" }` + +- [ ] **Step 4: Run existing USDC tests to make sure they still pass** + +Run: `npx vitest run packages/cli/src/commands/billing/__tests__/top-up.test.ts` +Expected: All existing tests pass. + +- [ ] **Step 5: Add credit card test — card on file, user accepts** + +```typescript + it("credit card: charges existing card on file", async () => { + mockBilling.getStatus + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 35.0 }); + mockBilling.getPaymentMethods.mockResolvedValue({ + paymentMethods: [ + { + id: "029641fc-3e5c-11f1-986c-5601121cbf6d", + stripePaymentMethodId: "pm_1ABC1234", + createdAt: "2026-04-20T15:00:00Z", + }, + ], + }); + mockBilling.purchaseCredits.mockResolvedValue({ + purchaseId: "a1b2c3d4", + amountCents: "2500", + }); + (confirm as unknown as ReturnType).mockResolvedValue(true); + + const cmd = createCommand({ amount: "25", method: "card" }); + const promise = cmd.run(); + for (let i = 0; i < 10; i++) { + await vi.advanceTimersByTimeAsync(5_000); + } + await promise; + const fullOutput = logOutput.join("\n"); + + expect(mockBilling.purchaseCredits).toHaveBeenCalledWith(2500, "029641fc-3e5c-11f1-986c-5601121cbf6d"); + expect(fullOutput).toContain("Payment submitted"); + expect(fullOutput).toContain("Credits received"); + }); +``` + +- [ ] **Step 6: Add credit card test — card on file, user declines (wants new card)** + +```typescript + it("credit card: opens checkout when user declines existing card", async () => { + const openMock = (await import("open")).default as ReturnType; + mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "active", remainingCredits: 10.0 }); + mockBilling.getPaymentMethods.mockResolvedValue({ + paymentMethods: [ + { + id: "029641fc-3e5c-11f1-986c-5601121cbf6d", + stripePaymentMethodId: "pm_1ABC1234", + createdAt: "2026-04-20T15:00:00Z", + }, + ], + }); + mockBilling.purchaseCredits.mockResolvedValue({ + checkoutSessionId: "cs_test_abc123", + checkoutUrl: "https://checkout.stripe.com/test", + amountCents: "2500", + }); + (confirm as unknown as ReturnType).mockResolvedValue(false); + + const cmd = createCommand({ amount: "25", method: "card" }); + const promise = cmd.run(); + await vi.advanceTimersByTimeAsync(200_000); + await promise; + const fullOutput = logOutput.join("\n"); + + expect(mockBilling.purchaseCredits).toHaveBeenCalledWith(2500, undefined); + expect(openMock).toHaveBeenCalledWith("https://checkout.stripe.com/test"); + expect(fullOutput).toContain("https://checkout.stripe.com/test"); + }); +``` + +- [ ] **Step 7: Add credit card test — no card on file** + +```typescript + it("credit card: opens checkout when no card on file", async () => { + const openMock = (await import("open")).default as ReturnType; + mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "active", remainingCredits: 10.0 }); + mockBilling.getPaymentMethods.mockResolvedValue({ paymentMethods: [] }); + mockBilling.purchaseCredits.mockResolvedValue({ + checkoutSessionId: "cs_test_abc123", + checkoutUrl: "https://checkout.stripe.com/test", + amountCents: "5000", + }); + + const cmd = createCommand({ amount: "50", method: "card" }); + const promise = cmd.run(); + await vi.advanceTimersByTimeAsync(200_000); + await promise; + const fullOutput = logOutput.join("\n"); + + expect(confirm).not.toHaveBeenCalled(); + expect(mockBilling.purchaseCredits).toHaveBeenCalledWith(5000, undefined); + expect(openMock).toHaveBeenCalledWith("https://checkout.stripe.com/test"); + expect(fullOutput).toContain("https://checkout.stripe.com/test"); + }); +``` + +- [ ] **Step 8: Add credit card test — amount below $5 minimum** + +```typescript + it("credit card: rejects amount below $5 minimum", async () => { + mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "active", remainingCredits: 10.0 }); + + const cmd = createCommand({ amount: "3", method: "card" }); + await expect(cmd.run()).rejects.toThrow("Minimum purchase is $5"); + }); +``` + +- [ ] **Step 9: Add credit card test — `--method card --amount 50` skips prompts** + +```typescript + it("credit card: --method and --amount flags skip prompts", async () => { + mockBilling.getStatus + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 60.0 }); + mockBilling.getPaymentMethods.mockResolvedValue({ paymentMethods: [] }); + mockBilling.purchaseCredits.mockResolvedValue({ + checkoutSessionId: "cs_test_abc123", + checkoutUrl: "https://checkout.stripe.com/test", + amountCents: "5000", + }); + + const cmd = createCommand({ amount: "50", method: "card" }); + const promise = cmd.run(); + for (let i = 0; i < 10; i++) { + await vi.advanceTimersByTimeAsync(5_000); + } + await promise; + + expect(select).not.toHaveBeenCalled(); + expect(input).not.toHaveBeenCalled(); + }); +``` + +- [ ] **Step 10: Run all tests** + +Run: `npx vitest run packages/cli/src/commands/billing/__tests__/top-up.test.ts` +Expected: All tests pass (existing USDC tests + new credit card tests). + +- [ ] **Step 11: Commit** + +```bash +git add packages/cli/src/commands/billing/__tests__/top-up.test.ts +git commit -m "test(cli): add credit card flow tests for billing top-up" +``` + +--- + +## Task 6: Final verification + +- [ ] **Step 1: Run full SDK type check** + +Run: `npx tsc --noEmit -p packages/sdk/tsconfig.json` +Expected: Clean exit. + +- [ ] **Step 2: Run full CLI type check** + +Run: `npx tsc --noEmit -p packages/cli/tsconfig.json` +Expected: Clean exit. + +- [ ] **Step 3: Run all billing tests** + +Run: `npx vitest run packages/cli/src/commands/billing/__tests__/` +Expected: All tests pass. + +- [ ] **Step 4: Commit any remaining fixes if needed** diff --git a/docs/superpowers/plans/2026-05-06-base-chain-usdc-topup.md b/docs/superpowers/plans/2026-05-06-base-chain-usdc-topup.md new file mode 100644 index 00000000..c85c0b57 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-base-chain-usdc-topup.md @@ -0,0 +1,670 @@ +# Base Chain USDC Top-Up Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow users to purchase credits via USDC on Base (Base Sepolia for now) in addition to Ethereum, with a chain selection prompt when both are available. + +**Architecture:** Add optional Base config fields (`baseUsdcCreditsAddress`, `baseRPCURL`) to `EnvironmentConfig`. The billing module creates chain-specific viem clients internally when `chain: "base"` is passed. The CLI prompts for chain selection only when Base is configured for the current environment. + +**Tech Stack:** TypeScript, viem (already has `baseSepolia` chain), vitest + +--- + +### Task 1: Add Base chain constants and config fields + +**Files:** +- Modify: `packages/sdk/src/client/common/constants.ts` +- Modify: `packages/sdk/src/client/common/types/index.ts` +- Modify: `packages/sdk/src/client/common/config/environment.ts` + +- [ ] **Step 1: Add Base Sepolia to SUPPORTED_CHAINS** + +In `packages/sdk/src/client/common/constants.ts`, add the `baseSepolia` import and include it in `SUPPORTED_CHAINS`: + +```typescript +import { sepolia, mainnet, baseSepolia } from "viem/chains"; + +export const SUPPORTED_CHAINS = [mainnet, sepolia, baseSepolia] as const; +``` + +- [ ] **Step 2: Add Base fields to EnvironmentConfig type** + +In `packages/sdk/src/client/common/types/index.ts`, add optional Base fields to the `EnvironmentConfig` interface: + +```typescript +export interface EnvironmentConfig { + name: string; + build: "dev" | "prod"; + chainID: bigint; + appControllerAddress: Address; + permissionControllerAddress: string; + erc7702DelegatorAddress: string; + kmsServerURL: string; + userApiServerURL: string; + defaultRPCURL: string; + billingRPCURL?: string; + usdcCreditsAddress?: Address; + baseUsdcCreditsAddress?: Address; + baseRPCURL?: string; +} +``` + +- [ ] **Step 3: Add Base Sepolia chain ID constant and populate config** + +In `packages/sdk/src/client/common/config/environment.ts`, add the chain ID constant and populate the `sepolia-dev` and `sepolia` environments: + +```typescript +export const BASE_SEPOLIA_CHAIN_ID = 84532; +``` + +Add to `sepolia-dev` environment object: +```typescript +baseUsdcCreditsAddress: "0x7673a47463F80c6a3553Db9E54c8cDcd5313d0ac", +baseRPCURL: "https://base-sepolia-rpc.publicnode.com", +``` + +Add to `sepolia` environment object: +```typescript +baseUsdcCreditsAddress: "0x7673a47463F80c6a3553Db9E54c8cDcd5313d0ac", +baseRPCURL: "https://base-sepolia-rpc.publicnode.com", +``` + +Do NOT add these to `mainnet-alpha` (not deployed yet). + +- [ ] **Step 4: Verify the SDK builds** + +Run: `cd packages/sdk && npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 5: Commit** + +```bash +git add packages/sdk/src/client/common/constants.ts packages/sdk/src/client/common/types/index.ts packages/sdk/src/client/common/config/environment.ts +git commit -m "feat: add Base Sepolia chain config for USDC credit purchases" +``` + +--- + +### Task 2: Add BillingChain type and update TopUpOpts/TopUpInfo + +**Files:** +- Modify: `packages/sdk/src/client/modules/billing/index.ts` + +- [ ] **Step 1: Add BillingChain type and update interfaces** + +At the top of `packages/sdk/src/client/modules/billing/index.ts` (after imports), add the chain type and update the opts/info interfaces: + +```typescript +export type BillingChain = "ethereum" | "base"; + +export interface TopUpOpts { + amount: bigint; + account?: Address; + chain?: BillingChain; +} + +export interface TopUpInfo { + usdcAddress: Address; + minimumPurchase: bigint; + usdcBalance: bigint; + currentAllowance: bigint; +} +``` + +Also add a new method to the `BillingModule` interface: + +```typescript +export interface BillingModule { + address: Address; + subscribe: (opts?: SubscriptionOpts) => Promise; + getStatus: (opts?: SubscriptionOpts) => Promise; + cancel: (opts?: SubscriptionOpts) => Promise; + getTopUpInfo: (opts?: { chain?: BillingChain }) => Promise; + topUp: (opts: TopUpOpts) => Promise; + getPaymentMethods: () => Promise; + purchaseCredits: (amountCents: number, paymentMethodId?: string) => Promise; + hasBaseSupport: () => boolean; +} +``` + +- [ ] **Step 2: Verify types compile** + +Run: `cd packages/sdk && npx tsc --noEmit` +Expected: Errors about implementation not matching interface (expected — we'll fix in next task) + +- [ ] **Step 3: Commit** + +```bash +git add packages/sdk/src/client/modules/billing/index.ts +git commit -m "feat: add BillingChain type and hasBaseSupport to billing module interface" +``` + +--- + +### Task 3: Implement chain-aware getTopUpInfo and topUp + +**Files:** +- Modify: `packages/sdk/src/client/modules/billing/index.ts` +- Modify: `packages/cli/src/client.ts` + +- [ ] **Step 1: Add privateKey to BillingModuleConfig and update createBillingModule signature** + +In `packages/sdk/src/client/modules/billing/index.ts`, update `BillingModuleConfig`: + +```typescript +export interface BillingModuleConfig { + verbose?: boolean; + walletClient: WalletClient; + skipTelemetry?: boolean; + publicClient: PublicClient; + environment: string; + privateKey?: Hex; +} +``` + +Add `Hex` to the existing viem import if not already there. Update the destructuring: + +```typescript +const { verbose = false, skipTelemetry = false, walletClient, publicClient, environment, privateKey } = config; +``` + +- [ ] **Step 2: Pass privateKey from CLI createBillingClient** + +In `packages/cli/src/client.ts`, update `createBillingClient` to pass the private key through: + +```typescript +return createBillingModule({ + verbose: flags.verbose, + walletClient, + publicClient, + environment, + skipTelemetry: true, + privateKey: privateKey as Hex, +}); +``` + +- [ ] **Step 3: Add helper to resolve chain-specific clients and config** + +Add these imports at the top of `packages/sdk/src/client/modules/billing/index.ts`: + +```typescript +import { createClients } from "../../common/utils/helpers"; +import { BASE_SEPOLIA_CHAIN_ID } from "../../common/config/environment"; +``` + +Then inside `createBillingModule`, after the existing `usdcCreditsAddress` resolution block, add: + +```typescript +const baseUsdcCreditsAddress = environmentConfig.baseUsdcCreditsAddress; +const baseRPCURL = environmentConfig.baseRPCURL; + +function resolveChainConfig(chain?: BillingChain) { + if (chain === "base") { + if (!baseUsdcCreditsAddress || !baseRPCURL) { + throw new Error(`Base chain not configured for environment "${environment}"`); + } + if (!privateKey) { + throw new Error("Private key required for Base chain transactions"); + } + const baseClients = createClients({ + privateKey, + rpcUrl: baseRPCURL, + chainId: BigInt(BASE_SEPOLIA_CHAIN_ID), + }); + return { + pub: baseClients.publicClient as PublicClient, + wallet: baseClients.walletClient as WalletClient, + creditsAddress: baseUsdcCreditsAddress, + envConfig: { + ...environmentConfig, + chainID: BigInt(BASE_SEPOLIA_CHAIN_ID), + defaultRPCURL: baseRPCURL, + }, + }; + } + return { + pub: publicClient, + wallet: walletClient, + creditsAddress: usdcCreditsAddress, + envConfig: environmentConfig, + }; +} +``` + +- [ ] **Step 4: Update getTopUpInfo to accept chain option** + +Replace the existing `getTopUpInfo` method with: + +```typescript +async getTopUpInfo(opts?: { chain?: BillingChain }): Promise { + const { pub, creditsAddress } = resolveChainConfig(opts?.chain); + + const usdcAddress = await pub.readContract({ + address: creditsAddress, + abi: USDCCreditsABI, + functionName: "usdc", + }) as Address; + + const [minimumPurchase, usdcBalance, currentAllowance] = await Promise.all([ + pub.readContract({ + address: creditsAddress, + abi: USDCCreditsABI, + functionName: "minimumPurchase", + }) as Promise, + pub.readContract({ + address: usdcAddress, + abi: ERC20ABI, + functionName: "balanceOf", + args: [address], + }) as Promise, + pub.readContract({ + address: usdcAddress, + abi: ERC20ABI, + functionName: "allowance", + args: [address, creditsAddress], + }) as Promise, + ]); + + return { usdcAddress, minimumPurchase, usdcBalance, currentAllowance }; +}, +``` + +- [ ] **Step 5: Update topUp to use chain-specific clients** + +Replace the existing `topUp` method with: + +```typescript +async topUp(opts: TopUpOpts): Promise { + return withSDKTelemetry( + { + functionName: "topUp", + skipTelemetry, + properties: { amount: opts.amount.toString(), chain: opts.chain || "ethereum" }, + }, + async () => { + const targetAccount = opts.account ?? address; + const { pub, wallet, creditsAddress, envConfig } = resolveChainConfig(opts.chain); + + const { usdcAddress, currentAllowance } = await module.getTopUpInfo({ chain: opts.chain }); + + const executions: Execution[] = []; + + if (currentAllowance < opts.amount) { + executions.push({ + target: usdcAddress, + value: 0n, + callData: encodeFunctionData({ + abi: ERC20ABI, + functionName: "approve", + args: [creditsAddress, opts.amount], + }), + }); + } + + executions.push({ + target: creditsAddress, + value: 0n, + callData: encodeFunctionData({ + abi: USDCCreditsABI, + functionName: "purchaseCreditsFor", + args: [opts.amount, targetAccount], + }), + }); + + const txHash = await executeBatch( + { + walletClient: wallet, + publicClient: pub, + environmentConfig: envConfig, + executions, + pendingMessage: "Submitting credit purchase...", + }, + logger, + ); + + return { txHash, walletAddress: address }; + }, + ); +}, +``` + +- [ ] **Step 6: Implement hasBaseSupport** + +Add after the `purchaseCredits` method: + +```typescript +hasBaseSupport(): boolean { + return !!baseUsdcCreditsAddress && !!baseRPCURL; +}, +``` + +- [ ] **Step 7: Verify the SDK builds** + +Run: `cd packages/sdk && npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 8: Commit** + +```bash +git add packages/sdk/src/client/modules/billing/index.ts packages/cli/src/client.ts +git commit -m "feat: implement chain-aware getTopUpInfo and topUp for Base support" +``` + +--- + +### Task 4: Export BillingChain from SDK package + +**Files:** +- Modify: `packages/sdk/src/client/index.ts` + +- [ ] **Step 1: Export BillingChain type from SDK entry point** + +In `packages/sdk/src/client/index.ts`, find the billing module exports and add `BillingChain`: + +```typescript +export { createBillingModule, type BillingModule, type BillingModuleConfig, type TopUpOpts, type TopUpResult, type TopUpInfo, type BillingChain } from "./modules/billing"; +``` + +If the export already exists as a group, just add `type BillingChain` to it. + +- [ ] **Step 2: Also export BASE_SEPOLIA_CHAIN_ID** + +Add to the environment config exports: + +```typescript +export { getEnvironmentConfig, getBillingEnvironmentConfig, getBuildType, getAvailableEnvironments, isEnvironmentAvailable, isMainnet, detectEnvironmentFromChainID, BASE_SEPOLIA_CHAIN_ID } from "./common/config/environment"; +``` + +- [ ] **Step 3: Verify build** + +Run: `cd packages/sdk && npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 4: Commit** + +```bash +git add packages/sdk/src/client/index.ts +git commit -m "feat: export BillingChain type and BASE_SEPOLIA_CHAIN_ID from SDK" +``` + +--- + +### Task 5: Add chain selection to CLI top-up command + +**Files:** +- Modify: `packages/cli/src/commands/billing/top-up.ts` + +- [ ] **Step 1: Add chain flag and import BillingChain type** + +Add a new optional `--chain` flag to `BillingTopUp.flags`: + +```typescript +chain: Flags.string({ + required: false, + description: "Blockchain network for USDC payment: ethereum or base", + options: ["ethereum", "base"], +}), +``` + +Add the `BillingChain` import at the top: + +```typescript +import { type BillingChain } from "@layr-labs/ecloud-sdk"; +``` + +- [ ] **Step 2: Add chain selection prompt in handleUsdc** + +In the `handleUsdc` method, add chain selection logic BEFORE calling `getTopUpInfo`. Insert after the method signature and before `const onChainState = await billing.getTopUpInfo();`: + +```typescript +let selectedChain: BillingChain = "ethereum"; + +if (billing.hasBaseSupport()) { + selectedChain = + (flags.chain as BillingChain) ?? + (await select({ + message: "Which network?", + choices: [ + { value: "ethereum", name: "Ethereum" }, + { value: "base", name: "Base" }, + ], + })); +} +``` + +- [ ] **Step 3: Pass chain to getTopUpInfo and topUp calls** + +Update the `getTopUpInfo` call: + +```typescript +const onChainState = await billing.getTopUpInfo({ chain: selectedChain }); +``` + +Update the `topUp` call: + +```typescript +const { txHash } = await billing.topUp({ + amount: amountRaw, + account: targetAccount, + chain: selectedChain, +}); +``` + +- [ ] **Step 4: Update the "No USDC" message to be chain-aware** + +Replace the zero-balance message block: + +```typescript +if (usdcBalance === BigInt(0)) { + const networkName = selectedChain === "base" ? "Base Sepolia" : "Sepolia"; + this.log(`\n${chalk.yellow(" No USDC in wallet.")}`); + this.log(` Send USDC on ${networkName} to: ${chalk.cyan(walletAddress)}`); + this.log(` Then re-run: ${chalk.cyan("ecloud billing top-up")}\n`); + return; +} +``` + +- [ ] **Step 5: Verify CLI builds** + +Run: `cd packages/cli && npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 6: Commit** + +```bash +git add packages/cli/src/commands/billing/top-up.ts +git commit -m "feat: add chain selection prompt for USDC top-up (Ethereum/Base)" +``` + +--- + +### Task 6: Write tests for chain selection in CLI top-up + +**Files:** +- Modify: `packages/cli/src/commands/billing/__tests__/top-up.test.ts` + +- [ ] **Step 1: Add hasBaseSupport to mock billing object** + +In the `mockBilling` setup in `beforeEach`, add the new method: + +```typescript +mockBilling = { + address: WALLET_ADDRESS, + getStatus: vi.fn(), + getTopUpInfo: vi.fn(), + topUp: vi.fn(), + getPaymentMethods: vi.fn(), + purchaseCredits: vi.fn(), + hasBaseSupport: vi.fn(), +}; +``` + +Update the type annotation for `mockBilling` to include it: + +```typescript +let mockBilling: { + address: string; + getStatus: ReturnType; + getTopUpInfo: ReturnType; + topUp: ReturnType; + getPaymentMethods: ReturnType; + purchaseCredits: ReturnType; + hasBaseSupport: ReturnType; +}; +``` + +By default in `beforeEach`, set `hasBaseSupport` to return false so existing tests are unaffected: + +```typescript +mockBilling.hasBaseSupport.mockReturnValue(false); +``` + +- [ ] **Step 2: Update existing topUp assertions to include chain field** + +The existing tests assert `mockBilling.topUp` was called with `{ amount, account }`. Now the CLI always passes `chain: "ethereum"` (the default). Update ALL existing `expect(mockBilling.topUp).toHaveBeenCalledWith(...)` assertions to include `chain: "ethereum"`: + +```typescript +// Before: +expect(mockBilling.topUp).toHaveBeenCalledWith({ + amount: BigInt(50_000_000), + account: WALLET_ADDRESS, +}); + +// After: +expect(mockBilling.topUp).toHaveBeenCalledWith({ + amount: BigInt(50_000_000), + account: WALLET_ADDRESS, + chain: "ethereum", +}); +``` + +Also update any `expect(mockBilling.getTopUpInfo).toHaveBeenCalled()` assertions to expect `{ chain: "ethereum" }` if they check arguments. + +- [ ] **Step 3: Add test - prompts for chain when Base is available** + +```typescript +it("usdc: prompts for chain selection when Base is available", async () => { + mockBilling.hasBaseSupport.mockReturnValue(true); + setupOnChainState(); + mockBilling.topUp.mockResolvedValue({ txHash: TX_HASH, walletAddress: WALLET_ADDRESS }); + mockBilling.getStatus + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 60.0 }); + + (select as unknown as ReturnType).mockResolvedValue("base"); + + const cmd = createCommand({ amount: "50", method: "usdc" }); + const promise = cmd.run(); + for (let i = 0; i < 10; i++) { + await vi.advanceTimersByTimeAsync(5_000); + } + await promise; + + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Which network?", + choices: expect.arrayContaining([ + expect.objectContaining({ value: "base" }), + expect.objectContaining({ value: "ethereum" }), + ]), + }), + ); + + expect(mockBilling.getTopUpInfo).toHaveBeenCalledWith({ chain: "base" }); + expect(mockBilling.topUp).toHaveBeenCalledWith({ + amount: BigInt(50_000_000), + account: WALLET_ADDRESS, + chain: "base", + }); +}); +``` + +- [ ] **Step 4: Add test - skips chain prompt when Base is NOT available** + +```typescript +it("usdc: skips chain prompt when Base is not configured", async () => { + mockBilling.hasBaseSupport.mockReturnValue(false); + setupOnChainState(); + mockBilling.topUp.mockResolvedValue({ txHash: TX_HASH, walletAddress: WALLET_ADDRESS }); + mockBilling.getStatus + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 60.0 }); + + const cmd = createCommand({ amount: "50", method: "usdc" }); + const promise = cmd.run(); + for (let i = 0; i < 10; i++) { + await vi.advanceTimersByTimeAsync(5_000); + } + await promise; + + expect(mockBilling.topUp).toHaveBeenCalledWith({ + amount: BigInt(50_000_000), + account: WALLET_ADDRESS, + chain: "ethereum", + }); +}); +``` + +- [ ] **Step 5: Add test - --chain flag skips prompt** + +```typescript +it("usdc: --chain flag skips network prompt", async () => { + mockBilling.hasBaseSupport.mockReturnValue(true); + setupOnChainState(); + mockBilling.topUp.mockResolvedValue({ txHash: TX_HASH, walletAddress: WALLET_ADDRESS }); + mockBilling.getStatus + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 60.0 }); + + const cmd = createCommand({ amount: "50", method: "usdc", chain: "base" }); + const promise = cmd.run(); + for (let i = 0; i < 10; i++) { + await vi.advanceTimersByTimeAsync(5_000); + } + await promise; + + expect(mockBilling.topUp).toHaveBeenCalledWith({ + amount: BigInt(50_000_000), + account: WALLET_ADDRESS, + chain: "base", + }); +}); +``` + +- [ ] **Step 6: Run tests** + +Run: `cd packages/cli && npx vitest run src/commands/billing/__tests__/top-up.test.ts` +Expected: All tests pass (existing + new) + +- [ ] **Step 7: Commit** + +```bash +git add packages/cli/src/commands/billing/__tests__/top-up.test.ts +git commit -m "test: add chain selection tests for Base USDC top-up" +``` + +--- + +### Task 7: Verify end-to-end flow compiles and tests pass + +**Files:** None (verification only) + +- [ ] **Step 1: Full SDK type check** + +Run: `cd packages/sdk && npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 2: Full CLI type check** + +Run: `cd packages/cli && npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 3: Run all CLI billing tests** + +Run: `cd packages/cli && npx vitest run src/commands/billing/__tests__/` +Expected: All tests pass + +- [ ] **Step 4: Verify existing non-billing tests still pass** + +Run: `cd packages/cli && npx vitest run` +Expected: All tests pass (no regressions) diff --git a/docs/superpowers/plans/2026-05-17-admin-and-coupon-commands.md b/docs/superpowers/plans/2026-05-17-admin-and-coupon-commands.md new file mode 100644 index 00000000..c9a438ac --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-admin-and-coupon-commands.md @@ -0,0 +1,934 @@ +# Admin & Coupon CLI Commands Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an `admin` command group (coupons + admins management) and a `billing redeem-coupon` command for users to redeem coupon codes for credits. + +**Architecture:** The billing API server already exposes admin (`/admin/coupons`, `/admin/admins`) and user-facing coupon (`/v1/coupons/redeem`) REST endpoints authenticated via EIP-712 signatures. We'll extend `BillingApiClient` in the SDK with these endpoint methods, create a new `AdminModule` in the SDK, add `redeemCoupon` to the existing `BillingModule`, then add CLI commands following the established oclif pattern. + +**Tech Stack:** TypeScript, oclif, viem (EIP-712 signatures), axios (HTTP), @inquirer/prompts, chalk + +--- + +### Task 1: Add Admin & Coupon API Methods to BillingApiClient + +**Files:** +- Modify: `packages/sdk/src/client/common/utils/billingapi.ts` +- Modify: `packages/sdk/src/client/common/types/index.ts` (or wherever billing types live) + +- [ ] **Step 1: Add types for admin and coupon API responses** + +First, find where billing types are defined: + +Run: `grep -r "ProductSubscriptionResponse" packages/sdk/src/client/common/types/ --include="*.ts" -l` + +Then add these types to the types file: + +```typescript +// Admin - Coupon types +export interface AdminCoupon { + id: string; + amountCents: number; + active: boolean; + redeemedBy: string; + redeemedAt: string | null; + createdBy: string; + createdAt: string; +} + +export interface CreateCouponResponse { + coupon: AdminCoupon; +} + +export interface ListCouponsResponse { + coupons: AdminCoupon[]; + total: number; +} + +export interface GetCouponResponse { + coupon: AdminCoupon; +} + +// Admin - Admin management types +export interface AdminUser { + id: string; + address: string; + createdAt: string; +} + +export interface AddAdminResponse { + admin: AdminUser; +} + +export interface ListAdminsResponse { + admins: AdminUser[]; +} + +// User-facing coupon redemption +export interface RedeemCouponResponse { + amountCents: number; +} +``` + +- [ ] **Step 2: Add admin and coupon methods to BillingApiClient** + +Add the following methods to `packages/sdk/src/client/common/utils/billingapi.ts`: + +```typescript +// ======================================================================== +// Admin - Coupon Methods +// ======================================================================== + +async createCoupon(amountCents: number): Promise { + const endpoint = `${this.config.billingApiServerURL}/admin/coupons`; + const resp = await this.makeAuthenticatedRequest(endpoint, "POST", "compute", { amountCents }); + return resp.json(); +} + +async listCoupons(opts?: { offset?: number; limit?: number; active?: boolean; redeemed?: boolean }): Promise { + const params = new URLSearchParams(); + if (opts?.offset !== undefined) params.set("offset", opts.offset.toString()); + if (opts?.limit !== undefined) params.set("limit", opts.limit.toString()); + if (opts?.active !== undefined) params.set("active", opts.active.toString()); + if (opts?.redeemed !== undefined) params.set("redeemed", opts.redeemed.toString()); + const qs = params.toString(); + const endpoint = `${this.config.billingApiServerURL}/admin/coupons${qs ? `?${qs}` : ""}`; + const resp = await this.makeAuthenticatedRequest(endpoint, "GET", "compute"); + return resp.json(); +} + +async getCoupon(id: string): Promise { + const endpoint = `${this.config.billingApiServerURL}/admin/coupons/${id}`; + const resp = await this.makeAuthenticatedRequest(endpoint, "GET", "compute"); + return resp.json(); +} + +async deactivateCoupon(id: string): Promise { + const endpoint = `${this.config.billingApiServerURL}/admin/coupons/${id}/deactivate`; + await this.makeAuthenticatedRequest(endpoint, "POST", "compute"); +} + +async redeemCouponForUser(id: string, address: string): Promise { + const endpoint = `${this.config.billingApiServerURL}/admin/coupons/${id}/redeem`; + await this.makeAuthenticatedRequest(endpoint, "POST", "compute", { address }); +} + +// ======================================================================== +// Admin - Admin Management Methods +// ======================================================================== + +async addAdmin(address: string): Promise { + const endpoint = `${this.config.billingApiServerURL}/admin/admins`; + const resp = await this.makeAuthenticatedRequest(endpoint, "POST", "compute", { address }); + return resp.json(); +} + +async removeAdmin(address: string): Promise { + const endpoint = `${this.config.billingApiServerURL}/admin/admins/${address}`; + await this.makeAuthenticatedRequest(endpoint, "DELETE", "compute"); +} + +async listAdmins(): Promise { + const endpoint = `${this.config.billingApiServerURL}/admin/admins`; + const resp = await this.makeAuthenticatedRequest(endpoint, "GET", "compute"); + return resp.json(); +} + +// ======================================================================== +// User - Coupon Redemption +// ======================================================================== + +async redeemCoupon(code: string): Promise { + const endpoint = `${this.config.billingApiServerURL}/v1/coupons/redeem`; + const resp = await this.makeAuthenticatedRequest(endpoint, "POST", "compute", { code }); + return resp.json(); +} +``` + +- [ ] **Step 3: Run typecheck to verify** + +Run: `cd /Users/seanmcgary/Code/ecloud && pnpm --filter @layr-labs/ecloud-sdk run typecheck` +Expected: PASS (no type errors) + +- [ ] **Step 4: Commit** + +```bash +git add packages/sdk/src/client/common/utils/billingapi.ts packages/sdk/src/client/common/types/ +git commit -m "feat(sdk): add admin and coupon API methods to BillingApiClient" +``` + +--- + +### Task 2: Create AdminModule in the SDK + +**Files:** +- Create: `packages/sdk/src/client/modules/admin/index.ts` +- Modify: `packages/sdk/src/client/index.ts` +- Modify: `packages/cli/src/client.ts` + +- [ ] **Step 1: Create the AdminModule** + +Create `packages/sdk/src/client/modules/admin/index.ts`: + +```typescript +import type { WalletClient, PublicClient, Address } from "viem"; +import { BillingApiClient } from "../../common/utils/billingapi"; +import { getBillingEnvironmentConfig, getBuildType } from "../../common/config/environment"; +import type { + AdminCoupon, + CreateCouponResponse, + ListCouponsResponse, + GetCouponResponse, + AdminUser, + AddAdminResponse, + ListAdminsResponse, +} from "../../common/types"; + +export interface AdminModule { + address: Address; + createCoupon: (amountCents: number) => Promise; + listCoupons: (opts?: { offset?: number; limit?: number; active?: boolean; redeemed?: boolean }) => Promise; + getCoupon: (id: string) => Promise; + deactivateCoupon: (id: string) => Promise; + redeemCouponForUser: (id: string, address: string) => Promise; + addAdmin: (address: string) => Promise; + removeAdmin: (address: string) => Promise; + listAdmins: () => Promise; +} + +export interface AdminModuleConfig { + verbose?: boolean; + walletClient: WalletClient; + publicClient: PublicClient; + environment: string; +} + +export function createAdminModule(config: AdminModuleConfig): AdminModule { + const { verbose = false, walletClient } = config; + + if (!walletClient.account) { + throw new Error("WalletClient must have an account attached"); + } + const address = walletClient.account.address as Address; + + const billingEnvConfig = getBillingEnvironmentConfig(getBuildType()); + const billingApi = new BillingApiClient(billingEnvConfig, walletClient, { verbose }); + + return { + address, + + async createCoupon(amountCents: number) { + return billingApi.createCoupon(amountCents); + }, + + async listCoupons(opts?) { + return billingApi.listCoupons(opts); + }, + + async getCoupon(id: string) { + return billingApi.getCoupon(id); + }, + + async deactivateCoupon(id: string) { + return billingApi.deactivateCoupon(id); + }, + + async redeemCouponForUser(id: string, userAddress: string) { + return billingApi.redeemCouponForUser(id, userAddress); + }, + + async addAdmin(adminAddress: string) { + return billingApi.addAdmin(adminAddress); + }, + + async removeAdmin(adminAddress: string) { + return billingApi.removeAdmin(adminAddress); + }, + + async listAdmins() { + return billingApi.listAdmins(); + }, + }; +} +``` + +- [ ] **Step 2: Export AdminModule from SDK index** + +Add to `packages/sdk/src/client/index.ts`: + +```typescript +export { + createAdminModule, + type AdminModule, + type AdminModuleConfig, +} from "./modules/admin"; +``` + +- [ ] **Step 3: Add `redeemCoupon` to the BillingModule interface and implementation** + +In `packages/sdk/src/client/modules/billing/index.ts`, add to the `BillingModule` interface: + +```typescript +redeemCoupon: (code: string) => Promise; +``` + +And in the `createBillingModule` function's returned module object: + +```typescript +async redeemCoupon(code: string) { + return billingApi.redeemCoupon(code); +}, +``` + +Import `RedeemCouponResponse` from the types file. + +- [ ] **Step 4: Add `createAdminClient` to the CLI's client.ts** + +Add to `packages/cli/src/client.ts`: + +```typescript +import { + createComputeModule, + createBillingModule, + createBuildModule, + createAdminModule, + getEnvironmentConfig, + requirePrivateKey, +} from "@layr-labs/ecloud-sdk"; + +// ... existing code ... + +export async function createAdminClient(flags: CommonFlags) { + flags = await validateCommonFlags(flags); + + const environment = flags.environment; + const environmentConfig = getEnvironmentConfig(environment); + const rpcUrl = flags["rpc-url"] || environmentConfig.billingRPCURL || environmentConfig.defaultRPCURL; + const { key: privateKey, source } = await requirePrivateKey({ + privateKey: flags["private-key"], + }); + + if (flags.verbose) { + console.log(`Using private key from: ${source}`); + } + + const { walletClient, publicClient } = createViemClients({ + privateKey: privateKey as Hex, + rpcUrl, + environment, + }); + + return createAdminModule({ + verbose: flags.verbose, + walletClient, + publicClient, + environment, + }); +} +``` + +- [ ] **Step 5: Run typecheck** + +Run: `cd /Users/seanmcgary/Code/ecloud && pnpm --filter @layr-labs/ecloud-sdk run typecheck && pnpm --filter @layr-labs/ecloud-cli run typecheck` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add packages/sdk/src/client/modules/admin/ packages/sdk/src/client/index.ts packages/sdk/src/client/modules/billing/index.ts packages/cli/src/client.ts +git commit -m "feat(sdk): add AdminModule and redeemCoupon to BillingModule" +``` + +--- + +### Task 3: Add `billing redeem-coupon` CLI Command + +**Files:** +- Create: `packages/cli/src/commands/billing/redeem-coupon.ts` + +- [ ] **Step 1: Create the redeem-coupon command** + +Create `packages/cli/src/commands/billing/redeem-coupon.ts`: + +```typescript +import { Command, Flags } from "@oclif/core"; +import { createBillingClient } from "../../client"; +import { commonFlags } from "../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../telemetry"; +import { input } from "@inquirer/prompts"; + +export default class BillingRedeemCoupon extends Command { + static description = "Redeem a coupon code for credits"; + + static examples = [ + "<%= config.bin %> billing redeem-coupon", + "<%= config.bin %> billing redeem-coupon --code ABC123", + ]; + + static flags = { + ...commonFlags, + code: Flags.string({ + required: false, + description: "Coupon code to redeem", + }), + }; + + async run() { + return withTelemetry(this, async () => { + const { flags } = await this.parse(BillingRedeemCoupon); + const billing = await createBillingClient(flags); + + const code = + flags.code ?? + (await input({ + message: "Enter your coupon code:", + validate: (val) => (val.trim().length > 0 ? true : "Coupon code is required"), + })); + + const result = await billing.redeemCoupon(code.trim()); + const dollars = (result.amountCents / 100).toFixed(2); + + this.log(`\n ${chalk.green("✓")} Coupon redeemed! ${chalk.cyan(`$${dollars}`)} in credits added to your account.`); + this.log(`\n Run ${chalk.cyan("ecloud billing status")} to see your updated balance.\n`); + }); + } +} +``` + +- [ ] **Step 2: Run typecheck** + +Run: `cd /Users/seanmcgary/Code/ecloud && pnpm --filter @layr-labs/ecloud-cli run typecheck` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add packages/cli/src/commands/billing/redeem-coupon.ts +git commit -m "feat(cli): add billing redeem-coupon command" +``` + +--- + +### Task 4: Add `admin coupons` CLI Commands + +**Files:** +- Create: `packages/cli/src/commands/admin/coupons/create.ts` +- Create: `packages/cli/src/commands/admin/coupons/list.ts` +- Create: `packages/cli/src/commands/admin/coupons/get.ts` +- Create: `packages/cli/src/commands/admin/coupons/deactivate.ts` +- Create: `packages/cli/src/commands/admin/coupons/redeem.ts` + +- [ ] **Step 1: Create `admin coupons create`** + +Create `packages/cli/src/commands/admin/coupons/create.ts`: + +```typescript +import { Command, Flags } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; +import { input } from "@inquirer/prompts"; + +export default class AdminCouponsCreate extends Command { + static description = "Create a new coupon"; + + static examples = [ + "<%= config.bin %> admin coupons create --amount 50", + ]; + + static flags = { + ...commonFlags, + amount: Flags.string({ + required: false, + description: "Coupon value in whole dollars", + }), + }; + + async run() { + return withTelemetry(this, async () => { + const { flags } = await this.parse(AdminCouponsCreate); + const admin = await createAdminClient(flags); + + const amountStr = + flags.amount ?? + (await input({ + message: "Coupon value in dollars:", + validate: (val) => { + const n = parseFloat(val); + if (isNaN(n) || n <= 0) return "Enter a positive number"; + return true; + }, + })); + + const dollars = parseFloat(amountStr); + const amountCents = Math.round(dollars * 100); + + const { coupon } = await admin.createCoupon(amountCents); + + this.log(`\n${chalk.green("✓")} Coupon created`); + this.log(` ID: ${chalk.cyan(coupon.id)}`); + this.log(` Value: ${chalk.cyan(`$${(coupon.amountCents / 100).toFixed(2)}`)}`); + this.log(` Active: ${coupon.active ? chalk.green("yes") : chalk.red("no")}\n`); + }); + } +} +``` + +- [ ] **Step 2: Create `admin coupons list`** + +Create `packages/cli/src/commands/admin/coupons/list.ts`: + +```typescript +import { Command, Flags } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; + +export default class AdminCouponsList extends Command { + static description = "List coupons"; + + static examples = [ + "<%= config.bin %> admin coupons list", + "<%= config.bin %> admin coupons list --active", + "<%= config.bin %> admin coupons list --redeemed", + ]; + + static flags = { + ...commonFlags, + active: Flags.boolean({ + required: false, + description: "Filter to active coupons only", + }), + redeemed: Flags.boolean({ + required: false, + description: "Filter to redeemed coupons only", + }), + limit: Flags.integer({ + required: false, + description: "Number of results to return", + default: 25, + }), + offset: Flags.integer({ + required: false, + description: "Offset for pagination", + default: 0, + }), + }; + + async run() { + return withTelemetry(this, async () => { + const { flags } = await this.parse(AdminCouponsList); + const admin = await createAdminClient(flags); + + const opts: { offset?: number; limit?: number; active?: boolean; redeemed?: boolean } = { + offset: flags.offset, + limit: flags.limit, + }; + if (flags.active) opts.active = true; + if (flags.redeemed) opts.redeemed = true; + + const { coupons, total } = await admin.listCoupons(opts); + + if (coupons.length === 0) { + this.log("\n No coupons found.\n"); + return; + } + + this.log(`\n${chalk.bold("Coupons")} (${coupons.length} of ${total}):\n`); + + for (const c of coupons) { + const value = `$${(c.amountCents / 100).toFixed(2)}`; + const status = c.redeemedBy + ? chalk.gray(`redeemed by ${c.redeemedBy}`) + : c.active + ? chalk.green("active") + : chalk.red("inactive"); + this.log(` ${chalk.cyan(c.id)} ${value} ${status}`); + } + this.log(); + }); + } +} +``` + +- [ ] **Step 3: Create `admin coupons get`** + +Create `packages/cli/src/commands/admin/coupons/get.ts`: + +```typescript +import { Args, Command } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; + +export default class AdminCouponsGet extends Command { + static description = "Get details of a coupon"; + + static examples = [ + "<%= config.bin %> admin coupons get ", + ]; + + static args = { + id: Args.string({ description: "Coupon ID", required: true }), + }; + + static flags = { + ...commonFlags, + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(AdminCouponsGet); + const admin = await createAdminClient(flags); + + const { coupon } = await admin.getCoupon(args.id); + + this.log(`\n${chalk.bold("Coupon Details:")}`); + this.log(` ID: ${chalk.cyan(coupon.id)}`); + this.log(` Value: ${chalk.cyan(`$${(coupon.amountCents / 100).toFixed(2)}`)}`); + this.log(` Active: ${coupon.active ? chalk.green("yes") : chalk.red("no")}`); + this.log(` Created by: ${coupon.createdBy}`); + this.log(` Created at: ${coupon.createdAt}`); + if (coupon.redeemedBy) { + this.log(` Redeemed by: ${coupon.redeemedBy}`); + this.log(` Redeemed at: ${coupon.redeemedAt}`); + } + this.log(); + }); + } +} +``` + +- [ ] **Step 4: Create `admin coupons deactivate`** + +Create `packages/cli/src/commands/admin/coupons/deactivate.ts`: + +```typescript +import { Args, Command } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; + +export default class AdminCouponsDeactivate extends Command { + static description = "Deactivate a coupon"; + + static examples = [ + "<%= config.bin %> admin coupons deactivate ", + ]; + + static args = { + id: Args.string({ description: "Coupon ID", required: true }), + }; + + static flags = { + ...commonFlags, + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(AdminCouponsDeactivate); + const admin = await createAdminClient(flags); + + await admin.deactivateCoupon(args.id); + + this.log(`\n ${chalk.green("✓")} Coupon ${chalk.cyan(args.id)} deactivated.\n`); + }); + } +} +``` + +- [ ] **Step 5: Create `admin coupons redeem`** + +Create `packages/cli/src/commands/admin/coupons/redeem.ts`: + +```typescript +import { Args, Command, Flags } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; + +export default class AdminCouponsRedeem extends Command { + static description = "Redeem a coupon for a user (admin action)"; + + static examples = [ + "<%= config.bin %> admin coupons redeem --address 0x...", + ]; + + static args = { + id: Args.string({ description: "Coupon ID", required: true }), + }; + + static flags = { + ...commonFlags, + address: Flags.string({ + required: true, + description: "User wallet address to redeem coupon for", + }), + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(AdminCouponsRedeem); + const admin = await createAdminClient(flags); + + await admin.redeemCouponForUser(args.id, flags.address); + + this.log(`\n ${chalk.green("✓")} Coupon ${chalk.cyan(args.id)} redeemed for ${chalk.cyan(flags.address)}.\n`); + }); + } +} +``` + +- [ ] **Step 6: Run typecheck** + +Run: `cd /Users/seanmcgary/Code/ecloud && pnpm --filter @layr-labs/ecloud-cli run typecheck` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add packages/cli/src/commands/admin/coupons/ +git commit -m "feat(cli): add admin coupons commands (create, list, get, deactivate, redeem)" +``` + +--- + +### Task 5: Add `admin admins` CLI Commands + +**Files:** +- Create: `packages/cli/src/commands/admin/admins/add.ts` +- Create: `packages/cli/src/commands/admin/admins/remove.ts` +- Create: `packages/cli/src/commands/admin/admins/list.ts` + +- [ ] **Step 1: Create `admin admins add`** + +Create `packages/cli/src/commands/admin/admins/add.ts`: + +```typescript +import { Args, Command } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; + +export default class AdminAdminsAdd extends Command { + static description = "Add a new admin"; + + static examples = [ + "<%= config.bin %> admin admins add 0x...", + ]; + + static args = { + address: Args.string({ description: "Wallet address to grant admin", required: true }), + }; + + static flags = { + ...commonFlags, + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(AdminAdminsAdd); + const admin = await createAdminClient(flags); + + const { admin: newAdmin } = await admin.addAdmin(args.address); + + this.log(`\n ${chalk.green("✓")} Admin added`); + this.log(` Address: ${chalk.cyan(newAdmin.address)}`); + this.log(` ID: ${newAdmin.id}\n`); + }); + } +} +``` + +- [ ] **Step 2: Create `admin admins remove`** + +Create `packages/cli/src/commands/admin/admins/remove.ts`: + +```typescript +import { Args, Command } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; + +export default class AdminAdminsRemove extends Command { + static description = "Remove an admin"; + + static examples = [ + "<%= config.bin %> admin admins remove 0x...", + ]; + + static args = { + address: Args.string({ description: "Wallet address to remove from admins", required: true }), + }; + + static flags = { + ...commonFlags, + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(AdminAdminsRemove); + const admin = await createAdminClient(flags); + + await admin.removeAdmin(args.address); + + this.log(`\n ${chalk.green("✓")} Admin ${chalk.cyan(args.address)} removed.\n`); + }); + } +} +``` + +- [ ] **Step 3: Create `admin admins list`** + +Create `packages/cli/src/commands/admin/admins/list.ts`: + +```typescript +import { Command } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; + +export default class AdminAdminsList extends Command { + static description = "List all admins"; + + static examples = [ + "<%= config.bin %> admin admins list", + ]; + + static flags = { + ...commonFlags, + }; + + async run() { + return withTelemetry(this, async () => { + const { flags } = await this.parse(AdminAdminsList); + const admin = await createAdminClient(flags); + + const { admins } = await admin.listAdmins(); + + if (admins.length === 0) { + this.log("\n No admins found.\n"); + return; + } + + this.log(`\n${chalk.bold("Admins")} (${admins.length}):\n`); + for (const a of admins) { + this.log(` ${chalk.cyan(a.address)} ${chalk.gray(a.createdAt)}`); + } + this.log(); + }); + } +} +``` + +- [ ] **Step 4: Run typecheck** + +Run: `cd /Users/seanmcgary/Code/ecloud && pnpm --filter @layr-labs/ecloud-cli run typecheck` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/commands/admin/admins/ +git commit -m "feat(cli): add admin admins commands (add, remove, list)" +``` + +--- + +### Task 6: Register `admin` Topics in package.json + +**Files:** +- Modify: `packages/cli/package.json` + +- [ ] **Step 1: Add admin topics to oclif config** + +In `packages/cli/package.json`, add to the `oclif.topics` object: + +```json +"admin": { + "description": "Admin operations (requires admin privileges)" +}, +"admin:coupons": { + "description": "Manage coupons" +}, +"admin:admins": { + "description": "Manage admin users" +} +``` + +- [ ] **Step 2: Verify CLI discovers commands** + +Run: `cd /Users/seanmcgary/Code/ecloud/packages/cli && pnpm run build && node bin/run.js admin --help` +Expected: Shows admin topic with coupons and admins sub-topics + +Run: `node bin/run.js admin coupons --help` +Expected: Lists create, list, get, deactivate, redeem commands + +Run: `node bin/run.js billing redeem-coupon --help` +Expected: Shows redeem-coupon command help + +- [ ] **Step 3: Commit** + +```bash +git add packages/cli/package.json +git commit -m "feat(cli): register admin topics in oclif config" +``` + +--- + +### Task 7: Export New Types from SDK + +**Files:** +- Modify: `packages/sdk/src/client/index.ts` + +- [ ] **Step 1: Ensure all new types are exported from SDK entrypoint** + +Verify that `RedeemCouponResponse` is exported via the existing `export * from "./common/types"` line. If admin types need explicit export (because they're in a new file), add: + +```typescript +export type { + AdminCoupon, + CreateCouponResponse, + ListCouponsResponse, + GetCouponResponse, + AdminUser, + AddAdminResponse, + ListAdminsResponse, + RedeemCouponResponse, +} from "./common/types"; +``` + +Also export `RedeemCouponResponse` from the billing module exports if needed: + +```typescript +export { + createBillingModule, + type BillingModule, + type BillingModuleConfig, + type BillingChain, + type TopUpOpts, + type TopUpResult, + type TopUpInfo, +} from "./modules/billing"; +``` + +- [ ] **Step 2: Final full typecheck and build** + +Run: `cd /Users/seanmcgary/Code/ecloud && pnpm --filter @layr-labs/ecloud-sdk run typecheck && pnpm --filter @layr-labs/ecloud-cli run typecheck && pnpm --filter @layr-labs/ecloud-cli run build` +Expected: All pass + +- [ ] **Step 3: Commit if any changes** + +```bash +git add packages/sdk/src/client/index.ts +git commit -m "feat(sdk): export admin and coupon types" +``` diff --git a/docs/superpowers/specs/2026-04-23-top-up-credit-card-design.md b/docs/superpowers/specs/2026-04-23-top-up-credit-card-design.md new file mode 100644 index 00000000..0df449d7 --- /dev/null +++ b/docs/superpowers/specs/2026-04-23-top-up-credit-card-design.md @@ -0,0 +1,211 @@ +# Top-Up Credit Card Support + +Add credit card purchasing to `ecloud billing top-up` alongside the existing USDC on-chain flow. + +## Motivation + +We're moving to a credit-based burndown system. Users need to purchase credits, and not everyone wants to use USDC on-chain. Adding credit card support via Stripe lets users top up with a familiar payment method. + +## API Routes + +Both routes live on the billing API server (`ECLOUD_BILLING_API_URL`), use the same EIP-712 signature auth as existing routes (`Authorization: Bearer `, `X-Account`, `X-Expiry`). + +### GET /v1/payment-methods + +Returns saved payment methods for the authenticated wallet. + +Request: no body, auth required. + +Response: +```json +{ + "paymentMethods": [ + { + "id": "029641fc-3e5c-11f1-986c-5601121cbf6d", + "stripePaymentMethodId": "pm_1ABC123...", + "createdAt": "2026-04-20T15:00:00Z" + } + ] +} +``` + +### POST /v1/credits/purchase + +Two modes depending on whether `paymentMethodId` is provided. + +**Direct charge (card on file):** + +Request: +```json +{ + "amountCents": 5000, + "paymentMethodId": "029641fc-3e5c-11f1-986c-5601121cbf6d" +} +``` + +Response: +```json +{ + "purchaseId": "a1b2c3d4-5e6f-11f1-986c-5601121cbf6d", + "amountCents": "5000" +} +``` + +**Checkout session (no card on file):** + +Request: +```json +{ + "amountCents": 5000 +} +``` + +Response: +```json +{ + "checkoutSessionId": "cs_test_abc123...", + "checkoutUrl": "https://checkout.stripe.com/c/pay/cs_test_abc123...", + "amountCents": "5000" +} +``` + +Minimum `amountCents`: 500 ($5.00). + +## Design + +### SDK: New methods on `BillingApiClient` + +File: `packages/sdk/src/client/common/utils/billingapi.ts` + +Add two methods to the existing `BillingApiClient` class: + +```typescript +async getPaymentMethods(): Promise +``` +- `GET ${billingApiServerURL}/v1/payment-methods` +- Uses `makeAuthenticatedRequest` with a dummy productId (e.g. `"compute"`) for signature generation since the auth scheme requires a product field. + +```typescript +async purchaseCredits(amountCents: number, paymentMethodId?: string): Promise +``` +- `POST ${billingApiServerURL}/v1/credits/purchase` +- Body: `{ amountCents }` or `{ amountCents, paymentMethodId }` depending on whether a payment method is provided. +- Uses `makeAuthenticatedRequest`. + +### SDK: New types + +File: `packages/sdk/src/client/common/types/index.ts` + +```typescript +export interface PaymentMethod { + id: string; + stripePaymentMethodId: string; + createdAt: string; +} + +export interface PaymentMethodsResponse { + paymentMethods: PaymentMethod[]; +} + +export interface CreditPurchaseResponse { + purchaseId?: string; + checkoutSessionId?: string; + checkoutUrl?: string; + amountCents: string; +} +``` + +`CreditPurchaseResponse` is a union-style interface: a direct charge returns `purchaseId` without checkout fields; a checkout session returns `checkoutSessionId` + `checkoutUrl` without `purchaseId`. + +### SDK: Export new methods from billing module + +File: `packages/sdk/src/client/modules/billing/index.ts` + +Expose the two new `BillingApiClient` methods through the `BillingModule` interface: + +```typescript +export interface BillingModule { + // ... existing methods ... + getPaymentMethods: () => Promise; + purchaseCredits: (amountCents: number, paymentMethodId?: string) => Promise; +} +``` + +Wire them to `billingApi.getPaymentMethods()` and `billingApi.purchaseCredits()` in `createBillingModule`. + +### CLI: Modified `top-up.ts` command + +File: `packages/cli/src/commands/billing/top-up.ts` + +#### New flag + +``` +--method usdc | card (optional, prompts if omitted) +``` + +#### Updated flow + +1. Show wallet address and current credit balance (unchanged). +2. **Payment method selection:** + - If `--method usdc` -> go to USDC path. + - If `--method card` -> go to credit card path. + - If no flag -> prompt user to choose between "USDC (on-chain)" and "Credit card". +3. **USDC path:** Unchanged from current implementation (steps 2-5 in existing code). +4. **Credit card path:** + a. Prompt for dollar amount (whole dollars, minimum $5). Skipped if `--amount` flag is provided. + b. Convert to cents: `amountCents = dollars * 100`. + c. Call `billing.getPaymentMethods()`. + d. If payment methods exist: + - Show: "Use card on file (pm_...1ABC)?" with yes/no prompt. + - If yes: call `billing.purchaseCredits(amountCents, paymentMethod.id)`. This returns `{ purchaseId, amountCents }`. Proceed to poll for credits. + - If no: call `billing.purchaseCredits(amountCents)` without payment method ID. This returns a checkout URL. Open in browser with `open`. Proceed to poll for credits. + e. If no payment methods: call `billing.purchaseCredits(amountCents)` (no payment method ID). Open checkout URL in browser. Proceed to poll for credits. +5. **Credit polling:** Same polling loop as today — poll `billing.getStatus()` until `remainingCredits` increases or timeout (3 minutes). + +#### Amount validation (credit card path) + +- Must be a whole dollar amount (integer). +- Minimum: $5 (500 cents). +- No maximum (Stripe handles limits). + +#### Non-interactive support + +For CI/scripting, all prompts can be skipped via flags: +- `--method card --amount 50` skips the method and amount prompts. +- Without a card on file, the checkout URL is printed to stdout (the `open` call will be attempted but the URL is always logged). +- With a card on file and no flag to choose it, the command will still prompt. Full non-interactive card selection is out of scope for this change. + +### CLI: Update command description and examples + +Update `static description` and `static examples` to reflect the new credit card option. + +### Tests + +File: `packages/cli/src/commands/billing/__tests__/top-up.test.ts` + +Add test cases: +- **Credit card, card on file, user accepts:** mock `getPaymentMethods` returning one card, mock `purchaseCredits` returning `{ purchaseId, amountCents }`, verify no browser open, verify credit polling. +- **Credit card, card on file, user declines (wants new card):** mock `purchaseCredits` returning `{ checkoutUrl, ... }`, verify `open` is called with checkout URL. +- **Credit card, no card on file:** mock `getPaymentMethods` returning empty array, mock `purchaseCredits` returning checkout URL, verify `open` is called. +- **`--method card --amount 50` skips prompts:** verify `select` and `input` are not called. +- **Amount below $5 minimum:** verify validation error. +- **Existing USDC tests remain unchanged.** + +Mock `billing.getPaymentMethods` and `billing.purchaseCredits` on the same `mockBilling` object used by existing tests. Mock `open` as already done in `subscribe.test.ts`. + +## Files changed + +| File | Change | +|------|--------| +| `packages/sdk/src/client/common/types/index.ts` | Add `PaymentMethod`, `PaymentMethodsResponse`, `CreditPurchaseResponse` | +| `packages/sdk/src/client/common/utils/billingapi.ts` | Add `getPaymentMethods()`, `purchaseCredits()` | +| `packages/sdk/src/client/modules/billing/index.ts` | Expose new methods on `BillingModule` | +| `packages/cli/src/commands/billing/top-up.ts` | Add `--method` flag, credit card flow, method selection prompt | +| `packages/cli/src/commands/billing/__tests__/top-up.test.ts` | Add credit card test cases | + +## Out of scope + +- Listing/managing saved payment methods (separate command later). +- Deleting payment methods. +- Full non-interactive card selection (auto-picking a saved card without prompting). +- Changing the subscribe command flow. diff --git a/packages/cli/package.json b/packages/cli/package.json index a522a919..d72dd5ff 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -80,6 +80,15 @@ "compute:env": { "hidden": true, "description": "Manage deployment environment [alias: env]" + }, + "admin": { + "description": "Admin operations (requires admin privileges)" + }, + "admin:coupons": { + "description": "Manage coupons" + }, + "admin:admins": { + "description": "Manage admin users" } } }, diff --git a/packages/cli/skills/deploy/SKILL.md b/packages/cli/skills/deploy/SKILL.md index cb15f52b..b580dada 100644 --- a/packages/cli/skills/deploy/SKILL.md +++ b/packages/cli/skills/deploy/SKILL.md @@ -22,13 +22,17 @@ You are deploying to EigenCloud TEE (Trusted Execution Environment) infrastructu 2. **Always use hex app IDs (0x...), never display names.** Name lookup is profile-based and silently fails on some commands (confirmed: `app info` by name returns `not found` while the same app works fine by ID). -3. **The CLI returns exit 1 for all errors.** There are no granular exit codes. Parse stdout/stderr content to determine what went wrong. +3. **Exit codes.** Most commands return exit `1` for any error (parse stdout/stderr to see what went wrong). **`app deploy` and `app upgrade` are the exception** — they emit distinct codes so you can branch without parsing text: + - `2` — invalid/missing input; failed **before** any build. Fix flags and re-run. + - `3` — build/push failed; **no on-chain transaction was attempted**. No image was produced. + - `4` — build/push succeeded but the **on-chain transaction failed**. The image is already built and pushed; re-running deploy/upgrade reuses it (you are not paying for another ~7-min build). + - `1` — generic/unclassified error. 4. **Env vars are encrypted client-side** before transmission. They are only decrypted inside the TEE. Never visible in logs or API responses. 5. **Derived keys (EVM + Solana wallets) are deterministic per app ID.** They persist across stop/start/upgrade. They are permanently lost if you terminate and redeploy — new app ID means new keys. -6. **`--json` is only available on:** `app releases`, `build submit`, `build status`, `build info`, `build list`, `build verify`. It is NOT available on: `app list`, `app info`, `app deploy`, `billing status`. Do not pass `--json` to commands that don't support it. +6. **`--json` is only available on:** `app status`, `app releases`, `build submit`, `build status`, `build info`, `build list`, `build verify`. It is NOT available on: `app list`, `app info`, `app deploy`, `billing status`. Do not pass `--json` to commands that don't support it. 7. **No `--dry-run` exists** anywhere in the CLI. No `--yes` on deploy/upgrade (they don't prompt interactively). `--force` only exists on: `app terminate`, `billing cancel`, `auth logout`. @@ -51,6 +55,7 @@ Run all five checks. Fix any that fail before proceeding. ```bash ecloud --version ``` + Pass: outputs `@layr-labs/ecloud-cli/`. Fail: `npm install -g @layr-labs/ecloud-cli` @@ -59,8 +64,10 @@ Fail: `npm install -g @layr-labs/ecloud-cli` ```bash ecloud auth whoami ``` + Pass: output contains `Address: 0x` and `Source: stored credentials`. Fail: + - New user → `ecloud auth generate --store` (generates key, stores in OS keyring) - Has existing key → `ecloud auth login` (interactive prompt for private key) - CI/automation → set `ECLOUD_PRIVATE_KEY` env var instead of keyring @@ -70,7 +77,9 @@ Fail: ```bash ecloud compute environment show ``` + Two environments exist: + - `sepolia` — **Testnet (default).** Uses Sepolia ETH for gas. Deploy here first to test. Billing subscription flow is the same but uses test payment methods. All on-chain records are on Sepolia. Dashboard: `verify-sepolia.eigencloud.xyz`. - `mainnet-alpha` — **Production.** Uses real ETH for gas, real USDC for payments. Apps are publicly visible on the mainnet verify dashboard: `verify.eigencloud.xyz`. Use when the app is ready for real users and real funds. @@ -84,8 +93,10 @@ Fix: `ecloud compute env set sepolia --yes` (or `mainnet-alpha`). ```bash ecloud billing status ``` + Pass: output contains `Status: ✓ Active`. Fail: + 1. `ecloud billing subscribe` — creates the subscription 2. `ecloud billing top-up --amount 50` — purchases USDC credits @@ -94,6 +105,7 @@ Fail: ```bash docker version --format '{{.Client.Version}}' ``` + Pass: outputs a version string. Fail: install Docker Desktop or Docker Engine. @@ -108,23 +120,29 @@ uname -m ``` **If `arm64` (Apple Silicon — most common dev machine):** + ```bash docker buildx build --platform linux/amd64 -t /: --push . ``` + This cross-compiles AND pushes in one step. The `--push` flag is required because buildx cross-compiled images can't be loaded into the local daemon. **If `x86_64` (native):** + ```bash docker build -t /: . && docker push /: ``` **After push, verify architecture:** + ```bash docker manifest inspect /: 2>&1 | grep architecture ``` + Must contain `amd64`. If it shows `arm64`, the image will crash in the TEE. **Image requirements:** + - Must be in a **public** registry (Docker Hub, GHCR, etc.) — the TEE pulls at deploy time - Must be OCI-compliant - App should bind to `0.0.0.0` on its listening port (not `127.0.0.1`) @@ -135,14 +153,14 @@ Must contain `amd64`. If it shows `arm64`, the image will crash in the TEE. ### Instance types -| Instance Type | TEE | Approx $/hr | -|--------------|-----|-------------| -| `g1-micro-1v` | vTPM | ~$0.03 | -| `g1-standard-2s` | — | ~$0.04 | -| `g1-standard-2t` | Intel TDX | ~$0.07 | -| `g1-standard-4s` | — | ~$0.12 | -| `g1-standard-4t` | Intel TDX | ~$0.33 | -| `g1-standard-8t` | Intel TDX | ~$0.66 | +| Instance Type | TEE | Approx $/hr | +| ---------------- | --------- | ----------- | +| `g1-micro-1v` | vTPM | ~$0.03 | +| `g1-standard-2s` | — | ~$0.04 | +| `g1-standard-2t` | Intel TDX | ~$0.07 | +| `g1-standard-4s` | — | ~$0.12 | +| `g1-standard-4t` | Intel TDX | ~$0.33 | +| `g1-standard-8t` | Intel TDX | ~$0.66 | Suffix convention (inferred, not officially documented): `t` = Intel TDX, `s` = likely AMD SEV, `v` = vTPM. Only TDX is confirmed in official EigenCloud docs. Prices are approximate — sourced from billing line items and may change. @@ -192,12 +210,15 @@ ecloud compute build submit \ This blocks and streams build logs by default. Use `--no-follow` to exit immediately after submission. If `--no-follow` was used, poll for completion: + ```bash ecloud compute build status --json ``` + On build failure: `ecloud compute build logs --tail 50` Then deploy with the verifiable flag: + ```bash ecloud compute app deploy \ --name \ @@ -211,42 +232,58 @@ ecloud compute app deploy \ ``` Additional flags for complex builds: + - Multi-stage with dependencies: `--build-dependencies sha256:...` - TLS via Caddy: `--build-caddyfile Caddyfile` ### Deploy output parsing -| stdout/stderr contains | Meaning | Next step | -|------------------------|---------|-----------| -| `0x` + 40 hex chars + dashboard URL | Success — deploy initiated | Save app ID → Gate 3 | -| `subscription not active` | No billing subscription | `ecloud billing subscribe` → retry | -| `insufficient credits` | USDC balance too low | `ecloud billing top-up --amount ` → retry | -| Image pull error | Image not public, wrong arch, or bad ref | Verify with `docker manifest inspect` → retry | -| `already exists` | App name collision | Change `--name` or use `upgrade` on existing app | +| stdout/stderr contains | Meaning | Next step | +| ----------------------------------- | ---------------------------------------- | ------------------------------------------------ | +| `0x` + 40 hex chars + dashboard URL | Success — deploy initiated | Save app ID → Gate 3 | +| `subscription not active` | No billing subscription | `ecloud billing subscribe` → retry | +| `insufficient credits` | USDC balance too low | `ecloud billing top-up --amount ` → retry | +| Image pull error | Image not public, wrong arch, or bad ref | Verify with `docker manifest inspect` → retry | +| `already exists` | App name collision | Change `--name` or use `upgrade` on existing app | --- ## Gate 3: Verify deployment -Deploy returns before the app is fully running. You must poll. +Deploy returns before the app is fully running. Wait for it to settle. -### Poll for running status +### Wait for running status + +**Use `app status --wait` — do NOT tight-loop `app info`.** A bare +`ecloud compute app info ` is a one-shot fetch with no pacing; calling +it in a shell loop (or a hardcoded `sleep`) hammers the API and trips server +rate limits. `status --wait` blocks using the CLI's internal watch loop, which +paces requests and backs off on `429/502/503/504`. ```bash -ecloud compute app info +# Blocks until the app reaches a terminal status or the timeout elapses. +ecloud compute app status --wait + +# Bound the wait (default 600s) and/or get machine-readable output: +ECLOUD_WATCH_TIMEOUT_SECONDS=180 ecloud compute app status --wait +ecloud compute app status --json # one-shot: {"appId","status"} ``` -Parse these fields from output: +`--json` emits `{"appId":"0x...","status":"Running"}`. Status is one of: +`Running`, `Deploying`, `Stopped`, `Terminated`, `Failed`, `Unknown`. + +- If `status --wait` returns `Running` → proceed to health check. +- If it times out (prints a recovery hint) → check `ecloud compute app info ` + once for details and `ecloud compute app logs `. +- If `Failed` → check logs: `ecloud compute app logs ` + +Use `app info ` (one-shot, not in a loop) only when you need richer +details — IP, EVM/Solana addresses, release info: -- **`Status:`** — one of: `Running`, `Deploying`, `Stopped`, `Terminated`, `Error` - **`IP:`** — public IP address (direct, no load balancer) - **`EVM Address:`** — TEE-derived EVM wallet (deterministic per app ID) - **`Solana Address:`** — TEE-derived Solana wallet (deterministic per app ID) -If `Deploying` → wait 15 seconds, poll again. Typical time to `Running`: 1-3 minutes. -If `Running` → proceed to health check. -If `Error` → check logs: `ecloud compute app logs ` - ### Health check The app's listening port is exposed directly on the public IP. Check whichever port your app binds to (templates default to 3000). @@ -255,11 +292,11 @@ The app's listening port is exposed directly on the public IP. Check whichever p curl -s -o /dev/null -w "%{http_code}" http://:/ ``` -| Response | Meaning | -|----------|---------| -| `200` | App is healthy and serving | -| `404` | App is running but has no route at `/` — check your app's routes | -| `000` | Connection refused — app not ready yet, wrong port, or app crashed on startup | +| Response | Meaning | +| -------- | ----------------------------------------------------------------------------- | +| `200` | App is healthy and serving | +| `404` | App is running but has no route at `/` — check your app's routes | +| `000` | Connection refused — app not ready yet, wrong port, or app crashed on startup | ### Verify provenance (verifiable builds only) @@ -289,6 +326,7 @@ ecloud compute app profile set \ **Constraints:** Profile name cannot contain spaces. Use hyphens or camelCase (e.g., `CitiBike-MPP` not `Citi Bike MPP`). Dashboard URLs: + - Sepolia: `https://verify-sepolia.eigencloud.xyz/app/` - Mainnet: `https://verify.eigencloud.xyz/app/` @@ -305,6 +343,7 @@ ecloud compute app upgrade \ ``` The repo must be public. After upgrade, verify provenance: + ```bash ecloud compute app releases --full ecloud compute build verify --json @@ -322,6 +361,7 @@ ecloud compute app upgrade \ ``` For verifiable upgrade: + ```bash ecloud compute app upgrade \ --verifiable \ @@ -330,7 +370,7 @@ ecloud compute app upgrade \ --verbose ``` -After upgrade, re-run Gate 3 (poll `app info` until `Running`, then health check). +After upgrade, re-run Gate 3 (`app status --wait` until `Running`, then health check). App ID and derived keys persist across upgrades. @@ -341,12 +381,15 @@ App ID and derived keys persist across upgrades. No native rollback command exists. Manual procedure: **1. Find previous release:** + ```bash ecloud compute app releases --json ``` + The `releases` array is ordered by creation time. Previous release = second-to-last entry. Extract `registryUrl` and `imageDigest`. **2. Upgrade to previous image:** + ```bash ecloud compute app upgrade \ --image-ref @ \ @@ -356,10 +399,12 @@ ecloud compute app upgrade \ **3. Verify:** re-run Gate 3. **If upgrade fails (app stuck or crashed):** + ```bash ecloud compute app terminate --force ecloud compute app deploy --name --image-ref --instance-type --verbose ``` + This creates a new app ID. Derived keys will be different. Only do this as a last resort. --- @@ -381,21 +426,21 @@ ecloud compute app info --address-count 5 # show more derived addresse ## Error recovery -| Error | Cause | Fix | -|-------|-------|-----| -| `App name 'X' not found` | Name lookup is profile-based, unreliable | Use hex app ID (0x...) | -| `subscription not active` | No billing subscription | `ecloud billing subscribe` | -| `insufficient credits` | USDC balance depleted | `ecloud billing top-up --amount ` | -| Image pull failure | Image not public or wrong architecture | `docker manifest inspect ` — verify `linux/amd64` | -| App crashes immediately after deploy | arm64 image deployed to TDX instance | Rebuild with `docker buildx build --platform linux/amd64` | -| `No builds found` on verify | Image was pushed directly, not via verifiable build | Redeploy with `--verifiable --repo --commit ` | -| Status stuck on `Deploying` | Large image or infrastructure delay | `ecloud compute app logs ` for diagnostics | -| Auth errors | Key not in keyring or expired | `ecloud auth whoami` to diagnose → `ecloud auth login` to fix | -| Wrong environment | Deployed to sepolia instead of mainnet (or vice versa) | `ecloud compute env show` → `ecloud compute env set --yes` | -| Logs return 425 error | Normal during provisioning (1-2+ min after deploy) | Wait and retry, or use `--watch` to stream when available | -| App shows "(unnamed)" on dashboard | `--name` only sets CLI name, not dashboard profile | `ecloud compute app profile set --name "Name"` | -| Profile name rejected | Spaces not allowed in profile names | Use hyphens or camelCase | -| Mainnet app unreachable despite "Running" status | Mainnet networking can take 5+ min after deploy (longer than sepolia) | Keep polling — sepolia ~30s, mainnet can be several minutes | +| Error | Cause | Fix | +| ------------------------------------------------ | --------------------------------------------------------------------- | ---------------------------------------------------------------- | +| `App name 'X' not found` | Name lookup is profile-based, unreliable | Use hex app ID (0x...) | +| `subscription not active` | No billing subscription | `ecloud billing subscribe` | +| `insufficient credits` | USDC balance depleted | `ecloud billing top-up --amount ` | +| Image pull failure | Image not public or wrong architecture | `docker manifest inspect ` — verify `linux/amd64` | +| App crashes immediately after deploy | arm64 image deployed to TDX instance | Rebuild with `docker buildx build --platform linux/amd64` | +| `No builds found` on verify | Image was pushed directly, not via verifiable build | Redeploy with `--verifiable --repo --commit ` | +| Status stuck on `Deploying` | Large image or infrastructure delay | `ecloud compute app logs ` for diagnostics | +| Auth errors | Key not in keyring or expired | `ecloud auth whoami` to diagnose → `ecloud auth login` to fix | +| Wrong environment | Deployed to sepolia instead of mainnet (or vice versa) | `ecloud compute env show` → `ecloud compute env set --yes` | +| Logs return 425 error | Normal during provisioning (1-2+ min after deploy) | Wait and retry, or use `--watch` to stream when available | +| App shows "(unnamed)" on dashboard | `--name` only sets CLI name, not dashboard profile | `ecloud compute app profile set --name "Name"` | +| Profile name rejected | Spaces not allowed in profile names | Use hyphens or camelCase | +| Mainnet app unreachable despite "Running" status | Mainnet networking can take 5+ min after deploy (longer than sepolia) | Keep polling — sepolia ~30s, mainnet can be several minutes | --- diff --git a/packages/cli/src/__tests__/flags.test.ts b/packages/cli/src/__tests__/flags.test.ts new file mode 100644 index 00000000..f1590e8f --- /dev/null +++ b/packages/cli/src/__tests__/flags.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +// Mock the two inputs the environment default depends on. +vi.mock("@layr-labs/ecloud-sdk", () => ({ + getBuildType: vi.fn(), +})); +vi.mock("../utils/globalConfig", () => ({ + getDefaultEnvironment: vi.fn(), +})); + +import { commonFlags } from "../flags"; +import { getBuildType } from "@layr-labs/ecloud-sdk"; +import { getDefaultEnvironment } from "../utils/globalConfig"; + +/** + * Prod builds default --environment to mainnet-alpha (was sepolia); + * dev builds stay sepolia-dev; an explicit configured default always wins. + * The default is an async thunk on the oclif flag definition. + */ +describe("commonFlags.environment default", () => { + // oclif stores the default function on the flag's `default` property. + const resolveDefault = () => + (commonFlags.environment as unknown as { default: () => Promise }).default(); + + afterEach(() => vi.clearAllMocks()); + + it("defaults to mainnet-alpha on prod builds with no configured default", async () => { + (getDefaultEnvironment as ReturnType).mockReturnValue(undefined); + (getBuildType as ReturnType).mockReturnValue("prod"); + await expect(resolveDefault()).resolves.toBe("mainnet-alpha"); + }); + + it("defaults to sepolia-dev on dev builds", async () => { + (getDefaultEnvironment as ReturnType).mockReturnValue(undefined); + (getBuildType as ReturnType).mockReturnValue("dev"); + await expect(resolveDefault()).resolves.toBe("sepolia-dev"); + }); + + it("honors a configured default over the build-type fallback", async () => { + (getDefaultEnvironment as ReturnType).mockReturnValue("sepolia"); + (getBuildType as ReturnType).mockReturnValue("prod"); + await expect(resolveDefault()).resolves.toBe("sepolia"); + }); +}); diff --git a/packages/cli/src/client.ts b/packages/cli/src/client.ts index 2478aa0b..7d09f938 100644 --- a/packages/cli/src/client.ts +++ b/packages/cli/src/client.ts @@ -2,15 +2,30 @@ import { createComputeModule, createBillingModule, createBuildModule, + createAdminModule, getEnvironmentConfig, requirePrivateKey, + type Logger, } from "@layr-labs/ecloud-sdk"; import { CommonFlags, validateCommonFlags } from "./flags"; import { getClientId } from "./utils/version"; import { createViemClients } from "./utils/viemClients"; import { Hex } from "viem"; -export async function createComputeClient(flags: CommonFlags) { +/** Options for {@link createComputeClient}. */ +export interface CreateComputeClientOptions { + /** + * Logger override forwarded to the SDK compute module. Commands emitting + * machine-readable output (`--json`) pass a stderr-routed logger so SDK + * progress messages never corrupt stdout. + */ + logger?: Logger; +} + +export async function createComputeClient( + flags: CommonFlags, + options: CreateComputeClientOptions = {}, +) { flags = await validateCommonFlags(flags); const environment = flags.environment; @@ -38,6 +53,7 @@ export async function createComputeClient(flags: CommonFlags) { environment, clientId: getClientId(), skipTelemetry: true, // CLI already has telemetry, skip SDK telemetry + logger: options.logger, }); } @@ -67,6 +83,7 @@ export async function createBillingClient(flags: CommonFlags) { publicClient, environment, skipTelemetry: true, + privateKey: privateKey as Hex, }); } @@ -98,3 +115,31 @@ export async function createBuildClient(flags: CommonFlags) { skipTelemetry: true, // CLI already has telemetry, skip SDK telemetry }); } + +export async function createAdminClient(flags: CommonFlags) { + flags = await validateCommonFlags(flags); + + const environment = flags.environment; + const environmentConfig = getEnvironmentConfig(environment); + const rpcUrl = flags["rpc-url"] || environmentConfig.billingRPCURL || environmentConfig.defaultRPCURL; + const { key: privateKey, source } = await requirePrivateKey({ + privateKey: flags["private-key"], + }); + + if (flags.verbose) { + console.log(`Using private key from: ${source}`); + } + + const { walletClient, publicClient } = createViemClients({ + privateKey: privateKey as Hex, + rpcUrl, + environment, + }); + + return createAdminModule({ + verbose: flags.verbose, + walletClient, + publicClient, + environment, + }); +} diff --git a/packages/cli/src/commands/admin/admins/add.ts b/packages/cli/src/commands/admin/admins/add.ts new file mode 100644 index 00000000..ef7860ca --- /dev/null +++ b/packages/cli/src/commands/admin/admins/add.ts @@ -0,0 +1,34 @@ +import { Args, Command } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; + +export default class AdminAdminsAdd extends Command { + static description = "Add a new admin"; + + static examples = [ + "<%= config.bin %> admin admins add 0x...", + ]; + + static args = { + address: Args.string({ description: "Wallet address to grant admin", required: true }), + }; + + static flags = { + ...commonFlags, + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(AdminAdminsAdd); + const admin = await createAdminClient(flags); + + const { admin: newAdmin } = await admin.addAdmin(args.address); + + this.log(`\n ${chalk.green("✓")} Admin added`); + this.log(` Address: ${chalk.cyan(newAdmin.address)}`); + this.log(` ID: ${newAdmin.id}\n`); + }); + } +} diff --git a/packages/cli/src/commands/admin/admins/list.ts b/packages/cli/src/commands/admin/admins/list.ts new file mode 100644 index 00000000..ab11edd8 --- /dev/null +++ b/packages/cli/src/commands/admin/admins/list.ts @@ -0,0 +1,37 @@ +import { Command } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; + +export default class AdminAdminsList extends Command { + static description = "List all admins"; + + static examples = [ + "<%= config.bin %> admin admins list", + ]; + + static flags = { + ...commonFlags, + }; + + async run() { + return withTelemetry(this, async () => { + const { flags } = await this.parse(AdminAdminsList); + const admin = await createAdminClient(flags); + + const { admins } = await admin.listAdmins(); + + if (admins.length === 0) { + this.log("\n No admins found.\n"); + return; + } + + this.log(`\n${chalk.bold("Admins")} (${admins.length}):\n`); + for (const a of admins) { + this.log(` ${chalk.cyan(a.address)} ${chalk.gray(a.createdAt)}`); + } + this.log(); + }); + } +} diff --git a/packages/cli/src/commands/admin/admins/remove.ts b/packages/cli/src/commands/admin/admins/remove.ts new file mode 100644 index 00000000..e4ebd587 --- /dev/null +++ b/packages/cli/src/commands/admin/admins/remove.ts @@ -0,0 +1,32 @@ +import { Args, Command } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; + +export default class AdminAdminsRemove extends Command { + static description = "Remove an admin"; + + static examples = [ + "<%= config.bin %> admin admins remove 0x...", + ]; + + static args = { + address: Args.string({ description: "Wallet address to remove from admins", required: true }), + }; + + static flags = { + ...commonFlags, + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(AdminAdminsRemove); + const admin = await createAdminClient(flags); + + await admin.removeAdmin(args.address); + + this.log(`\n ${chalk.green("✓")} Admin ${chalk.cyan(args.address)} removed.\n`); + }); + } +} diff --git a/packages/cli/src/commands/admin/coupons/create.ts b/packages/cli/src/commands/admin/coupons/create.ts new file mode 100644 index 00000000..7d5b7026 --- /dev/null +++ b/packages/cli/src/commands/admin/coupons/create.ts @@ -0,0 +1,50 @@ +import { Command, Flags } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; +import { input } from "@inquirer/prompts"; + +export default class AdminCouponsCreate extends Command { + static description = "Create a new coupon"; + + static examples = [ + "<%= config.bin %> admin coupons create --amount 50", + ]; + + static flags = { + ...commonFlags, + amount: Flags.string({ + required: false, + description: "Coupon value in whole dollars", + }), + }; + + async run() { + return withTelemetry(this, async () => { + const { flags } = await this.parse(AdminCouponsCreate); + const admin = await createAdminClient(flags); + + const amountStr = + flags.amount ?? + (await input({ + message: "Coupon value in dollars:", + validate: (val) => { + const n = parseFloat(val); + if (isNaN(n) || n <= 0) return "Enter a positive number"; + return true; + }, + })); + + const dollars = parseFloat(amountStr); + const amountCents = Math.round(dollars * 100); + + const { coupon } = await admin.createCoupon(amountCents); + + this.log(`\n${chalk.green("✓")} Coupon created`); + this.log(` ID: ${chalk.cyan(coupon.id)}`); + this.log(` Value: ${chalk.cyan(`$${(coupon.amountCents / 100).toFixed(2)}`)}`); + this.log(` Active: ${coupon.active ? chalk.green("yes") : chalk.red("no")}\n`); + }); + } +} diff --git a/packages/cli/src/commands/admin/coupons/deactivate.ts b/packages/cli/src/commands/admin/coupons/deactivate.ts new file mode 100644 index 00000000..025ac411 --- /dev/null +++ b/packages/cli/src/commands/admin/coupons/deactivate.ts @@ -0,0 +1,32 @@ +import { Args, Command } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; + +export default class AdminCouponsDeactivate extends Command { + static description = "Deactivate a coupon"; + + static examples = [ + "<%= config.bin %> admin coupons deactivate ", + ]; + + static args = { + id: Args.string({ description: "Coupon ID", required: true }), + }; + + static flags = { + ...commonFlags, + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(AdminCouponsDeactivate); + const admin = await createAdminClient(flags); + + await admin.deactivateCoupon(args.id); + + this.log(`\n ${chalk.green("✓")} Coupon ${chalk.cyan(args.id)} deactivated.\n`); + }); + } +} diff --git a/packages/cli/src/commands/admin/coupons/get.ts b/packages/cli/src/commands/admin/coupons/get.ts new file mode 100644 index 00000000..7497d27d --- /dev/null +++ b/packages/cli/src/commands/admin/coupons/get.ts @@ -0,0 +1,42 @@ +import { Args, Command } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; + +export default class AdminCouponsGet extends Command { + static description = "Get details of a coupon"; + + static examples = [ + "<%= config.bin %> admin coupons get ", + ]; + + static args = { + id: Args.string({ description: "Coupon ID", required: true }), + }; + + static flags = { + ...commonFlags, + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(AdminCouponsGet); + const admin = await createAdminClient(flags); + + const { coupon } = await admin.getCoupon(args.id); + + this.log(`\n${chalk.bold("Coupon Details:")}`); + this.log(` ID: ${chalk.cyan(coupon.id)}`); + this.log(` Value: ${chalk.cyan(`$${(coupon.amountCents / 100).toFixed(2)}`)}`); + this.log(` Active: ${coupon.active ? chalk.green("yes") : chalk.red("no")}`); + this.log(` Created by: ${coupon.createdBy}`); + this.log(` Created at: ${coupon.createdAt}`); + if (coupon.redeemedBy) { + this.log(` Redeemed by: ${coupon.redeemedBy}`); + this.log(` Redeemed at: ${coupon.redeemedAt}`); + } + this.log(); + }); + } +} diff --git a/packages/cli/src/commands/admin/coupons/list.ts b/packages/cli/src/commands/admin/coupons/list.ts new file mode 100644 index 00000000..42a07cb9 --- /dev/null +++ b/packages/cli/src/commands/admin/coupons/list.ts @@ -0,0 +1,71 @@ +import { Command, Flags } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; + +export default class AdminCouponsList extends Command { + static description = "List coupons"; + + static examples = [ + "<%= config.bin %> admin coupons list", + "<%= config.bin %> admin coupons list --active", + "<%= config.bin %> admin coupons list --redeemed", + ]; + + static flags = { + ...commonFlags, + active: Flags.boolean({ + required: false, + description: "Filter to active coupons only", + }), + redeemed: Flags.boolean({ + required: false, + description: "Filter to redeemed coupons only", + }), + limit: Flags.integer({ + required: false, + description: "Number of results to return", + default: 25, + }), + offset: Flags.integer({ + required: false, + description: "Offset for pagination", + default: 0, + }), + }; + + async run() { + return withTelemetry(this, async () => { + const { flags } = await this.parse(AdminCouponsList); + const admin = await createAdminClient(flags); + + const opts: { offset?: number; limit?: number; active?: boolean; redeemed?: boolean } = { + offset: flags.offset, + limit: flags.limit, + }; + if (flags.active) opts.active = true; + if (flags.redeemed) opts.redeemed = true; + + const { coupons, total } = await admin.listCoupons(opts); + + if (coupons.length === 0) { + this.log("\n No coupons found.\n"); + return; + } + + this.log(`\n${chalk.bold("Coupons")} (${coupons.length} of ${total}):\n`); + + for (const c of coupons) { + const value = `$${(c.amountCents / 100).toFixed(2)}`; + const status = c.redeemedBy + ? chalk.gray(`redeemed by ${c.redeemedBy}`) + : c.active + ? chalk.green("active") + : chalk.red("inactive"); + this.log(` ${chalk.cyan(c.id)} ${value} ${status}`); + } + this.log(); + }); + } +} diff --git a/packages/cli/src/commands/admin/coupons/redeem.ts b/packages/cli/src/commands/admin/coupons/redeem.ts new file mode 100644 index 00000000..05c907fa --- /dev/null +++ b/packages/cli/src/commands/admin/coupons/redeem.ts @@ -0,0 +1,36 @@ +import { Args, Command, Flags } from "@oclif/core"; +import { createAdminClient } from "../../../client"; +import { commonFlags } from "../../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../../telemetry"; + +export default class AdminCouponsRedeem extends Command { + static description = "Redeem a coupon for a user (admin action)"; + + static examples = [ + "<%= config.bin %> admin coupons redeem --address 0x...", + ]; + + static args = { + id: Args.string({ description: "Coupon ID", required: true }), + }; + + static flags = { + ...commonFlags, + address: Flags.string({ + required: true, + description: "User wallet address to redeem coupon for", + }), + }; + + async run() { + return withTelemetry(this, async () => { + const { args, flags } = await this.parse(AdminCouponsRedeem); + const admin = await createAdminClient(flags); + + await admin.redeemCouponForUser(args.id, flags.address); + + this.log(`\n ${chalk.green("✓")} Coupon ${chalk.cyan(args.id)} redeemed for ${chalk.cyan(flags.address)}.\n`); + }); + } +} diff --git a/packages/cli/src/commands/billing/__tests__/status.test.ts b/packages/cli/src/commands/billing/__tests__/status.test.ts index 70b76c47..e2bb0d24 100644 --- a/packages/cli/src/commands/billing/__tests__/status.test.ts +++ b/packages/cli/src/commands/billing/__tests__/status.test.ts @@ -8,7 +8,16 @@ vi.mock("../../../telemetry", () => ({ withTelemetry: vi.fn((_cmd: unknown, fn: () => Promise) => fn()), })); +vi.mock("@layr-labs/ecloud-sdk", () => ({ + getEnvironmentConfig: vi.fn(() => ({ defaultRPCURL: "https://rpc.example" })), +})); + +vi.mock("../../../utils/viemClients", () => ({ + createViemClients: vi.fn(), +})); + import { createBillingClient } from "../../../client"; +import { createViemClients } from "../../../utils/viemClients"; describe("ecloud billing status — top-up hint", () => { let logOutput: string[]; @@ -19,6 +28,7 @@ describe("ecloud billing status — top-up hint", () => { beforeEach(() => { logOutput = []; + warnOutput = []; mockBilling = { address: "0xabcdef1234567890abcdef1234567890abcdef12", getStatus: vi.fn(), @@ -26,15 +36,24 @@ describe("ecloud billing status — top-up hint", () => { (createBillingClient as ReturnType).mockResolvedValue(mockBilling); }); - async function runStatusCommand(statusResult: Record) { + let warnOutput: string[]; + + async function runStatusCommand( + statusResult: Record, + flagOverrides: Record = {}, + ) { const { default: BillingStatus } = await import("../status"); mockBilling.getStatus.mockResolvedValue(statusResult); const cmd = new BillingStatus([], {} as any); cmd.parse = vi.fn().mockResolvedValue({ - flags: { product: "compute", verbose: false }, + flags: { product: "compute", verbose: false, ...flagOverrides }, }); cmd.log = vi.fn((...args: string[]) => logOutput.push(args.join(" "))); + cmd.warn = vi.fn((msg: string | Error) => { + warnOutput.push(typeof msg === "string" ? msg : msg.message); + return msg as string & Error; + }); cmd.debug = vi.fn(); await cmd.run(); @@ -84,4 +103,38 @@ describe("ecloud billing status — top-up hint", () => { expect(fullOutput).not.toContain("Need more credits?"); }); + + describe("wallet ETH balance line", () => { + it("warns (does not silently swallow) when the balance read fails, but still completes", async () => { + (createViemClients as ReturnType).mockImplementation(() => { + throw new Error("invalid private key"); + }); + + const output = await runStatusCommand( + { subscriptionStatus: "active", productId: "compute" }, + { "private-key": "0xbad", environment: "sepolia" }, + ); + + // The command still finishes and prints the rest of the status. + expect(output.join("\n")).toContain("Subscription Status:"); + // The failure reason is surfaced, not discarded. + expect(warnOutput.join("\n")).toMatch(/wallet ETH|balance/i); + expect(warnOutput.join("\n")).toContain("invalid private key"); + }); + + it("prints the ETH line on a successful balance read", async () => { + (createViemClients as ReturnType).mockReturnValue({ + publicClient: { getBalance: vi.fn().mockResolvedValue(1000000000000000000n) }, + address: "0xabcdef1234567890abcdef1234567890abcdef12", + }); + + const output = await runStatusCommand( + { subscriptionStatus: "active", productId: "compute" }, + { "private-key": "0xgood", environment: "sepolia" }, + ); + + expect(output.join("\n")).toMatch(/Wallet ETH.*1 ETH/); + expect(warnOutput).toHaveLength(0); + }); + }); }); diff --git a/packages/cli/src/commands/billing/__tests__/top-up.test.ts b/packages/cli/src/commands/billing/__tests__/top-up.test.ts index 986e02f2..66174a9b 100644 --- a/packages/cli/src/commands/billing/__tests__/top-up.test.ts +++ b/packages/cli/src/commands/billing/__tests__/top-up.test.ts @@ -10,11 +10,16 @@ vi.mock("../../../telemetry", () => ({ vi.mock("@inquirer/prompts", () => ({ input: vi.fn(), + select: vi.fn(), +})); + +vi.mock("open", () => ({ + default: vi.fn(), })); import BillingTopUp from "../top-up"; import { createBillingClient } from "../../../client"; -import { input } from "@inquirer/prompts"; +import { input, select } from "@inquirer/prompts"; const WALLET_ADDRESS = "0x1234567890abcdef1234567890abcdef12345678"; const TX_HASH = "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"; @@ -26,6 +31,9 @@ describe("ecloud billing top-up", () => { getStatus: ReturnType; getTopUpInfo: ReturnType; topUp: ReturnType; + getPaymentMethods: ReturnType; + purchaseCredits: ReturnType; + hasBaseSupport: ReturnType; }; beforeEach(() => { @@ -37,7 +45,11 @@ describe("ecloud billing top-up", () => { getStatus: vi.fn(), getTopUpInfo: vi.fn(), topUp: vi.fn(), + getPaymentMethods: vi.fn(), + purchaseCredits: vi.fn(), + hasBaseSupport: vi.fn(), }; + mockBilling.hasBaseSupport.mockReturnValue(false); (createBillingClient as ReturnType).mockResolvedValue(mockBilling); (input as ReturnType).mockResolvedValue("50"); @@ -86,6 +98,8 @@ describe("ecloud billing top-up", () => { return cmd; } + // ── USDC Tests ── + it("happy path: sufficient balance, purchase succeeds", async () => { setupOnChainState(); mockBilling.topUp.mockResolvedValue({ txHash: TX_HASH, walletAddress: WALLET_ADDRESS }); @@ -93,32 +107,26 @@ describe("ecloud billing top-up", () => { .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 60.0 }); - const cmd = createCommand({ amount: "50" }); + const cmd = createCommand({ amount: "50", method: "usdc" }); const promise = cmd.run(); - // Advance timers to resolve the polling setTimeout for (let i = 0; i < 10; i++) { await vi.advanceTimersByTimeAsync(5_000); } await promise; const fullOutput = logOutput.join("\n"); - // Shows wallet address expect(fullOutput).toContain(WALLET_ADDRESS); - // Shows credits expect(fullOutput).toContain("$10.00"); - // Shows USDC balance expect(fullOutput).toContain("100 USDC"); - // Shows purchase step expect(fullOutput).toContain("Purchasing"); expect(fullOutput).toContain("Transaction confirmed"); - // Shows final balance after polling expect(fullOutput).toContain("Credits received"); expect(fullOutput).toContain("$60.00"); - // Verify topUp was called with correct args expect(mockBilling.topUp).toHaveBeenCalledWith({ amount: BigInt(50_000_000), account: WALLET_ADDRESS, + chain: "ethereum", }); }); @@ -126,7 +134,7 @@ describe("ecloud billing top-up", () => { setupOnChainState({ usdcBalance: BigInt(0) }); mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "inactive" }); - const cmd = createCommand({ amount: "50" }); + const cmd = createCommand({ amount: "50", method: "usdc" }); await cmd.run(); const fullOutput = logOutput.join("\n"); @@ -134,7 +142,6 @@ describe("ecloud billing top-up", () => { expect(fullOutput).toContain("Send USDC on Sepolia to"); expect(fullOutput).toContain(WALLET_ADDRESS); - // Should not have called topUp expect(mockBilling.topUp).not.toHaveBeenCalled(); }); @@ -142,7 +149,7 @@ describe("ecloud billing top-up", () => { setupOnChainState({ minimumPurchase: BigInt(10_000_000) }); // 10 USDC minimum mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "inactive" }); - const cmd = createCommand({ amount: "5" }); + const cmd = createCommand({ amount: "5", method: "usdc" }); await expect(cmd.run()).rejects.toThrow("Minimum purchase is 10 USDC"); }); @@ -154,7 +161,7 @@ describe("ecloud billing top-up", () => { .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 60.0 }); - const cmd = createCommand({ amount: "50", account: targetAccount }); + const cmd = createCommand({ amount: "50", method: "usdc", account: targetAccount }); const promise = cmd.run(); for (let i = 0; i < 10; i++) { await vi.advanceTimersByTimeAsync(5_000); @@ -162,28 +169,25 @@ describe("ecloud billing top-up", () => { await promise; const fullOutput = logOutput.join("\n"); - // Shows target account expect(fullOutput).toContain(targetAccount); - // Verify topUp was called with the target account expect(mockBilling.topUp).toHaveBeenCalledWith({ amount: BigInt(50_000_000), account: targetAccount, + chain: "ethereum", }); }); it("billing API poll timeout: shows timeout message", async () => { setupOnChainState(); mockBilling.topUp.mockResolvedValue({ txHash: TX_HASH, walletAddress: WALLET_ADDRESS }); - // getStatus always returns the same credits (no increase) mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "active", remainingCredits: 10.0, }); - const cmd = createCommand({ amount: "50" }); + const cmd = createCommand({ amount: "50", method: "usdc" }); const promise = cmd.run(); - // Advance past the 3-minute poll timeout await vi.advanceTimersByTimeAsync(200_000); await promise; const fullOutput = logOutput.join("\n"); @@ -199,7 +203,7 @@ describe("ecloud billing top-up", () => { .mockResolvedValueOnce({ subscriptionStatus: "inactive" }) .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 100.0 }); - const cmd = createCommand({ amount: "100" }); + const cmd = createCommand({ amount: "100", method: "usdc" }); const promise = cmd.run(); for (let i = 0; i < 10; i++) { await vi.advanceTimersByTimeAsync(5_000); @@ -214,17 +218,219 @@ describe("ecloud billing top-up", () => { mockBilling.topUp.mockResolvedValue({ txHash: TX_HASH, walletAddress: WALLET_ADDRESS }); mockBilling.getStatus.mockRejectedValue(new Error("API unavailable")); - const cmd = createCommand({ amount: "50" }); + const cmd = createCommand({ amount: "50", method: "usdc" }); const promise = cmd.run(); - // Advance past poll timeout since getStatus always errors await vi.advanceTimersByTimeAsync(200_000); await promise; const fullOutput = logOutput.join("\n"); - // Should still proceed with on-chain purchase expect(fullOutput).toContain("Purchasing"); expect(fullOutput).toContain("Transaction confirmed"); - // Will timeout on polling since status always errors expect(fullOutput).toContain("Credits haven't appeared yet"); }); + + it("usdc: prompts for chain selection when Base is available", async () => { + mockBilling.hasBaseSupport.mockReturnValue(true); + setupOnChainState(); + mockBilling.topUp.mockResolvedValue({ txHash: TX_HASH, walletAddress: WALLET_ADDRESS }); + mockBilling.getStatus + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 60.0 }); + + (select as unknown as ReturnType).mockResolvedValue("base"); + + const cmd = createCommand({ amount: "50", method: "usdc" }); + const promise = cmd.run(); + for (let i = 0; i < 10; i++) { + await vi.advanceTimersByTimeAsync(5_000); + } + await promise; + + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Which network?", + choices: expect.arrayContaining([ + expect.objectContaining({ value: "base" }), + expect.objectContaining({ value: "ethereum" }), + ]), + }), + ); + + expect(mockBilling.getTopUpInfo).toHaveBeenCalledWith({ chain: "base" }); + expect(mockBilling.topUp).toHaveBeenCalledWith({ + amount: BigInt(50_000_000), + account: WALLET_ADDRESS, + chain: "base", + }); + }); + + it("usdc: skips chain prompt when Base is not configured", async () => { + mockBilling.hasBaseSupport.mockReturnValue(false); + setupOnChainState(); + mockBilling.topUp.mockResolvedValue({ txHash: TX_HASH, walletAddress: WALLET_ADDRESS }); + mockBilling.getStatus + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 60.0 }); + + const cmd = createCommand({ amount: "50", method: "usdc" }); + const promise = cmd.run(); + for (let i = 0; i < 10; i++) { + await vi.advanceTimersByTimeAsync(5_000); + } + await promise; + + expect(mockBilling.topUp).toHaveBeenCalledWith({ + amount: BigInt(50_000_000), + account: WALLET_ADDRESS, + chain: "ethereum", + }); + }); + + it("usdc: --chain flag skips network prompt", async () => { + mockBilling.hasBaseSupport.mockReturnValue(true); + setupOnChainState(); + mockBilling.topUp.mockResolvedValue({ txHash: TX_HASH, walletAddress: WALLET_ADDRESS }); + mockBilling.getStatus + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 60.0 }); + + const cmd = createCommand({ amount: "50", method: "usdc", chain: "base" }); + const promise = cmd.run(); + for (let i = 0; i < 10; i++) { + await vi.advanceTimersByTimeAsync(5_000); + } + await promise; + + expect(mockBilling.topUp).toHaveBeenCalledWith({ + amount: BigInt(50_000_000), + account: WALLET_ADDRESS, + chain: "base", + }); + }); + + // ── Credit Card Tests ── + + it("credit card: charges selected card on file", async () => { + mockBilling.getStatus + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 35.0 }); + mockBilling.getPaymentMethods.mockResolvedValue({ + paymentMethods: [ + { + id: "029641fc-3e5c-11f1-986c-5601121cbf6d", + stripePaymentMethodId: "pm_1ABC1234", + brand: "visa", + last4: "1234", + createdAt: "2026-04-20T15:00:00Z", + }, + { + id: "139752fd-4e6d-22f2-a97d-6712232dcg7e", + stripePaymentMethodId: "pm_2DEF5678", + brand: "mastercard", + last4: "5678", + createdAt: "2026-04-21T10:00:00Z", + }, + ], + }); + mockBilling.purchaseCredits.mockResolvedValue({ + purchaseId: "a1b2c3d4", + amountCents: "2500", + }); + (select as unknown as ReturnType).mockResolvedValue("029641fc-3e5c-11f1-986c-5601121cbf6d"); + + const cmd = createCommand({ amount: "25", method: "card" }); + const promise = cmd.run(); + for (let i = 0; i < 10; i++) { + await vi.advanceTimersByTimeAsync(5_000); + } + await promise; + const fullOutput = logOutput.join("\n"); + + expect(mockBilling.purchaseCredits).toHaveBeenCalledWith(2500, "029641fc-3e5c-11f1-986c-5601121cbf6d"); + expect(fullOutput).toContain("Payment submitted"); + expect(fullOutput).toContain("Credits received"); + }); + + it("credit card: opens checkout when user selects add new card", async () => { + const openMock = (await import("open")).default as ReturnType; + mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "active", remainingCredits: 10.0 }); + mockBilling.getPaymentMethods.mockResolvedValue({ + paymentMethods: [ + { + id: "029641fc-3e5c-11f1-986c-5601121cbf6d", + stripePaymentMethodId: "pm_1ABC1234", + brand: "visa", + last4: "1234", + createdAt: "2026-04-20T15:00:00Z", + }, + ], + }); + mockBilling.purchaseCredits.mockResolvedValue({ + checkoutSessionId: "cs_test_abc123", + checkoutUrl: "https://checkout.stripe.com/test", + amountCents: "2500", + }); + (select as unknown as ReturnType).mockResolvedValue("new"); + + const cmd = createCommand({ amount: "25", method: "card" }); + const promise = cmd.run(); + await vi.advanceTimersByTimeAsync(200_000); + await promise; + const fullOutput = logOutput.join("\n"); + + expect(mockBilling.purchaseCredits).toHaveBeenCalledWith(2500, undefined); + expect(openMock).toHaveBeenCalledWith("https://checkout.stripe.com/test"); + expect(fullOutput).toContain("https://checkout.stripe.com/test"); + }); + + it("credit card: opens checkout when no card on file", async () => { + const openMock = (await import("open")).default as ReturnType; + mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "active", remainingCredits: 10.0 }); + mockBilling.getPaymentMethods.mockResolvedValue({ paymentMethods: [] }); + mockBilling.purchaseCredits.mockResolvedValue({ + checkoutSessionId: "cs_test_abc123", + checkoutUrl: "https://checkout.stripe.com/test", + amountCents: "5000", + }); + + const cmd = createCommand({ amount: "50", method: "card" }); + const promise = cmd.run(); + await vi.advanceTimersByTimeAsync(200_000); + await promise; + const fullOutput = logOutput.join("\n"); + + expect(select).not.toHaveBeenCalled(); + expect(mockBilling.purchaseCredits).toHaveBeenCalledWith(5000, undefined); + expect(openMock).toHaveBeenCalledWith("https://checkout.stripe.com/test"); + expect(fullOutput).toContain("https://checkout.stripe.com/test"); + }); + + it("credit card: rejects amount below $5 minimum", async () => { + mockBilling.getStatus.mockResolvedValue({ subscriptionStatus: "active", remainingCredits: 10.0 }); + + const cmd = createCommand({ amount: "3", method: "card" }); + await expect(cmd.run()).rejects.toThrow("Minimum purchase is $5"); + }); + + it("credit card: --method and --amount flags skip prompts", async () => { + mockBilling.getStatus + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 10.0 }) + .mockResolvedValueOnce({ subscriptionStatus: "active", remainingCredits: 60.0 }); + mockBilling.getPaymentMethods.mockResolvedValue({ paymentMethods: [] }); + mockBilling.purchaseCredits.mockResolvedValue({ + checkoutSessionId: "cs_test_abc123", + checkoutUrl: "https://checkout.stripe.com/test", + amountCents: "5000", + }); + + const cmd = createCommand({ amount: "50", method: "card" }); + const promise = cmd.run(); + for (let i = 0; i < 10; i++) { + await vi.advanceTimersByTimeAsync(5_000); + } + await promise; + + expect(select).not.toHaveBeenCalled(); + expect(input).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/commands/billing/cancel.ts b/packages/cli/src/commands/billing/cancel.ts index c81276ee..1bc0b31b 100644 --- a/packages/cli/src/commands/billing/cancel.ts +++ b/packages/cli/src/commands/billing/cancel.ts @@ -10,8 +10,7 @@ export default class BillingCancel extends Command { static description = "Cancel subscription"; static flags = { - "private-key": commonFlags["private-key"], - verbose: commonFlags.verbose, + ...commonFlags, product: Flags.string({ required: false, description: "Product ID", diff --git a/packages/cli/src/commands/billing/list-cards.ts b/packages/cli/src/commands/billing/list-cards.ts new file mode 100644 index 00000000..1e0fafb6 --- /dev/null +++ b/packages/cli/src/commands/billing/list-cards.ts @@ -0,0 +1,36 @@ +import { Command } from "@oclif/core"; +import { createBillingClient } from "../../client"; +import { commonFlags } from "../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../telemetry"; + +export default class BillingListCards extends Command { + static description = "List credit cards on file"; + + static flags = { + ...commonFlags, + }; + + async run() { + return withTelemetry(this, async () => { + const { flags } = await this.parse(BillingListCards); + const billing = await createBillingClient(flags); + + const { paymentMethods } = await billing.getPaymentMethods(); + + if (paymentMethods.length === 0) { + this.log(`\n ${chalk.gray("No cards on file.")}`); + this.log(` Run ${chalk.cyan("ecloud billing top-up --method card")} to add one.\n`); + return; + } + + this.log(`\n${chalk.bold("Cards on file:")}`); + for (const card of paymentMethods) { + const brand = card.brand.charAt(0).toUpperCase() + card.brand.slice(1); + const added = new Date(card.createdAt).toLocaleDateString(); + this.log(` • ${brand} ending in ${chalk.bold(card.last4)} ${chalk.gray(`added ${added}`)}`); + } + this.log(); + }); + } +} diff --git a/packages/cli/src/commands/billing/redeem-coupon.ts b/packages/cli/src/commands/billing/redeem-coupon.ts new file mode 100644 index 00000000..4ed6a126 --- /dev/null +++ b/packages/cli/src/commands/billing/redeem-coupon.ts @@ -0,0 +1,43 @@ +import { Command, Flags } from "@oclif/core"; +import { createBillingClient } from "../../client"; +import { commonFlags } from "../../flags"; +import chalk from "chalk"; +import { withTelemetry } from "../../telemetry"; +import { input } from "@inquirer/prompts"; + +export default class BillingRedeemCoupon extends Command { + static description = "Redeem a coupon code for credits"; + + static examples = [ + "<%= config.bin %> billing redeem-coupon", + "<%= config.bin %> billing redeem-coupon --code ABC123", + ]; + + static flags = { + ...commonFlags, + code: Flags.string({ + required: false, + description: "Coupon code to redeem", + }), + }; + + async run() { + return withTelemetry(this, async () => { + const { flags } = await this.parse(BillingRedeemCoupon); + const billing = await createBillingClient(flags); + + const code = + flags.code ?? + (await input({ + message: "Enter your coupon code:", + validate: (val) => (val.trim().length > 0 ? true : "Coupon code is required"), + })); + + const result = await billing.redeemCoupon(code.trim()); + const dollars = (result.amountCents / 100).toFixed(2); + + this.log(`\n ${chalk.green("✓")} Coupon redeemed! ${chalk.cyan(`$${dollars}`)} in credits added to your account.`); + this.log(`\n Run ${chalk.cyan("ecloud billing status")} to see your updated balance.\n`); + }); + } +} diff --git a/packages/cli/src/commands/billing/status.ts b/packages/cli/src/commands/billing/status.ts index fba72130..992368ec 100644 --- a/packages/cli/src/commands/billing/status.ts +++ b/packages/cli/src/commands/billing/status.ts @@ -1,6 +1,10 @@ import { Command, Flags } from "@oclif/core"; import { createBillingClient } from "../../client"; import { commonFlags } from "../../flags"; +import { getEnvironmentConfig } from "@layr-labs/ecloud-sdk"; +import { createViemClients } from "../../utils/viemClients"; +import { errorMessage } from "../../utils/exitCodes"; +import { formatEther } from "viem"; import chalk from "chalk"; import { withTelemetry } from "../../telemetry"; @@ -58,6 +62,33 @@ export default class BillingStatus extends Command { this.log(`\n${chalk.bold("Subscription Status:")}`); this.log(` Wallet: ${billing.address}`); + + // Show the wallet's on-chain ETH so the credit-vs-gas gap is visible + // before a deploy: compute credits do NOT pay on-chain gas. + // Best-effort — never fail `billing status` if the balance read fails. + try { + const privateKey = flags["private-key"]; + if (privateKey) { + const environment = flags.environment; + const environmentConfig = getEnvironmentConfig(environment); + const rpcUrl = flags["rpc-url"] || environmentConfig.defaultRPCURL; + const { publicClient, address } = createViemClients({ privateKey, rpcUrl, environment }); + const balanceWei = await publicClient.getBalance({ address }); + const eth = formatEther(balanceWei); + const note = + balanceWei === BigInt(0) + ? chalk.yellow(" (fund with ETH to pay deploy/upgrade gas)") + : ""; + this.log(` Wallet ETH (${environment}): ${chalk.cyan(`${eth} ETH`)}${note}`); + } + } catch (err) { + // Best-effort: the ETH line is informational, so a failure here must + // not abort `billing status`. But surface the reason rather than + // swallowing it — a malformed --private-key or an unreachable RPC is + // worth telling the user about. + this.warn(`Could not read wallet ETH balance: ${errorMessage(err)}`); + } + this.log(` Status: ${formatStatus(result.subscriptionStatus)}`); this.log(` Product: ${result.productId}`); @@ -75,7 +106,9 @@ export default class BillingStatus extends Command { const product = `${flags.product.charAt(0).toUpperCase()}${flags.product.slice(1)}`; const isChainSpecific = item.description.match(/\b(sepolia|mainnet)\b/i); if (isChainSpecific) { - const chain = item.description.toLowerCase().includes("sepolia") ? "Sepolia" : "Mainnet"; + const chain = item.description.toLowerCase().includes("sepolia") + ? "Sepolia" + : "Mainnet"; this.log( ` • ${product} (${chain}): $${item.subtotal.toFixed(2)} (${item.quantity} vCPU hours × $${item.price.toFixed(3)}/vCPU hour)`, ); @@ -88,28 +121,11 @@ export default class BillingStatus extends Command { } } - // Display invoice summary with credits - if (result.creditsApplied !== undefined && result.creditsApplied > 0) { - this.log(`\n${chalk.bold(" Invoice Summary:")}`); - const subtotal = result.upcomingInvoiceSubtotal ?? result.upcomingInvoiceTotal ?? 0; - this.log(` Subtotal: $${subtotal.toFixed(2)}`); - this.log(` Credits Applied: ${chalk.green(`-$${result.creditsApplied.toFixed(2)}`)}`); - this.log(` ${"─".repeat(21)}`); - this.log(` Total Due: $${(result.upcomingInvoiceTotal ?? 0).toFixed(2)}`); - - if (result.remainingCredits !== undefined) { - this.log( - `\n ${chalk.bold("Remaining Credits:")} ${chalk.cyan(`$${result.remainingCredits.toFixed(2)}`)}${formatExpiry(result.nextCreditExpiry)}`, - ); - } - } else if (result.upcomingInvoiceTotal !== undefined) { - this.log(`\n Upcoming Invoice: $${result.upcomingInvoiceTotal.toFixed(2)}`); - if (result.remainingCredits !== undefined && result.remainingCredits > 0) { - this.log( - ` ${chalk.bold("Available Credits:")} ${chalk.cyan(`$${result.remainingCredits.toFixed(2)}`)}${formatExpiry(result.nextCreditExpiry)}`, - ); - } - } + // Display remaining credits + const credits = result.remainingCredits ?? 0; + this.log( + ` Credits: ${chalk.cyan(`$${credits.toFixed(2)}`)}${formatExpiry(result.nextCreditExpiry)}`, + ); // Display cancellation information if (result.cancelAtPeriodEnd) { diff --git a/packages/cli/src/commands/billing/top-up.ts b/packages/cli/src/commands/billing/top-up.ts index 7637507b..be54e931 100644 --- a/packages/cli/src/commands/billing/top-up.ts +++ b/packages/cli/src/commands/billing/top-up.ts @@ -1,14 +1,15 @@ /** - * ecloud billing top-up — Purchase EigenCompute credits with USDC + * ecloud billing top-up — Purchase EigenCompute credits with USDC or credit card * * Executes USDCCredits.purchaseCreditsFor(amount, account) on-chain via the SDK - * billing module's topUp() method (EIP-7702 batched transaction). + * billing module's topUp() method (EIP-7702 batched transaction), or initiates + * credit card checkout via the purchaseCredits API. * * Flow: * 1. Check current credit balance - * 2. Read wallet's USDC balance via SDK - * 3. If USDC available → prompt for amount → SDK topUp() → poll for confirmation - * 4. If no USDC → show wallet address, tell user to fund it + * 2. Prompt for payment method (USDC or card) + * 3. USDC: Read wallet's USDC balance via SDK → prompt for amount → SDK topUp() → poll + * 4. Card: Prompt for amount → check existing payment methods → purchaseCredits API → poll */ import { Command, Flags } from "@oclif/core"; @@ -16,20 +17,33 @@ import { createBillingClient } from "../../client"; import { commonFlags } from "../../flags"; import { type Address, formatUnits } from "viem"; import chalk from "chalk"; -import { input } from "@inquirer/prompts"; +import { input, select } from "@inquirer/prompts"; +import open from "open"; import { withTelemetry } from "../../telemetry"; +import { type BillingChain } from "@layr-labs/ecloud-sdk"; const POLL_INTERVAL_MS = 5_000; const POLL_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes export default class BillingTopUp extends Command { - static description = "Purchase EigenCompute credits with USDC"; + static description = "Purchase EigenCompute credits with USDC or credit card"; + + static examples = [ + "<%= config.bin %> billing top-up", + "<%= config.bin %> billing top-up --method usdc --amount 50", + "<%= config.bin %> billing top-up --method card --amount 25", + ]; static flags = { ...commonFlags, + method: Flags.string({ + required: false, + description: "Payment method: usdc (on-chain) or card (credit card)", + options: ["usdc", "card"], + }), amount: Flags.string({ required: false, - description: "Amount of USDC to spend (e.g., '50')", + description: "Amount to spend (USDC for on-chain, whole dollars for card)", }), account: Flags.string({ required: false, @@ -42,15 +56,18 @@ export default class BillingTopUp extends Command { options: ["compute"], env: "ECLOUD_PRODUCT_ID", }), + chain: Flags.string({ + required: false, + description: "Blockchain network for USDC payment: ethereum or base", + options: ["ethereum", "base"], + }), }; async run() { return withTelemetry(this, async () => { const { flags } = await this.parse(BillingTopUp); - // Create billing client const billing = await createBillingClient(flags); - const walletAddress = billing.address; const targetAccount = (flags.account as Address) ?? walletAddress; @@ -61,9 +78,7 @@ export default class BillingTopUp extends Command { this.log(` ${chalk.bold("Target:")} ${targetAccount}`); } - // ── Step 1: Show current credit balance ── - // Track total credits (remaining + applied) so we detect top-ups even - // when new credits are immediately consumed by an outstanding bill. + // Show current credit balance let baselineTotal: number | undefined; try { const status = await billing.getStatus({ @@ -72,108 +87,214 @@ export default class BillingTopUp extends Command { const remaining = status.remainingCredits ?? 0; const applied = status.creditsApplied ?? 0; baselineTotal = remaining + applied; - if (status.remainingCredits !== undefined) { - this.log(` ${chalk.bold("Credits:")} ${chalk.cyan(`$${status.remainingCredits.toFixed(2)}`)}`); - } + this.log(` ${chalk.bold("Credits:")} ${chalk.cyan(`$${remaining.toFixed(2)}`)}`); } catch { this.debug("Could not fetch current credit balance"); } - // ── Step 2: Read on-chain state via SDK ── - const onChainState = await billing.getTopUpInfo(); - const { usdcBalance, minimumPurchase } = onChainState; - - const balanceFormatted = formatUnits(usdcBalance, 6); - this.log(` ${chalk.bold("USDC:")} ${balanceFormatted} USDC`); + // Select payment method + const method = + flags.method ?? + (await select({ + message: "How would you like to pay?", + choices: [ + { value: "card", name: "Credit card" }, + { value: "usdc", name: "USDC (on-chain)" }, + ], + })); - if (usdcBalance === BigInt(0)) { - this.log(`\n${chalk.yellow(" No USDC in wallet.")}`); - this.log(` Send USDC on Sepolia to: ${chalk.cyan(walletAddress)}`); - this.log(` Then re-run: ${chalk.cyan("ecloud billing top-up")}\n`); - return; + if (method === "usdc") { + await this.handleUsdc(billing, flags, walletAddress, targetAccount, baselineTotal); + } else { + await this.handleCard(billing, flags, baselineTotal); } + }); + } + + private async handleUsdc( + billing: Awaited>, + flags: Record, + walletAddress: Address, + targetAccount: Address, + baselineTotal: number | undefined, + ) { + let selectedChain: BillingChain = "ethereum"; - // ── Step 3: Prompt for amount ── - const minimumFormatted = formatUnits(minimumPurchase, 6); - const amountStr = - flags.amount ?? - (await input({ - message: `How much USDC to spend on credits? (minimum: ${minimumFormatted})`, - validate: (val) => { - const n = parseFloat(val); - if (isNaN(n) || n <= 0) return "Enter a positive number"; - const raw = BigInt(Math.round(n * 1e6)); - if (raw < minimumPurchase) - return `Minimum purchase is ${minimumFormatted} USDC`; - if (raw > usdcBalance) - return `Insufficient balance. You have ${balanceFormatted} USDC`; - return true; - }, + if (billing.hasBaseSupport()) { + selectedChain = + (flags.chain as BillingChain) ?? + (await select({ + message: "Which network?", + choices: [ + { value: "ethereum", name: "Ethereum" }, + { value: "base", name: "Base" }, + ], })); + } - const amountFloat = parseFloat(amountStr); - const amountRaw = BigInt(Math.round(amountFloat * 1e6)); + const onChainState = await billing.getTopUpInfo({ chain: selectedChain }); + const { usdcBalance, minimumPurchase } = onChainState; - if (amountRaw < minimumPurchase) { - this.error(`Minimum purchase is ${minimumFormatted} USDC`); - } - if (amountRaw > usdcBalance) { - this.error( - `Insufficient USDC balance. You have ${balanceFormatted} USDC but requested ${amountFloat.toFixed(2)}`, - ); - } + const balanceFormatted = formatUnits(usdcBalance, 6); + this.log(` ${chalk.bold("USDC:")} ${balanceFormatted} USDC`); + + if (usdcBalance === BigInt(0)) { + const networkName = selectedChain === "base" ? "Base Sepolia" : "Sepolia"; + this.log(`\n${chalk.yellow(" No USDC in wallet.")}`); + this.log(` Send USDC on ${networkName} to: ${chalk.cyan(walletAddress)}`); + this.log(` Then re-run: ${chalk.cyan("ecloud billing top-up")}\n`); + return; + } + + const minimumFormatted = formatUnits(minimumPurchase, 6); + const amountStr = + flags.amount ?? + (await input({ + message: `How much USDC to spend on credits? (minimum: ${minimumFormatted})`, + validate: (val) => { + const n = parseFloat(val); + if (isNaN(n) || n <= 0) return "Enter a positive number"; + const raw = BigInt(Math.round(n * 1e6)); + if (raw < minimumPurchase) + return `Minimum purchase is ${minimumFormatted} USDC`; + if (raw > usdcBalance) + return `Insufficient balance. You have ${balanceFormatted} USDC`; + return true; + }, + })); + + const amountFloat = parseFloat(amountStr); + const amountRaw = BigInt(Math.round(amountFloat * 1e6)); + + if (amountRaw < minimumPurchase) { + this.error(`Minimum purchase is ${minimumFormatted} USDC`); + } + if (amountRaw > usdcBalance) { + this.error( + `Insufficient USDC balance. You have ${balanceFormatted} USDC but requested ${amountFloat.toFixed(2)}`, + ); + } + + this.log(`\n Purchasing ${chalk.bold(`$${amountFloat.toFixed(2)}`)} in credits...`); + + const { txHash } = await billing.topUp({ + amount: amountRaw, + account: targetAccount, + chain: selectedChain, + }); + this.log(` ${chalk.green("✓")} Transaction confirmed: ${txHash}`); + + await this.pollForCredits(billing, flags, baselineTotal, amountFloat); + } - this.log(`\n Purchasing ${chalk.bold(`$${amountFloat.toFixed(2)}`)} in credits...`); + private async handleCard( + billing: Awaited>, + flags: Record, + baselineTotal: number | undefined, + ) { + const MINIMUM_DOLLARS = 5; - // ── Step 4: Execute on-chain purchase via SDK ── - const { txHash } = await billing.topUp({ - amount: amountRaw, - account: targetAccount, + // Prompt for amount + const amountStr = + flags.amount ?? + (await input({ + message: `How many dollars of credits to purchase? (minimum: $${MINIMUM_DOLLARS})`, + validate: (val) => { + const n = parseInt(val, 10); + if (isNaN(n) || n <= 0) return "Enter a positive whole number"; + if (n.toString() !== val.trim()) return "Enter a whole dollar amount (no cents)"; + if (n < MINIMUM_DOLLARS) return `Minimum purchase is $${MINIMUM_DOLLARS}`; + return true; + }, + })); + + const dollars = parseInt(amountStr, 10); + if (isNaN(dollars) || dollars < MINIMUM_DOLLARS) { + this.error(`Minimum purchase is $${MINIMUM_DOLLARS}`); + } + const amountCents = dollars * 100; + + // Check for existing payment methods + const { paymentMethods } = await billing.getPaymentMethods(); + + let paymentMethodId: string | undefined; + + if (paymentMethods.length > 0) { + const choices = paymentMethods.map((card) => ({ + value: card.id, + name: `${card.brand.charAt(0).toUpperCase() + card.brand.slice(1)} ending in ${card.last4}`, + })); + choices.push({ value: "new", name: "Add a new card" }); + + const selection = await select({ + message: "Which card would you like to use?", + choices, }); - this.log(` ${chalk.green("✓")} Transaction confirmed: ${txHash}`); - - // ── Step 5: Poll billing API for credit confirmation ── - this.log(chalk.gray("\n Waiting for credits to appear...")); - const startTime = Date.now(); - while (Date.now() - startTime < POLL_TIMEOUT_MS) { - await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); - try { - const status = await billing.getStatus({ - productId: flags.product as "compute", - }); - const remaining = status.remainingCredits ?? 0; - const applied = status.creditsApplied ?? 0; - const currentTotal = remaining + applied; - this.debug(`Poll: remaining=${remaining}, applied=${applied}, total=${currentTotal}, baseline=${baselineTotal}`); - if ( - baselineTotal === undefined || currentTotal > baselineTotal - ) { - const creditsAdded = baselineTotal !== undefined ? currentTotal - baselineTotal : undefined; - const isMatched = creditsAdded !== undefined && Math.abs(creditsAdded - amountFloat * 2) < 0.01; - const appliedFromTopUp = creditsAdded !== undefined ? creditsAdded - remaining : 0; - - this.log(`\n ${chalk.green("✓")} Credits received: ${chalk.cyan(`$${(creditsAdded ?? amountFloat).toFixed(2)}`)}`); - if (isMatched) { - this.log(` ${chalk.green("✓")} Includes $${amountFloat.toFixed(2)} match bonus!`); - } - if (remaining > 0) { - this.log(` Remaining balance: ${chalk.cyan(`$${remaining.toFixed(2)}`)}`); - } - if (appliedFromTopUp > 0) { - this.log(` ${chalk.gray(`$${appliedFromTopUp.toFixed(2)} applied to current bill`)}`); - } - this.log(); - return; + + if (selection !== "new") { + paymentMethodId = selection; + } + } + + this.log(`\n Purchasing ${chalk.bold(`$${dollars}`)} in credits...`); + + const result = await billing.purchaseCredits(amountCents, paymentMethodId); + + if (result.checkoutUrl) { + this.log(`\n ${chalk.cyan(result.checkoutUrl)}`); + this.log(chalk.gray(" Opening checkout in browser...")); + await open(result.checkoutUrl); + } else if (result.checkoutSessionId) { + this.error( + "Checkout session created but no URL was returned. Please contact support.", + ); + } else { + this.log(` ${chalk.green("✓")} Payment submitted`); + } + + await this.pollForCredits(billing, flags, baselineTotal, dollars); + } + + private async pollForCredits( + billing: Awaited>, + flags: Record, + baselineTotal: number | undefined, + amountPurchased: number, + ) { + this.log(chalk.gray("\n Waiting for credits to appear...")); + const startTime = Date.now(); + while (Date.now() - startTime < POLL_TIMEOUT_MS) { + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + try { + const status = await billing.getStatus({ + productId: flags.product as "compute", + }); + const remaining = status.remainingCredits ?? 0; + const applied = status.creditsApplied ?? 0; + const currentTotal = remaining + applied; + this.debug( + `Poll: remaining=${remaining}, applied=${applied}, total=${currentTotal}, baseline=${baselineTotal}`, + ); + if (baselineTotal === undefined || currentTotal > baselineTotal) { + const creditsAdded = + baselineTotal !== undefined ? currentTotal - baselineTotal : undefined; + this.log( + `\n ${chalk.green("✓")} Credits received: ${chalk.cyan(`$${(creditsAdded ?? amountPurchased).toFixed(2)}`)}`, + ); + if (remaining > 0) { + this.log(` Remaining balance: ${chalk.cyan(`$${remaining.toFixed(2)}`)}`); } - } catch { - this.debug("Error polling for credit balance"); + this.log(); + return; } + } catch { + this.debug("Error polling for credit balance"); } + } - this.log( - `\n ${chalk.yellow("⚠")} Credits haven't appeared yet. This can take a few minutes.`, - ); - this.log(` ${chalk.gray("Check your balance:")} ecloud billing status\n`); - }); + this.log( + `\n ${chalk.yellow("⚠")} Credits haven't appeared yet. This can take a few minutes.`, + ); + this.log(` ${chalk.gray("Check your balance:")} ecloud billing status\n`); } } diff --git a/packages/cli/src/commands/compute/app/__tests__/status.test.ts b/packages/cli/src/commands/compute/app/__tests__/status.test.ts new file mode 100644 index 00000000..5165bae6 --- /dev/null +++ b/packages/cli/src/commands/compute/app/__tests__/status.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const APP = "0x01d3e5851c5F361b4E4988fd3cCc503a6D7b5c09"; + +const getStatuses = vi.fn(); +vi.mock("@layr-labs/ecloud-sdk", () => ({ + getEnvironmentConfig: vi.fn(() => ({ defaultRPCURL: "https://rpc.test" })), + UserApiClient: class { + getStatuses = getStatuses; + }, + WatchTimeoutError: class WatchTimeoutError extends Error {}, +})); +vi.mock("../../../../flags", () => ({ + commonFlags: {}, + validateCommonFlags: vi.fn(async (f: Record) => ({ + ...f, + environment: "sepolia-dev", + "private-key": "0xkey", + })), +})); +vi.mock("../../../../utils/prompts", () => ({ + getOrPromptAppID: vi.fn(async () => APP), +})); +vi.mock("../../../../utils/version", () => ({ getClientId: vi.fn(() => "test") })); +vi.mock("../../../../utils/viemClients", () => ({ + createViemClients: vi.fn(() => ({ publicClient: {}, walletClient: {} })), +})); +const watchDeployment = vi.fn(); +const createComputeClient = vi.fn(async () => ({ app: { watchDeployment } })); +vi.mock("../../../../client", () => ({ + createComputeClient: (...args: unknown[]) => createComputeClient(...(args as [])), +})); + +describe("compute app status", () => { + let logOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + logOutput = []; + }); + + async function runCommand(flags: Record) { + const { default: AppStatus } = await import("../status"); + const cmd = new AppStatus([], {} as any); + cmd.parse = vi.fn().mockResolvedValue({ args: { "app-id": APP }, flags }); + cmd.log = vi.fn((...a: string[]) => logOutput.push(a.join(" "))); + cmd.warn = vi.fn() as any; + await cmd.run(); + return logOutput; + } + + it("prints the one-shot status from getStatuses", async () => { + getStatuses.mockResolvedValue([{ address: APP, status: "Running" }]); + const out = await runCommand({ wait: false, json: false }); + expect(getStatuses).toHaveBeenCalledWith([APP]); + expect(watchDeployment).not.toHaveBeenCalled(); + expect(out.join("\n")).toContain("Running"); + }); + + it("emits machine-readable JSON with --json", async () => { + getStatuses.mockResolvedValue([{ address: APP, status: "Terminated" }]); + const out = await runCommand({ wait: false, json: true }); + expect(JSON.parse(out[0])).toEqual({ appId: APP, status: "Terminated" }); + }); + + it("falls back to Unknown when the API returns nothing", async () => { + getStatuses.mockResolvedValue([]); + const out = await runCommand({ wait: false, json: true }); + expect(JSON.parse(out[0])).toEqual({ appId: APP, status: "Unknown" }); + }); + + it("--wait blocks via watchDeployment for transitional statuses, then does a final status read", async () => { + // Initial read: still deploying -> should wait. Final read: settled. + getStatuses + .mockResolvedValueOnce([{ address: APP, status: "Deploying" }]) + .mockResolvedValueOnce([{ address: APP, status: "Running" }]); + watchDeployment.mockResolvedValue(undefined); + const out = await runCommand({ wait: true, json: true, "watch-timeout": 30 }); + expect(watchDeployment).toHaveBeenCalledWith(APP, { timeoutSeconds: 30 }); + expect(JSON.parse(out[0])).toEqual({ appId: APP, status: "Running" }); + }); + + it("--wait returns immediately for Running without watching", async () => { + getStatuses.mockResolvedValue([{ address: APP, status: "Running" }]); + const out = await runCommand({ wait: true, json: false }); + expect(watchDeployment).not.toHaveBeenCalled(); + expect(out.join("\n")).toContain("Running"); + }); + + it("--wait returns immediately for Terminated without watching", async () => { + getStatuses.mockResolvedValue([{ address: APP, status: "Terminated" }]); + const out = await runCommand({ wait: true, json: true }); + expect(watchDeployment).not.toHaveBeenCalled(); + expect(JSON.parse(out[0])).toEqual({ appId: APP, status: "Terminated" }); + }); + + it("--wait returns immediately for Stopped without watching", async () => { + getStatuses.mockResolvedValue([{ address: APP, status: "Stopped" }]); + const out = await runCommand({ wait: true, json: true }); + expect(watchDeployment).not.toHaveBeenCalled(); + expect(JSON.parse(out[0])).toEqual({ appId: APP, status: "Stopped" }); + }); + + it("--wait --json keeps stdout pure JSON by routing SDK progress off stdout", async () => { + // Transitional initial status forces a watch; the SDK progress logger + // must not be allowed to write to stdout or it corrupts the JSON. + getStatuses + .mockResolvedValueOnce([{ address: APP, status: "Deploying" }]) + .mockResolvedValueOnce([{ address: APP, status: "Running" }]); + watchDeployment.mockResolvedValue(undefined); + + const out = await runCommand({ wait: true, json: true, "watch-timeout": 30 }); + + // The compute client must be built with a logger override so the SDK + // does not print "Waiting for app to start..." / "Status: ..." to stdout. + const clientOpts = createComputeClient.mock.calls[0]?.[1] as { logger?: unknown } | undefined; + expect(clientOpts?.logger).toBeDefined(); + + // stdout is exactly one line, and that line is the JSON object. + expect(out).toHaveLength(1); + expect(JSON.parse(out[0])).toEqual({ appId: APP, status: "Running" }); + }); +}); diff --git a/packages/cli/src/commands/compute/app/deploy.ts b/packages/cli/src/commands/compute/app/deploy.ts index 202b17b1..c3507993 100644 --- a/packages/cli/src/commands/compute/app/deploy.ts +++ b/packages/cli/src/commands/compute/app/deploy.ts @@ -1,18 +1,18 @@ import { Command, Flags } from "@oclif/core"; -import { getEnvironmentConfig, UserApiClient, isMainnet } from "@layr-labs/ecloud-sdk"; +import { getEnvironmentConfig, isMainnet, WatchTimeoutError } from "@layr-labs/ecloud-sdk"; +import type { PrepareDeployResult, GasEstimate } from "@layr-labs/ecloud-sdk"; import { withTelemetry } from "../../../telemetry"; import { commonFlags, applyTxOverrides } from "../../../flags"; import { createComputeClient } from "../../../client"; import { createViemClients } from "../../../utils/viemClients"; import { - getDockerfileInteractive, + getDockerfile, getImageReferenceInteractive, getOrPromptAppName, - getEnvFileInteractive, - getInstanceTypeInteractive, - type SkuInfo, - getLogSettingsInteractive, - getResourceUsageMonitoringInteractive, + getEnvFile, + getInstanceType, + getLogSettings, + getResourceUsageMonitoring, getAppProfileInteractive, LogVisibility, ResourceUsageMonitoring, @@ -22,9 +22,11 @@ import { promptVerifiableGitSourceInputs, promptVerifiablePrebuiltImageRef, imagePathToBlob, + isNonInteractive, + collectMissingRequiredInputs, } from "../../../utils/prompts"; import { invalidateProfileCache, setLinkedAppForDirectory } from "../../../utils/globalConfig"; -import { getClientId } from "../../../utils/version"; +import { fetchAvailableInstanceTypes } from "../../../utils/instanceTypes"; import chalk from "chalk"; import { createBuildClient } from "../../../client"; import { formatVerifiableBuildSummary } from "../../../utils/build"; @@ -36,6 +38,7 @@ import { } from "../../../utils/dockerhub"; import { isTlsEnabledFromEnvFile } from "../../../utils/tls"; import { mergeInlineEnvVars } from "../../../utils/env"; +import { stageFailure } from "../../../utils/exitCodes"; import type { SubmitBuildRequest } from "@layr-labs/ecloud-sdk"; export default class AppDeploy extends Command { @@ -72,7 +75,8 @@ export default class AppDeploy extends Command { }), "log-visibility": Flags.string({ required: false, - description: "Log visibility setting: public, private, or off", + description: + "Log visibility setting: public, private, or off (non-interactive default: private)", options: ["public", "private", "off"], env: "ECLOUD_LOG_VISIBILITY", }), @@ -88,7 +92,8 @@ export default class AppDeploy extends Command { }), "resource-usage-monitoring": Flags.string({ required: false, - description: "Resource usage monitoring: enable or disable", + description: + "Resource usage monitoring: enable or disable (non-interactive default: disable)", options: ["enable", "disable"], env: "ECLOUD_RESOURCE_USAGE_MONITORING", }), @@ -146,12 +151,49 @@ export default class AppDeploy extends Command { force: Flags.boolean({ description: "Skip all confirmation prompts", default: false, + env: "ECLOUD_FORCE", + }), + "watch-timeout": Flags.integer({ + description: + "Maximum seconds to wait for the app to start before returning a recovery hint (default: 600)", + env: "ECLOUD_WATCH_TIMEOUT_SECONDS", }), }; async run() { return withTelemetry(this, async () => { const { flags } = await this.parse(AppDeploy); + + // Resolve the interactivity decision once (flag › CI › !TTY) and thread it + // into the optional-input helpers. They take it as a parameter rather than + // re-deriving from process internally, so --non-interactive is honored + // even on a TTY and the helpers stay pure/testable. + const nonInteractive = isNonInteractive(flags); + + // Non-interactive: report every missing required input at once instead of + // failing one prompt at a time. + if (nonInteractive) { + const missing = collectMissingRequiredInputs( + { + imageRef: flags["image-ref"], + dockerfile: flags.dockerfile, + verifiable: flags.verifiable, + repo: flags.repo, + commit: flags.commit, + name: flags.name, + }, + "name", + ); + if (missing.length > 0) { + const { message, exit } = stageFailure( + "deploy", + "invalid-input", + `Missing required input(s) for non-interactive deploy:\n - ${missing.join("\n - ")}`, + ); + this.error(message, { exit }); + } + } + const compute = await createComputeClient(flags); // Get validated values from flags (mutated by createComputeClient) @@ -282,7 +324,7 @@ export default class AppDeploy extends Command { : await promptVerifiableGitSourceInputs(); // Prompt for env file after git inputs - envFilePath = await getEnvFileInteractive(flags["env-file"]); + envFilePath = await getEnvFile(flags["env-file"], nonInteractive); const includeTlsCaddyfile = isTlsEnabledFromEnvFile(envFilePath); if (includeTlsCaddyfile && !inputs.caddyfilePath) { @@ -359,9 +401,17 @@ export default class AppDeploy extends Command { } } - // 1. Get dockerfile path interactively (skip when using verifiable image) + // 1. Get dockerfile path interactively (skip when using verifiable image). + // Also skip when --image-ref is explicitly provided and no --dockerfile was: + // the user is deploying an existing image, so a stray Dockerfile in the + // working directory must not trigger a "build or deploy existing?" prompt + // (or, in non-interactive mode, silently flip the deploy to a local build). const isVerifiable = verifiableMode !== "none"; - const dockerfilePath = isVerifiable ? "" : await getDockerfileInteractive(flags.dockerfile); + const deployExistingImageRef = !!flags["image-ref"] && !flags.dockerfile; + const dockerfilePath = + isVerifiable || deployExistingImageRef + ? "" + : await getDockerfile(flags.dockerfile, nonInteractive); const buildFromDockerfile = dockerfilePath !== ""; // 2. Get image reference interactively (context-aware) @@ -380,7 +430,7 @@ export default class AppDeploy extends Command { ); // 4. Get env file path interactively - envFilePath = envFilePath ?? (await getEnvFileInteractive(flags["env-file"])); + envFilePath = envFilePath ?? (await getEnvFile(flags["env-file"], nonInteractive)); // 4b. Merge inline --env KEY=VALUE vars (overrides env file values) if (flags.env && flags.env.length > 0) { @@ -394,20 +444,23 @@ export default class AppDeploy extends Command { privateKey, rpcUrl, ); - const instanceType = await getInstanceTypeInteractive( + const instanceType = await getInstanceType( flags["instance-type"], - "", // No default for new deployments + "", // No pinned default for new deployments; non-interactive falls back to g1-standard-2s availableTypes, + nonInteractive, ); // 6. Get log visibility interactively - const logSettings = await getLogSettingsInteractive( + const logSettings = await getLogSettings( flags["log-visibility"] as LogVisibility | undefined, + nonInteractive, ); // 7. Get resource usage monitoring interactively - const resourceUsageMonitoring = await getResourceUsageMonitoringInteractive( + const resourceUsageMonitoring = await getResourceUsageMonitoring( flags["resource-usage-monitoring"] as ResourceUsageMonitoring | undefined, + nonInteractive, ); // 8. Prepare deployment (builds image, pushes to registry, prepares batch, estimates gas) @@ -423,28 +476,38 @@ export default class AppDeploy extends Command { // the normal prepareDeploy path so that layerRemoteImageIfNeeded can // add the ecloud runtime layer (startup script, KMS client, Caddy) if // the image doesn't already have it. - const { prepared, gasEstimate } = - verifiableMode === "git" - ? await compute.app.prepareDeployFromVerifiableBuild({ - name: appName, - imageRef, - imageDigest: verifiableImageDigest!, - envFile: envFilePath, - instanceType, - logVisibility, - resourceUsageMonitoring, - billTo: "developer", - }) - : await compute.app.prepareDeploy({ - name: appName, - dockerfile: dockerfilePath, - imageRef, - envFile: envFilePath, - instanceType, - logVisibility, - resourceUsageMonitoring, - billTo: "developer", - }); + // Build/push stage — failures here mean no image was produced and no + // on-chain tx was attempted. Distinct exit code so callers don't confuse + // it with an on-chain failure. + let prepared: PrepareDeployResult["prepared"]; + let gasEstimate: GasEstimate; + try { + ({ prepared, gasEstimate } = + verifiableMode === "git" + ? await compute.app.prepareDeployFromVerifiableBuild({ + name: appName, + imageRef, + imageDigest: verifiableImageDigest!, + envFile: envFilePath, + instanceType, + logVisibility, + resourceUsageMonitoring, + billTo: "developer", + }) + : await compute.app.prepareDeploy({ + name: appName, + dockerfile: dockerfilePath, + imageRef, + envFile: envFilePath, + instanceType, + logVisibility, + resourceUsageMonitoring, + billTo: "developer", + })); + } catch (err) { + const { message, exit } = stageFailure("deploy", "build", err); + this.error(message, { exit }); + } // 9. Apply gas overrides if provided, show estimate, and prompt for confirmation on mainnet const finalTx = await applyTxOverrides(gasEstimate, flags, { publicClient, address }); @@ -468,8 +531,16 @@ export default class AppDeploy extends Command { } } - // 10. Execute the deployment - const res = await compute.app.executeDeploy(prepared, finalTx); + // 10. Execute the deployment (on-chain stage). The image is already + // built+pushed at this point; a failure here is distinct from a build + // failure and a re-run will reuse the pushed image. + let res: Awaited>; + try { + res = await compute.app.executeDeploy(prepared, finalTx); + } catch (err) { + const { message, exit } = stageFailure("deploy", "onchain", err); + this.error(message, { exit }); + } // 11. Collect app profile while deployment is in progress (optional) if (!flags["skip-profile"]) { @@ -533,7 +604,41 @@ export default class AppDeploy extends Command { } // 12. Watch until app is running - const ipAddress = await compute.app.watchDeployment(res.appId); + let ipAddress: string | undefined; + try { + ipAddress = await compute.app.watchDeployment(res.appId, { + timeoutSeconds: flags["watch-timeout"], + }); + } catch (watchErr: any) { + if (watchErr instanceof WatchTimeoutError) { + this.log( + `\n${chalk.yellow("⚠")} ${chalk.yellow( + `Deployment did not reach Running within ${watchErr.elapsedSeconds}s (last status: ${watchErr.lastStatus ?? "unknown"}).`, + )}`, + ); + this.log( + chalk.gray( + `The deploy transaction succeeded, but the orchestrator hasn't reported the app as Running yet.`, + ), + ); + this.log(chalk.gray(` appId: ${res.appId}`)); + if (res.txHash) { + this.log(chalk.gray(` txHash: ${res.txHash}`)); + } + this.log( + chalk.gray( + `Check progress later with: ${chalk.cyan(`ecloud compute app info ${res.appId}`)}`, + ), + ); + this.log( + chalk.gray( + `Override the watch timeout with the ${chalk.cyan("ECLOUD_WATCH_TIMEOUT_SECONDS")} environment variable.`, + ), + ); + this.exit(1); + } + throw watchErr; + } try { const cwd = process.env.INIT_CWD || process.cwd(); @@ -562,35 +667,3 @@ export default class AppDeploy extends Command { }); } } - -/** - * Fetch available instance types from backend - */ -async function fetchAvailableInstanceTypes( - environment: string, - environmentConfig: any, - privateKey: string, - rpcUrl: string, -): Promise { - try { - const { publicClient, walletClient } = createViemClients({ - privateKey, - rpcUrl, - environment, - }); - const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { - clientId: getClientId(), - }); - - const skuList = await userApiClient.getSKUs(); - if (skuList.skus.length === 0) { - throw new Error("No instance types available from server"); - } - - return skuList.skus; - } catch (err: any) { - console.warn(`Failed to fetch instance types: ${err.message}`); - // Return a default fallback - return [{ sku: "g1-standard-4t", description: "4 vCPUs, 16 GB memory, TDX" }]; - } -} diff --git a/packages/cli/src/commands/compute/app/status.ts b/packages/cli/src/commands/compute/app/status.ts new file mode 100644 index 00000000..7dad7174 --- /dev/null +++ b/packages/cli/src/commands/compute/app/status.ts @@ -0,0 +1,173 @@ +import { Command, Args, Flags } from "@oclif/core"; +import { getEnvironmentConfig, UserApiClient, WatchTimeoutError } from "@layr-labs/ecloud-sdk"; +import { commonFlags, validateCommonFlags } from "../../../flags"; +import { getOrPromptAppID } from "../../../utils/prompts"; +import { getClientId } from "../../../utils/version"; +import { createComputeClient } from "../../../client"; +import { createViemClients } from "../../../utils/viemClients"; +import chalk from "chalk"; + +export default class AppStatus extends Command { + static description = + "Show an app's current status. Use --wait to block until it settles instead of polling `app info` in a loop."; + + static examples = [ + "<%= config.bin %> compute app status 0xabc...", + "<%= config.bin %> compute app status 0xabc... --json", + "<%= config.bin %> compute app status 0xabc... --wait", + "ECLOUD_WATCH_TIMEOUT_SECONDS=120 <%= config.bin %> compute app status 0xabc... --wait", + ]; + + static args = { + "app-id": Args.string({ + description: "App ID or name (env: ECLOUD_APP_ID)", + required: false, + }), + }; + + static flags = { + ...commonFlags, + wait: Flags.boolean({ + description: + "Block until the app reaches a terminal status (Running/Stopped) or the watch timeout elapses, instead of returning immediately", + default: false, + }), + "watch-timeout": Flags.integer({ + description: + "With --wait: maximum seconds to wait before returning a recovery hint (default: 600)", + env: "ECLOUD_WATCH_TIMEOUT_SECONDS", + }), + json: Flags.boolean({ + description: "Output machine-readable JSON ({ appId, status })", + default: false, + }), + }; + + async run() { + const { args, flags } = await this.parse(AppStatus); + + const validatedFlags = await validateCommonFlags(flags); + const environment = validatedFlags.environment; + const environmentConfig = getEnvironmentConfig(environment); + const rpcUrl = validatedFlags["rpc-url"] || environmentConfig.defaultRPCURL; + const privateKey = validatedFlags["private-key"]!; + + const appID = await getOrPromptAppID({ + appID: args["app-id"] ?? process.env.ECLOUD_APP_ID, + environment, + privateKey, + rpcUrl, + action: "check status of", + }); + + const { publicClient, walletClient } = createViemClients({ + privateKey, + rpcUrl, + environment, + }); + const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { + clientId: getClientId(), + }); + + // One-shot read first. --wait only blocks while the app is still + // transitioning; a settled status (Running/Stopped/Terminated/Failed/...) + // returns immediately instead of polling until the watch timeout. + const initialStatus = await this.readStatus(userApiClient, appID); + + if (flags.wait && isTransitionalStatus(initialStatus)) { + // Reuse the bounded watch machinery: polls server-side at a + // fixed cadence with 429/5xx backoff, throws WatchTimeoutError on timeout. + // In --json mode, route SDK progress to stderr so stdout stays pure JSON. + const compute = await createComputeClient( + validatedFlags, + flags.json ? { logger: stderrLogger } : {}, + ); + try { + await compute.app.watchDeployment(appID, { + timeoutSeconds: flags["watch-timeout"], + }); + } catch (err) { + if (err instanceof WatchTimeoutError) { + // Fall through to a final one-shot read + hint below rather than crash. + if (!flags.json) { + this.warn( + `Timed out after ${err.elapsedSeconds}s waiting for ${appID}. ` + + `Check 'ecloud compute app info ${appID}' or the orchestrator logs.`, + ); + } + } else { + throw err; + } + } + + // Final read after waiting; the status has (hopefully) settled. + const finalStatus = await this.readStatus(userApiClient, appID); + this.emit(appID, finalStatus, flags.json); + return; + } + + this.emit(appID, initialStatus, flags.json); + } + + /** Fetch the current status for an app, defaulting to "Unknown". */ + private async readStatus(userApiClient: UserApiClient, appID: string): Promise { + const statuses = await userApiClient.getStatuses([appID]); + return statuses[0]?.status || "Unknown"; + } + + /** Write the status as JSON (machine-readable) or a formatted line. */ + private emit(appID: string, status: string, json: boolean): void { + if (json) { + this.log(JSON.stringify({ appId: appID, status })); + return; + } + this.log(`${chalk.bold(appID)}: ${formatStatus(status)}`); + } +} + +/** + * Statuses that represent an in-progress transition the orchestrator will move + * out of on its own. Only these are worth blocking on with --wait; any other + * (settled) status returns immediately. Compared case-insensitively so a casing + * change on the server side doesn't silently turn --wait into a no-op. + */ +const TRANSITIONAL_STATUSES = new Set([ + "created", + "deploying", + "upgrading", + "resuming", + "stopping", + "terminating", +]); + +function isTransitionalStatus(status: string): boolean { + return TRANSITIONAL_STATUSES.has(status.toLowerCase()); +} + +/** + * Logger that routes every level to stderr. Used in --json mode so SDK + * progress output ("Waiting for app to start...", "Status: ...") never + * pollutes the JSON object on stdout. + */ +const stderrLogger = { + debug: (...args: unknown[]) => console.error(...args), + info: (...args: unknown[]) => console.error(...args), + warn: (...args: unknown[]) => console.error(...args), + error: (...args: unknown[]) => console.error(...args), +}; + +function formatStatus(status: string): string { + switch (status.toLowerCase()) { + case "running": + case "started": + return chalk.green(status); + case "failed": + case "terminated": + return chalk.red(status); + case "stopped": + case "suspended": + return chalk.yellow(status); + default: + return status; + } +} diff --git a/packages/cli/src/commands/compute/app/upgrade.ts b/packages/cli/src/commands/compute/app/upgrade.ts index 90a3ad04..7004e226 100644 --- a/packages/cli/src/commands/compute/app/upgrade.ts +++ b/packages/cli/src/commands/compute/app/upgrade.ts @@ -1,17 +1,22 @@ import { Command, Args, Flags } from "@oclif/core"; -import { getEnvironmentConfig, UserApiClient, isMainnet } from "@layr-labs/ecloud-sdk"; +import { + getEnvironmentConfig, + UserApiClient, + isMainnet, + WatchTimeoutError, +} from "@layr-labs/ecloud-sdk"; +import type { PrepareUpgradeResult, GasEstimate } from "@layr-labs/ecloud-sdk"; import { withTelemetry } from "../../../telemetry"; import { commonFlags, applyTxOverrides } from "../../../flags"; import { createBuildClient, createComputeClient } from "../../../client"; import { createViemClients } from "../../../utils/viemClients"; import { - getDockerfileInteractive, + getDockerfile, getImageReferenceInteractive, - getEnvFileInteractive, - getInstanceTypeInteractive, - type SkuInfo, - getLogSettingsInteractive, - getResourceUsageMonitoringInteractive, + getEnvFile, + getInstanceType, + getLogSettings, + getResourceUsageMonitoring, getOrPromptAppID, LogVisibility, ResourceUsageMonitoring, @@ -20,8 +25,11 @@ import { promptVerifiableSourceType, promptVerifiableGitSourceInputs, promptVerifiablePrebuiltImageRef, + isNonInteractive, + collectMissingRequiredInputs, } from "../../../utils/prompts"; import { getClientId } from "../../../utils/version"; +import { fetchAvailableInstanceTypes } from "../../../utils/instanceTypes"; import { setLinkedAppForDirectory, invalidateProfileCache } from "../../../utils/globalConfig"; import chalk from "chalk"; import { formatVerifiableBuildSummary } from "../../../utils/build"; @@ -33,6 +41,7 @@ import { } from "../../../utils/dockerhub"; import { isTlsEnabledFromEnvFile } from "../../../utils/tls"; import { mergeInlineEnvVars } from "../../../utils/env"; +import { stageFailure } from "../../../utils/exitCodes"; import type { SubmitBuildRequest } from "@layr-labs/ecloud-sdk"; export default class AppUpgrade extends Command { @@ -40,7 +49,7 @@ export default class AppUpgrade extends Command { static args = { "app-id": Args.string({ - description: "App ID or name to upgrade", + description: "App ID or name to upgrade (env: ECLOUD_APP_ID)", required: false, }), }; @@ -76,7 +85,8 @@ export default class AppUpgrade extends Command { }), "log-visibility": Flags.string({ required: false, - description: "Log visibility setting: public, private, or off", + description: + "Log visibility setting: public, private, or off (non-interactive default: private)", options: ["public", "private", "off"], env: "ECLOUD_LOG_VISIBILITY", }), @@ -87,7 +97,8 @@ export default class AppUpgrade extends Command { }), "resource-usage-monitoring": Flags.string({ required: false, - description: "Resource usage monitoring: enable or disable", + description: + "Resource usage monitoring: enable or disable (non-interactive default: disable)", options: ["enable", "disable"], env: "ECLOUD_RESOURCE_USAGE_MONITORING", }), @@ -129,12 +140,55 @@ export default class AppUpgrade extends Command { force: Flags.boolean({ description: "Skip all confirmation prompts", default: false, + env: "ECLOUD_FORCE", + }), + "watch-timeout": Flags.integer({ + description: + "Maximum seconds to wait for the upgrade to complete before returning a recovery hint (default: 600)", + env: "ECLOUD_WATCH_TIMEOUT_SECONDS", }), }; async run() { return withTelemetry(this, async () => { const { args, flags } = await this.parse(AppUpgrade); + + // Resolve the app to upgrade from the positional arg or ECLOUD_APP_ID env + // (oclif Args don't support env bindings directly). + const appIdInput = args["app-id"] ?? process.env.ECLOUD_APP_ID; + + // Resolve the interactivity decision once (flag › CI › !TTY) and thread it + // into the optional-input helpers. They take it as a parameter rather than + // re-deriving from process internally, so --non-interactive is honored + // even on a TTY and the helpers stay pure/testable. + const nonInteractive = isNonInteractive(flags); + + // Non-interactive: report every missing required input at once instead of + // failing one prompt at a time. + if (nonInteractive) { + const missing = collectMissingRequiredInputs( + { + imageRef: flags["image-ref"], + dockerfile: flags.dockerfile, + verifiable: flags.verifiable, + repo: flags.repo, + commit: flags.commit, + }, + "app-id", + ); + if (!appIdInput) { + missing.push("app-id (positional arg or ECLOUD_APP_ID)"); + } + if (missing.length > 0) { + const { message, exit } = stageFailure( + "upgrade", + "invalid-input", + `Missing required input(s) for non-interactive upgrade:\n - ${missing.join("\n - ")}`, + ); + this.error(message, { exit }); + } + } + const compute = await createComputeClient(flags); // Get validated values from flags (mutated by createComputeClient) @@ -145,7 +199,7 @@ export default class AppUpgrade extends Command { // 1. Get app ID interactively if not provided const appID = await getOrPromptAppID({ - appID: args["app-id"], + appID: appIdInput, environment, privateKey, rpcUrl, @@ -218,7 +272,7 @@ export default class AppUpgrade extends Command { : await promptVerifiableGitSourceInputs(); // Prompt for env file after git inputs - envFilePath = await getEnvFileInteractive(flags["env-file"]); + envFilePath = await getEnvFile(flags["env-file"], nonInteractive); const includeTlsCaddyfile = isTlsEnabledFromEnvFile(envFilePath); if (includeTlsCaddyfile && !inputs.caddyfilePath) { inputs.caddyfilePath = "Caddyfile"; @@ -290,9 +344,17 @@ export default class AppUpgrade extends Command { } } - // 2. Get dockerfile path interactively (skip when using verifiable image) + // 2. Get dockerfile path interactively (skip when using verifiable image). + // Also skip when --image-ref is explicitly provided and no --dockerfile was: + // the user is upgrading to an existing image, so a stray Dockerfile in the + // working directory must not trigger a "build or deploy existing?" prompt + // (or, in non-interactive mode, silently flip the upgrade to a local build). const isVerifiable = verifiableMode !== "none"; - const dockerfilePath = isVerifiable ? "" : await getDockerfileInteractive(flags.dockerfile); + const deployExistingImageRef = !!flags["image-ref"] && !flags.dockerfile; + const dockerfilePath = + isVerifiable || deployExistingImageRef + ? "" + : await getDockerfile(flags.dockerfile, nonInteractive); const buildFromDockerfile = dockerfilePath !== ""; // 3. Get image reference interactively (context-aware) @@ -301,7 +363,7 @@ export default class AppUpgrade extends Command { : await getImageReferenceInteractive(flags["image-ref"], buildFromDockerfile); // 4. Get env file path interactively - envFilePath = envFilePath ?? (await getEnvFileInteractive(flags["env-file"])); + envFilePath = envFilePath ?? (await getEnvFile(flags["env-file"], nonInteractive)); // 4b. Merge inline --env KEY=VALUE vars (overrides env file values) if (flags.env && flags.env.length > 0) { @@ -334,20 +396,23 @@ export default class AppUpgrade extends Command { privateKey, rpcUrl, ); - const instanceType = await getInstanceTypeInteractive( + const instanceType = await getInstanceType( flags["instance-type"], currentInstanceType, availableTypes, + nonInteractive, ); // 7. Get log visibility interactively - const logSettings = await getLogSettingsInteractive( + const logSettings = await getLogSettings( flags["log-visibility"] as LogVisibility | undefined, + nonInteractive, ); // 8. Get resource usage monitoring interactively - const resourceUsageMonitoring = await getResourceUsageMonitoringInteractive( + const resourceUsageMonitoring = await getResourceUsageMonitoring( flags["resource-usage-monitoring"] as ResourceUsageMonitoring | undefined, + nonInteractive, ); // 9. Prepare upgrade (builds image, pushes to registry, prepares batch, estimates gas) @@ -362,24 +427,33 @@ export default class AppUpgrade extends Command { // the normal prepareUpgrade path so that layerRemoteImageIfNeeded can // add the ecloud runtime layer (startup script, KMS client, Caddy) if // the image doesn't already have it. - const { prepared, gasEstimate } = - verifiableMode === "git" - ? await compute.app.prepareUpgradeFromVerifiableBuild(appID, { - imageRef, - imageDigest: verifiableImageDigest!, - envFile: envFilePath, - instanceType, - logVisibility, - resourceUsageMonitoring, - }) - : await compute.app.prepareUpgrade(appID, { - dockerfile: dockerfilePath, - imageRef, - envFile: envFilePath, - instanceType, - logVisibility, - resourceUsageMonitoring, - }); + // Build/push stage — failures here mean no image was produced and no + // on-chain tx was attempted. + let prepared: PrepareUpgradeResult["prepared"]; + let gasEstimate: GasEstimate; + try { + ({ prepared, gasEstimate } = + verifiableMode === "git" + ? await compute.app.prepareUpgradeFromVerifiableBuild(appID, { + imageRef, + imageDigest: verifiableImageDigest!, + envFile: envFilePath, + instanceType, + logVisibility, + resourceUsageMonitoring, + }) + : await compute.app.prepareUpgrade(appID, { + dockerfile: dockerfilePath, + imageRef, + envFile: envFilePath, + instanceType, + logVisibility, + resourceUsageMonitoring, + })); + } catch (err) { + const { message, exit } = stageFailure("upgrade", "build", err); + this.error(message, { exit }); + } // 10. Apply gas overrides if provided, show estimate, and prompt for confirmation on mainnet const finalTx = await applyTxOverrides(gasEstimate, flags, { publicClient, address }); @@ -403,11 +477,44 @@ export default class AppUpgrade extends Command { } } - // 11. Execute the upgrade - const res = await compute.app.executeUpgrade(prepared, finalTx); + // 11. Execute the upgrade (on-chain stage). Image already built+pushed; + // a failure here is distinct from a build failure and a re-run reuses the + // pushed image. + let res: Awaited>; + try { + res = await compute.app.executeUpgrade(prepared, finalTx); + } catch (err) { + const { message, exit } = stageFailure("upgrade", "onchain", err); + this.error(message, { exit }); + } // 12. Watch until upgrade completes - await compute.app.watchUpgrade(res.appId); + try { + await compute.app.watchUpgrade(res.appId, { timeoutSeconds: flags["watch-timeout"] }); + } catch (err: any) { + if (err instanceof WatchTimeoutError) { + this.log(""); + this.log( + chalk.yellow( + `Timed out after ${err.elapsedSeconds}s waiting for upgrade to complete (last status: ${err.lastStatus ?? "unknown"}).`, + ), + ); + this.log(chalk.gray("The on-chain transaction was submitted; the orchestrator may")); + this.log(chalk.gray("still be processing. To check the current status, run:")); + this.log(""); + this.log(` ${chalk.cyan(`ecloud compute app info ${res.appId}`)}`); + this.log(""); + this.log(chalk.gray(`appId: ${res.appId}`)); + this.log(chalk.gray(`txHash: ${res.txHash}`)); + this.log( + chalk.gray( + `(override the watch deadline with ECLOUD_WATCH_TIMEOUT_SECONDS, currently ${err.timeoutSeconds}s)`, + ), + ); + this.exit(1); + } + throw err; + } try { const cwd = process.env.INIT_CWD || process.cwd(); @@ -460,35 +567,3 @@ export default class AppUpgrade extends Command { }); } } - -/** - * Fetch available instance types from backend - */ -async function fetchAvailableInstanceTypes( - environment: string, - environmentConfig: any, - privateKey: string, - rpcUrl: string, -): Promise { - try { - const { publicClient, walletClient } = createViemClients({ - privateKey, - rpcUrl, - environment, - }); - const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { - clientId: getClientId(), - }); - - const skuList = await userApiClient.getSKUs(); - if (skuList.skus.length === 0) { - throw new Error("No instance types available from server"); - } - - return skuList.skus; - } catch (err: any) { - console.warn(`Failed to fetch instance types: ${err.message}`); - // Return a default fallback - return [{ sku: "g1-standard-4t", description: "4 vCPUs, 16 GB memory, TDX" }]; - } -} diff --git a/packages/cli/src/flags.ts b/packages/cli/src/flags.ts index a64e7680..a32fb8c2 100644 --- a/packages/cli/src/flags.ts +++ b/packages/cli/src/flags.ts @@ -12,6 +12,7 @@ export type CommonFlags = { "max-fee-per-gas"?: string; "max-priority-fee"?: string; nonce?: string; + "non-interactive"?: boolean; }; export const commonFlags = { @@ -20,7 +21,7 @@ export const commonFlags = { description: "Deployment environment to use", env: "ECLOUD_ENV", default: async () => - getDefaultEnvironment() || (getBuildType() === "dev" ? "sepolia-dev" : "sepolia"), + getDefaultEnvironment() || (getBuildType() === "dev" ? "sepolia-dev" : "mainnet-alpha"), }), "private-key": Flags.string({ required: false, @@ -51,6 +52,13 @@ export const commonFlags = { required: false, description: 'Override transaction nonce (integer or "latest" to replace a stuck transaction)', }), + "non-interactive": Flags.boolean({ + required: false, + description: + "Assume non-interactive mode: default safe prompts and error all-at-once on missing required inputs", + env: "ECLOUD_NON_INTERACTIVE", + default: false, + }), }; /** @@ -102,7 +110,9 @@ export async function applyTxOverrides( } else { const parsed = Number(nonceStr); if (!Number.isInteger(parsed) || parsed < 0) { - throw new Error(`Invalid nonce: "${nonceStr}". Must be a non-negative integer or "latest".`); + throw new Error( + `Invalid nonce: "${nonceStr}". Must be a non-negative integer or "latest".`, + ); } nonce = parsed; } diff --git a/packages/cli/src/hooks/init/__tests__/version-check.test.ts b/packages/cli/src/hooks/init/__tests__/version-check.test.ts index 303ed587..cb1d37fe 100644 --- a/packages/cli/src/hooks/init/__tests__/version-check.test.ts +++ b/packages/cli/src/hooks/init/__tests__/version-check.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from "vitest"; vi.mock("@inquirer/prompts", () => ({ confirm: vi.fn(), @@ -45,12 +45,24 @@ function mockFetch(distTags: Record | null, ok = true) { } describe("version-check init hook", () => { + const origTTY = process.stdin.isTTY; + const origCI = process.env.CI; + beforeEach(() => { vi.clearAllMocks(); (getCliVersion as Mock).mockReturnValue("1.0.0"); (getBuildType as Mock).mockReturnValue("prod"); (loadGlobalConfig as Mock).mockReturnValue({}); (saveGlobalConfig as Mock).mockImplementation(() => {}); + // Default to an interactive terminal so the update-prompt path runs. + process.stdin.isTTY = true; + delete process.env.CI; + }); + + afterEach(() => { + process.stdin.isTTY = origTTY; + if (origCI === undefined) delete process.env.CI; + else process.env.CI = origCI; }); it("skips check for upgrade command", async () => { @@ -98,6 +110,30 @@ describe("version-check init hook", () => { expect(upgradePackage).not.toHaveBeenCalled(); }); + // In non-interactive mode the hook must not block on the update + // prompt (it ran before the command and read as a failure in CI/agents). + it("does not prompt in non-interactive mode (no TTY)", async () => { + process.stdin.isTTY = false; + mockFetch({ latest: "2.0.0" }); + const ctx = createMockContext(); + + await hook.call(ctx as any, { id: "auth:login" } as any); + + expect(confirm).not.toHaveBeenCalled(); + expect(upgradePackage).not.toHaveBeenCalled(); + }); + + it("does not prompt when CI=true even on a TTY", async () => { + process.stdin.isTTY = true; + process.env.CI = "true"; + mockFetch({ latest: "2.0.0" }); + const ctx = createMockContext(); + + await hook.call(ctx as any, { id: "auth:login" } as any); + + expect(confirm).not.toHaveBeenCalled(); + }); + it("upgrades when user confirms", async () => { mockFetch({ latest: "2.0.0" }); (confirm as Mock).mockResolvedValue(true); diff --git a/packages/cli/src/hooks/init/version-check.ts b/packages/cli/src/hooks/init/version-check.ts index e6426cca..16f29d38 100644 --- a/packages/cli/src/hooks/init/version-check.ts +++ b/packages/cli/src/hooks/init/version-check.ts @@ -12,7 +12,12 @@ const NPM_REGISTRY_URL = "https://registry.npmjs.org/@layr-labs/ecloud-cli"; const VERSION_CHECK_TIMEOUT_MS = 3000; const VERSION_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; -function parseVersion(v: string): { major: number; minor: number; patch: number; prerelease: string | null } { +function parseVersion(v: string): { + major: number; + minor: number; + patch: number; + prerelease: string | null; +} { const clean = v.replace(/^v/, ""); const [core, ...rest] = clean.split("-"); const [major = 0, minor = 0, patch = 0] = core.split(".").map(Number); @@ -110,6 +115,15 @@ const hook: Hook<"init"> = async function (options) { ), ); + // Non-interactive (CI / no TTY): never block the command on an update prompt. + // The hook runs before the command parses flags, so it cannot see + // --non-interactive; CI and isTTY cover the agent/CI failure mode. + if (process.env.CI === "true" || !process.stdin.isTTY) { + globalConfig.last_version_check = now; + saveGlobalConfig(globalConfig); + return; + } + const shouldUpdate = await confirm({ message: "Would you like to update now?", default: true, diff --git a/packages/cli/src/utils/__tests__/dockerhub.test.ts b/packages/cli/src/utils/__tests__/dockerhub.test.ts new file mode 100644 index 00000000..bc48e4bf --- /dev/null +++ b/packages/cli/src/utils/__tests__/dockerhub.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveDockerHubImageDigest } from "../dockerhub"; + +const IMAGE = "docker.io/eigenlayer/eigencloud-containers:demo"; +const DIGEST = "sha256:" + "a".repeat(64); + +/** Build a fetch stub that routes by URL/method to canned responses. */ +function stubFetch(handlers: { + manifestList?: unknown; // multi-platform index returned for the manifest GET + singleManifest?: { config: { digest: string } }; // single-platform manifest + config?: { os?: string; architecture?: string }; // config blob +}) { + const json = (body: unknown, headers: Record = {}) => + ({ + ok: true, + status: 200, + headers: { get: (k: string) => headers[k.toLowerCase()] ?? null }, + json: async () => body, + text: async () => JSON.stringify(body), + }) as unknown as Response; + + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL, init?: { method?: string }) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + + if (url.includes("auth.docker.io/token")) return json({ token: "t" }); + + if (url.includes("/manifests/")) { + // The digest HEAD/GET: return the content-digest header. + if (method === "HEAD") return json({}, { "docker-content-digest": DIGEST }); + // The platform-check GET (Accept includes index types) OR digest GET fallback. + if (handlers.manifestList) + return json(handlers.manifestList, { "docker-content-digest": DIGEST }); + return json(handlers.singleManifest ?? {}, { "docker-content-digest": DIGEST }); + } + + if (url.includes("/blobs/")) return json(handlers.config ?? {}); + + throw new Error(`unexpected fetch: ${method} ${url}`); + }), + ); +} + +describe("resolveDockerHubImageDigest amd64 enforcement", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("accepts a multi-platform image that includes linux/amd64", async () => { + stubFetch({ + manifestList: { + manifests: [ + { platform: { os: "linux", architecture: "arm64" } }, + { platform: { os: "linux", architecture: "amd64" } }, + ], + }, + }); + await expect(resolveDockerHubImageDigest(IMAGE)).resolves.toBe(DIGEST); + }); + + it("rejects a multi-platform image with no linux/amd64 entry", async () => { + stubFetch({ + manifestList: { manifests: [{ platform: { os: "linux", architecture: "arm64" } }] }, + }); + await expect(resolveDockerHubImageDigest(IMAGE)).rejects.toThrow(/linux\/amd64/); + }); + + it("accepts a single-platform linux/amd64 image (config blob)", async () => { + stubFetch({ + singleManifest: { config: { digest: "sha256:" + "c".repeat(64) } }, + config: { os: "linux", architecture: "amd64" }, + }); + await expect(resolveDockerHubImageDigest(IMAGE)).resolves.toBe(DIGEST); + }); + + it("rejects a single-platform arm64 image", async () => { + stubFetch({ + singleManifest: { config: { digest: "sha256:" + "c".repeat(64) } }, + config: { os: "linux", architecture: "arm64" }, + }); + await expect(resolveDockerHubImageDigest(IMAGE)).rejects.toThrow(/linux\/amd64/); + }); +}); diff --git a/packages/cli/src/utils/__tests__/exitCodes.test.ts b/packages/cli/src/utils/__tests__/exitCodes.test.ts new file mode 100644 index 00000000..7f757811 --- /dev/null +++ b/packages/cli/src/utils/__tests__/exitCodes.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { EXIT_CODES, errorMessage, stageFailure } from "../exitCodes"; +import { InsufficientGasError } from "@layr-labs/ecloud-sdk"; + +describe("errorMessage", () => { + it("extracts Error.message", () => { + expect(errorMessage(new Error("boom"))).toBe("boom"); + }); + + it("stringifies non-Error values", () => { + expect(errorMessage("plain string")).toBe("plain string"); + expect(errorMessage(42)).toBe("42"); + }); +}); + +describe("stageFailure — maps a failed deploy/upgrade stage to message + exit code", () => { + it("invalid-input maps to exit 2 before any build", () => { + const { exit } = stageFailure("deploy", "invalid-input", "two flags missing"); + expect(exit).toBe(EXIT_CODES.INVALID_INPUT); + }); + + it("build-stage failure maps to exit 3 and says no on-chain tx was attempted", () => { + const { message, exit } = stageFailure("deploy", "build", new Error("docker push 500")); + expect(exit).toBe(EXIT_CODES.BUILD_FAILED); + expect(message).toContain("Build/push failed"); + expect(message).toContain("no deployment was attempted"); + expect(message).toContain("docker push 500"); + }); + + it("on-chain-stage failure maps to exit 4 and notes the image is already pushed", () => { + const { message, exit } = stageFailure("deploy", "onchain", new Error("nonce too low")); + expect(exit).toBe(EXIT_CODES.ONCHAIN_FAILED); + expect(message).toContain("On-chain deployment failed"); + expect(message).toContain("nonce too low"); + expect(message).toContain("re-running deploy will reuse it"); + }); + + it("uses operation-specific wording for upgrade", () => { + const build = stageFailure("upgrade", "build", new Error("x")); + expect(build.message).toContain("no upgrade was attempted"); + + const onchain = stageFailure("upgrade", "onchain", new Error("x")); + expect(onchain.message).toContain("On-chain upgrade failed"); + expect(onchain.message).toContain("re-running upgrade will reuse it"); + }); + + it("reclassifies an insufficient-gas failure caught in the build stage as on-chain (exit 4)", () => { + // The gas pre-flight runs inside prepare*() AFTER the image is built+pushed, + // so it surfaces through the build try/catch. But the image already exists, + // so it must NOT be reported as exit 3 "no was attempted" — it is an + // on-chain-readiness failure (exit 4, "re-run reuses the pushed image"). + const gasErr = new InsufficientGasError({ + address: "0xabc0000000000000000000000000000000000abc", + requiredWei: BigInt(2), + availableWei: BigInt(1), + }); + const { message, exit } = stageFailure("deploy", "build", gasErr); + expect(exit).toBe(EXIT_CODES.ONCHAIN_FAILED); + expect(message).toContain("Insufficient ETH for gas"); + expect(message).not.toContain("no deployment was attempted"); + }); + + it("each stage carries a distinct exit code", () => { + const exits = (["invalid-input", "build", "onchain"] as const).map( + (s) => stageFailure("deploy", s, "e").exit, + ); + expect(new Set(exits).size).toBe(3); + }); +}); diff --git a/packages/cli/src/utils/__tests__/prompts.test.ts b/packages/cli/src/utils/__tests__/prompts.test.ts index ecb3ee0c..54894bb2 100644 --- a/packages/cli/src/utils/__tests__/prompts.test.ts +++ b/packages/cli/src/utils/__tests__/prompts.test.ts @@ -1,5 +1,35 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { getEnvironmentInteractive, promptUseVerifiableBuild } from "../prompts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "fs"; + +// Any helper that falls through to an interactive prompt is a bug in these +// tests' scenarios (we always run with a non-interactive intent). Make that +// fail loudly and deterministically instead of hanging on real stdin. +vi.mock("@inquirer/prompts", () => ({ + select: vi.fn(async () => { + throw new Error("unexpected interactive select()"); + }), + input: vi.fn(async () => { + throw new Error("unexpected interactive input()"); + }), + password: vi.fn(async () => { + throw new Error("unexpected interactive password()"); + }), + confirm: vi.fn(async () => { + throw new Error("unexpected interactive confirm()"); + }), +})); + +import { + getEnvironmentInteractive, + promptUseVerifiableBuild, + getDockerfile, + getEnvFile, + getLogSettings, + getResourceUsageMonitoring, + getInstanceType, + isNonInteractive, + collectMissingRequiredInputs, +} from "../prompts"; /** * Regression tests for two non-interactive mode bugs introduced in PR #126: @@ -69,18 +99,291 @@ describe("prompts non-interactive regressions", () => { await expect(promptUseVerifiableBuild(true)).resolves.toBe(false); }); - it("throws the 'Use --force' guidance when force is false in non-TTY mode", async () => { + // confirmWithDefault returns the default in non-TTY mode instead of + // throwing, so a verifiable-build confirm resolves to false (regular + // build) rather than erroring. Optional confirms never block a + // non-interactive run. + it("resolves to false (regular build) when force is false in non-TTY mode", async () => { + process.stdin.isTTY = false; + await expect(promptUseVerifiableBuild(false)).resolves.toBe(false); + }); + + it("defaults force to false and still resolves to false in non-TTY mode", async () => { + process.stdin.isTTY = false; + await expect(promptUseVerifiableBuild()).resolves.toBe(false); + }); + }); +}); + +/** + * In non-interactive (non-TTY) mode, the optional deploy / upgrade prompts + * must fall back to a safe default with a warning instead of + * throwing "Cannot prompt in non-interactive mode". Required inputs that have + * no safe default (e.g. --instance-type) must still error. + */ +describe("non-interactive flag defaulting", () => { + const origIsTTY = process.stdin.isTTY; + + afterEach(() => { + process.stdin.isTTY = origIsTTY; + vi.restoreAllMocks(); + }); + + describe("getEnvFile", () => { + it("defaults to no env file in non-TTY mode when none is found", async () => { + process.stdin.isTTY = false; + // No explicit path, and no auto-detected .env on disk. + vi.spyOn(fs, "existsSync").mockReturnValue(false); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await expect(getEnvFile(undefined, true)).resolves.toBe(""); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/--env-file.*no env file/)); + }); + + it("still returns an explicitly provided, existing env file in non-TTY mode", async () => { + process.stdin.isTTY = false; + vi.spyOn(fs, "existsSync").mockImplementation((p) => p === "custom.env"); + await expect(getEnvFile("custom.env")).resolves.toBe("custom.env"); + }); + }); + + describe("getLogSettings", () => { + it("defaults to private logs in non-TTY mode", async () => { process.stdin.isTTY = false; - await expect(promptUseVerifiableBuild(false)).rejects.toThrow( - /Cannot confirm "Build from verifiable source\?" in non-interactive mode\. Use --force/, + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await expect(getLogSettings(undefined, true)).resolves.toEqual({ + logRedirect: "always", + publicLogs: false, + }); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/--log-visibility.*private/)); + }); + + it("never silently defaults to public in non-TTY mode", async () => { + process.stdin.isTTY = false; + vi.spyOn(console, "warn").mockImplementation(() => {}); + const settings = await getLogSettings(undefined, true); + expect(settings.publicLogs).toBe(false); + }); + + it("honors an explicit --log-visibility value regardless of TTY", async () => { + process.stdin.isTTY = false; + await expect(getLogSettings("public")).resolves.toEqual({ + logRedirect: "always", + publicLogs: true, + }); + }); + }); + + describe("getResourceUsageMonitoring", () => { + it("defaults to disable in non-TTY mode", async () => { + process.stdin.isTTY = false; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await expect(getResourceUsageMonitoring(undefined, true)).resolves.toBe("disable"); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/--resource-usage-monitoring.*disable/), ); }); - it("defaults force to false so existing callers still see the non-interactive error", async () => { + it("honors an explicit value regardless of TTY", async () => { process.stdin.isTTY = false; - await expect(promptUseVerifiableBuild()).rejects.toThrow( - /Cannot confirm "Build from verifiable source\?" in non-interactive mode/, + await expect(getResourceUsageMonitoring("enable")).resolves.toBe("enable"); + }); + }); + + describe("getDockerfile", () => { + it("returns '' (deploy existing image) when no Dockerfile exists, even in non-TTY mode", async () => { + process.stdin.isTTY = false; + vi.spyOn(fs, "existsSync").mockReturnValue(false); + await expect(getDockerfile(undefined)).resolves.toBe(""); + }); + + it("defaults to building the discovered Dockerfile in non-TTY mode", async () => { + process.stdin.isTTY = false; + vi.spyOn(fs, "existsSync").mockReturnValue(true); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const result = await getDockerfile(undefined, true); + expect(result).toMatch(/Dockerfile$/); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/--dockerfile.*build from/)); + }); + + it("returns an explicitly provided Dockerfile path verbatim", async () => { + process.stdin.isTTY = false; + await expect(getDockerfile("./custom/Dockerfile")).resolves.toBe("./custom/Dockerfile"); + }); + }); + + describe("getInstanceType", () => { + const types = [ + { sku: "g1-standard-2s", friendly_name: "Standard 2s", description: "2 vCPU, SEV-SNP" }, + { sku: "g1-standard-4t", friendly_name: "Standard 4t", description: "4 vCPU, TDX" }, + ]; + + it("returns an explicitly provided, valid instance type in non-TTY mode", async () => { + process.stdin.isTTY = false; + await expect(getInstanceType("g1-standard-2s", "", types)).resolves.toBe("g1-standard-2s"); + }); + + // Deploy with no instance type defaults to g1-standard-2s in non-interactive mode. + it("defaults to g1-standard-2s in non-interactive deploy when available", async () => { + process.stdin.isTTY = false; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + await expect(getInstanceType(undefined, "", types, true)).resolves.toBe("g1-standard-2s"); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/--instance-type.*g1-standard-2s/)); + }); + + // Upgrade reuses the currently pinned type (defaultSKU) instead of prompting. + it("reuses defaultSKU (pinned type) in non-interactive upgrade", async () => { + process.stdin.isTTY = false; + vi.spyOn(console, "warn").mockImplementation(() => {}); + await expect(getInstanceType(undefined, "g1-standard-4t", types, true)).resolves.toBe( + "g1-standard-4t", ); }); + + it("errors in non-interactive when the default SKU is not offered", async () => { + process.stdin.isTTY = false; + await expect( + getInstanceType( + undefined, + "", + [{ sku: "g1-micro-1v", friendly_name: "m", description: "" }], + true, + ), + ).rejects.toThrow(/instance-type/); + }); + }); +}); + +/** + * The optional-input helpers must honor an injected non-interactive decision, + * not re-derive it from process.stdin/CI internally. This is what lets + * `--non-interactive` work on a real TTY (where isTTY is true and CI is unset): + * the command resolves isNonInteractive(flags) once and threads the boolean + * down. Each test below runs on a TTY with CI unset — so a helper that ignores + * the injected flag would fall through to a (mocked, throwing) prompt. + */ +describe("optional-input helpers honor injected nonInteractive on a TTY", () => { + const origIsTTY = process.stdin.isTTY; + const origCI = process.env.CI; + + beforeEach(() => { + process.stdin.isTTY = true; + delete process.env.CI; + }); + afterEach(() => { + process.stdin.isTTY = origIsTTY; + if (origCI === undefined) delete process.env.CI; + else process.env.CI = origCI; + vi.restoreAllMocks(); + }); + + it("getEnvFile defaults to no env file", async () => { + vi.spyOn(fs, "existsSync").mockReturnValue(false); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + await expect(getEnvFile(undefined, true)).resolves.toBe(""); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/--env-file.*no env file/)); + }); + + it("getLogSettings defaults to private logs", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + await expect(getLogSettings(undefined, true)).resolves.toEqual({ + logRedirect: "always", + publicLogs: false, + }); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/--log-visibility.*private/)); + }); + + it("getResourceUsageMonitoring defaults to disable", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + await expect(getResourceUsageMonitoring(undefined, true)).resolves.toBe("disable"); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/--resource-usage-monitoring.*disable/), + ); + }); + + it("getDockerfile defaults to building the discovered Dockerfile", async () => { + vi.spyOn(fs, "existsSync").mockReturnValue(true); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const result = await getDockerfile(undefined, true); + expect(result).toMatch(/Dockerfile$/); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/--dockerfile.*build from/)); + }); + + it("the same helpers still prompt on a TTY when nonInteractive is false", async () => { + // Sanity: when the injected decision is interactive, the helper reaches the + // (mocked) prompt rather than silently defaulting. Proves the boolean is + // actually driving the branch, not being ignored. + vi.spyOn(fs, "existsSync").mockReturnValue(false); + await expect(getLogSettings(undefined, false)).rejects.toThrow(/unexpected interactive/); + }); +}); + +describe("isNonInteractive detection", () => { + const origTTY = process.stdin.isTTY; + const origCI = process.env.CI; + afterEach(() => { + process.stdin.isTTY = origTTY; + if (origCI === undefined) delete process.env.CI; + else process.env.CI = origCI; + }); + + it("true when --non-interactive flag is set, even on a TTY", () => { + process.stdin.isTTY = true; + delete process.env.CI; + expect(isNonInteractive({ "non-interactive": true })).toBe(true); + }); + it("true when CI=true, even on a TTY", () => { + process.stdin.isTTY = true; + process.env.CI = "true"; + expect(isNonInteractive()).toBe(true); + }); + it("true when no TTY", () => { + process.stdin.isTTY = false; + delete process.env.CI; + expect(isNonInteractive()).toBe(true); + }); + it("false on a TTY with no CI and no flag", () => { + process.stdin.isTTY = true; + delete process.env.CI; + expect(isNonInteractive()).toBe(false); + }); +}); + +describe("collectMissingRequiredInputs reports all missing at once", () => { + it("returns [] when image source + name present", () => { + expect(collectMissingRequiredInputs({ imageRef: "r", name: "n" }, "name")).toEqual([]); + }); + it("lists both missing image source and name", () => { + const m = collectMissingRequiredInputs({ verifiable: false }, "name"); + expect(m.join(" ")).toMatch(/image source/); + expect(m.join(" ")).toMatch(/--name/); + }); + it("accepts verifiable git source as image source", () => { + const m = collectMissingRequiredInputs( + { verifiable: true, repo: "x", commit: "y", name: "n" }, + "name", + ); + expect(m).toEqual([]); + }); + it("reports only the image source for upgrade (app-id handled at call site)", () => { + const m = collectMissingRequiredInputs({}, "app-id"); + expect(m).toEqual([expect.stringMatching(/image source/)]); + }); + it("requires --image-ref as the push destination when building from --dockerfile", () => { + // A local Dockerfile build still needs somewhere to push the built image. + // Non-interactively, a missing --image-ref must be reported here (exit 2), + // not deferred to an interactive prompt that then throws as exit 1. + const m = collectMissingRequiredInputs({ dockerfile: "Dockerfile", name: "n" }, "name"); + expect(m).toEqual([expect.stringMatching(/--image-ref/)]); + }); + it("accepts --dockerfile together with --image-ref", () => { + const m = collectMissingRequiredInputs( + { dockerfile: "Dockerfile", imageRef: "r", name: "n" }, + "name", + ); + expect(m).toEqual([]); }); }); diff --git a/packages/cli/src/utils/dockerhub.ts b/packages/cli/src/utils/dockerhub.ts index 70b29373..fca52205 100644 --- a/packages/cli/src/utils/dockerhub.ts +++ b/packages/cli/src/utils/dockerhub.ts @@ -102,5 +102,93 @@ export async function resolveDockerHubImageDigest(imageRef: string): Promise { + const base = `https://registry-1.docker.io/v2/${owner}/${repo}`; + const headers = { Authorization: `Bearer ${token}`, Accept: AMD64_ACCEPT }; + + const res = await fetch(`${base}/manifests/${encodeURIComponent(tag)}`, { headers }); + if (!res.ok) { + const body = await safeReadText(res); + throw new Error( + `Failed to read manifest for ${imageRef} (${res.status}): ${body || res.statusText}`, + ); + } + const manifest = (await res.json()) as { + manifests?: Array<{ platform?: { os?: string; architecture?: string } }>; + config?: { digest?: string }; + architecture?: string; + os?: string; + }; + + const isAmd64 = (os?: string, arch?: string) => os === "linux" && arch === "amd64"; + + // Multi-platform: scan the index entries. + if (Array.isArray(manifest.manifests) && manifest.manifests.length > 0) { + const platforms = manifest.manifests.map((m) => + m.platform ? `${m.platform.os}/${m.platform.architecture}` : "unknown", + ); + if (manifest.manifests.some((m) => isAmd64(m.platform?.os, m.platform?.architecture))) { + return; + } + throw amd64Error(imageRef, platforms); + } + + // Single-platform: the architecture lives in the config blob, not the manifest. + const configDigest = manifest.config?.digest; + if (!configDigest) { + throw amd64Error(imageRef, ["unknown (no platform info in manifest)"]); + } + const cfgRes = await fetch(`${base}/blobs/${configDigest}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!cfgRes.ok) { + throw amd64Error(imageRef, ["unknown (could not read image config)"]); + } + const cfg = (await cfgRes.json()) as { architecture?: string; os?: string }; + if (isAmd64(cfg.os, cfg.architecture)) { + return; + } + throw amd64Error(imageRef, [`${cfg.os ?? "unknown"}/${cfg.architecture ?? "unknown"}`]); +} + +function amd64Error(imageRef: string, platforms: string[]): Error { + return new Error( + `ecloud requires linux/amd64 images for TEE deployment.\n\n` + + `Image: ${imageRef}\n` + + `Found platform(s): ${platforms.join(", ")}\n` + + `Required platform: linux/amd64\n\n` + + `To fix: rebuild for linux/amd64 (e.g. docker buildx build --platform linux/amd64 ... --push), ` + + `or use a verifiable build (--verifiable --repo --commit ), which builds server-side.`, + ); +} diff --git a/packages/cli/src/utils/exitCodes.ts b/packages/cli/src/utils/exitCodes.ts new file mode 100644 index 00000000..760a1ad5 --- /dev/null +++ b/packages/cli/src/utils/exitCodes.ts @@ -0,0 +1,83 @@ +import { InsufficientGasError } from "@layr-labs/ecloud-sdk"; + +/** + * Distinct process exit codes for deploy/upgrade so a caller (CI, agent) keying + * off exit status can tell *which stage* failed. + * + * A ~7-minute build that succeeds and then fails on-chain must be + * distinguishable from a build that never produced an image. + * + * Exit code 1 is intentionally NOT defined here: it is oclif's default for any + * unclassified error (a plain `this.error(msg)` with no `exit` option). The + * codes below are the stage-specific ones we assign on top of that baseline. + * + * 2 invalid or missing input — fails before any build + * 3 build/push failed — no on-chain transaction was attempted + * 4 build/push succeeded but the on-chain transaction failed + * (the image is already built+pushed; a re-run reuses it) + */ +export const EXIT_CODES = { + INVALID_INPUT: 2, + BUILD_FAILED: 3, + ONCHAIN_FAILED: 4, +} as const; + +export type DeployStageExitCode = (typeof EXIT_CODES)[keyof typeof EXIT_CODES]; + +/** Extract a human-readable message from an unknown thrown value. */ +export function errorMessage(err: unknown): string { + if (err instanceof Error) return err.message; + return String(err); +} + +/** The operation a failure occurred under (drives the user-facing wording). */ +export type DeployOperation = "deploy" | "upgrade"; + +/** Which stage of deploy/upgrade failed (drives the exit code). */ +export type DeployStage = "invalid-input" | "build" | "onchain"; + +/** + * Map a failed deploy/upgrade stage to a user-facing message and the matching + * process exit code, so a caller (CI, agent) keying off exit status can tell + * *which stage* failed. Pure — the command layer feeds the result straight to + * `this.error(message, { exit })`. + * + * - build: no image was produced, so no on-chain tx was attempted (exit 3). + * - onchain: the image is already built+pushed; a re-run reuses it (exit 4). + * + * Exception: the gas pre-flight (`assertSufficientGas`) runs inside prepare*() + * AFTER the image is built and pushed, so an `InsufficientGasError` surfaces + * through the build try/catch. The image already exists, so it is reclassified + * as an on-chain-readiness failure (exit 4) rather than a build failure (exit + * 3) — reporting "no was attempted" would be wrong and misleading. + */ +export function stageFailure( + operation: DeployOperation, + stage: DeployStage, + err: unknown, +): { message: string; exit: DeployStageExitCode } { + const noun = operation === "deploy" ? "deployment" : "upgrade"; + + // The image is already pushed by the time gas is checked, so treat an + // insufficient-gas failure as on-chain regardless of which stage caught it. + if (err instanceof InsufficientGasError) { + stage = "onchain"; + } + + switch (stage) { + case "invalid-input": + return { message: errorMessage(err), exit: EXIT_CODES.INVALID_INPUT }; + case "build": + return { + message: `Build/push failed (no ${noun} was attempted): ${errorMessage(err)}`, + exit: EXIT_CODES.BUILD_FAILED, + }; + case "onchain": + return { + message: + `On-chain ${noun} failed after the image was built and pushed: ${errorMessage(err)}\n` + + `The image is already pushed — re-running ${operation} will reuse it.`, + exit: EXIT_CODES.ONCHAIN_FAILED, + }; + } +} diff --git a/packages/cli/src/utils/instanceTypes.ts b/packages/cli/src/utils/instanceTypes.ts new file mode 100644 index 00000000..fec2b8a9 --- /dev/null +++ b/packages/cli/src/utils/instanceTypes.ts @@ -0,0 +1,40 @@ +import { UserApiClient } from "@layr-labs/ecloud-sdk"; +import { createViemClients } from "./viemClients"; +import { getClientId } from "./version"; +import type { SkuInfo } from "./prompts"; + +/** + * Fetch the instance types (SKUs) offered by the backend for an environment. + * + * Best-effort: on any failure (network, auth, empty list) this warns and returns + * a single safe fallback SKU rather than aborting, so deploy/upgrade can still + * proceed with a sensible default. + */ +export async function fetchAvailableInstanceTypes( + environment: string, + environmentConfig: any, + privateKey: string, + rpcUrl: string, +): Promise { + try { + const { publicClient, walletClient } = createViemClients({ + privateKey, + rpcUrl, + environment, + }); + const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { + clientId: getClientId(), + }); + + const skuList = await userApiClient.getSKUs(); + if (skuList.skus.length === 0) { + throw new Error("No instance types available from server"); + } + + return skuList.skus; + } catch (err: any) { + console.warn(`Failed to fetch instance types: ${err.message}`); + // Return a default fallback + return [{ sku: "g1-standard-4t", description: "4 vCPUs, 16 GB memory, TDX" }]; + } +} diff --git a/packages/cli/src/utils/prompts.ts b/packages/cli/src/utils/prompts.ts index 1c16c7cc..58ae8a45 100644 --- a/packages/cli/src/utils/prompts.ts +++ b/packages/cli/src/utils/prompts.ts @@ -62,12 +62,89 @@ function ensureInteractive(missingFlagHint: string): void { } } +/** + * True when the CLI should run without interactive prompts. + * + * Precedence: an explicit `--non-interactive` flag, then `CI=true`, then the + * absence of a TTY. `isTTY` alone is unreliable in pipes/CI, so the flag and CI + * env give callers (agents, scripts) a deterministic signal. + * + * The optional deploy/upgrade prompts (env file, log visibility, resource-usage + * monitoring, Dockerfile-vs-existing-image, instance type) all have a safe + * default, so rather than throwing via `ensureInteractive` they fall back to + * that default and log what was chosen. Required inputs with no safe default + * (image source, app name/id) are reported all-at-once by the command layer. + */ +export function isNonInteractive(flags?: { "non-interactive"?: boolean }): boolean { + if (flags?.["non-interactive"]) return true; + if (process.env.CI === "true") return true; + return !process.stdin.isTTY; +} + +/** + * Emit a single, consistent line noting that an optional flag was defaulted + * because we are running without a TTY. Keeps the choice visible/auditable in + * CI logs. + */ +function warnDefaulted(flagHint: string, chosen: string): void { + console.warn(`Warning: ${flagHint} not set in non-interactive mode; defaulting to ${chosen}.`); +} + +/** The set of required deploy/upgrade inputs that have no safe default. */ +export interface RequiredInputState { + imageRef?: string; + dockerfile?: string; + verifiable?: boolean; + repo?: string; + commit?: string; + name?: string; +} + +/** + * Collect ALL missing required deploy/upgrade inputs for non-interactive mode, + * so the caller can report them in one error instead of one prompt at a time. + * + * Covers the image source (and, for deploy, `--name`). The upgrade `app-id` is + * a positional arg, so its presence is checked at the call site; this helper + * only reports the image-source half for `"app-id"` callers. + * + * @param identityFlag "name" for deploy, "app-id" for upgrade. + */ +export function collectMissingRequiredInputs( + state: RequiredInputState, + identityFlag: "name" | "app-id", +): string[] { + const missing: string[] = []; + const hasImageSource = + !!state.imageRef || + !!state.dockerfile || + (!!state.verifiable && !!state.repo && !!state.commit); + if (!hasImageSource) { + missing.push( + "an image source (one of: --image-ref, --dockerfile, or --verifiable with --repo and --commit)", + ); + } else if (state.dockerfile && !state.imageRef && !state.verifiable) { + // A local --dockerfile build still needs a registry destination to push + // the built image to. Without --image-ref, the non-interactive run would + // otherwise fall through to an interactive --image-ref prompt and throw. + missing.push("--image-ref (registry destination for the built image)"); + } + if (identityFlag === "name" && !state.name) { + missing.push("--name"); + } + return missing; +} + // ==================== Dockerfile Selection ==================== /** - * Prompt for Dockerfile selection + * Resolve the Dockerfile path: explicit flag, else the discovered ./Dockerfile. + * When nonInteractive, defaults instead of prompting for build-vs-existing. */ -export async function getDockerfileInteractive(dockerfilePath?: string): Promise { +export async function getDockerfile( + dockerfilePath?: string, + nonInteractive: boolean = false, +): Promise { // Check if provided via option if (dockerfilePath) { return dockerfilePath; @@ -83,6 +160,17 @@ export async function getDockerfileInteractive(dockerfilePath?: string): Promise return ""; } + // In non-interactive mode we cannot ask the user to choose between building + // the discovered Dockerfile and deploying an existing image. Default to + // building from the discovered Dockerfile — the intent when a Dockerfile is + // sitting in the working directory. Callers that pass --image-ref skip this + // helper entirely (see deploy.ts / upgrade.ts), so this default never + // overrides an explicit image reference. + if (nonInteractive) { + warnDefaulted("--dockerfile/--image-ref", `build from '${dockerfilePath_resolved}'`); + return dockerfilePath_resolved; + } + // Interactive prompt when Dockerfile exists ensureInteractive("--dockerfile or --image-ref"); console.log(`\nFound Dockerfile in ${cwd}`); @@ -809,9 +897,13 @@ export async function promptBuildIdFromRecentBuilds(options: { // ==================== Environment File Selection ==================== /** - * Prompt for environment file + * Resolve the env file path: explicit flag, else auto-detected ./.env. + * When nonInteractive, defaults to no env file instead of prompting. */ -export async function getEnvFileInteractive(envFilePath?: string): Promise { +export async function getEnvFile( + envFilePath?: string, + nonInteractive: boolean = false, +): Promise { if (envFilePath && fs.existsSync(envFilePath)) { return envFilePath; } @@ -820,6 +912,13 @@ export async function getEnvFileInteractive(envFilePath?: string): Promise { if (instanceType) { // Validate provided instance type @@ -907,6 +1014,25 @@ export async function getInstanceTypeInteractive( throw new Error(`Invalid instance-type: ${instanceType} (must be one of: ${validSKUs})`); } + // Non-interactive: pick a default instead of prompting. + // Callers pass isNonInteractive(flags) (flag + CI + isTTY) as this argument. + if (nonInteractive) { + // Upgrade path: reuse the currently pinned type when one is known. + if (defaultSKU) { + warnDefaulted("--instance-type", `current type '${defaultSKU}'`); + return defaultSKU; + } + // Deploy path: smallest TEE tier, if the backend offers it. + if (availableTypes.some((t) => t.sku === DEFAULT_NONINTERACTIVE_SKU)) { + warnDefaulted("--instance-type", `'${DEFAULT_NONINTERACTIVE_SKU}'`); + return DEFAULT_NONINTERACTIVE_SKU; + } + const validSKUs = availableTypes.map((t) => t.sku).join(", "); + throw new Error( + `Cannot pick a default --instance-type in non-interactive mode: '${DEFAULT_NONINTERACTIVE_SKU}' not offered (available: ${validSKUs}). Provide --instance-type.`, + ); + } + ensureInteractive("--instance-type"); const isCurrentType = defaultSKU !== ""; @@ -949,10 +1075,12 @@ export async function getInstanceTypeInteractive( export type LogVisibility = "public" | "private" | "off"; /** - * Prompt for log settings + * Resolve log settings from the --log-visibility value. + * When nonInteractive, defaults to private instead of prompting. */ -export async function getLogSettingsInteractive( +export async function getLogSettings( logVisibility?: LogVisibility, + nonInteractive: boolean = false, ): Promise<{ logRedirect: string; publicLogs: boolean }> { if (logVisibility) { switch (logVisibility) { @@ -969,6 +1097,13 @@ export async function getLogSettingsInteractive( } } + // In non-interactive mode, default to private logs. Never silently default + // to public — private is the conservative choice for an unattended deploy. + if (nonInteractive) { + warnDefaulted("--log-visibility", "'private'"); + return { logRedirect: "always", publicLogs: false }; + } + ensureInteractive("--log-visibility"); const choice = await select({ @@ -1422,10 +1557,12 @@ async function getAppIDInteractiveFromRegistry( export type ResourceUsageMonitoring = "enable" | "disable"; /** - * Prompt for resource usage monitoring settings + * Resolve the resource-usage-monitoring setting from the flag value. + * When nonInteractive, defaults to disable instead of prompting. */ -export async function getResourceUsageMonitoringInteractive( +export async function getResourceUsageMonitoring( resourceUsageMonitoring?: ResourceUsageMonitoring, + nonInteractive: boolean = false, ): Promise { if (resourceUsageMonitoring) { switch (resourceUsageMonitoring) { @@ -1439,6 +1576,12 @@ export async function getResourceUsageMonitoringInteractive( } } + // In non-interactive mode, default to disabled resource usage monitoring. + if (nonInteractive) { + warnDefaulted("--resource-usage-monitoring", "'disable'"); + return "disable"; + } + ensureInteractive("--resource-usage-monitoring"); const choice = await select({ @@ -1469,9 +1612,7 @@ export async function confirmWithDefault( defaultValue: boolean = false, ): Promise { if (!process.stdin.isTTY) { - throw new Error( - `Cannot confirm "${prompt}" in non-interactive mode. Use --force to skip confirmation prompts.`, - ); + return defaultValue; } return await inquirerConfirm({ message: prompt, diff --git a/packages/sdk/src/client/common/abis/AppController.json b/packages/sdk/src/client/common/abis/AppController.json index 4608a2ed..918b5beb 100644 --- a/packages/sdk/src/client/common/abis/AppController.json +++ b/packages/sdk/src/client/common/abis/AppController.json @@ -135,6 +135,19 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "confirmUpgrade", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "createApp", @@ -187,6 +200,62 @@ "name": "encryptedEnv", "type": "bytes", "internalType": "bytes" + }, + { + "name": "containerPolicy", + "type": "tuple", + "internalType": "structIAppController.ContainerPolicy", + "components": [ + { + "name": "args", + "type": "string[]", + "internalType": "string[]" + }, + { + "name": "cmdOverride", + "type": "string[]", + "internalType": "string[]" + }, + { + "name": "env", + "type": "tuple[]", + "internalType": "structIAppController.EnvVar[]", + "components": [ + { + "name": "key", + "type": "string", + "internalType": "string" + }, + { + "name": "value", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "envOverride", + "type": "tuple[]", + "internalType": "structIAppController.EnvVar[]", + "components": [ + { + "name": "key", + "type": "string", + "internalType": "string" + }, + { + "name": "value", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "restartPolicy", + "type": "string", + "internalType": "string" + } + ] } ] } @@ -252,6 +321,62 @@ "name": "encryptedEnv", "type": "bytes", "internalType": "bytes" + }, + { + "name": "containerPolicy", + "type": "tuple", + "internalType": "structIAppController.ContainerPolicy", + "components": [ + { + "name": "args", + "type": "string[]", + "internalType": "string[]" + }, + { + "name": "cmdOverride", + "type": "string[]", + "internalType": "string[]" + }, + { + "name": "env", + "type": "tuple[]", + "internalType": "structIAppController.EnvVar[]", + "components": [ + { + "name": "key", + "type": "string", + "internalType": "string" + }, + { + "name": "value", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "envOverride", + "type": "tuple[]", + "internalType": "structIAppController.EnvVar[]", + "components": [ + { + "name": "key", + "type": "string", + "internalType": "string" + }, + { + "name": "value", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "restartPolicy", + "type": "string", + "internalType": "string" + } + ] } ] } @@ -265,6 +390,44 @@ ], "stateMutability": "nonpayable" }, + { + "type": "function", + "name": "createEmptyApp", + "inputs": [ + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "createEmptyAppWithIsolatedBilling", + "inputs": [ + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "domainSeparator", @@ -299,26 +462,26 @@ }, { "type": "function", - "name": "getBillingType", + "name": "getAppCreator", "inputs": [ { "name": "app", "type": "address", - "internalType": "address" + "internalType": "contractIApp" } ], "outputs": [ { "name": "", - "type": "uint8", - "internalType": "uint8" + "type": "address", + "internalType": "address" } ], "stateMutability": "view" }, { "type": "function", - "name": "getAppCreator", + "name": "getAppLatestReleaseBlockNumber", "inputs": [ { "name": "app", @@ -329,15 +492,15 @@ "outputs": [ { "name": "", - "type": "address", - "internalType": "address" + "type": "uint32", + "internalType": "uint32" } ], "stateMutability": "view" }, { "type": "function", - "name": "getAppLatestReleaseBlockNumber", + "name": "getAppOperatorSetId", "inputs": [ { "name": "app", @@ -356,7 +519,7 @@ }, { "type": "function", - "name": "getAppOperatorSetId", + "name": "getAppPendingReleaseBlockNumber", "inputs": [ { "name": "app", @@ -433,6 +596,11 @@ "type": "uint32", "internalType": "uint32" }, + { + "name": "pendingReleaseBlockNumber", + "type": "uint32", + "internalType": "uint32" + }, { "name": "status", "type": "uint8", @@ -489,6 +657,11 @@ "type": "uint32", "internalType": "uint32" }, + { + "name": "pendingReleaseBlockNumber", + "type": "uint32", + "internalType": "uint32" + }, { "name": "status", "type": "uint8", @@ -545,6 +718,11 @@ "type": "uint32", "internalType": "uint32" }, + { + "name": "pendingReleaseBlockNumber", + "type": "uint32", + "internalType": "uint32" + }, { "name": "status", "type": "uint8", @@ -601,6 +779,11 @@ "type": "uint32", "internalType": "uint32" }, + { + "name": "pendingReleaseBlockNumber", + "type": "uint32", + "internalType": "uint32" + }, { "name": "status", "type": "uint8", @@ -611,6 +794,44 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "getBillingAccount", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getBillingType", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "outputs": [ + { + "name": "", + "type": "uint8", + "internalType": "enumIAppController.BillingType" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "getMaxActiveAppsPerUser", @@ -866,6 +1087,62 @@ "name": "encryptedEnv", "type": "bytes", "internalType": "bytes" + }, + { + "name": "containerPolicy", + "type": "tuple", + "internalType": "structIAppController.ContainerPolicy", + "components": [ + { + "name": "args", + "type": "string[]", + "internalType": "string[]" + }, + { + "name": "cmdOverride", + "type": "string[]", + "internalType": "string[]" + }, + { + "name": "env", + "type": "tuple[]", + "internalType": "structIAppController.EnvVar[]", + "components": [ + { + "name": "key", + "type": "string", + "internalType": "string" + }, + { + "name": "value", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "envOverride", + "type": "tuple[]", + "internalType": "structIAppController.EnvVar[]", + "components": [ + { + "name": "key", + "type": "string", + "internalType": "string" + }, + { + "name": "value", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "restartPolicy", + "type": "string", + "internalType": "string" + } + ] } ] } @@ -1061,6 +1338,62 @@ "name": "encryptedEnv", "type": "bytes", "internalType": "bytes" + }, + { + "name": "containerPolicy", + "type": "tuple", + "internalType": "structIAppController.ContainerPolicy", + "components": [ + { + "name": "args", + "type": "string[]", + "internalType": "string[]" + }, + { + "name": "cmdOverride", + "type": "string[]", + "internalType": "string[]" + }, + { + "name": "env", + "type": "tuple[]", + "internalType": "structIAppController.EnvVar[]", + "components": [ + { + "name": "key", + "type": "string", + "internalType": "string" + }, + { + "name": "value", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "envOverride", + "type": "tuple[]", + "internalType": "structIAppController.EnvVar[]", + "components": [ + { + "name": "key", + "type": "string", + "internalType": "string" + }, + { + "name": "value", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "restartPolicy", + "type": "string", + "internalType": "string" + } + ] } ] } @@ -1112,6 +1445,25 @@ ], "anonymous": false }, + { + "type": "event", + "name": "UpgradeConfirmed", + "inputs": [ + { + "name": "app", + "type": "address", + "indexed": true, + "internalType": "contractIApp" + }, + { + "name": "pendingReleaseBlockNumber", + "type": "uint32", + "indexed": false, + "internalType": "uint32" + } + ], + "anonymous": false + }, { "type": "error", "name": "AccountHasActiveApps", @@ -1167,6 +1519,11 @@ "name": "MoreThanOneArtifact", "inputs": [] }, + { + "type": "error", + "name": "NoPendingUpgrade", + "inputs": [] + }, { "type": "error", "name": "SignatureExpired", diff --git a/packages/sdk/src/client/common/abis/AppController.v1_4.json b/packages/sdk/src/client/common/abis/AppController.v1_4.json new file mode 100644 index 00000000..4608a2ed --- /dev/null +++ b/packages/sdk/src/client/common/abis/AppController.v1_4.json @@ -0,0 +1,1186 @@ +[ + { + "type": "constructor", + "inputs": [ + { + "name": "_version", + "type": "string", + "internalType": "string" + }, + { + "name": "_permissionController", + "type": "address", + "internalType": "contractIPermissionController" + }, + { + "name": "_releaseManager", + "type": "address", + "internalType": "contractIReleaseManager" + }, + { + "name": "_computeAVSRegistrar", + "type": "address", + "internalType": "contractIComputeAVSRegistrar" + }, + { + "name": "_computeOperator", + "type": "address", + "internalType": "contractIComputeOperator" + }, + { + "name": "_appBeacon", + "type": "address", + "internalType": "contractIBeacon" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "API_PERMISSION_TYPEHASH", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "appBeacon", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contractIBeacon" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "calculateApiPermissionDigestHash", + "inputs": [ + { + "name": "permission", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "expiry", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "calculateAppId", + "inputs": [ + { + "name": "deployer", + "type": "address", + "internalType": "address" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contractIApp" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "computeAVSRegistrar", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contractIComputeAVSRegistrar" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "computeOperator", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contractIComputeOperator" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "createApp", + "inputs": [ + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "release", + "type": "tuple", + "internalType": "structIAppController.Release", + "components": [ + { + "name": "rmsRelease", + "type": "tuple", + "internalType": "structIReleaseManagerTypes.Release", + "components": [ + { + "name": "artifacts", + "type": "tuple[]", + "internalType": "structIReleaseManagerTypes.Artifact[]", + "components": [ + { + "name": "digest", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "registry", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "upgradeByTime", + "type": "uint32", + "internalType": "uint32" + } + ] + }, + { + "name": "publicEnv", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "encryptedEnv", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "outputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "createAppWithIsolatedBilling", + "inputs": [ + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "release", + "type": "tuple", + "internalType": "structIAppController.Release", + "components": [ + { + "name": "rmsRelease", + "type": "tuple", + "internalType": "structIReleaseManagerTypes.Release", + "components": [ + { + "name": "artifacts", + "type": "tuple[]", + "internalType": "structIReleaseManagerTypes.Artifact[]", + "components": [ + { + "name": "digest", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "registry", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "upgradeByTime", + "type": "uint32", + "internalType": "uint32" + } + ] + }, + { + "name": "publicEnv", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "encryptedEnv", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "outputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "domainSeparator", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getActiveAppCount", + "inputs": [ + { + "name": "user", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint32", + "internalType": "uint32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getBillingType", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint8", + "internalType": "uint8" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getAppCreator", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getAppLatestReleaseBlockNumber", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "outputs": [ + { + "name": "", + "type": "uint32", + "internalType": "uint32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getAppOperatorSetId", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "outputs": [ + { + "name": "", + "type": "uint32", + "internalType": "uint32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getAppStatus", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "outputs": [ + { + "name": "", + "type": "uint8", + "internalType": "enumIAppController.AppStatus" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getApps", + "inputs": [ + { + "name": "offset", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "limit", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "apps", + "type": "address[]", + "internalType": "contractIApp[]" + }, + { + "name": "appConfigsMem", + "type": "tuple[]", + "internalType": "structIAppController.AppConfig[]", + "components": [ + { + "name": "creator", + "type": "address", + "internalType": "address" + }, + { + "name": "operatorSetId", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "latestReleaseBlockNumber", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "status", + "type": "uint8", + "internalType": "enumIAppController.AppStatus" + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getAppsByBillingAccount", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "offset", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "limit", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "apps", + "type": "address[]", + "internalType": "contractIApp[]" + }, + { + "name": "appConfigsMem", + "type": "tuple[]", + "internalType": "structIAppController.AppConfig[]", + "components": [ + { + "name": "creator", + "type": "address", + "internalType": "address" + }, + { + "name": "operatorSetId", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "latestReleaseBlockNumber", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "status", + "type": "uint8", + "internalType": "enumIAppController.AppStatus" + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getAppsByCreator", + "inputs": [ + { + "name": "creator", + "type": "address", + "internalType": "address" + }, + { + "name": "offset", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "limit", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "apps", + "type": "address[]", + "internalType": "contractIApp[]" + }, + { + "name": "appConfigsMem", + "type": "tuple[]", + "internalType": "structIAppController.AppConfig[]", + "components": [ + { + "name": "creator", + "type": "address", + "internalType": "address" + }, + { + "name": "operatorSetId", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "latestReleaseBlockNumber", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "status", + "type": "uint8", + "internalType": "enumIAppController.AppStatus" + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getAppsByDeveloper", + "inputs": [ + { + "name": "developer", + "type": "address", + "internalType": "address" + }, + { + "name": "offset", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "limit", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "apps", + "type": "address[]", + "internalType": "contractIApp[]" + }, + { + "name": "appConfigsMem", + "type": "tuple[]", + "internalType": "structIAppController.AppConfig[]", + "components": [ + { + "name": "creator", + "type": "address", + "internalType": "address" + }, + { + "name": "operatorSetId", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "latestReleaseBlockNumber", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "status", + "type": "uint8", + "internalType": "enumIAppController.AppStatus" + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getMaxActiveAppsPerUser", + "inputs": [ + { + "name": "user", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint32", + "internalType": "uint32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "globalActiveAppCount", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint32", + "internalType": "uint32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "admin", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "maxGlobalActiveApps", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint32", + "internalType": "uint32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "permissionController", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contractIPermissionController" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "releaseManager", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contractIReleaseManager" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "setMaxActiveAppsPerUser", + "inputs": [ + { + "name": "user", + "type": "address", + "internalType": "address" + }, + { + "name": "limit", + "type": "uint32", + "internalType": "uint32" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setMaxGlobalActiveApps", + "inputs": [ + { + "name": "limit", + "type": "uint32", + "internalType": "uint32" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "startApp", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "stopApp", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "suspend", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "apps", + "type": "address[]", + "internalType": "contractIApp[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "terminateApp", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "terminateAppByAdmin", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "updateAppMetadataURI", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + }, + { + "name": "metadataURI", + "type": "string", + "internalType": "string" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "upgradeApp", + "inputs": [ + { + "name": "app", + "type": "address", + "internalType": "contractIApp" + }, + { + "name": "release", + "type": "tuple", + "internalType": "structIAppController.Release", + "components": [ + { + "name": "rmsRelease", + "type": "tuple", + "internalType": "structIReleaseManagerTypes.Release", + "components": [ + { + "name": "artifacts", + "type": "tuple[]", + "internalType": "structIReleaseManagerTypes.Artifact[]", + "components": [ + { + "name": "digest", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "registry", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "upgradeByTime", + "type": "uint32", + "internalType": "uint32" + } + ] + }, + { + "name": "publicEnv", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "encryptedEnv", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "version", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "AppCreated", + "inputs": [ + { + "name": "creator", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "app", + "type": "address", + "indexed": true, + "internalType": "contractIApp" + }, + { + "name": "operatorSetId", + "type": "uint32", + "indexed": false, + "internalType": "uint32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "AppMetadataURIUpdated", + "inputs": [ + { + "name": "app", + "type": "address", + "indexed": true, + "internalType": "contractIApp" + }, + { + "name": "metadataURI", + "type": "string", + "indexed": false, + "internalType": "string" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "AppStarted", + "inputs": [ + { + "name": "app", + "type": "address", + "indexed": true, + "internalType": "contractIApp" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "AppStopped", + "inputs": [ + { + "name": "app", + "type": "address", + "indexed": true, + "internalType": "contractIApp" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "AppSuspended", + "inputs": [ + { + "name": "app", + "type": "address", + "indexed": true, + "internalType": "contractIApp" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "AppTerminated", + "inputs": [ + { + "name": "app", + "type": "address", + "indexed": true, + "internalType": "contractIApp" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "AppTerminatedByAdmin", + "inputs": [ + { + "name": "app", + "type": "address", + "indexed": true, + "internalType": "contractIApp" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "AppUpgraded", + "inputs": [ + { + "name": "app", + "type": "address", + "indexed": true, + "internalType": "contractIApp" + }, + { + "name": "rmsReleaseId", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "release", + "type": "tuple", + "indexed": false, + "internalType": "structIAppController.Release", + "components": [ + { + "name": "rmsRelease", + "type": "tuple", + "internalType": "structIReleaseManagerTypes.Release", + "components": [ + { + "name": "artifacts", + "type": "tuple[]", + "internalType": "structIReleaseManagerTypes.Artifact[]", + "components": [ + { + "name": "digest", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "registry", + "type": "string", + "internalType": "string" + } + ] + }, + { + "name": "upgradeByTime", + "type": "uint32", + "internalType": "uint32" + } + ] + }, + { + "name": "publicEnv", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "encryptedEnv", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "GlobalMaxActiveAppsSet", + "inputs": [ + { + "name": "limit", + "type": "uint32", + "indexed": false, + "internalType": "uint32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint8", + "indexed": false, + "internalType": "uint8" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "MaxActiveAppsSet", + "inputs": [ + { + "name": "user", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "limit", + "type": "uint32", + "indexed": false, + "internalType": "uint32" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "AccountHasActiveApps", + "inputs": [] + }, + { + "type": "error", + "name": "AppAlreadyExists", + "inputs": [] + }, + { + "type": "error", + "name": "AppDoesNotExist", + "inputs": [] + }, + { + "type": "error", + "name": "GlobalMaxActiveAppsExceeded", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidAppStatus", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidPermissions", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidReleaseMetadataURI", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidShortString", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidSignature", + "inputs": [] + }, + { + "type": "error", + "name": "MaxActiveAppsExceeded", + "inputs": [] + }, + { + "type": "error", + "name": "MoreThanOneArtifact", + "inputs": [] + }, + { + "type": "error", + "name": "SignatureExpired", + "inputs": [] + }, + { + "type": "error", + "name": "StringTooLong", + "inputs": [ + { + "name": "str", + "type": "string", + "internalType": "string" + } + ] + } +] diff --git a/packages/sdk/src/client/common/config/environment.ts b/packages/sdk/src/client/common/config/environment.ts index db092f6f..a1009e63 100644 --- a/packages/sdk/src/client/common/config/environment.ts +++ b/packages/sdk/src/client/common/config/environment.ts @@ -2,12 +2,12 @@ * Environment configuration for different networks */ -import { Address } from "viem"; import { BillingEnvironmentConfig, EnvironmentConfig } from "../types"; // Chain IDs export const SEPOLIA_CHAIN_ID = 11155111; export const MAINNET_CHAIN_ID = 1; +export const BASE_SEPOLIA_CHAIN_ID = 84532; // Common addresses across all chains export const CommonAddresses: Record = { @@ -40,17 +40,21 @@ const ENVIRONMENTS: Record> = { name: "sepolia", build: "dev", appControllerAddress: "0xa86DC1C47cb2518327fB4f9A1627F51966c83B92", + releaseAbiVersion: "v1.5", // AppController upgraded to v1.5.x (containerPolicy) permissionControllerAddress: ChainAddresses[SEPOLIA_CHAIN_ID].PermissionController, erc7702DelegatorAddress: CommonAddresses.ERC7702Delegator, kmsServerURL: "http://10.128.0.57:8080", userApiServerURL: "https://userapi-compute-sepolia-dev.eigencloud.xyz", defaultRPCURL: "https://ethereum-sepolia-rpc.publicnode.com", usdcCreditsAddress: "0xbdA3897c3A428763B59015C64AB766c288C97376", + baseUsdcCreditsAddress: "0x7673a47463F80c6a3553Db9E54c8cDcd5313d0ac", + baseRPCURL: "https://base-sepolia-rpc.publicnode.com", }, sepolia: { name: "sepolia", build: "prod", appControllerAddress: "0x0dd810a6ffba6a9820a10d97b659f07d8d23d4E2", + releaseAbiVersion: "v1.4", // prod still on AppController v1.4.0 (3-field Release) permissionControllerAddress: ChainAddresses[SEPOLIA_CHAIN_ID].PermissionController, erc7702DelegatorAddress: CommonAddresses.ERC7702Delegator, kmsServerURL: "http://10.128.15.203:8080", @@ -58,11 +62,14 @@ const ENVIRONMENTS: Record> = { defaultRPCURL: "https://ethereum-sepolia-rpc.publicnode.com", billingRPCURL: "https://ethereum-rpc.publicnode.com", usdcCreditsAddress: "0xed9c88640ca9149Bd9f7ee6620074af10F2E145d", + baseUsdcCreditsAddress: "0x7673a47463F80c6a3553Db9E54c8cDcd5313d0ac", + baseRPCURL: "https://base-sepolia-rpc.publicnode.com", }, "mainnet-alpha": { name: "mainnet-alpha", build: "prod", appControllerAddress: "0xc38d35Fc995e75342A21CBd6D770305b142Fbe67", + releaseAbiVersion: "v1.4", // prod still on AppController v1.4.0 (3-field Release) permissionControllerAddress: ChainAddresses[MAINNET_CHAIN_ID].PermissionController, erc7702DelegatorAddress: CommonAddresses.ERC7702Delegator, kmsServerURL: "http://10.128.0.2:8080", @@ -135,6 +142,12 @@ export function getEnvironmentConfig(environment: string, chainID?: bigint): Env ...env, chainID: BigInt(resolvedChainID), ...(apiUrlOverride ? { userApiServerURL: apiUrlOverride } : {}), + ...(process.env.ECLOUD_USER_API_URL && { + userApiServerURL: process.env.ECLOUD_USER_API_URL, + }), + ...(process.env.ECLOUD_RPC_URL && { + defaultRPCURL: process.env.ECLOUD_RPC_URL, + }), }; } @@ -153,7 +166,12 @@ export function getBillingEnvironmentConfig(build: "dev" | "prod"): { if (apiUrlOverride) { return { billingApiServerURL: apiUrlOverride }; } - return config; + return { + ...config, + ...(process.env.ECLOUD_BILLING_API_URL && { + billingApiServerURL: process.env.ECLOUD_BILLING_API_URL, + }), + }; } /** diff --git a/packages/sdk/src/client/common/constants.ts b/packages/sdk/src/client/common/constants.ts index b4528512..291aed6b 100644 --- a/packages/sdk/src/client/common/constants.ts +++ b/packages/sdk/src/client/common/constants.ts @@ -2,9 +2,9 @@ * Constants used throughout the SDK */ -import { sepolia, mainnet } from "viem/chains"; +import { sepolia, mainnet, baseSepolia } from "viem/chains"; -export const SUPPORTED_CHAINS = [mainnet, sepolia] as const; +export const SUPPORTED_CHAINS = [mainnet, sepolia, baseSepolia] as const; export const DOCKER_PLATFORM = "linux/amd64"; export const REGISTRY_PROPAGATION_WAIT_SECONDS = 3; diff --git a/packages/sdk/src/client/common/contract/caller.ts b/packages/sdk/src/client/common/contract/caller.ts index 0999f9f0..8520f701 100644 --- a/packages/sdk/src/client/common/contract/caller.ts +++ b/packages/sdk/src/client/common/contract/caller.ts @@ -31,12 +31,71 @@ import { DeployProgressCallback, SequentialDeployResult, } from "../types"; -import { Release } from "../types"; +import { Release, ContainerPolicy, EMPTY_CONTAINER_POLICY } from "../types"; import { getChainFromID } from "../utils/helpers"; -import AppControllerABI from "../abis/AppController.json"; +// The on-chain AppController `Release` struct differs by contract version: +// v1.4.x (sepolia, mainnet-alpha): 3-field Release +// v1.5.x (sepolia-dev): 4-field Release (+ containerPolicy, KMS-006) +// The two ABIs also diverge on createApp/upgradeApp selectors and getApps +// AppConfig shape, so we select the whole ABI by environment version. +import AppControllerABIv1_5 from "../abis/AppController.json"; +import AppControllerABIv1_4 from "../abis/AppController.v1_4.json"; import PermissionControllerABI from "../abis/PermissionController.json"; +/** + * Select the AppController ABI matching the environment's deployed contract + * version. Defaults to the latest (v1.5) when a config omits the field, so + * new environments opt into older behavior explicitly. + */ +function appControllerAbiFor(environmentConfig: EnvironmentConfig) { + return environmentConfig.releaseAbiVersion === "v1.4" + ? AppControllerABIv1_4 + : AppControllerABIv1_5; +} + +/** Whether this environment's Release struct carries the v1.5+ containerPolicy field. */ +function supportsContainerPolicy(environmentConfig: EnvironmentConfig): boolean { + return environmentConfig.releaseAbiVersion !== "v1.4"; +} + +/** + * Build the viem-encodable `containerPolicy` tuple for the AppController + * `Release` struct (v1.5.0+). Falls back to an empty policy when the caller + * does not supply one, which preserves the image's own entrypoint/env. + */ +function containerPolicyForViem(policy: ContainerPolicy = EMPTY_CONTAINER_POLICY) { + return { + args: policy.args, + cmdOverride: policy.cmdOverride, + env: policy.env.map((e) => ({ key: e.key, value: e.value })), + envOverride: policy.envOverride.map((e) => ({ key: e.key, value: e.value })), + restartPolicy: policy.restartPolicy, + }; +} + +/** + * Build the viem-encodable `release` tuple for createApp/upgradeApp, including + * the v1.5+ `containerPolicy` field only when the target contract expects it. + */ +function releaseForViem(release: Release, environmentConfig: EnvironmentConfig) { + const base = { + rmsRelease: { + artifacts: release.rmsRelease.artifacts.map((artifact) => ({ + digest: `0x${bytesToHex(artifact.digest).slice(2).padStart(64, "0")}` as Hex, + registry: artifact.registry, + })), + upgradeByTime: release.rmsRelease.upgradeByTime, + }, + publicEnv: bytesToHex(release.publicEnv) as Hex, + encryptedEnv: bytesToHex(release.encryptedEnv) as Hex, + }; + if (!supportsContainerPolicy(environmentConfig)) { + return base; + } + return { ...base, containerPolicy: containerPolicyForViem(release.containerPolicy) }; +} + /** * Gas estimation result */ @@ -187,7 +246,7 @@ export async function calculateAppID(options: CalculateAppIDOptions): Promise ({ - digest: `0x${bytesToHex(artifact.digest).slice(2).padStart(64, "0")}` as Hex, - registry: artifact.registry, - })), - upgradeByTime: release.rmsRelease.upgradeByTime, - }, - publicEnv: bytesToHex(release.publicEnv) as Hex, - encryptedEnv: bytesToHex(release.encryptedEnv) as Hex, - }; + // Convert Release Uint8Array values to hex strings for viem (version-aware: + // includes containerPolicy only on v1.5+ contracts). + const release_ = releaseForViem(release, environmentConfig); const functionName = options.billTo === "app" ? "createAppWithIsolatedBilling" : "createApp"; const createData = encodeFunctionData({ - abi: AppControllerABI, + abi: appControllerAbiFor(environmentConfig), functionName, - args: [saltHex, releaseForViem], + args: [saltHex, release_], }); // 3. Pack accept admin call @@ -738,24 +788,13 @@ export async function prepareUpgradeBatch( needsPermissionChange, } = options; - // 1. Pack upgrade app call - // Convert Release Uint8Array values to hex strings for viem - const releaseForViem = { - rmsRelease: { - artifacts: release.rmsRelease.artifacts.map((artifact) => ({ - digest: `0x${bytesToHex(artifact.digest).slice(2).padStart(64, "0")}` as Hex, - registry: artifact.registry, - })), - upgradeByTime: release.rmsRelease.upgradeByTime, - }, - publicEnv: bytesToHex(release.publicEnv) as Hex, - encryptedEnv: bytesToHex(release.encryptedEnv) as Hex, - }; + // 1. Pack upgrade app call (version-aware Release encoding). + const release_ = releaseForViem(release, environmentConfig); const upgradeData = encodeFunctionData({ - abi: AppControllerABI, + abi: appControllerAbiFor(environmentConfig), functionName: "upgradeApp", - args: [appID, releaseForViem], + args: [appID, release_], }); // 2. Start with upgrade execution @@ -947,7 +986,7 @@ export async function sendAndWaitForTransaction( if (callError.data) { try { const decoded = decodeErrorResult({ - abi: AppControllerABI, + abi: appControllerAbiFor(environmentConfig), data: callError.data, }); const formattedError = formatAppControllerError(decoded); @@ -1017,7 +1056,7 @@ export async function getActiveAppCount( ): Promise { const count = await publicClient.readContract({ address: environmentConfig.appControllerAddress, - abi: AppControllerABI, + abi: appControllerAbiFor(environmentConfig), functionName: "getActiveAppCount", args: [user], }); @@ -1035,7 +1074,7 @@ export async function getMaxActiveAppsPerUser( ): Promise { const quota = await publicClient.readContract({ address: environmentConfig.appControllerAddress, - abi: AppControllerABI, + abi: appControllerAbiFor(environmentConfig), functionName: "getMaxActiveAppsPerUser", args: [user], }); @@ -1060,7 +1099,7 @@ export async function getAppsByCreator( ): Promise<{ apps: Address[]; appConfigs: AppConfig[] }> { const result = (await publicClient.readContract({ address: environmentConfig.appControllerAddress, - abi: AppControllerABI, + abi: appControllerAbiFor(environmentConfig), functionName: "getAppsByCreator", args: [creator, offset, limit], })) as [Address[], AppConfig[]]; @@ -1084,7 +1123,7 @@ export async function getAppsByDeveloper( ): Promise<{ apps: Address[]; appConfigs: AppConfig[] }> { const result = (await publicClient.readContract({ address: environmentConfig.appControllerAddress, - abi: AppControllerABI, + abi: appControllerAbiFor(environmentConfig), functionName: "getAppsByDeveloper", args: [developer, offset, limit], })) as [Address[], AppConfig[]]; @@ -1106,7 +1145,7 @@ export async function getBillingType( ): Promise { const result = await publicClient.readContract({ address: environmentConfig.appControllerAddress, - abi: AppControllerABI, + abi: appControllerAbiFor(environmentConfig), functionName: "getBillingType", args: [app], }); @@ -1125,7 +1164,7 @@ export async function getAppsByBillingAccount( ): Promise<{ apps: Address[]; appConfigs: AppConfig[] }> { const result = (await publicClient.readContract({ address: environmentConfig.appControllerAddress, - abi: AppControllerABI, + abi: appControllerAbiFor(environmentConfig), functionName: "getAppsByBillingAccount", args: [account, offset, limit], })) as [Address[], AppConfig[]]; @@ -1184,7 +1223,7 @@ export async function getAppLatestReleaseBlockNumbers( publicClient .readContract({ address: environmentConfig.appControllerAddress, - abi: AppControllerABI, + abi: appControllerAbiFor(environmentConfig), functionName: "getAppLatestReleaseBlockNumber", args: [appID], }) @@ -1253,7 +1292,7 @@ export async function suspend( const { walletClient, publicClient, environmentConfig, account, apps } = options; const suspendData = encodeFunctionData({ - abi: AppControllerABI, + abi: appControllerAbiFor(environmentConfig), functionName: "suspend", args: [account, apps], }); diff --git a/packages/sdk/src/client/common/contract/env-abi-selection.test.ts b/packages/sdk/src/client/common/contract/env-abi-selection.test.ts new file mode 100644 index 00000000..1621d06f --- /dev/null +++ b/packages/sdk/src/client/common/contract/env-abi-selection.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { encodeFunctionData, type Hex } from "viem"; +import AppControllerABIv1_5 from "../abis/AppController.json"; +import AppControllerABIv1_4 from "../abis/AppController.v1_4.json"; +import { getEnvironmentConfig } from "../config/environment"; +import { EMPTY_CONTAINER_POLICY } from "../types"; + +// Mirror caller.ts selection logic to assert config wires the right ABI per env. +function abiFor(v?: string) { + return v === "v1.4" ? AppControllerABIv1_4 : AppControllerABIv1_5; +} + +describe("env -> AppController ABI selection", () => { + const rms = { + artifacts: [{ digest: `0x${"11".repeat(32)}` as Hex, registry: "r" }], + upgradeByTime: 4_000_000_000, + }; + const origBuild = process.env.BUILD_TYPE; + const cases: Array<[string, string, string]> = [["sepolia-dev", "dev", "0x5e92a19f"]]; + for (const [env, build, sel] of cases) { + it(`${env} selects selector ${sel}`, () => { + process.env.BUILD_TYPE = build; + const cfg = getEnvironmentConfig(env); + const abi = abiFor(cfg.releaseAbiVersion); + const rel = + cfg.releaseAbiVersion === "v1.4" + ? { rmsRelease: rms, publicEnv: "0x" as Hex, encryptedEnv: "0x" as Hex } + : { + rmsRelease: rms, + publicEnv: "0x" as Hex, + encryptedEnv: "0x" as Hex, + containerPolicy: EMPTY_CONTAINER_POLICY, + }; + const data = encodeFunctionData({ + abi, + functionName: "createApp", + args: [`0x${"22".repeat(32)}` as Hex, rel], + }); + expect(data.slice(0, 10)).toBe(sel); + if (origBuild === undefined) delete process.env.BUILD_TYPE; + else process.env.BUILD_TYPE = origBuild; + }); + } + it("prod envs are pinned to v1.4", () => { + process.env.BUILD_TYPE = "prod"; + expect(getEnvironmentConfig("sepolia").releaseAbiVersion).toBe("v1.4"); + expect(getEnvironmentConfig("mainnet-alpha").releaseAbiVersion).toBe("v1.4"); + if (origBuild === undefined) delete process.env.BUILD_TYPE; + else process.env.BUILD_TYPE = origBuild; + }); +}); diff --git a/packages/sdk/src/client/common/contract/release-encoding.test.ts b/packages/sdk/src/client/common/contract/release-encoding.test.ts new file mode 100644 index 00000000..ce520d6f --- /dev/null +++ b/packages/sdk/src/client/common/contract/release-encoding.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { decodeFunctionData, encodeFunctionData, type Hex } from "viem"; +import AppControllerABIv1_5 from "../abis/AppController.json"; +import AppControllerABIv1_4 from "../abis/AppController.v1_4.json"; +import { EMPTY_CONTAINER_POLICY, type ContainerPolicy } from "../types"; + +/** + * Regression guard for the per-environment AppController `Release` ABI. + * + * v1.5.x (sepolia-dev) added a 4th field `containerPolicy` to the on-chain + * `Release` struct (eigenx-contracts KMS-006), changing the `createApp` + * selector from 0xa60daa8f to 0x5e92a19f. v1.4.x (sepolia, mainnet-alpha) is + * still on the 3-field struct. The SDK ships both ABIs and selects per + * environment, so both shapes must keep encoding to their respective selectors. + */ +describe("AppController Release encoding (per-version)", () => { + const SALT = `0x${"22".repeat(32)}` as Hex; + const APP = `0x${"33".repeat(20)}` as Hex; + const rmsRelease = { + artifacts: [{ digest: `0x${"11".repeat(32)}` as Hex, registry: "docker.io/acme/app" }], + upgradeByTime: 4_000_000_000, + }; + const release3 = { rmsRelease, publicEnv: "0x" as Hex, encryptedEnv: "0x" as Hex }; + const release4 = { ...release3, containerPolicy: EMPTY_CONTAINER_POLICY }; + + describe("v1.5 (4-field, sepolia-dev)", () => { + it("encodes createApp with the v1.5 selector", () => { + const data = encodeFunctionData({ + abi: AppControllerABIv1_5, + functionName: "createApp", + args: [SALT, release4], + }); + expect(data.slice(0, 10)).toBe("0x5e92a19f"); + }); + + it("encodes createAppWithIsolatedBilling and upgradeApp without throwing", () => { + expect(() => + encodeFunctionData({ + abi: AppControllerABIv1_5, + functionName: "createAppWithIsolatedBilling", + args: [SALT, release4], + }), + ).not.toThrow(); + expect(() => + encodeFunctionData({ + abi: AppControllerABIv1_5, + functionName: "upgradeApp", + args: [APP, release4], + }), + ).not.toThrow(); + }); + + it("round-trips the containerPolicy field", () => { + const policy: ContainerPolicy = { + args: ["--flag"], + cmdOverride: ["/bin/run"], + env: [{ key: "FOO", value: "bar" }], + envOverride: [], + restartPolicy: "always", + }; + const data = encodeFunctionData({ + abi: AppControllerABIv1_5, + functionName: "createApp", + args: [SALT, { ...release3, containerPolicy: policy }], + }); + const { args } = decodeFunctionData({ abi: AppControllerABIv1_5, data }); + const release = args![1] as { containerPolicy: ContainerPolicy }; + expect(release.containerPolicy).toEqual(policy); + }); + + it("rejects the 3-field shape (arity guard)", () => { + expect(() => + encodeFunctionData({ + abi: AppControllerABIv1_5, + functionName: "createApp", + args: [SALT, release3], + }), + ).toThrow(); + }); + }); + + describe("v1.4 (3-field, sepolia / mainnet-alpha)", () => { + it("encodes createApp with the legacy selector", () => { + const data = encodeFunctionData({ + abi: AppControllerABIv1_4, + functionName: "createApp", + args: [SALT, release3], + }); + expect(data.slice(0, 10)).toBe("0xa60daa8f"); + }); + + it("encodes createAppWithIsolatedBilling and upgradeApp without throwing", () => { + expect(() => + encodeFunctionData({ + abi: AppControllerABIv1_4, + functionName: "createAppWithIsolatedBilling", + args: [SALT, release3], + }), + ).not.toThrow(); + expect(() => + encodeFunctionData({ + abi: AppControllerABIv1_4, + functionName: "upgradeApp", + args: [APP, release3], + }), + ).not.toThrow(); + }); + }); +}); diff --git a/packages/sdk/src/client/common/contract/watcher.ts b/packages/sdk/src/client/common/contract/watcher.ts index 3482ff00..71cf2c2f 100644 --- a/packages/sdk/src/client/common/contract/watcher.ts +++ b/packages/sdk/src/client/common/contract/watcher.ts @@ -17,13 +17,70 @@ export interface WatchUntilRunningOptions { publicClient: PublicClient; environmentConfig: EnvironmentConfig; appId: Address; + /** + * Maximum seconds to wait before throwing {@link WatchTimeoutError}. + * Precedence: explicit value > `ECLOUD_WATCH_TIMEOUT_SECONDS` env var > 600s default. + */ + timeoutSeconds?: number; } const WATCH_POLL_INTERVAL_SECONDS = 5; +const WATCH_HEARTBEAT_INTERVAL_SECONDS = 30; +export const WATCH_DEFAULT_TIMEOUT_SECONDS = 10 * 60; const APP_STATUS_RUNNING = "Running"; const APP_STATUS_FAILED = "Failed"; // const APP_STATUS_DEPLOYING = 'Deploying'; +/** + * Typed error thrown when watch loops exceed their timeout budget. + * + * Callers (e.g. the CLI) can catch this specifically to surface a + * troubleshooting hint without treating it as a generic failure. + */ +export class WatchTimeoutError extends Error { + public readonly appId: string; + public readonly elapsedSeconds: number; + public readonly lastStatus: string | undefined; + public readonly timeoutSeconds: number; + + constructor(args: { + appId: string; + elapsedSeconds: number; + lastStatus: string | undefined; + timeoutSeconds: number; + message?: string; + }) { + super( + args.message ?? + `Timed out after ${args.elapsedSeconds}s waiting for app ${args.appId} (last status: ${args.lastStatus ?? "unknown"})`, + ); + this.name = "WatchTimeoutError"; + this.appId = args.appId; + this.elapsedSeconds = args.elapsedSeconds; + this.lastStatus = args.lastStatus; + this.timeoutSeconds = args.timeoutSeconds; + } +} + +/** + * Resolve the watch timeout in seconds, honoring the + * ECLOUD_WATCH_TIMEOUT_SECONDS environment override. + */ +function resolveWatchTimeoutSeconds(explicit?: number): number { + if (typeof explicit === "number" && Number.isFinite(explicit) && explicit > 0) { + return Math.floor(explicit); + } + const raw = process.env.ECLOUD_WATCH_TIMEOUT_SECONDS; + if (raw === undefined || raw === "") { + return WATCH_DEFAULT_TIMEOUT_SECONDS; + } + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) { + return WATCH_DEFAULT_TIMEOUT_SECONDS; + } + return Math.floor(parsed); +} + /** * Watch app until it reaches Running status with IP address */ @@ -79,8 +136,23 @@ export async function watchUntilRunning( // Main watch loop const startTime = Date.now(); + const timeoutSeconds = resolveWatchTimeoutSeconds(options.timeoutSeconds); let lastLoggedStatus: string | undefined; + let lastHeartbeatAt = startTime; while (true) { + const elapsedMs = Date.now() - startTime; + const elapsed = Math.round(elapsedMs / 1000); + + // Bound the loop: surface a typed timeout so callers can hint the user. + if (elapsed >= timeoutSeconds) { + throw new WatchTimeoutError({ + appId, + elapsedSeconds: elapsed, + lastStatus: lastLoggedStatus, + timeoutSeconds, + }); + } + try { // Fetch app info const info = await userApiClient.getInfos([appId], 1); @@ -93,11 +165,16 @@ export async function watchUntilRunning( const currentStatus = appInfo.status; const currentIP = appInfo.ip || ""; - // Log status changes and elapsed time - const elapsed = Math.round((Date.now() - startTime) / 1000); + // Log status transitions, plus a periodic heartbeat so non-TTY + // stdout (where carriage-return updates are invisible) still shows + // progress when the status string is unchanged for a long time. if (currentStatus !== lastLoggedStatus) { logger.info(`Status: ${currentStatus} (${elapsed}s)`); lastLoggedStatus = currentStatus; + lastHeartbeatAt = Date.now(); + } else if (Date.now() - lastHeartbeatAt >= WATCH_HEARTBEAT_INTERVAL_SECONDS * 1000) { + logger.info(`Status: ${currentStatus} (${elapsed}s)`); + lastHeartbeatAt = Date.now(); } // Check stop condition @@ -108,6 +185,10 @@ export async function watchUntilRunning( // Wait before next poll await sleep(WATCH_POLL_INTERVAL_SECONDS * 1000); } catch (error: any) { + // Re-throw typed terminal errors so the caller can react to them. + if (error instanceof WatchTimeoutError) { + throw error; + } logger.warn(`Failed to fetch app info: ${error.message}`); await sleep(WATCH_POLL_INTERVAL_SECONDS * 1000); } @@ -122,20 +203,34 @@ export interface WatchUntilUpgradeCompleteOptions { publicClient: PublicClient; environmentConfig: EnvironmentConfig; appId: Address; + /** + * Maximum time (in seconds) to wait for the upgrade to complete before + * throwing a {@link WatchTimeoutError}. If unspecified, defaults to + * the value of the `ECLOUD_WATCH_TIMEOUT_SECONDS` env var, falling back to + * {@link WATCH_DEFAULT_TIMEOUT_SECONDS}. + */ + timeoutSeconds?: number; } const APP_STATUS_STOPPED = "Stopped"; +// WATCH_DEFAULT_TIMEOUT_SECONDS, WatchTimeoutError, and resolveWatchTimeoutSeconds +// are shared with watchUntilRunning and declared once near the top of this file. + /** * Watch app until upgrade completes * For upgrades, we watch until the app reaches Stopped status (upgrade complete) - * or Running status (if it was running before upgrade) + * or Running status (if it was running before upgrade). + * + * Throws {@link WatchTimeoutError} if the timeout elapses before a + * terminal status is observed. */ export async function watchUntilUpgradeComplete( options: WatchUntilUpgradeCompleteOptions, logger: Logger, ): Promise { const { walletClient, publicClient, environmentConfig, appId } = options; + const timeoutSeconds = resolveWatchTimeoutSeconds(options.timeoutSeconds); // Create UserAPI client const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient); @@ -193,7 +288,21 @@ export async function watchUntilUpgradeComplete( }; // Main watch loop + const startTime = Date.now(); + const deadline = startTime + timeoutSeconds * 1000; + let lastLoggedStatus: string | undefined; + let lastObservedStatus: string | undefined; while (true) { + if (Date.now() >= deadline) { + const elapsedSeconds = Math.round((Date.now() - startTime) / 1000); + throw new WatchTimeoutError({ + appId, + lastStatus: lastObservedStatus, + elapsedSeconds, + timeoutSeconds, + }); + } + try { // Fetch app info const info = await userApiClient.getInfos([appId], 1); @@ -205,6 +314,14 @@ export async function watchUntilUpgradeComplete( const appInfo = info[0]; const currentStatus = appInfo.status; const currentIP = appInfo.ip || ""; + lastObservedStatus = currentStatus; + + // Log status changes and elapsed time on a new line per transition + const elapsed = Math.round((Date.now() - startTime) / 1000); + if (currentStatus !== lastLoggedStatus) { + logger.info(`Status: ${currentStatus} (${elapsed}s)`); + lastLoggedStatus = currentStatus; + } // Check stop condition if (stopCondition(currentStatus, currentIP)) { @@ -214,6 +331,14 @@ export async function watchUntilUpgradeComplete( // Wait before next poll await sleep(WATCH_POLL_INTERVAL_SECONDS * 1000); } catch (error: any) { + // Re-throw timeout errors and terminal failures from stopCondition. + if (error instanceof WatchTimeoutError) { + throw error; + } + // Heuristic: stopCondition throws plain Error for "Failed" state — let it propagate. + if (typeof error?.message === "string" && error.message.includes("Failed")) { + throw error; + } logger.warn(`Failed to fetch app info: ${error.message}`); await sleep(WATCH_POLL_INTERVAL_SECONDS * 1000); } diff --git a/packages/sdk/src/client/common/gas/__tests__/insufficientGas.test.ts b/packages/sdk/src/client/common/gas/__tests__/insufficientGas.test.ts new file mode 100644 index 00000000..a77468dc --- /dev/null +++ b/packages/sdk/src/client/common/gas/__tests__/insufficientGas.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import type { Address, PublicClient } from "viem"; +import { assertSufficientGas, InsufficientGasError } from "../insufficientGas"; +import type { GasEstimate } from "../../contract/caller"; + +const ADDR = "0x540d6701c396f77c3601FC34585107497ED71495" as Address; + +function gasEstimate(maxCostWei: bigint): GasEstimate { + return { + gasLimit: BigInt(21000), + maxFeePerGas: BigInt(1), + maxPriorityFeePerGas: BigInt(1), + maxCostWei, + maxCostEth: "0", + }; +} + +function clientWithBalance(balanceWei: bigint): PublicClient { + return { getBalance: async () => balanceWei } as unknown as PublicClient; +} + +describe("assertSufficientGas", () => { + it("passes when balance exceeds the estimate", async () => { + await expect( + assertSufficientGas({ + publicClient: clientWithBalance(BigInt(10)), + address: ADDR, + gasEstimate: gasEstimate(BigInt(5)), + }), + ).resolves.toBeUndefined(); + }); + + it("passes when balance exactly equals the estimate", async () => { + await expect( + assertSufficientGas({ + publicClient: clientWithBalance(BigInt(5)), + address: ADDR, + gasEstimate: gasEstimate(BigInt(5)), + }), + ).resolves.toBeUndefined(); + }); + + it("throws InsufficientGasError on dust below the estimate (not just zero)", async () => { + await expect( + assertSufficientGas({ + publicClient: clientWithBalance(BigInt(4)), + address: ADDR, + gasEstimate: gasEstimate(BigInt(5)), + }), + ).rejects.toBeInstanceOf(InsufficientGasError); + }); + + it("error carries required/available and a credits-don't-pay-gas message", async () => { + try { + await assertSufficientGas({ + publicClient: clientWithBalance(BigInt(0)), + address: ADDR, + gasEstimate: gasEstimate(BigInt(7)), + }); + throw new Error("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(InsufficientGasError); + const e = err as InsufficientGasError; + expect(e.requiredWei).toBe(BigInt(7)); + expect(e.availableWei).toBe(BigInt(0)); + expect(e.address).toBe(ADDR); + expect(e.message).toMatch(/credits do not pay on-chain gas/i); + } + }); +}); diff --git a/packages/sdk/src/client/common/gas/insufficientGas.ts b/packages/sdk/src/client/common/gas/insufficientGas.ts new file mode 100644 index 00000000..b43d4932 --- /dev/null +++ b/packages/sdk/src/client/common/gas/insufficientGas.ts @@ -0,0 +1,56 @@ +import type { Address, PublicClient } from "viem"; +import { formatEther } from "viem"; +import type { GasEstimate } from "../contract/caller"; + +/** + * Thrown when the wallet's native ETH balance is below the estimated on-chain + * gas cost for a deploy/upgrade. + * + * Compute credits (Stripe / USDC-converted) do NOT pay on-chain gas — gas is + * paid by the user's EOA at send time via EIP-7702. Without this pre-flight the + * transaction reverts only after submission, which to an agent is + * indistinguishable from other failures. + */ +export class InsufficientGasError extends Error { + public readonly address: Address; + public readonly requiredWei: bigint; + public readonly availableWei: bigint; + public readonly requiredEth: string; + public readonly availableEth: string; + + constructor(args: { address: Address; requiredWei: bigint; availableWei: bigint }) { + const requiredEth = formatEther(args.requiredWei); + const availableEth = formatEther(args.availableWei); + super( + `Insufficient ETH for gas: wallet ${args.address} has ${availableEth} ETH but ` + + `this transaction needs ~${requiredEth} ETH.\n` + + `Compute credits do not pay on-chain gas — fund the wallet with ETH and retry.`, + ); + this.name = "InsufficientGasError"; + this.address = args.address; + this.requiredWei = args.requiredWei; + this.availableWei = args.availableWei; + this.requiredEth = requiredEth; + this.availableEth = availableEth; + } +} + +/** + * Pre-flight gate: throw {@link InsufficientGasError} when the wallet's ETH + * balance is below the estimated gas cost. The threshold is the gas estimate + * (`maxCostWei`), not zero — dust below the cost must still fail. + */ +export async function assertSufficientGas(args: { + publicClient: PublicClient; + address: Address; + gasEstimate: GasEstimate; +}): Promise { + const availableWei = await args.publicClient.getBalance({ address: args.address }); + if (availableWei < args.gasEstimate.maxCostWei) { + throw new InsufficientGasError({ + address: args.address, + requiredWei: args.gasEstimate.maxCostWei, + availableWei, + }); + } +} diff --git a/packages/sdk/src/client/common/registry/__tests__/digest.test.ts b/packages/sdk/src/client/common/registry/__tests__/digest.test.ts new file mode 100644 index 00000000..05c249b7 --- /dev/null +++ b/packages/sdk/src/client/common/registry/__tests__/digest.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +// Mock child_process.execFile so promisify(execFile) is drivable. The real +// signature is execFile(cmd, args, opts, cb); promisify calls the (err, {stdout}) +// node-style callback. We route by the docker subcommand. +const responses: Record = {}; +vi.mock("child_process", () => ({ + execFile: ( + _cmd: string, + args: string[], + _opts: unknown, + cb: (err: Error | null, res?: { stdout: string; stderr: string }) => void, + ) => { + const sub = args[0]; // "manifest" | "inspect" + const key = sub === "manifest" ? "manifest" : "inspect"; + const out = responses[key]; + if (out === undefined) { + cb(new Error(`no mock for docker ${args.join(" ")}`)); + return; + } + cb(null, { stdout: out, stderr: "" }); + }, +})); + +import { getImageDigestAndName } from "../digest"; + +describe("getImageDigestAndName amd64 enforcement", () => { + afterEach(() => { + for (const k of Object.keys(responses)) delete responses[k]; + vi.clearAllMocks(); + }); + + it("throws (fail closed) when architecture is undetectable — no assume-amd64", async () => { + // Single-platform manifest (no .manifests[]), so it calls `docker inspect`, + // whose output has NO Architecture field but the manifest has a config digest. + responses.manifest = JSON.stringify({ config: { digest: "sha256:" + "b".repeat(64) } }); + responses.inspect = JSON.stringify([{ Os: "linux" /* Architecture missing */ }]); + + await expect(getImageDigestAndName("docker.io/x/y:tag")).rejects.toThrow(/linux\/amd64/); + }); + + it("rejects a single-platform arm64 image", async () => { + responses.manifest = JSON.stringify({ config: { digest: "sha256:" + "b".repeat(64) } }); + responses.inspect = JSON.stringify([ + { Os: "linux", Architecture: "arm64", RepoDigests: ["x@sha256:" + "c".repeat(64)] }, + ]); + + await expect(getImageDigestAndName("docker.io/x/y:tag")).rejects.toThrow(/linux\/amd64/); + }); + + it("rejects a multi-platform image with no linux/amd64 entry", async () => { + responses.manifest = JSON.stringify({ + manifests: [ + { digest: "sha256:" + "d".repeat(64), platform: { os: "linux", architecture: "arm64" } }, + ], + }); + await expect(getImageDigestAndName("docker.io/x/y:tag")).rejects.toThrow(/linux\/amd64/); + }); + + it("remediation offers buildx and the --verifiable --repo --commit server-side path", async () => { + // The remediation must point at both fixes a non-amd64 --image-ref has: + // rebuild for amd64 with buildx, OR switch to a server-side verifiable build + // (which needs no local Docker at all). Kept consistent with the CLI's + // prebuilt-image rejection so both arm64 entry points say the same thing. + responses.manifest = JSON.stringify({ + manifests: [ + { digest: "sha256:" + "d".repeat(64), platform: { os: "linux", architecture: "arm64" } }, + ], + }); + await expect(getImageDigestAndName("docker.io/x/y:tag")).rejects.toThrow(/buildx/); + await expect(getImageDigestAndName("docker.io/x/y:tag")).rejects.toThrow( + /--verifiable --repo .* --commit/, + ); + }); +}); diff --git a/packages/sdk/src/client/common/registry/digest.ts b/packages/sdk/src/client/common/registry/digest.ts index a7452431..69324894 100644 --- a/packages/sdk/src/client/common/registry/digest.ts +++ b/packages/sdk/src/client/common/registry/digest.ts @@ -116,18 +116,11 @@ async function extractDigestFromSinglePlatform( : null; if (!config) { - // Try to get from manifest config digest - if (manifest.config?.digest) { - const digest = hexStringToBytes32(manifest.config.digest); - const registry = extractRegistryName(imageRef); - // Assume linux/amd64 if we can't determine platform - return { - digest, - registry, - platform: DOCKER_PLATFORM, - }; - } - throw new Error(`Could not determine platform for ${imageRef}`); + // Architecture is undetectable from `docker inspect`. Previously this + // assumed linux/amd64 and deployed anyway — the silent hole that let an + // arm64 image through to crash on first request in the TEE. Fail closed + // instead: refuse rather than guess the platform. + throw createPlatformErrorMessage(imageRef, ["unknown (could not determine architecture)"]); } const platform = `${config.os}/${config.architecture}`; @@ -236,14 +229,11 @@ Image: ${imageRef} Found platform(s): ${platforms.join(", ")} Required platform: ${DOCKER_PLATFORM} -To fix this issue: -1. Manual fix: - a. Rebuild your image with the correct platform: - docker build --platform ${DOCKER_PLATFORM} -t ${imageRef} . - b. Push the rebuilt image to your remote registry: - docker push ${imageRef} - -2. Or use the SDK to build with the correct platform automatically.`; +To fix, either: +1. Rebuild the image for ${DOCKER_PLATFORM} and push it: + docker buildx build --platform ${DOCKER_PLATFORM} -t ${imageRef} --push . +2. Or use a verifiable build (--verifiable --repo --commit ), which + builds server-side and needs no local Docker.`; return new Error(errorMsg); } diff --git a/packages/sdk/src/client/common/release/prepare.ts b/packages/sdk/src/client/common/release/prepare.ts index 59fbc70e..8333d087 100644 --- a/packages/sdk/src/client/common/release/prepare.ts +++ b/packages/sdk/src/client/common/release/prepare.ts @@ -71,6 +71,14 @@ export async function prepareRelease( logger.info(`Waiting ${REGISTRY_PROPAGATION_WAIT_SECONDS} seconds for registry propagation...`); await new Promise((resolve) => setTimeout(resolve, REGISTRY_PROPAGATION_WAIT_SECONDS * 1000)); } else { + // Pre-flight: verify the remote image is linux/amd64 BEFORE the slow + // pull+layer+push. getImageDigestAndName runs `docker manifest + // inspect` (no pull) and throws the platform-remediation error for a + // non-amd64 image, so an arm64 --image-ref fails in seconds instead of + // minutes — and never slips through to crash in the TEE. + logger.info("Verifying image platform (linux/amd64)..."); + await getImageDigestAndName(imageRef); + // Layer remote image if needed logger.info("Checking if image needs layering..."); finalImageRef = await layerRemoteImageIfNeeded( diff --git a/packages/sdk/src/client/common/types/index.ts b/packages/sdk/src/client/common/types/index.ts index c42a8d71..0106305d 100644 --- a/packages/sdk/src/client/common/types/index.ts +++ b/packages/sdk/src/client/common/types/index.ts @@ -232,11 +232,21 @@ export interface BillingEnvironmentConfig { billingApiServerURL: string; } +/** + * On-chain AppController ABI version for an environment. + * - "v1.4": 3-field Release struct (sepolia, mainnet-alpha) + * - "v1.5": 4-field Release struct with containerPolicy (sepolia-dev) + * Omitted defaults to the latest ("v1.5") in the contract caller. + */ +export type AppControllerAbiVersion = "v1.4" | "v1.5"; + export interface EnvironmentConfig { name: string; build: "dev" | "prod"; chainID: bigint; appControllerAddress: Address; + /** Deployed AppController ABI version; selects Release encoding. Defaults to v1.5 when omitted. */ + releaseAbiVersion?: AppControllerAbiVersion; permissionControllerAddress: string; erc7702DelegatorAddress: string; kmsServerURL: string; @@ -244,8 +254,40 @@ export interface EnvironmentConfig { defaultRPCURL: string; billingRPCURL?: string; usdcCreditsAddress?: Address; + baseUsdcCreditsAddress?: Address; + baseRPCURL?: string; +} + +export interface EnvVar { + key: string; + value: string; } +/** + * Container runtime policy attached to a release (AppController v1.5.0+). + * + * Added to the on-chain `Release` struct in eigenx-contracts (KMS-006). All + * fields are optional knobs over the container's entrypoint/runtime; an empty + * policy (see EMPTY_CONTAINER_POLICY) preserves the image's own + * CMD/ENTRYPOINT/env. + */ +export interface ContainerPolicy { + args: string[]; + cmdOverride: string[]; + env: EnvVar[]; + envOverride: EnvVar[]; + restartPolicy: string; +} + +/** An empty ContainerPolicy — defers entirely to the image defaults. */ +export const EMPTY_CONTAINER_POLICY: ContainerPolicy = { + args: [], + cmdOverride: [], + env: [], + envOverride: [], + restartPolicy: "", +}; + export interface Release { rmsRelease: { artifacts: Array<{ @@ -256,6 +298,10 @@ export interface Release { }; publicEnv: Uint8Array; // JSON bytes encryptedEnv: Uint8Array; // Encrypted string bytes + // Container runtime policy (AppController v1.5.0+ `Release.containerPolicy`). + // Optional in the SDK type for backwards-compatible construction; the encoder + // substitutes EMPTY_CONTAINER_POLICY when callers omit it. + containerPolicy?: ContainerPolicy; } export interface ParsedEnvironment { @@ -419,6 +465,25 @@ export interface SubscriptionOpts { cancelUrl?: string; } +export interface PaymentMethod { + id: string; + stripePaymentMethodId: string; + brand: string; + last4: string; + createdAt: string; +} + +export interface PaymentMethodsResponse { + paymentMethods: PaymentMethod[]; +} + +export interface CreditPurchaseResponse { + purchaseId?: string; + checkoutSessionId?: string; + checkoutUrl?: string; + amountCents: string; +} + // Billing environment configuration export interface BillingEnvironmentConfig { billingApiServerURL: string; @@ -446,3 +511,47 @@ export interface SequentialDeployResult { setPublicLogs?: Hex; }; } + +// Admin - Coupon types +export interface AdminCoupon { + id: string; + amountCents: number; + active: boolean; + redeemedBy: string; + redeemedAt: string | null; + createdBy: string; + createdAt: string; +} + +export interface CreateCouponResponse { + coupon: AdminCoupon; +} + +export interface ListCouponsResponse { + coupons: AdminCoupon[]; + total: number; +} + +export interface GetCouponResponse { + coupon: AdminCoupon; +} + +// Admin - Admin management types +export interface AdminUser { + id: string; + address: string; + createdAt: string; +} + +export interface AddAdminResponse { + admin: AdminUser; +} + +export interface ListAdminsResponse { + admins: AdminUser[]; +} + +// User-facing coupon redemption +export interface RedeemCouponResponse { + amountCents: number; +} diff --git a/packages/sdk/src/client/common/utils/__tests__/retry.test.ts b/packages/sdk/src/client/common/utils/__tests__/retry.test.ts new file mode 100644 index 00000000..c2004861 --- /dev/null +++ b/packages/sdk/src/client/common/utils/__tests__/retry.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +// Mock axios so requestWithRetry's HTTP calls are controllable. +const axiosMock = vi.fn(); +vi.mock("axios", () => ({ + default: (config: unknown) => axiosMock(config), +})); + +import { requestWithRetry } from "../retry"; + +/** + * requestWithRetry retries 429 (rate limit) AND transient gateway + * errors 502/503/504, then returns the last response. 2xx/4xx (other than 429) + * are returned immediately without retry. + */ +describe("requestWithRetry retryable statuses", () => { + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it("returns immediately on 200 (no retry)", async () => { + axiosMock.mockResolvedValueOnce({ status: 200, headers: {}, data: "ok" }); + const res = await requestWithRetry({ url: "x" }); + expect(res.status).toBe(200); + expect(axiosMock).toHaveBeenCalledTimes(1); + }); + + it("does not retry a 404", async () => { + axiosMock.mockResolvedValueOnce({ status: 404, headers: {}, data: "nope" }); + const res = await requestWithRetry({ url: "x" }); + expect(res.status).toBe(404); + expect(axiosMock).toHaveBeenCalledTimes(1); + }); + + for (const status of [429, 502, 503, 504]) { + it(`retries ${status} then succeeds`, async () => { + vi.useFakeTimers(); + axiosMock + .mockResolvedValueOnce({ status, headers: { "retry-after": "0" }, data: "" }) + .mockResolvedValueOnce({ status: 200, headers: {}, data: "ok" }); + + const promise = requestWithRetry({ url: "x" }); + await vi.runAllTimersAsync(); + const res = await promise; + + expect(res.status).toBe(200); + expect(axiosMock).toHaveBeenCalledTimes(2); + }); + } +}); diff --git a/packages/sdk/src/client/common/utils/billingapi.ts b/packages/sdk/src/client/common/utils/billingapi.ts index 3dbbfc37..b767f22a 100644 --- a/packages/sdk/src/client/common/utils/billingapi.ts +++ b/packages/sdk/src/client/common/utils/billingapi.ts @@ -18,6 +18,14 @@ import { CreateSubscriptionResponse, GetSubscriptionOptions, ProductSubscriptionResponse, + PaymentMethodsResponse, + CreditPurchaseResponse, + CreateCouponResponse, + ListCouponsResponse, + GetCouponResponse, + AddAdminResponse, + ListAdminsResponse, + RedeemCouponResponse, } from "../types"; import { calculateBillingAuthSignature } from "./auth"; import { BillingEnvironmentConfig } from "../types"; @@ -37,6 +45,8 @@ export interface BillingApiClientOptions { * When false (default), uses EIP-712 typed data signatures for each request. */ useSession?: boolean; + /** Log request/response details to stderr */ + verbose?: boolean; } /** @@ -176,6 +186,94 @@ export class BillingApiClient { await this.makeAuthenticatedRequest(endpoint, "DELETE", productId); } + async getPaymentMethods(): Promise { + const endpoint = `${this.config.billingApiServerURL}/v1/payment-methods`; + const resp = await this.makeAuthenticatedRequest(endpoint, "GET", "compute"); + return resp.json(); + } + + async purchaseCredits( + amountCents: number, + paymentMethodId?: string, + ): Promise { + const endpoint = `${this.config.billingApiServerURL}/v1/credits/purchase`; + const body: Record = { amountCents }; + if (paymentMethodId) { + body.paymentMethodId = paymentMethodId; + } + const resp = await this.makeAuthenticatedRequest(endpoint, "POST", "compute", body); + return resp.json(); + } + + // ========================================================================== + // Admin - Coupon Methods + // ========================================================================== + + async createCoupon(amountCents: number): Promise { + const endpoint = `${this.config.billingApiServerURL}/admin/coupons`; + const resp = await this.makeAuthenticatedRequest(endpoint, "POST", "compute", { amountCents }); + return resp.json(); + } + + async listCoupons(opts?: { offset?: number; limit?: number; active?: boolean; redeemed?: boolean }): Promise { + const params = new URLSearchParams(); + if (opts?.offset !== undefined) params.set("offset", opts.offset.toString()); + if (opts?.limit !== undefined) params.set("limit", opts.limit.toString()); + if (opts?.active !== undefined) params.set("active", opts.active.toString()); + if (opts?.redeemed !== undefined) params.set("redeemed", opts.redeemed.toString()); + const qs = params.toString(); + const endpoint = `${this.config.billingApiServerURL}/admin/coupons${qs ? `?${qs}` : ""}`; + const resp = await this.makeAuthenticatedRequest(endpoint, "GET", "compute"); + return resp.json(); + } + + async getCoupon(id: string): Promise { + const endpoint = `${this.config.billingApiServerURL}/admin/coupons/${id}`; + const resp = await this.makeAuthenticatedRequest(endpoint, "GET", "compute"); + return resp.json(); + } + + async deactivateCoupon(id: string): Promise { + const endpoint = `${this.config.billingApiServerURL}/admin/coupons/${id}/deactivate`; + await this.makeAuthenticatedRequest(endpoint, "POST", "compute"); + } + + async redeemCouponForUser(id: string, address: string): Promise { + const endpoint = `${this.config.billingApiServerURL}/admin/coupons/${id}/redeem`; + await this.makeAuthenticatedRequest(endpoint, "POST", "compute", { address }); + } + + // ========================================================================== + // Admin - Admin Management Methods + // ========================================================================== + + async addAdmin(address: string): Promise { + const endpoint = `${this.config.billingApiServerURL}/admin/admins`; + const resp = await this.makeAuthenticatedRequest(endpoint, "POST", "compute", { address }); + return resp.json(); + } + + async removeAdmin(address: string): Promise { + const endpoint = `${this.config.billingApiServerURL}/admin/admins/${address}`; + await this.makeAuthenticatedRequest(endpoint, "DELETE", "compute"); + } + + async listAdmins(): Promise { + const endpoint = `${this.config.billingApiServerURL}/admin/admins`; + const resp = await this.makeAuthenticatedRequest(endpoint, "GET", "compute"); + return resp.json(); + } + + // ========================================================================== + // User - Coupon Redemption + // ========================================================================== + + async redeemCoupon(code: string): Promise { + const endpoint = `${this.config.billingApiServerURL}/v1/coupons/redeem`; + const resp = await this.makeAuthenticatedRequest(endpoint, "POST", "compute", { code }); + return resp.json(); + } + // ========================================================================== // Internal Methods // ========================================================================== @@ -191,10 +289,25 @@ export class BillingApiClient { productId: ProductID, body?: Record, ): Promise<{ json: () => Promise; text: () => Promise }> { - if (this.useSession) { - return this.makeSessionAuthenticatedRequest(url, method, body); + if (this.options.verbose) { + console.debug(`[BillingAPI] ${method} ${url}`); + if (body) { + console.debug(`[BillingAPI] Payload:`, JSON.stringify(body, null, 2)); + } + } + const resp = this.useSession + ? await this.makeSessionAuthenticatedRequest(url, method, body) + : await this.makeSignatureAuthenticatedRequest(url, method, productId, body); + + if (this.options.verbose) { + const data = await resp.json(); + console.debug(`[BillingAPI] Response:`, JSON.stringify(data, null, 2)); + return { + json: async () => data, + text: async () => JSON.stringify(data), + }; } - return this.makeSignatureAuthenticatedRequest(url, method, productId, body); + return resp; } /** diff --git a/packages/sdk/src/client/common/utils/retry.ts b/packages/sdk/src/client/common/utils/retry.ts index 6bd0419e..5da194d2 100644 --- a/packages/sdk/src/client/common/utils/retry.ts +++ b/packages/sdk/src/client/common/utils/retry.ts @@ -4,6 +4,13 @@ const MAX_RETRIES = 5; const INITIAL_BACKOFF_MS = 1000; const MAX_BACKOFF_MS = 30000; +/** + * HTTP statuses worth retrying with backoff: rate limiting (429) and transient + * gateway/availability errors (502/503/504). Agents that poll status hit these + * under load, and they are routinely recoverable on retry. + */ +const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]); + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -27,7 +34,7 @@ export async function requestWithRetry(config: AxiosRequestConfig): Promise true }); lastResponse = res; - if (res.status !== 429) { + if (!RETRYABLE_STATUSES.has(res.status)) { return res; } diff --git a/packages/sdk/src/client/index.ts b/packages/sdk/src/client/index.ts index 9a3b1fd2..2246f1d2 100644 --- a/packages/sdk/src/client/index.ts +++ b/packages/sdk/src/client/index.ts @@ -39,6 +39,7 @@ export { executeDeploy, watchDeployment, type PrepareDeployResult, + type WatchDeploymentOptions, } from "./modules/compute/app/deploy"; export { SDKUpgradeOptions, @@ -47,7 +48,10 @@ export { executeUpgrade, watchUpgrade, type PrepareUpgradeResult, + type WatchUpgradeOptions, } from "./modules/compute/app/upgrade"; +export { WatchTimeoutError, WATCH_DEFAULT_TIMEOUT_SECONDS } from "./common/contract/watcher"; +export { InsufficientGasError, assertSufficientGas } from "./common/gas/insufficientGas"; // Export compute module for standalone use export { @@ -62,7 +66,16 @@ export { createBillingModule, type BillingModule, type BillingModuleConfig, + type BillingChain, + type TopUpOpts, + type TopUpResult, + type TopUpInfo, } from "./modules/billing"; +export { + createAdminModule, + type AdminModule, + type AdminModuleConfig, +} from "./modules/admin"; // Export environment config utilities export { @@ -72,6 +85,7 @@ export { getBuildType, isMainnet, getBillingEnvironmentConfig, + BASE_SEPOLIA_CHAIN_ID, } from "./common/config/environment"; export { isSubscriptionActive } from "./common/utils/billing"; diff --git a/packages/sdk/src/client/modules/admin/index.ts b/packages/sdk/src/client/modules/admin/index.ts new file mode 100644 index 00000000..aeaac1c5 --- /dev/null +++ b/packages/sdk/src/client/modules/admin/index.ts @@ -0,0 +1,77 @@ +import type { WalletClient, PublicClient, Address } from "viem"; +import { BillingApiClient } from "../../common/utils/billingapi"; +import { getBillingEnvironmentConfig, getBuildType } from "../../common/config/environment"; +import type { + CreateCouponResponse, + ListCouponsResponse, + GetCouponResponse, + AddAdminResponse, + ListAdminsResponse, +} from "../../common/types"; + +export interface AdminModule { + address: Address; + createCoupon: (amountCents: number) => Promise; + listCoupons: (opts?: { offset?: number; limit?: number; active?: boolean; redeemed?: boolean }) => Promise; + getCoupon: (id: string) => Promise; + deactivateCoupon: (id: string) => Promise; + redeemCouponForUser: (id: string, address: string) => Promise; + addAdmin: (address: string) => Promise; + removeAdmin: (address: string) => Promise; + listAdmins: () => Promise; +} + +export interface AdminModuleConfig { + verbose?: boolean; + walletClient: WalletClient; + publicClient: PublicClient; + environment: string; +} + +export function createAdminModule(config: AdminModuleConfig): AdminModule { + const { verbose = false, walletClient } = config; + + if (!walletClient.account) { + throw new Error("WalletClient must have an account attached"); + } + const address = walletClient.account.address as Address; + + const billingEnvConfig = getBillingEnvironmentConfig(getBuildType()); + const billingApi = new BillingApiClient(billingEnvConfig, walletClient, { verbose }); + + return { + address, + + async createCoupon(amountCents: number) { + return billingApi.createCoupon(amountCents); + }, + + async listCoupons(opts?) { + return billingApi.listCoupons(opts); + }, + + async getCoupon(id: string) { + return billingApi.getCoupon(id); + }, + + async deactivateCoupon(id: string) { + return billingApi.deactivateCoupon(id); + }, + + async redeemCouponForUser(id: string, userAddress: string) { + return billingApi.redeemCouponForUser(id, userAddress); + }, + + async addAdmin(adminAddress: string) { + return billingApi.addAdmin(adminAddress); + }, + + async removeAdmin(adminAddress: string) { + return billingApi.removeAdmin(adminAddress); + }, + + async listAdmins() { + return billingApi.listAdmins(); + }, + }; +} diff --git a/packages/sdk/src/client/modules/billing/index.ts b/packages/sdk/src/client/modules/billing/index.ts index d68f477d..45e3ca5d 100644 --- a/packages/sdk/src/client/modules/billing/index.ts +++ b/packages/sdk/src/client/modules/billing/index.ts @@ -9,7 +9,8 @@ import type { WalletClient, PublicClient } from "viem"; import { type Address, type Hex, encodeFunctionData } from "viem"; import { BillingApiClient } from "../../common/utils/billingapi"; -import { getBillingEnvironmentConfig, getBuildType, getEnvironmentConfig } from "../../common/config/environment"; +import { getBillingEnvironmentConfig, getBuildType, getEnvironmentConfig, BASE_SEPOLIA_CHAIN_ID } from "../../common/config/environment"; +import { createClients } from "../../common/utils/helpers"; import { getLogger, isSubscriptionActive } from "../../common/utils"; import { withSDKTelemetry } from "../../common/telemetry/wrapper"; import { executeBatch, type Execution } from "../../common/contract/eip7702"; @@ -23,13 +24,20 @@ import type { SubscribeResponse, CancelResponse, ProductSubscriptionResponse, + PaymentMethodsResponse, + CreditPurchaseResponse, + RedeemCouponResponse, } from "../../common/types"; +export type BillingChain = "ethereum" | "base"; + export interface TopUpOpts { /** Amount in raw USDC units (6 decimals, e.g. 50_000_000n = 50 USDC) */ amount: bigint; /** Target account for purchaseCreditsFor (defaults to wallet address) */ account?: Address; + /** Which blockchain to transact on (defaults to "ethereum") */ + chain?: BillingChain; } export interface TopUpResult { @@ -50,9 +58,14 @@ export interface BillingModule { getStatus: (opts?: SubscriptionOpts) => Promise; cancel: (opts?: SubscriptionOpts) => Promise; /** Read on-chain state needed for top-up */ - getTopUpInfo: () => Promise; + getTopUpInfo: (opts?: { chain?: BillingChain }) => Promise; /** Purchase credits with USDC on-chain */ topUp: (opts: TopUpOpts) => Promise; + getPaymentMethods: () => Promise; + purchaseCredits: (amountCents: number, paymentMethodId?: string) => Promise; + /** Check if Base chain is configured for this environment */ + hasBaseSupport: () => boolean; + redeemCoupon: (code: string) => Promise; } export interface BillingModuleConfig { @@ -61,10 +74,11 @@ export interface BillingModuleConfig { skipTelemetry?: boolean; // Skip telemetry when called from CLI publicClient: PublicClient; environment: string; + privateKey?: Hex; } export function createBillingModule(config: BillingModuleConfig): BillingModule { - const { verbose = false, skipTelemetry = false, walletClient, publicClient, environment } = config; + const { verbose = false, skipTelemetry = false, walletClient, publicClient, environment, privateKey } = config; // Get address from wallet client's account if (!walletClient.account) { @@ -78,42 +92,84 @@ export function createBillingModule(config: BillingModuleConfig): BillingModule const billingEnvConfig = getBillingEnvironmentConfig(getBuildType()); // Create billing API client - const billingApi = new BillingApiClient(billingEnvConfig, walletClient); + const billingApi = new BillingApiClient(billingEnvConfig, walletClient, { verbose }); // Resolve on-chain config const environmentConfig = getEnvironmentConfig(environment); - const usdcCreditsAddress = environmentConfig.usdcCreditsAddress; - if (!usdcCreditsAddress) { + if (!environmentConfig.usdcCreditsAddress) { throw new Error(`USDCCredits contract address not configured for environment "${environment}"`); } + const usdcCreditsAddress: Address = environmentConfig.usdcCreditsAddress; + + const baseUsdcCreditsAddress = environmentConfig.baseUsdcCreditsAddress; + const baseRPCURL = environmentConfig.baseRPCURL; + + function resolveChainConfig(chain?: BillingChain): { + pub: PublicClient; + wallet: WalletClient; + creditsAddress: Address; + envConfig: typeof environmentConfig; + } { + if (chain === "base") { + if (!baseUsdcCreditsAddress || !baseRPCURL) { + throw new Error(`Base chain not configured for environment "${environment}"`); + } + if (!privateKey) { + throw new Error("Private key required for Base chain transactions"); + } + const baseClients = createClients({ + privateKey, + rpcUrl: baseRPCURL, + chainId: BigInt(BASE_SEPOLIA_CHAIN_ID), + }); + return { + pub: baseClients.publicClient as PublicClient, + wallet: baseClients.walletClient as WalletClient, + creditsAddress: baseUsdcCreditsAddress, + envConfig: { + ...environmentConfig, + chainID: BigInt(BASE_SEPOLIA_CHAIN_ID), + defaultRPCURL: baseRPCURL, + }, + }; + } + return { + pub: publicClient, + wallet: walletClient, + creditsAddress: usdcCreditsAddress, + envConfig: environmentConfig, + }; + } const module: BillingModule = { address, - async getTopUpInfo(): Promise { - const usdcAddress = await publicClient.readContract({ - address: usdcCreditsAddress, + async getTopUpInfo(opts?: { chain?: BillingChain }): Promise { + const { pub, creditsAddress } = resolveChainConfig(opts?.chain); + + const usdcAddress = await pub.readContract({ + address: creditsAddress, abi: USDCCreditsABI, functionName: "usdc", }) as Address; const [minimumPurchase, usdcBalance, currentAllowance] = await Promise.all([ - publicClient.readContract({ - address: usdcCreditsAddress, + pub.readContract({ + address: creditsAddress, abi: USDCCreditsABI, functionName: "minimumPurchase", }) as Promise, - publicClient.readContract({ + pub.readContract({ address: usdcAddress, abi: ERC20ABI, functionName: "balanceOf", args: [address], }) as Promise, - publicClient.readContract({ + pub.readContract({ address: usdcAddress, abi: ERC20ABI, functionName: "allowance", - args: [address, usdcCreditsAddress], + args: [address, creditsAddress], }) as Promise, ]); @@ -125,13 +181,14 @@ export function createBillingModule(config: BillingModuleConfig): BillingModule { functionName: "topUp", skipTelemetry, - properties: { amount: opts.amount.toString() }, + properties: { amount: opts.amount.toString(), chain: opts.chain || "ethereum" }, }, async () => { const targetAccount = opts.account ?? address; + const { pub, wallet, creditsAddress, envConfig } = resolveChainConfig(opts.chain); // Read on-chain state - const { usdcAddress, currentAllowance } = await module.getTopUpInfo(); + const { usdcAddress, currentAllowance } = await module.getTopUpInfo({ chain: opts.chain }); // Build executions array const executions: Execution[] = []; @@ -144,14 +201,14 @@ export function createBillingModule(config: BillingModuleConfig): BillingModule callData: encodeFunctionData({ abi: ERC20ABI, functionName: "approve", - args: [usdcCreditsAddress, opts.amount], + args: [creditsAddress, opts.amount], }), }); } // Always include purchaseCreditsFor executions.push({ - target: usdcCreditsAddress, + target: creditsAddress, value: 0n, callData: encodeFunctionData({ abi: USDCCreditsABI, @@ -162,9 +219,9 @@ export function createBillingModule(config: BillingModuleConfig): BillingModule const txHash = await executeBatch( { - walletClient, - publicClient, - environmentConfig, + walletClient: wallet, + publicClient: pub, + environmentConfig: envConfig, executions, pendingMessage: "Submitting credit purchase...", }, @@ -281,6 +338,22 @@ export function createBillingModule(config: BillingModuleConfig): BillingModule }, ); }, + + async getPaymentMethods() { + return billingApi.getPaymentMethods(); + }, + + async purchaseCredits(amountCents: number, paymentMethodId?: string) { + return billingApi.purchaseCredits(amountCents, paymentMethodId); + }, + + hasBaseSupport(): boolean { + return !!baseUsdcCreditsAddress && !!baseRPCURL; + }, + + async redeemCoupon(code: string) { + return billingApi.redeemCoupon(code); + }, }; return module; diff --git a/packages/sdk/src/client/modules/compute/app/deploy.ts b/packages/sdk/src/client/modules/compute/app/deploy.ts index 2b5ea5d6..5eec33d1 100644 --- a/packages/sdk/src/client/modules/compute/app/deploy.ts +++ b/packages/sdk/src/client/modules/compute/app/deploy.ts @@ -29,6 +29,7 @@ import { } from "../../../common/contract/caller"; import { estimateBatchGas, createAuthorizationList } from "../../../common/contract/eip7702"; import { type GasEstimate } from "../../../common/contract/caller"; +import { assertSufficientGas } from "../../../common/gas/insufficientGas"; import { watchUntilRunning } from "../../../common/contract/watcher"; import { validateAppName, @@ -271,6 +272,13 @@ export async function prepareDeployFromVerifiableBuild( authorizationList, }); + // Pre-flight: block if the wallet can't cover gas (credits don't pay it). + await assertSufficientGas({ + publicClient: batch.publicClient, + address: batch.walletClient.account!.address, + gasEstimate, + }); + // Extract only data fields for public type (clients stay internal) const data: PreparedDeployData = { appId: batch.appId, @@ -655,6 +663,13 @@ export async function prepareDeploy( authorizationList, }); + // 10b. Pre-flight: block if the wallet can't cover gas (credits don't pay it). + await assertSufficientGas({ + publicClient: batch.publicClient, + address: batch.walletClient.account!.address, + gasEstimate, + }); + // Extract only data fields for public type (clients stay internal) const data: PreparedDeployData = { appId: batch.appId, @@ -711,6 +726,10 @@ export async function executeDeploy(options: ExecuteDeployOptions): Promise { return withSDKTelemetry( { @@ -735,6 +755,7 @@ export async function watchDeployment( publicClient, environmentConfig, appId: appId as Address, + timeoutSeconds: opts?.timeoutSeconds, }, logger, ); diff --git a/packages/sdk/src/client/modules/compute/app/index.ts b/packages/sdk/src/client/modules/compute/app/index.ts index ccf6921a..f663d9d5 100644 --- a/packages/sdk/src/client/modules/compute/app/index.ts +++ b/packages/sdk/src/client/modules/compute/app/index.ts @@ -16,6 +16,7 @@ import { prepareDeployFromVerifiableBuild as prepareDeployFromVerifiableBuildFn, executeDeploy as executeDeployFn, watchDeployment as watchDeploymentFn, + type WatchDeploymentOptions, } from "./deploy"; import { upgrade as upgradeApp, @@ -23,6 +24,7 @@ import { prepareUpgradeFromVerifiableBuild as prepareUpgradeFromVerifiableBuildFn, executeUpgrade as executeUpgradeFn, watchUpgrade as watchUpgradeFn, + type WatchUpgradeOptions, } from "./upgrade"; import { createApp, CreateAppOpts } from "./create"; import { logs, LogsOptions } from "./logs"; @@ -55,6 +57,7 @@ import type { PrepareUpgradeFromVerifiableBuildOpts, PreparedDeploy, PreparedUpgrade, + Logger, } from "../../../common/types"; import { getLogger } from "../../../common/utils"; @@ -125,7 +128,10 @@ export interface AppModule { gasEstimate: GasEstimate; }>; executeDeploy: (prepared: PreparedDeploy, gas?: GasEstimate) => Promise; - watchDeployment: (appId: AppId) => Promise; + watchDeployment: ( + appId: AppId, + opts?: WatchDeploymentOptions, + ) => Promise; // Granular upgrade control prepareUpgrade: ( @@ -143,7 +149,7 @@ export interface AppModule { gasEstimate: GasEstimate; }>; executeUpgrade: (prepared: PreparedUpgrade, gas?: GasEstimate) => Promise; - watchUpgrade: (appId: AppId) => Promise; + watchUpgrade: (appId: AppId, opts?: WatchUpgradeOptions) => Promise; // Profile management setProfile: (appId: AppId, profile: AppProfile) => Promise; @@ -176,6 +182,12 @@ export interface AppModuleConfig { environment: string; clientId?: string; skipTelemetry?: boolean; // Skip telemetry when called from CLI + /** + * Optional logger override. Defaults to a stdout/stderr logger that respects + * `verbose`. Callers producing machine-readable output pass a logger that + * keeps progress off stdout. + */ + logger?: Logger; } export function createAppModule(ctx: AppModuleConfig): AppModule { @@ -191,8 +203,8 @@ export function createAppModule(ctx: AppModuleConfig): AppModule { // Pull config for selected Environment const environment = getEnvironmentConfig(ctx.environment); - // Get logger that respects verbose setting - const logger = getLogger(ctx.verbose); + // Use the caller-provided logger if any, else one that respects verbose. + const logger = ctx.logger ?? getLogger(ctx.verbose); return { async create(opts) { @@ -314,7 +326,7 @@ export function createAppModule(ctx: AppModuleConfig): AppModule { }; }, - async watchDeployment(appId) { + async watchDeployment(appId, opts) { return watchDeploymentFn( appId, walletClient, @@ -322,6 +334,7 @@ export function createAppModule(ctx: AppModuleConfig): AppModule { environment, logger, skipTelemetry, + opts, ); }, @@ -383,8 +396,16 @@ export function createAppModule(ctx: AppModuleConfig): AppModule { }; }, - async watchUpgrade(appId) { - return watchUpgradeFn(appId, walletClient, publicClient, environment, logger, skipTelemetry); + async watchUpgrade(appId, opts) { + return watchUpgradeFn( + appId, + walletClient, + publicClient, + environment, + logger, + skipTelemetry, + opts, + ); }, // Profile management diff --git a/packages/sdk/src/client/modules/compute/app/upgrade.ts b/packages/sdk/src/client/modules/compute/app/upgrade.ts index 4c857e9d..b9151eca 100644 --- a/packages/sdk/src/client/modules/compute/app/upgrade.ts +++ b/packages/sdk/src/client/modules/compute/app/upgrade.ts @@ -27,6 +27,7 @@ import { type GasEstimate, } from "../../../common/contract/caller"; import { estimateBatchGas, createAuthorizationList } from "../../../common/contract/eip7702"; +import { assertSufficientGas } from "../../../common/gas/insufficientGas"; import { watchUntilUpgradeComplete } from "../../../common/contract/watcher"; import { validateAppID, @@ -201,6 +202,13 @@ export async function prepareUpgradeFromVerifiableBuild( authorizationList, }); + // Pre-flight: block if the wallet can't cover gas (credits don't pay it). + await assertSufficientGas({ + publicClient: batch.publicClient, + address: batch.walletClient.account!.address, + gasEstimate, + }); + // Extract only data fields for public type (clients stay internal) const data: PreparedUpgradeData = { appId: batch.appId, @@ -489,6 +497,13 @@ export async function prepareUpgrade( authorizationList, }); + // Pre-flight: block if the wallet can't cover gas (credits don't pay it). + await assertSufficientGas({ + publicClient: batch.publicClient, + address: batch.walletClient.account!.address, + gasEstimate, + }); + // Extract only data fields for public type (clients stay internal) const data: PreparedUpgradeData = { appId: batch.appId, @@ -543,6 +558,10 @@ export async function executeUpgrade(options: ExecuteUpgradeOptions): Promise { return withSDKTelemetry( { @@ -567,6 +587,7 @@ export async function watchUpgrade( publicClient, environmentConfig, appId: appId as Address, + timeoutSeconds: opts?.timeoutSeconds, }, logger, ); diff --git a/packages/sdk/src/client/modules/compute/index.ts b/packages/sdk/src/client/modules/compute/index.ts index 8c87ccc6..d0726fd2 100644 --- a/packages/sdk/src/client/modules/compute/index.ts +++ b/packages/sdk/src/client/modules/compute/index.ts @@ -3,6 +3,7 @@ */ import { type WalletClient, type PublicClient } from "viem"; +import { type Logger } from "../../common/types"; import { createAppModule, type AppModule } from "./app"; export interface ComputeModule { @@ -16,6 +17,13 @@ export interface ComputeModuleConfig { environment: string; clientId?: string; skipTelemetry?: boolean; + /** + * Optional logger override. When provided, the module routes all progress + * output through it instead of the default stdout/stderr logger. Callers + * emitting machine-readable output (e.g. `--json`) pass a logger that writes + * to stderr so stdout stays pure. + */ + logger?: Logger; } export function createComputeModule(config: ComputeModuleConfig): ComputeModule {