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/src/client.ts b/packages/cli/src/client.ts index 2478aa0b..dfea37f8 100644 --- a/packages/cli/src/client.ts +++ b/packages/cli/src/client.ts @@ -2,6 +2,7 @@ import { createComputeModule, createBillingModule, createBuildModule, + createAdminModule, getEnvironmentConfig, requirePrivateKey, } from "@layr-labs/ecloud-sdk"; @@ -67,6 +68,7 @@ export async function createBillingClient(flags: CommonFlags) { publicClient, environment, skipTelemetry: true, + privateKey: privateKey as Hex, }); } @@ -98,3 +100,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__/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/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..2089dd04 100644 --- a/packages/cli/src/commands/billing/status.ts +++ b/packages/cli/src/commands/billing/status.ts @@ -88,28 +88,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/sdk/src/client/common/config/environment.ts b/packages/sdk/src/client/common/config/environment.ts index db092f6f..0bb4d76d 100644 --- a/packages/sdk/src/client/common/config/environment.ts +++ b/packages/sdk/src/client/common/config/environment.ts @@ -8,6 +8,7 @@ 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 = { @@ -46,6 +47,8 @@ const ENVIRONMENTS: Record> = { 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", @@ -58,6 +61,8 @@ 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", @@ -135,6 +140,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 +164,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/types/index.ts b/packages/sdk/src/client/common/types/index.ts index c42a8d71..e3ec18dd 100644 --- a/packages/sdk/src/client/common/types/index.ts +++ b/packages/sdk/src/client/common/types/index.ts @@ -244,6 +244,8 @@ export interface EnvironmentConfig { defaultRPCURL: string; billingRPCURL?: string; usdcCreditsAddress?: Address; + baseUsdcCreditsAddress?: Address; + baseRPCURL?: string; } export interface Release { @@ -419,6 +421,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 +467,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/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/index.ts b/packages/sdk/src/client/index.ts index 9a3b1fd2..5f2e987d 100644 --- a/packages/sdk/src/client/index.ts +++ b/packages/sdk/src/client/index.ts @@ -62,7 +62,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 +81,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;