diff --git a/packages/cli/skills/deploy/SKILL.md b/packages/cli/skills/deploy/SKILL.md index b580dad..636db36 100644 --- a/packages/cli/skills/deploy/SKILL.md +++ b/packages/cli/skills/deploy/SKILL.md @@ -260,8 +260,13 @@ it in a shell loop (or a hardcoded `sleep`) hammers the API and trips server rate limits. `status --wait` blocks using the CLI's internal watch loop, which paces requests and backs off on `429/502/503/504`. +`--wait` only blocks while the app is still transitioning (`Deploying`, +`Upgrading`, `Resuming`, `Stopping`, `Terminating`, `Created`). If the app has +already settled (`Running`, `Stopped`, `Terminated`, `Suspended`, `Failed`), it +returns immediately — so it is always safe to call, even after the app is up. + ```bash -# Blocks until the app reaches a terminal status or the timeout elapses. +# Blocks only if still transitioning; otherwise returns the settled status now. ecloud compute app status --wait # Bound the wait (default 600s) and/or get machine-readable output: diff --git a/packages/cli/src/commands/billing/__tests__/status.test.ts b/packages/cli/src/commands/billing/__tests__/status.test.ts index e2bb0d2..eb66b9d 100644 --- a/packages/cli/src/commands/billing/__tests__/status.test.ts +++ b/packages/cli/src/commands/billing/__tests__/status.test.ts @@ -24,6 +24,8 @@ describe("ecloud billing status — top-up hint", () => { let mockBilling: { address: string; getStatus: ReturnType; + getAccountCredits: ReturnType; + getTopUpInfo: ReturnType; }; beforeEach(() => { @@ -32,6 +34,13 @@ describe("ecloud billing status — top-up hint", () => { mockBilling = { address: "0xabcdef1234567890abcdef1234567890abcdef12", getStatus: vi.fn(), + getAccountCredits: vi.fn().mockResolvedValue({ + remainingCredits: 0, + permanentCredits: 0, + promotionalCredits: 0, + nextPromotionalCreditExpiry: 0, + }), + getTopUpInfo: vi.fn().mockResolvedValue({ usdcBalance: 0n }), }; (createBillingClient as ReturnType).mockResolvedValue(mockBilling); }); @@ -133,8 +142,29 @@ describe("ecloud billing status — top-up hint", () => { { "private-key": "0xgood", environment: "sepolia" }, ); - expect(output.join("\n")).toMatch(/Wallet ETH.*1 ETH/); + expect(output.join("\n")).toMatch(/Wallet \(sepolia\):/); + expect(output.join("\n")).toMatch(/ETH:\s+1 ETH/); expect(warnOutput).toHaveLength(0); }); + + it("renders the promotional/paid credit split", async () => { + (createViemClients as ReturnType).mockReturnValue({ + publicClient: { getBalance: vi.fn().mockResolvedValue(0n) }, + address: "0xabcdef1234567890abcdef1234567890abcdef12", + }); + mockBilling.getAccountCredits.mockResolvedValue({ + remainingCredits: 25, + permanentCredits: 0, + promotionalCredits: 25, + nextPromotionalCreditExpiry: 0, + }); + const output = await runStatusCommand( + { subscriptionStatus: "active", productId: "compute" }, + { "private-key": "0xgood", environment: "sepolia" }, + ); + const out = output.join("\n"); + expect(out).toContain("Credits (Stripe):"); + expect(out).toContain("Promotional:"); + }); }); }); diff --git a/packages/cli/src/commands/billing/status.ts b/packages/cli/src/commands/billing/status.ts index 992368e..490449a 100644 --- a/packages/cli/src/commands/billing/status.ts +++ b/packages/cli/src/commands/billing/status.ts @@ -1,12 +1,13 @@ import { Command, Flags } from "@oclif/core"; import { createBillingClient } from "../../client"; import { commonFlags } from "../../flags"; -import { getEnvironmentConfig } from "@layr-labs/ecloud-sdk"; +import { getEnvironmentConfig, type AccountCreditsResponse } from "@layr-labs/ecloud-sdk"; import { createViemClients } from "../../utils/viemClients"; import { errorMessage } from "../../utils/exitCodes"; -import { formatEther } from "viem"; +import { formatUnits } from "viem"; import chalk from "chalk"; import { withTelemetry } from "../../telemetry"; +import { formatFundsBlock, formatLineItem, formatEthDisplay } from "../../utils/billingFormat"; export default class BillingStatus extends Command { static description = "Show subscription status"; @@ -31,9 +32,6 @@ export default class BillingStatus extends Command { productId: flags.product as "compute", }); - const formatExpiry = (timestamp?: number) => - timestamp ? ` (expires ${new Date(timestamp * 1000).toLocaleDateString()})` : ""; - // Format status with appropriate color and symbol const formatStatus = (status: string) => { switch (status) { @@ -63,32 +61,6 @@ export default class BillingStatus extends Command { this.log(`\n${chalk.bold("Subscription Status:")}`); this.log(` Wallet: ${billing.address}`); - // Show the wallet's on-chain ETH so the credit-vs-gas gap is visible - // before a deploy: compute credits do NOT pay on-chain gas. - // Best-effort — never fail `billing status` if the balance read fails. - try { - const privateKey = flags["private-key"]; - if (privateKey) { - const environment = flags.environment; - const environmentConfig = getEnvironmentConfig(environment); - const rpcUrl = flags["rpc-url"] || environmentConfig.defaultRPCURL; - const { publicClient, address } = createViemClients({ privateKey, rpcUrl, environment }); - const balanceWei = await publicClient.getBalance({ address }); - const eth = formatEther(balanceWei); - const note = - balanceWei === BigInt(0) - ? chalk.yellow(" (fund with ETH to pay deploy/upgrade gas)") - : ""; - this.log(` Wallet ETH (${environment}): ${chalk.cyan(`${eth} ETH`)}${note}`); - } - } catch (err) { - // Best-effort: the ETH line is informational, so a failure here must - // not abort `billing status`. But surface the reason rather than - // swallowing it — a malformed --private-key or an unreachable RPC is - // worth telling the user about. - this.warn(`Could not read wallet ETH balance: ${errorMessage(err)}`); - } - this.log(` Status: ${formatStatus(result.subscriptionStatus)}`); this.log(` Product: ${result.productId}`); @@ -102,30 +74,52 @@ export default class BillingStatus extends Command { // Display line items if available if (result.lineItems && result.lineItems.length > 0) { this.log(`\n${chalk.bold(" Line Items:")}`); + const productLabel = `${flags.product.charAt(0).toUpperCase()}${flags.product.slice(1)}`; for (const item of result.lineItems) { - const product = `${flags.product.charAt(0).toUpperCase()}${flags.product.slice(1)}`; - const isChainSpecific = item.description.match(/\b(sepolia|mainnet)\b/i); - if (isChainSpecific) { - const chain = item.description.toLowerCase().includes("sepolia") - ? "Sepolia" - : "Mainnet"; - this.log( - ` • ${product} (${chain}): $${item.subtotal.toFixed(2)} (${item.quantity} vCPU hours × $${item.price.toFixed(3)}/vCPU hour)`, - ); - } else { - const sku = item.description.split(" ").slice(-2).join(" ") || "Unknown"; - this.log( - ` • ${product} (${sku}): $${item.subtotal.toFixed(2)} (${item.quantity} hours × $${item.price.toFixed(3)}/hour)`, - ); - } + this.log(formatLineItem(item, productLabel)); } } - // Display remaining credits - const credits = result.remainingCredits ?? 0; - this.log( - ` Credits: ${chalk.cyan(`$${credits.toFixed(2)}`)}${formatExpiry(result.nextCreditExpiry)}`, - ); + // --- Funds: Stripe credit split + on-chain wallet (each best-effort) --- + let credits: AccountCreditsResponse | undefined; + try { + credits = await billing.getAccountCredits(); + } catch (err) { + this.warn(`Could not read credit split: ${errorMessage(err)}`); + } + + let walletEthFormatted: string | undefined; + let walletUsdcFormatted: string | undefined; + const privateKey = flags["private-key"]; + if (privateKey) { + const environment = flags.environment; + const environmentConfig = getEnvironmentConfig(environment); + const rpcUrl = flags["rpc-url"] || environmentConfig.defaultRPCURL; + try { + const { publicClient, address } = createViemClients({ privateKey, rpcUrl, environment }); + const balanceWei = await publicClient.getBalance({ address }); + walletEthFormatted = formatEthDisplay(balanceWei); + } catch (err) { + this.warn(`Could not read wallet ETH balance: ${errorMessage(err)}`); + } + try { + const { usdcBalance } = await billing.getTopUpInfo(); + walletUsdcFormatted = formatUnits(usdcBalance, 6); + } catch (err) { + this.warn(`Could not read wallet USDC balance: ${errorMessage(err)}`); + } + } + + for (const line of formatFundsBlock({ + env: flags.environment, + credits, + remainingCreditsFallback: result.remainingCredits, + nextCreditExpiryFallback: result.nextCreditExpiry, + walletEthFormatted, + walletUsdcFormatted, + })) { + this.log(line); + } // Display cancellation information if (result.cancelAtPeriodEnd) { diff --git a/packages/cli/src/commands/compute/app/__tests__/deploy.guard.test.ts b/packages/cli/src/commands/compute/app/__tests__/deploy.guard.test.ts new file mode 100644 index 0000000..17d00a4 --- /dev/null +++ b/packages/cli/src/commands/compute/app/__tests__/deploy.guard.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Address } from "viem"; + +const APP_A = "0x01d3e5851c5F361b4E4988fd3cCc503a6D7b5c09" as Address; + +const findLiveAppByName = vi.fn(); +vi.mock("../../../../utils/appCollision", () => ({ + findLiveAppByName: (...a: unknown[]) => findLiveAppByName(...(a as [])), +})); + +import { assertNoLiveNameCollision } from "../deploy"; + +function makeCtx() { + const errors: { message: string; exit?: number }[] = []; + const warns: string[] = []; + return { + errors, + warns, + cmd: { + error: (message: string, opts?: { exit?: number }) => { + errors.push({ message, exit: opts?.exit }); + throw new Error(message); // oclif's this.error throws + }, + warn: (message: string) => warns.push(message), + }, + }; +} + +const ARGS = { environment: "sepolia-dev", privateKey: "0xkey", rpcUrl: "https://rpc.test", name: "foo" }; + +describe("assertNoLiveNameCollision", () => { + beforeEach(() => vi.clearAllMocks()); + + it("errors with exit 2 and references upgrade when a live same-named app exists", async () => { + findLiveAppByName.mockResolvedValue(APP_A); + const { cmd, errors } = makeCtx(); + + await expect( + assertNoLiveNameCollision(cmd as any, { ...ARGS, forceNew: false }), + ).rejects.toThrow(); + + expect(errors).toHaveLength(1); + expect(errors[0].exit).toBe(2); + expect(errors[0].message).toContain(APP_A); + expect(errors[0].message).toContain("upgrade"); + expect(errors[0].message).toContain("--force-new"); + }); + + it("skips the check entirely when --force-new is set", async () => { + const { cmd } = makeCtx(); + await assertNoLiveNameCollision(cmd as any, { ...ARGS, forceNew: true }); + expect(findLiveAppByName).not.toHaveBeenCalled(); + }); + + it("proceeds (no error) when there is no collision", async () => { + findLiveAppByName.mockResolvedValue(undefined); + const { cmd, errors } = makeCtx(); + await assertNoLiveNameCollision(cmd as any, { ...ARGS, forceNew: false }); + expect(errors).toHaveLength(0); + }); + + it("fails open (warns, does not error) when the collision check throws", async () => { + findLiveAppByName.mockRejectedValue(new Error("rpc down")); + const { cmd, errors, warns } = makeCtx(); + + await assertNoLiveNameCollision(cmd as any, { ...ARGS, forceNew: false }); + + expect(errors).toHaveLength(0); + expect(warns).toHaveLength(1); + expect(warns[0]).toContain(ARGS.name); + }); +}); diff --git a/packages/cli/src/commands/compute/app/__tests__/upgrade.reconcile.test.ts b/packages/cli/src/commands/compute/app/__tests__/upgrade.reconcile.test.ts new file mode 100644 index 0000000..05e20c8 --- /dev/null +++ b/packages/cli/src/commands/compute/app/__tests__/upgrade.reconcile.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const reconcileReleaseDigest = vi.fn(); + +import { reconcileAndReport } from "../upgrade"; + +function makeCmd() { + const logs: string[] = []; + const warns: string[] = []; + return { + logs, + warns, + cmd: { + log: (m = "") => logs.push(m), + warn: (m: string) => warns.push(m), + debug: (_m: string) => {}, + }, + }; +} + +const APP = "0x01d3e5851c5F361b4E4988fd3cCc503a6D7b5c09"; +const DIGEST = `sha256:${"a".repeat(64)}`; + +describe("reconcileAndReport", () => { + beforeEach(() => vi.clearAllMocks()); + + it("logs the confirmed digest when reconciliation matches", async () => { + const compute = { app: { reconcileReleaseDigest: reconcileReleaseDigest.mockResolvedValue({ matched: true, lastDigest: DIGEST, elapsedMs: 10 }) } }; + const { cmd, logs, warns } = makeCmd(); + await reconcileAndReport(cmd as any, compute as any, APP, DIGEST); + expect(reconcileReleaseDigest).toHaveBeenCalledWith(APP, DIGEST); + expect(logs.join("\n")).toContain(DIGEST); + expect(warns).toHaveLength(0); + }); + + it("warns about pending propagation when reconciliation times out", async () => { + const compute = { app: { reconcileReleaseDigest: reconcileReleaseDigest.mockResolvedValue({ matched: false, lastDigest: "sha256:old", elapsedMs: 45000 }) } }; + const { cmd, warns } = makeCmd(); + await reconcileAndReport(cmd as any, compute as any, APP, DIGEST); + expect(warns).toHaveLength(1); + expect(warns[0].toLowerCase()).toContain("propagation"); + expect(warns[0]).toContain(APP); + }); + + it("skips silently when no expected digest is known", async () => { + const compute = { app: { reconcileReleaseDigest } }; + const { cmd, warns } = makeCmd(); + await reconcileAndReport(cmd as any, compute as any, APP, undefined); + expect(reconcileReleaseDigest).not.toHaveBeenCalled(); + expect(warns).toHaveLength(0); + }); + + it("fails open (no throw, no warn) when reconciliation itself errors", async () => { + const compute = { app: { reconcileReleaseDigest: reconcileReleaseDigest.mockRejectedValue(new Error("boom")) } }; + const { cmd, warns } = makeCmd(); + await expect(reconcileAndReport(cmd as any, compute as any, APP, DIGEST)).resolves.toBeUndefined(); + expect(warns).toHaveLength(0); + }); +}); diff --git a/packages/cli/src/commands/compute/app/deploy.ts b/packages/cli/src/commands/compute/app/deploy.ts index c350799..09a709f 100644 --- a/packages/cli/src/commands/compute/app/deploy.ts +++ b/packages/cli/src/commands/compute/app/deploy.ts @@ -36,11 +36,61 @@ import { assertEigencloudContainersImageRef, resolveDockerHubImageDigest, } from "../../../utils/dockerhub"; -import { isTlsEnabledFromEnvFile } from "../../../utils/tls"; +import { isTlsEnabledFromEnvFile, TLS_DISABLED_WARNING } from "../../../utils/tls"; import { mergeInlineEnvVars } from "../../../utils/env"; import { stageFailure } from "../../../utils/exitCodes"; +import { findLiveAppByName } from "../../../utils/appCollision"; import type { SubmitBuildRequest } from "@layr-labs/ecloud-sdk"; +/** Args for the deploy-time name-collision guard. */ +export interface NameCollisionGuardArgs { + environment: string; + privateKey: string; + rpcUrl: string; + name: string; + forceNew: boolean; +} + +/** + * Guard: refuse to provision a second billable app that shares a live app's + * name. Errors (exit 2) on a collision unless forceNew. Fail-open — if the + * check itself cannot complete, warn and let the deploy proceed. + * + * `cmd` is the oclif Command (for this.error/this.warn); typed minimally so the + * function stays unit-testable without a full Command instance. + */ +export async function assertNoLiveNameCollision( + cmd: { error(message: string, opts?: { exit?: number }): never; warn(message: string): void }, + args: NameCollisionGuardArgs, +): Promise { + if (args.forceNew) { + return; + } + + let existing: Awaited>; + try { + existing = await findLiveAppByName({ + environment: args.environment, + privateKey: args.privateKey, + rpcUrl: args.rpcUrl, + name: args.name, + }); + } catch { + cmd.warn( + `Could not verify whether an app named '${args.name}' already exists; proceeding.`, + ); + return; + } + + if (existing) { + cmd.error( + `App '${args.name}' already exists at ${existing} — upgrade it with ` + + `'ecloud compute app upgrade ${existing}', or pass --force-new to deploy a separate app.`, + { exit: 2 }, + ); + } +} + export default class AppDeploy extends Command { static description = "Deploy new app"; @@ -153,6 +203,11 @@ export default class AppDeploy extends Command { default: false, env: "ECLOUD_FORCE", }), + "force-new": Flags.boolean({ + description: + "Deploy a new app even if a non-terminated app with the same name already exists. Without this, deploy errors and points you to 'app upgrade'.", + default: false, + }), "watch-timeout": Flags.integer({ description: "Maximum seconds to wait for the app to start before returning a recovery hint (default: 600)", @@ -429,6 +484,17 @@ export default class AppDeploy extends Command { skipDefaultAppName, ); + // 3b. Guard against accidentally provisioning a duplicate billable app: + // error if a non-terminated app with this name already exists on-chain + // for the caller (unless --force-new). Fail-open inside the guard. + await assertNoLiveNameCollision(this, { + environment, + privateKey, + rpcUrl, + name: appName, + forceNew: flags["force-new"], + }); + // 4. Get env file path interactively envFilePath = envFilePath ?? (await getEnvFile(flags["env-file"], nonInteractive)); @@ -437,6 +503,12 @@ export default class AppDeploy extends Command { envFilePath = mergeInlineEnvVars(envFilePath, flags.env); } + // 4c. Warn if DOMAIN is unset — the app will run, but nothing binds + // ports 80/443, so HTTP(S) requests are refused with no other signal. + if (!isTlsEnabledFromEnvFile(envFilePath)) { + this.warn(TLS_DISABLED_WARNING); + } + // 5. Get instance type interactively const availableTypes = await fetchAvailableInstanceTypes( environment, diff --git a/packages/cli/src/commands/compute/app/info.ts b/packages/cli/src/commands/compute/app/info.ts index 511867a..636a0c6 100644 --- a/packages/cli/src/commands/compute/app/info.ts +++ b/packages/cli/src/commands/compute/app/info.ts @@ -164,6 +164,7 @@ export default class AppInfo extends Command { printAppDisplay(display, this.log.bind(this), " ", { singleAddress: false, showProfile: true, + showTls: true, }); // Show verifiability status diff --git a/packages/cli/src/commands/compute/app/status.ts b/packages/cli/src/commands/compute/app/status.ts index 7dad717..fdc6137 100644 --- a/packages/cli/src/commands/compute/app/status.ts +++ b/packages/cli/src/commands/compute/app/status.ts @@ -29,7 +29,7 @@ export default class AppStatus extends Command { ...commonFlags, wait: Flags.boolean({ description: - "Block until the app reaches a terminal status (Running/Stopped) or the watch timeout elapses, instead of returning immediately", + "Block while the app is still transitioning (Deploying/Upgrading/etc.) until it settles or the watch timeout elapses. Already-settled statuses (Running/Stopped/Terminated/...) return immediately.", default: false, }), "watch-timeout": Flags.integer({ diff --git a/packages/cli/src/commands/compute/app/upgrade.ts b/packages/cli/src/commands/compute/app/upgrade.ts index 7004e22..78bb4d4 100644 --- a/packages/cli/src/commands/compute/app/upgrade.ts +++ b/packages/cli/src/commands/compute/app/upgrade.ts @@ -39,11 +39,42 @@ import { assertEigencloudContainersImageRef, resolveDockerHubImageDigest, } from "../../../utils/dockerhub"; -import { isTlsEnabledFromEnvFile } from "../../../utils/tls"; +import { isTlsEnabledFromEnvFile, TLS_DISABLED_WARNING } from "../../../utils/tls"; import { mergeInlineEnvVars } from "../../../utils/env"; import { stageFailure } from "../../../utils/exitCodes"; import type { SubmitBuildRequest } from "@layr-labs/ecloud-sdk"; +/** + * After an upgrade, reconcile the indexer-served release digest against the + * digest we just deployed. Match → confirm. Timeout → warn (propagation in + * progress) but do not fail. Unknown expected digest → skip. Fail-open: a + * reconciliation error never blocks the already-successful upgrade. + */ +export async function reconcileAndReport( + cmd: { log(message?: string): void; warn(message: string): void; debug?(message: string): void }, + compute: { app: { reconcileReleaseDigest(appId: string, expected: string, opts?: { intervalMs?: number; timeoutMs?: number }): Promise<{ matched: boolean; lastDigest?: string; elapsedMs: number }> } }, + appId: string, + expectedDigest: string | undefined, +): Promise { + if (!expectedDigest) { + return; // can't reconcile without a target; no regression vs. prior behavior + } + try { + // Use the SDK's default poll cadence and timeout (currently 3s / 45s). + const result = await compute.app.reconcileReleaseDigest(appId, expectedDigest); + if (result.matched) { + cmd.log(`Upgraded to ${expectedDigest}`); + } else { + cmd.warn( + `New release not yet visible — indexer propagation in progress. ` + + `Re-check with 'ecloud compute app releases ${appId}' shortly.`, + ); + } + } catch (err: any) { + cmd.debug?.(`reconcileReleaseDigest failed (ignored): ${err?.message ?? err}`); + } +} + export default class AppUpgrade extends Command { static description = "Upgrade existing deployment"; @@ -370,6 +401,12 @@ export default class AppUpgrade extends Command { envFilePath = mergeInlineEnvVars(envFilePath, flags.env); } + // 4c. Warn if DOMAIN is unset — the app will run, but nothing binds + // ports 80/443, so HTTP(S) requests are refused with no other signal. + if (!isTlsEnabledFromEnvFile(envFilePath)) { + this.warn(TLS_DISABLED_WARNING); + } + // 5. Get current instance type (best-effort, used as default) const { publicClient, walletClient, address } = createViemClients({ privateKey, @@ -455,6 +492,9 @@ export default class AppUpgrade extends Command { this.error(message, { exit }); } + // Digest we expect the upgrade to publish, for post-upgrade reconciliation. + const expectedDigest: string | undefined = verifiableImageDigest ?? prepared.imageDigest; + // 10. Apply gas overrides if provided, show estimate, and prompt for confirmation on mainnet const finalTx = await applyTxOverrides(gasEstimate, flags, { publicClient, address }); if (flags["max-fee-per-gas"] || flags["max-priority-fee"]) { @@ -527,6 +567,17 @@ export default class AppUpgrade extends Command { `\n✅ ${chalk.green(`App upgraded successfully ${chalk.bold(`(id: ${res.appId}, image: ${res.imageRef})`)}`)}`, ); + await reconcileAndReport( + { + log: (m?: string) => this.log(m), + warn: (m: string) => this.warn(m), + debug: (m: string) => this.debug(m), + }, + compute, + res.appId, + expectedDigest, + ); + // Update profile name if --name was provided (merge with existing profile to avoid wiping fields) if (flags.name) { try { diff --git a/packages/cli/src/utils/__tests__/appCollision.test.ts b/packages/cli/src/utils/__tests__/appCollision.test.ts new file mode 100644 index 0000000..56d998a --- /dev/null +++ b/packages/cli/src/utils/__tests__/appCollision.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Address } from "viem"; + +const APP_A = "0x01d3e5851c5F361b4E4988fd3cCc503a6D7b5c09" as Address; +const APP_B = "0x02d3e5851c5F361b4E4988fd3cCc503a6D7b5c10" as Address; + +// SDK: getAllAppsByDeveloper + UserApiClient +const getAllAppsByDeveloper = vi.fn(); +vi.mock("@layr-labs/ecloud-sdk", () => ({ + getEnvironmentConfig: vi.fn(() => ({ name: "sepolia-dev", defaultRPCURL: "https://rpc.test" })), + getAllAppsByDeveloper: (...a: unknown[]) => getAllAppsByDeveloper(...(a as [])), + UserApiClient: class {}, +})); + +// viem clients (CLI wrapper) +vi.mock("../viemClients", () => ({ + createViemClients: vi.fn(() => ({ + publicClient: {}, + walletClient: { account: { address: "0xdev" } }, + address: "0xdev", + })), +})); + +vi.mock("../version", () => ({ getClientId: vi.fn(() => "test") })); + +// profile-name resolution +const getAppInfosChunked = vi.fn(); +vi.mock("../appResolver", () => ({ + getAppInfosChunked: (...a: unknown[]) => getAppInfosChunked(...(a as [])), +})); + +import { findLiveAppByName } from "../appCollision"; + +const ARGS = { + environment: "sepolia-dev", + privateKey: "0xkey", + rpcUrl: "https://rpc.test", +}; + +const RUNNING = 1; +const TERMINATED = 3; + +describe("findLiveAppByName", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns the address of a live app with a matching profile name", async () => { + getAllAppsByDeveloper.mockResolvedValue({ + apps: [APP_A], + appConfigs: [{ status: RUNNING }], + }); + getAppInfosChunked.mockResolvedValue([{ address: APP_A, status: "Running", profile: { name: "foo" } }]); + + const hit = await findLiveAppByName({ ...ARGS, name: "foo" }); + expect(hit).toBe(APP_A); + }); + + it("ignores a terminated app with the same name", async () => { + getAllAppsByDeveloper.mockResolvedValue({ + apps: [APP_A], + appConfigs: [{ status: TERMINATED }], + }); + getAppInfosChunked.mockResolvedValue([]); + + const hit = await findLiveAppByName({ ...ARGS, name: "foo" }); + expect(hit).toBeUndefined(); + expect(getAppInfosChunked).toHaveBeenCalledWith(expect.anything(), []); + }); + + it("returns undefined when no live app's name matches", async () => { + getAllAppsByDeveloper.mockResolvedValue({ + apps: [APP_A, APP_B], + appConfigs: [{ status: RUNNING }, { status: RUNNING }], + }); + getAppInfosChunked.mockResolvedValue([ + { address: APP_A, status: "Running", profile: { name: "bar" } }, + { address: APP_B, status: "Running", profile: { name: "baz" } }, + ]); + + const hit = await findLiveAppByName({ ...ARGS, name: "foo" }); + expect(hit).toBeUndefined(); + }); + + it("matches case-insensitively and ignores surrounding whitespace", async () => { + getAllAppsByDeveloper.mockResolvedValue({ + apps: [APP_A], + appConfigs: [{ status: RUNNING }], + }); + getAppInfosChunked.mockResolvedValue([{ address: APP_A, status: "Running", profile: { name: "Foo" } }]); + + const hit = await findLiveAppByName({ ...ARGS, name: " FOO " }); + expect(hit).toBe(APP_A); + }); + + it("ignores live apps that have no profile name yet", async () => { + getAllAppsByDeveloper.mockResolvedValue({ + apps: [APP_A], + appConfigs: [{ status: RUNNING }], + }); + getAppInfosChunked.mockResolvedValue([{ address: APP_A, status: "Running", profile: undefined }]); + + const hit = await findLiveAppByName({ ...ARGS, name: "foo" }); + expect(hit).toBeUndefined(); + }); + + it("fails open (returns undefined) when enumeration throws", async () => { + getAllAppsByDeveloper.mockRejectedValue(new Error("rpc down")); + + const hit = await findLiveAppByName({ ...ARGS, name: "foo" }); + expect(hit).toBeUndefined(); + }); + + it("fails open (returns undefined) when profile resolution throws", async () => { + getAllAppsByDeveloper.mockResolvedValue({ + apps: [APP_A], + appConfigs: [{ status: RUNNING }], + }); + getAppInfosChunked.mockRejectedValue(new Error("userapi 500")); + + const hit = await findLiveAppByName({ ...ARGS, name: "foo" }); + expect(hit).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/utils/__tests__/billingFormat.test.ts b/packages/cli/src/utils/__tests__/billingFormat.test.ts new file mode 100644 index 0000000..98c11d8 --- /dev/null +++ b/packages/cli/src/utils/__tests__/billingFormat.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect } from "vitest"; +import { formatFundsBlock } from "../billingFormat"; + +const credits = { + remainingCredits: 25, + permanentCredits: 0, + promotionalCredits: 25, + nextPromotionalCreditExpiry: 1751328000, // 2025-07-01 UTC +}; + +describe("formatFundsBlock", () => { + it("renders promotional (with expiry), paid, total, and wallet ETH+USDC", () => { + const out = formatFundsBlock({ + env: "sepolia", + credits, + walletEthFormatted: "0.0", + walletUsdcFormatted: "0.00", + }).join("\n"); + expect(out).toContain("Credits (Stripe):"); + expect(out).toContain("Promotional:"); + expect(out).toContain("$25.00"); + expect(out).toContain("expires"); + expect(out).toContain("Paid:"); + expect(out).toContain("Total:"); + expect(out).toContain("separate from"); + expect(out).toContain("Wallet (sepolia):"); + expect(out).toContain("0.0 ETH"); + expect(out).toContain("0.00 USDC"); + }); + + it("omits the expiry suffix when there is no promotional expiry", () => { + const out = formatFundsBlock({ + env: "sepolia", + credits: { ...credits, nextPromotionalCreditExpiry: 0 }, + walletEthFormatted: "0.0", + walletUsdcFormatted: "0.00", + }).join("\n"); + expect(out).toContain("Promotional:"); + expect(out).not.toContain("expires"); + }); + + it("falls back to a single Stripe line when the split is unavailable", () => { + const out = formatFundsBlock({ + env: "sepolia", + credits: undefined, + remainingCreditsFallback: 12.5, + walletEthFormatted: "0.0", + walletUsdcFormatted: "0.00", + }).join("\n"); + expect(out).toContain("Credit balance (Stripe): $12.50"); + expect(out).not.toContain("Promotional:"); + }); + + it("omits the USDC line when wallet USDC is unavailable", () => { + const out = formatFundsBlock({ + env: "sepolia", + credits, + walletEthFormatted: "0.0", + walletUsdcFormatted: undefined, + }).join("\n"); + expect(out).toContain("0.0 ETH"); + expect(out).not.toContain("USDC"); + }); + + it("omits the ETH line when wallet ETH is unavailable", () => { + const out = formatFundsBlock({ + env: "sepolia", + credits, + walletEthFormatted: undefined, + walletUsdcFormatted: "0.00", + }).join("\n"); + expect(out).not.toContain("ETH"); + expect(out).toContain("0.00 USDC"); + }); + + it("omits the entire Wallet block when neither ETH nor USDC is available", () => { + const out = formatFundsBlock({ + env: "sepolia", + credits, + }).join("\n"); + expect(out).not.toContain("Wallet ("); + }); + + it("shows the fund-with-ETH note for any zero-valued ETH string", () => { + for (const z of ["0", "0.0", "0.00", "0.000000000000000000"]) { + const out = formatFundsBlock({ + env: "sepolia", + credits, + walletEthFormatted: z, + walletUsdcFormatted: "0.00", + }).join("\n"); + expect(out).toContain("fund with ETH"); + } + }); + + it("does NOT show the fund-with-ETH note when ETH is non-zero", () => { + const out = formatFundsBlock({ + env: "sepolia", + credits, + walletEthFormatted: "1.5", + walletUsdcFormatted: "0.00", + }).join("\n"); + expect(out).toContain("1.5 ETH"); + expect(out).not.toContain("fund with ETH"); + }); +}); + +import { formatLineItem } from "../billingFormat"; + +describe("formatLineItem", () => { + const base = { currency: "usd" }; + + it("parses the SKU from the API description and uses structured numerics", () => { + const line = formatLineItem( + { ...base, description: "0 × Pro 1 (at $0.07395890411 / month)", price: 0.07395890411, quantity: 0, subtotal: 0 }, + "Compute", + ); + expect(line).toContain("Compute (Pro 1)"); + expect(line).toContain("$0.00"); + // API price is the hourly rate (matches the published pricing table), + // despite the description text saying "/ month". + expect(line).toContain("0 hours × $0.074/hour"); + expect(line).not.toContain("month))"); + }); + + it("handles a multi-word SKU", () => { + const line = formatLineItem( + { ...base, description: "2 × Enterprise 1 (at $0.32875342466 / month)", price: 0.32875342466, quantity: 2, subtotal: 0.66 }, + "Compute", + ); + expect(line).toContain("Compute (Enterprise 1)"); + expect(line).toContain("$0.66"); + expect(line).toContain("2 hours × $0.329/hour"); + }); + + it("uses structured fields, not numbers embedded in the description", () => { + const line = formatLineItem( + { ...base, description: "0 × Starter 2 (at $9.99 / month)", price: 0.05, quantity: 7, subtotal: 1.23 }, + "Compute", + ); + expect(line).toContain("Compute (Starter 2)"); + expect(line).toContain("$1.23"); + expect(line).toContain("7 hours × $0.050/hour"); + expect(line).not.toContain("9.99"); + }); + + it("falls back to the raw description when the format does not match", () => { + const line = formatLineItem( + { ...base, description: "weird unparseable format", price: 0.1, quantity: 1, subtotal: 0.1 }, + "Compute", + ); + expect(line).toContain("weird unparseable format"); + expect(line).not.toContain("Compute ("); + }); +}); + +import { formatEthDisplay } from "../billingFormat"; + +describe("formatEthDisplay", () => { + it("rounds to at most 4 decimal places (the most significant)", () => { + expect(formatEthDisplay(98925371351956974n)).toBe("0.0989"); // 0.098925371351956974 + }); + it("strips trailing zeros (up to 4 dp, not padded)", () => { + expect(formatEthDisplay(1500000000000000000n)).toBe("1.5"); // 1.5 ETH + expect(formatEthDisplay(1000000000000000000n)).toBe("1"); // 1 ETH + }); + it("renders exact zero as '0' (keeps the fund-with-ETH note firing)", () => { + expect(formatEthDisplay(0n)).toBe("0"); + }); + it("rounds sub-0.0001 dust down to '0'", () => { + expect(formatEthDisplay(100000000000n)).toBe("0"); // 0.0000001 ETH + }); +}); diff --git a/packages/cli/src/utils/__tests__/format.tls.test.ts b/packages/cli/src/utils/__tests__/format.tls.test.ts new file mode 100644 index 0000000..d9a8dc2 --- /dev/null +++ b/packages/cli/src/utils/__tests__/format.tls.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; +import { printAppDisplay } from "../format"; + +// Minimal FormattedAppDisplay stand-in (only fields printAppDisplay reads). +function fakeDisplay() { + return { + name: "n", + id: "0xabc", + releaseTime: "-", + status: "Running", + instance: "g1", + ip: "1.2.3.4", + cpu: "-", + memory: "-", + memoryUsage: "", + evmAddresses: [], + solanaAddresses: [], + profile: undefined, + } as any; +} + +describe("printAppDisplay TLS line", () => { + it("emits a TLS line referencing configure tls", () => { + const lines: string[] = []; + printAppDisplay(fakeDisplay(), (m) => lines.push(m), " ", { showTls: true }); + const out = lines.join("\n"); + expect(out).toContain("TLS:"); + expect(out).toContain("configure tls"); + }); + + it("prints TLS right after the IP line", () => { + const lines: string[] = []; + printAppDisplay(fakeDisplay(), (m) => lines.push(m), " ", { showTls: true }); + const ipIdx = lines.findIndex((l) => l.includes("IP:")); + const tlsIdx = lines.findIndex((l) => l.includes("TLS:")); + expect(ipIdx).toBeGreaterThanOrEqual(0); + expect(tlsIdx).toBe(ipIdx + 1); + }); + + it("omits the TLS line when showTls is not set (list mode)", () => { + const lines: string[] = []; + printAppDisplay(fakeDisplay(), (m) => lines.push(m)); + expect(lines.join("\n")).not.toContain("TLS:"); + }); +}); diff --git a/packages/cli/src/utils/__tests__/tls.test.ts b/packages/cli/src/utils/__tests__/tls.test.ts new file mode 100644 index 0000000..5d56628 --- /dev/null +++ b/packages/cli/src/utils/__tests__/tls.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { + isTlsEnabledFromEnvFile, + isTlsEnabledFromDomain, + TLS_DISABLED_WARNING, + TLS_INFO_LINE, +} from "../tls"; + +describe("isTlsEnabledFromDomain", () => { + it("is false for empty/undefined/localhost, true otherwise", () => { + expect(isTlsEnabledFromDomain(undefined)).toBe(false); + expect(isTlsEnabledFromDomain("")).toBe(false); + expect(isTlsEnabledFromDomain(" ")).toBe(false); + expect(isTlsEnabledFromDomain("localhost")).toBe(false); + expect(isTlsEnabledFromDomain("LocalHost")).toBe(false); + expect(isTlsEnabledFromDomain("app.example.com")).toBe(true); + }); +}); + +describe("isTlsEnabledFromEnvFile", () => { + let dir: string; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "tls-test-")); + }); + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it("returns false when no path is given", () => { + expect(isTlsEnabledFromEnvFile(undefined)).toBe(false); + }); + + it("returns false when the file does not exist", () => { + expect(isTlsEnabledFromEnvFile(path.join(dir, "nope.env"))).toBe(false); + }); + + it("returns false when DOMAIN is absent", () => { + const p = path.join(dir, "a.env"); + fs.writeFileSync(p, "FOO=bar\nBAZ=qux\n"); + expect(isTlsEnabledFromEnvFile(p)).toBe(false); + }); + + it("returns false when DOMAIN is localhost", () => { + const p = path.join(dir, "b.env"); + fs.writeFileSync(p, "DOMAIN=localhost\n"); + expect(isTlsEnabledFromEnvFile(p)).toBe(false); + }); + + it("returns true when DOMAIN is a real host", () => { + const p = path.join(dir, "c.env"); + fs.writeFileSync(p, "FOO=bar\nDOMAIN=app.example.com\n"); + expect(isTlsEnabledFromEnvFile(p)).toBe(true); + }); +}); + +describe("TLS user-facing strings", () => { + it("both reference the configure tls remediation command", () => { + expect(TLS_DISABLED_WARNING).toContain("configure tls"); + expect(TLS_INFO_LINE).toContain("configure tls"); + }); + it("the disabled warning mentions DOMAIN and the ports", () => { + expect(TLS_DISABLED_WARNING).toContain("DOMAIN"); + expect(TLS_DISABLED_WARNING).toContain("80/443"); + }); +}); diff --git a/packages/cli/src/utils/appCollision.ts b/packages/cli/src/utils/appCollision.ts new file mode 100644 index 0000000..11b6006 --- /dev/null +++ b/packages/cli/src/utils/appCollision.ts @@ -0,0 +1,80 @@ +/** + * Deploy-time guard against accidentally provisioning a second billable app + * with a name that is already in use by a live app for the same developer. + * + * Authoritative by construction: enumeration is a direct on-chain read + * (getAllAppsByDeveloper) and names come from the coordinator-DB-backed /info + * profile — neither touches the lagging Ponder indexer. The check is + * fail-open: any read error returns undefined so a transient blip never blocks + * a legitimate deploy. + */ +import { Address } from "viem"; +import { + getAllAppsByDeveloper, + getEnvironmentConfig, + UserApiClient, +} from "@layr-labs/ecloud-sdk"; +import { createViemClients } from "./viemClients"; +import { getAppInfosChunked } from "./appResolver"; +import { getClientId } from "./version"; + +// Mirrors ContractAppStatusTerminated in prompts.ts (AppStatus enum). +const CONTRACT_APP_STATUS_TERMINATED = 3; + +export interface FindLiveAppByNameArgs { + environment: string; + privateKey: string; + rpcUrl: string; + name: string; +} + +/** Normalize a profile name for comparison: trimmed + lowercased. */ +function normalizeName(name: string): string { + return name.trim().toLowerCase(); +} + +/** + * Return the address of a non-terminated app owned by the caller whose profile + * name matches `name`, or undefined if none (or if the check cannot complete). + */ +export async function findLiveAppByName( + args: FindLiveAppByNameArgs, +): Promise
{ + try { + const environmentConfig = getEnvironmentConfig(args.environment); + const { publicClient, walletClient, address } = createViemClients({ + privateKey: args.privateKey, + rpcUrl: args.rpcUrl, + environment: args.environment, + }); + + const { apps, appConfigs } = await getAllAppsByDeveloper( + publicClient, + environmentConfig, + address, + ); + + // Keep only non-terminated apps. + const liveApps: Address[] = []; + for (let i = 0; i < apps.length; i++) { + if (appConfigs[i]?.status !== CONTRACT_APP_STATUS_TERMINATED) { + liveApps.push(apps[i]); + } + } + const userApiClient = new UserApiClient(environmentConfig, walletClient, publicClient, { + clientId: getClientId(), + }); + const infos = await getAppInfosChunked(userApiClient, liveApps); + + const target = normalizeName(args.name); + const match = infos.find( + (info) => info.profile?.name && normalizeName(info.profile.name) === target, + ); + return match?.address; + } catch (error) { + // Fail-open: never block a deploy on a read failure. Log at debug so a + // maintainer can see why the collision guard didn't fire. + console.debug?.("findLiveAppByName: name-collision check failed, proceeding:", error); + return undefined; + } +} diff --git a/packages/cli/src/utils/billingFormat.ts b/packages/cli/src/utils/billingFormat.ts new file mode 100644 index 0000000..6f332d0 --- /dev/null +++ b/packages/cli/src/utils/billingFormat.ts @@ -0,0 +1,108 @@ +import chalk from "chalk"; +import { formatEther } from "viem"; +import type { AccountCreditsResponse, SubscriptionLineItem } from "@layr-labs/ecloud-sdk"; + +/** + * Format a wei balance as ETH for display: the 18-decimal raw value is noise, + * so round to at most 4 decimal places (enough to judge gas) and strip trailing + * zeros. Exact zero renders as "0" (which keeps the "fund with ETH" hint firing, + * since a sub-0.0001 dust balance also rounds to "0" and can't pay gas anyway). + */ +export function formatEthDisplay(wei: bigint): string { + const eth = Number(formatEther(wei)); + // toFixed(4) then drop trailing zeros and any dangling decimal point. + return eth.toFixed(4).replace(/\.?0+$/, ""); +} + +/** + * Render one subscription line item as a display line. + * + * The API description has the form " × (at $ / month)" — the + * SKU name (e.g. "Pro 1") lives only in that string, so we parse it out, but + * the quantity/price/subtotal come from the structured fields (never parsed + * from the text). If the description doesn't match the expected shape, fall + * back to printing it verbatim rather than rendering garbage. + * + * NOTE on units: the API `price` is the *hourly* rate (e.g. Pro 1 = $0.074/hr, + * per the published pricing table), and `quantity` is metered hours — despite + * the description string mislabeling the rate "/ month". We render /hour to + * match the authoritative pricing, not the description's text. + */ +export function formatLineItem(item: SubscriptionLineItem, productLabel: string): string { + const match = item.description.match(/×\s*(.+?)\s*\(at\b/); + const sku = match?.[1]?.trim(); + const label = sku ? `${productLabel} (${sku})` : item.description; + return ` • ${label}: $${item.subtotal.toFixed(2)} (${item.quantity} hours × $${item.price.toFixed(3)}/hour)`; +} + +export interface FundsBlockInput { + /** Environment name, for the Wallet header (e.g. "sepolia"). */ + env: string; + /** The 3-way split; undefined when the read failed (→ fallback line). */ + credits?: AccountCreditsResponse; + /** getStatus().remainingCredits, used only in degraded mode. */ + remainingCreditsFallback?: number; + /** getStatus().nextCreditExpiry, used only in degraded mode. */ + nextCreditExpiryFallback?: number; + /** Pre-formatted wallet ETH (e.g. "0.0"); undefined → omit the ETH line. */ + walletEthFormatted?: string; + /** Pre-formatted wallet USDC (e.g. "0.00"); undefined → omit the USDC line. */ + walletUsdcFormatted?: string; +} + +function expirySuffix(unixSeconds?: number): string { + if (!unixSeconds) return ""; + return ` (expires ${new Date(unixSeconds * 1000).toLocaleDateString()})`; +} + +/** + * Build the "Credits (Stripe)" + "Wallet" lines for `billing status`. + * Pure: no I/O, returns the lines to print. + */ +export function formatFundsBlock(input: FundsBlockInput): string[] { + const lines: string[] = []; + + // --- Credits (Stripe) --- + if (input.credits) { + const c = input.credits; + lines.push(`\n${chalk.bold("Credits (Stripe):")}`); + lines.push( + ` Promotional: ${chalk.cyan(`$${c.promotionalCredits.toFixed(2)}`)}${expirySuffix( + c.nextPromotionalCreditExpiry, + )}`, + ); + lines.push(` Paid: ${chalk.cyan(`$${c.permanentCredits.toFixed(2)}`)}`); + lines.push(` Total: ${chalk.cyan(`$${c.remainingCredits.toFixed(2)}`)}`); + lines.push( + chalk.gray(" (Stripe credits pay compute usage — separate from the on-chain wallet below)"), + ); + } else { + // Degraded: split unavailable → single line from getStatus().remainingCredits. + const remaining = input.remainingCreditsFallback ?? 0; + lines.push( + `\n Credit balance (Stripe): ${chalk.cyan(`$${remaining.toFixed(2)}`)}${expirySuffix( + input.nextCreditExpiryFallback, + )}`, + ); + lines.push(chalk.gray(" (separate from on-chain wallet ETH/USDC)")); + } + + // --- Wallet (on-chain) --- + const hasEth = input.walletEthFormatted !== undefined; + const hasUsdc = input.walletUsdcFormatted !== undefined; + if (hasEth || hasUsdc) { + lines.push(`\n${chalk.bold(`Wallet (${input.env}):`)}`); + if (hasEth) { + const note = + Number(input.walletEthFormatted) === 0 + ? chalk.yellow(" (fund with ETH to pay deploy/upgrade gas)") + : ""; + lines.push(` ETH: ${chalk.cyan(`${input.walletEthFormatted} ETH`)}${note}`); + } + if (hasUsdc) { + lines.push(` USDC: ${chalk.cyan(`${input.walletUsdcFormatted} USDC`)}`); + } + } + + return lines; +} diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index 3b9164d..e7b59c4 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -4,6 +4,7 @@ import chalk from "chalk"; import type { AppInfo } from "@layr-labs/ecloud-sdk"; +import { TLS_INFO_LINE } from "./tls"; /** * Format bytes to human readable string @@ -191,15 +192,20 @@ export function printAppDisplay( singleAddress?: boolean; /** Show profile details section */ showProfile?: boolean; + /** Show the static TLS/ports info line (app info only, not list) */ + showTls?: boolean; } = {}, ): void { - const { singleAddress = false, showProfile = false } = options; + const { singleAddress = false, showProfile = false, showTls = false } = options; log(`${indent}ID: ${display.id}`); log(`${indent}Release Time: ${display.releaseTime}`); log(`${indent}Status: ${display.status}`); log(`${indent}Instance: ${display.instance}`); log(`${indent}IP: ${display.ip}`); + if (showTls) { + log(`${indent}TLS: ${chalk.gray(TLS_INFO_LINE)}`); + } log(`${indent}CPU: ${display.cpu}`); log(`${indent}Memory: ${display.memory} ${display.memoryUsage}`); diff --git a/packages/cli/src/utils/tls.ts b/packages/cli/src/utils/tls.ts index 172ad33..9fa5ddb 100644 --- a/packages/cli/src/utils/tls.ts +++ b/packages/cli/src/utils/tls.ts @@ -26,3 +26,17 @@ export function isTlsEnabledFromEnvFile(envFilePath: string | undefined): boolea if (!match?.[1]) return false; return isTlsEnabledFromDomain(match[1]); } + +/** + * Warning shown at deploy/upgrade when DOMAIN is unset (TLS off): the app will + * run, but nothing binds ports 80/443, so HTTP(S) requests are refused. + */ +export const TLS_DISABLED_WARNING = + "DOMAIN not set → ports 80/443 will not be reachable. Run `ecloud compute app configure tls` to enable HTTPS."; + +/** + * Static TLS line for `app info`. DOMAIN is encrypted (private env), so the + * actual on/off state can't be read back from the server — this is informational. + */ +export const TLS_INFO_LINE = + "Set via DOMAIN env (not shown here). If unset, ports 80/443 are closed — run `ecloud compute app configure tls`."; diff --git a/packages/sdk/src/client/common/contract/__tests__/reconcile.test.ts b/packages/sdk/src/client/common/contract/__tests__/reconcile.test.ts new file mode 100644 index 0000000..1a35167 --- /dev/null +++ b/packages/sdk/src/client/common/contract/__tests__/reconcile.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { reconcileReleaseDigest, normalizeDigest } from "../reconcile"; + +// Minimal UserApiClient stand-in: only getApp is used. +function clientReturning(...digestsPerCall: Array) { + let i = 0; + return { + getApp: vi.fn(async () => { + const d = digestsPerCall[Math.min(i, digestsPerCall.length - 1)]; + i++; + return { id: "0xapp", releases: d === undefined ? [] : [{ imageDigest: d }] }; + }), + } as any; +} + +describe("normalizeDigest", () => { + it("strips sha256: and 0x prefixes and lowercases", () => { + const hex = "a".repeat(64); + expect(normalizeDigest(`sha256:${hex.toUpperCase()}`)).toBe(hex); + expect(normalizeDigest(`0x${hex}`)).toBe(hex); + expect(normalizeDigest(hex)).toBe(hex); + }); + it("returns empty string for undefined", () => { + expect(normalizeDigest(undefined)).toBe(""); + }); +}); + +describe("reconcileReleaseDigest", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + const DIGEST = `sha256:${"b".repeat(64)}`; + + it("returns matched immediately when the first read already matches", async () => { + const client = clientReturning(DIGEST); + const p = reconcileReleaseDigest(client, "0xapp", DIGEST, { intervalMs: 1000, timeoutMs: 10000 }); + await vi.runAllTimersAsync(); + const result = await p; + expect(result.matched).toBe(true); + expect(client.getApp).toHaveBeenCalledTimes(1); + }); + + it("matches after several polls (digest catches up)", async () => { + const client = clientReturning(`sha256:${"c".repeat(64)}`, undefined, DIGEST); + const p = reconcileReleaseDigest(client, "0xapp", DIGEST, { intervalMs: 1000, timeoutMs: 10000 }); + await vi.runAllTimersAsync(); + const result = await p; + expect(result.matched).toBe(true); + expect(client.getApp.mock.calls.length).toBeGreaterThanOrEqual(3); + }); + + it("returns matched:false after the timeout when the digest never appears", async () => { + const client = clientReturning(`sha256:${"c".repeat(64)}`); + const p = reconcileReleaseDigest(client, "0xapp", DIGEST, { intervalMs: 1000, timeoutMs: 3000 }); + await vi.runAllTimersAsync(); + const result = await p; + expect(result.matched).toBe(false); + expect(result.lastDigest).toBe(`sha256:${"c".repeat(64)}`); + }); + + it("treats a read error as a non-match and keeps polling until match", async () => { + let call = 0; + const client = { + getApp: vi.fn(async () => { + call++; + if (call === 1) throw new Error("indexer 500"); + return { id: "0xapp", releases: [{ imageDigest: DIGEST }] }; + }), + } as any; + const p = reconcileReleaseDigest(client, "0xapp", DIGEST, { intervalMs: 1000, timeoutMs: 10000 }); + await vi.runAllTimersAsync(); + const result = await p; + expect(result.matched).toBe(true); + expect(call).toBeGreaterThanOrEqual(2); + }); + + it("returns matched:false immediately when the expected digest is empty", async () => { + const client = clientReturning(`sha256:${"e".repeat(64)}`); + const p = reconcileReleaseDigest(client, "0xapp", "", { intervalMs: 1000, timeoutMs: 10000 }); + await vi.runAllTimersAsync(); + const result = await p; + expect(result.matched).toBe(false); + expect(result.elapsedMs).toBe(0); + expect(client.getApp).not.toHaveBeenCalled(); + }); + + it("matches regardless of digest prefix/case differences", async () => { + const client = clientReturning(`0x${"d".repeat(64)}`); + const p = reconcileReleaseDigest(client, "0xapp", `sha256:${"D".repeat(64)}`, { intervalMs: 1000, timeoutMs: 5000 }); + await vi.runAllTimersAsync(); + const result = await p; + expect(result.matched).toBe(true); + }); +}); diff --git a/packages/sdk/src/client/common/contract/reconcile.ts b/packages/sdk/src/client/common/contract/reconcile.ts new file mode 100644 index 0000000..32bac8b --- /dev/null +++ b/packages/sdk/src/client/common/contract/reconcile.ts @@ -0,0 +1,84 @@ +/** + * Release-digest reconciliation. + * + * After an upgrade tx lands and watchUpgrade returns (which keys off app + * STATUS, served from the coordinator DB), the release DIGEST is served from a + * separate Ponder indexer (GraphQL) that lags. This polls getApp until the + * reported release digest matches the digest we just deployed, so callers never + * read the stale pre-upgrade digest. It never throws on timeout — the caller + * decides how to surface a not-yet-propagated result. + */ +import type { Address } from "viem"; +import type { UserApiClient } from "../utils/userapi"; + +export interface ReconcileReleaseDigestOptions { + /** Poll cadence. Default 3000ms. */ + intervalMs?: number; + /** Max wall-clock before giving up (not an error). Default 45000ms. */ + timeoutMs?: number; +} + +export interface ReconcileResult { + matched: boolean; + /** The most recent observed digest (raw, un-normalized), if any. */ + lastDigest?: string; + elapsedMs: number; +} + +const DEFAULT_INTERVAL_MS = 3000; +const DEFAULT_TIMEOUT_MS = 45000; + +/** Normalize a digest for comparison: strip `sha256:`/`0x`, lowercase. */ +export function normalizeDigest(digest: string | undefined): string { + if (!digest) return ""; + return digest.trim().toLowerCase().replace(/^sha256:/, "").replace(/^0x/, ""); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Poll until releases[0].imageDigest matches expectedDigest (normalized) or the + * timeout elapses. Read failures are treated as a non-match and retried. + * + * releases[0] is the newest: both backends return releases newest-first. + */ +export async function reconcileReleaseDigest( + userApiClient: UserApiClient, + appId: string, + expectedDigest: string, + opts?: ReconcileReleaseDigestOptions, +): Promise { + const intervalMs = opts?.intervalMs ?? DEFAULT_INTERVAL_MS; + const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const target = normalizeDigest(expectedDigest); + const startMs = Date.now(); + let lastDigest: string | undefined; + + // Nothing to reconcile against — return immediately rather than burning the + // full timeout budget polling for a target that can never match. + if (!target) { + return { matched: false, lastDigest: undefined, elapsedMs: 0 }; + } + + while (true) { + try { + const app = await userApiClient.getApp(appId as Address); + lastDigest = app.releases?.[0]?.imageDigest; + if (target && normalizeDigest(lastDigest) === target) { + return { matched: true, lastDigest, elapsedMs: Date.now() - startMs }; + } + } catch (error) { + // Transient read failure — treat as non-match and retry within budget. + // Logged at debug so a persistently-failing indexer is distinguishable + // from slow propagation. + console.debug?.("reconcileReleaseDigest: getApp read failed, retrying:", error); + } + + if (Date.now() - startMs >= timeoutMs) { + return { matched: false, lastDigest, elapsedMs: Date.now() - startMs }; + } + await sleep(intervalMs); + } +} diff --git a/packages/sdk/src/client/common/release/prepare.ts b/packages/sdk/src/client/common/release/prepare.ts index 8333d08..4d3dc4f 100644 --- a/packages/sdk/src/client/common/release/prepare.ts +++ b/packages/sdk/src/client/common/release/prepare.ts @@ -14,6 +14,8 @@ import { REGISTRY_PROPAGATION_WAIT_SECONDS } from "../constants"; import { parseAndValidateEnvFile } from "../env/parser"; +import { bytesToHex } from "viem"; + import { Release, EnvironmentConfig, Logger, AppId } from "../types"; export interface PrepareReleaseOptions { @@ -30,6 +32,8 @@ export interface PrepareReleaseOptions { export interface PrepareReleaseResult { release: Release; finalImageRef: string; + /** The pushed image digest as a 0x-prefixed hex string (bytes32). */ + imageDigest: string; } /** @@ -190,5 +194,6 @@ export async function prepareRelease( return { release, finalImageRef, + imageDigest: bytesToHex(digest), }; } diff --git a/packages/sdk/src/client/common/types/index.ts b/packages/sdk/src/client/common/types/index.ts index 0106305..ddeea51 100644 --- a/packages/sdk/src/client/common/types/index.ts +++ b/packages/sdk/src/client/common/types/index.ts @@ -179,6 +179,12 @@ export interface PreparedUpgrade { appId: AppId; /** Final image reference */ imageRef: string; + /** + * Digest of the image this upgrade will publish, when known. May be + * `0x`-prefixed hex (built path) or `sha256:`-prefixed (verifiable path); + * compare via `reconcileReleaseDigest` / `normalizeDigest`, which accept either. + */ + imageDigest?: string; } export interface LifecycleOpts { @@ -457,6 +463,21 @@ export interface ProductSubscriptionResponse { portalUrl?: string; } +/** + * The 3-way credit split from billing-api `GET /v1/accounts/{eth}/credits`. + * All credit figures are in dollars. Distinct from on-chain wallet balances. + */ +export interface AccountCreditsResponse { + /** Total spendable Stripe credit balance, in dollars. */ + remainingCredits: number; + /** Paid credits (Category: Paid / PermanentCredits), in dollars. */ + permanentCredits: number; + /** Promotional credits (Category: Promotional), in dollars. */ + promotionalCredits: number; + /** Unix seconds of the next promotional-credit expiry; 0 if none. */ + nextPromotionalCreditExpiry: number; +} + export interface SubscriptionOpts { productId?: ProductID; /** URL to redirect to after successful checkout */ diff --git a/packages/sdk/src/client/common/utils/__tests__/billingapi.credits.test.ts b/packages/sdk/src/client/common/utils/__tests__/billingapi.credits.test.ts new file mode 100644 index 0000000..82abe2a --- /dev/null +++ b/packages/sdk/src/client/common/utils/__tests__/billingapi.credits.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi } from "vitest"; +import { BillingApiClient } from "../billingapi"; + +const ETH = "0x01d3e5851c5F361b4E4988fd3cCc503a6D7b5c09"; + +function makeClient(jsonResponse: unknown) { + const client = new BillingApiClient( + { billingApiServerURL: "https://billing.test" } as any, + {} as any, + { verbose: false } as any, + ); + const spy = vi + .spyOn(client as any, "makeAuthenticatedRequest") + .mockResolvedValue({ json: async () => jsonResponse, text: async () => "" }); + return { client, spy }; +} + +describe("BillingApiClient.getAccountCredits", () => { + it("calls GET /v1/accounts/{eth}/credits and maps camelCase fields", async () => { + const { client, spy } = makeClient({ + remainingCredits: 25, + permanentCredits: 0, + promotionalCredits: 25, + nextPromotionalCreditExpiry: 1751328000, + }); + const res = await client.getAccountCredits(ETH); + expect(spy).toHaveBeenCalledWith( + `https://billing.test/accounts/${ETH}/credits`, + "GET", + "compute", + ); + expect(res).toEqual({ + remainingCredits: 25, + permanentCredits: 0, + promotionalCredits: 25, + nextPromotionalCreditExpiry: 1751328000, + }); + }); + + it("maps snake_case fields and defaults missing ones to 0", async () => { + const { client } = makeClient({ promotional_credits: 10 }); + const res = await client.getAccountCredits(ETH); + expect(res).toEqual({ + remainingCredits: 0, + permanentCredits: 0, + promotionalCredits: 10, + nextPromotionalCreditExpiry: 0, + }); + }); + + it("coerces non-numeric/garbage values to 0", async () => { + const { client } = makeClient({ + remainingCredits: "abc", + permanentCredits: null, + promotionalCredits: 15, + nextPromotionalCreditExpiry: {}, + }); + const res = await client.getAccountCredits(ETH); + expect(res).toEqual({ + remainingCredits: 0, + permanentCredits: 0, + promotionalCredits: 15, + nextPromotionalCreditExpiry: 0, + }); + }); +}); diff --git a/packages/sdk/src/client/common/utils/billingapi.ts b/packages/sdk/src/client/common/utils/billingapi.ts index b767f22..fa7f526 100644 --- a/packages/sdk/src/client/common/utils/billingapi.ts +++ b/packages/sdk/src/client/common/utils/billingapi.ts @@ -26,6 +26,7 @@ import { AddAdminResponse, ListAdminsResponse, RedeemCouponResponse, + AccountCreditsResponse, } from "../types"; import { calculateBillingAuthSignature } from "./auth"; import { BillingEnvironmentConfig } from "../types"; @@ -168,6 +169,29 @@ export class BillingApiClient { return resp.json(); } + async getAccountCredits(ethAddress: string): Promise { + const endpoint = `${this.config.billingApiServerURL}/accounts/${ethAddress}/credits`; + const resp = await this.makeAuthenticatedRequest(endpoint, "GET", "compute"); + const raw = (await resp.json()) as Record; + const num = (...candidates: unknown[]): number => { + for (const c of candidates) { + if (c === undefined || c === null) continue; + const n = Number(c); + if (Number.isFinite(n)) return n; + } + return 0; + }; + return { + remainingCredits: num(raw.remainingCredits, raw.remaining_credits), + permanentCredits: num(raw.permanentCredits, raw.permanent_credits), + promotionalCredits: num(raw.promotionalCredits, raw.promotional_credits), + nextPromotionalCreditExpiry: num( + raw.nextPromotionalCreditExpiry, + raw.next_promotional_credit_expiry, + ), + }; + } + async getSubscription( productId: ProductID = "compute", options?: GetSubscriptionOptions, diff --git a/packages/sdk/src/client/index.ts b/packages/sdk/src/client/index.ts index 2246f1d..4c40380 100644 --- a/packages/sdk/src/client/index.ts +++ b/packages/sdk/src/client/index.ts @@ -51,6 +51,12 @@ export { type WatchUpgradeOptions, } from "./modules/compute/app/upgrade"; export { WatchTimeoutError, WATCH_DEFAULT_TIMEOUT_SECONDS } from "./common/contract/watcher"; +export { + reconcileReleaseDigest, + normalizeDigest, + type ReconcileReleaseDigestOptions, + type ReconcileResult, +} from "./common/contract/reconcile"; export { InsufficientGasError, assertSufficientGas } from "./common/gas/insufficientGas"; // Export compute module for standalone use diff --git a/packages/sdk/src/client/modules/billing/index.ts b/packages/sdk/src/client/modules/billing/index.ts index 45e3ca5..0fc13f5 100644 --- a/packages/sdk/src/client/modules/billing/index.ts +++ b/packages/sdk/src/client/modules/billing/index.ts @@ -27,6 +27,7 @@ import type { PaymentMethodsResponse, CreditPurchaseResponse, RedeemCouponResponse, + AccountCreditsResponse, } from "../../common/types"; export type BillingChain = "ethereum" | "base"; @@ -56,6 +57,8 @@ export interface BillingModule { address: Address; subscribe: (opts?: SubscriptionOpts) => Promise; getStatus: (opts?: SubscriptionOpts) => Promise; + /** Read the 3-way Stripe credit split (promotional/paid/total) for the wallet. */ + getAccountCredits: () => Promise; cancel: (opts?: SubscriptionOpts) => Promise; /** Read on-chain state needed for top-up */ getTopUpInfo: (opts?: { chain?: BillingChain }) => Promise; @@ -304,6 +307,10 @@ export function createBillingModule(config: BillingModuleConfig): BillingModule ); }, + async getAccountCredits(): Promise { + return billingApi.getAccountCredits(address); + }, + async cancel(opts) { return withSDKTelemetry( { diff --git a/packages/sdk/src/client/modules/compute/app/index.ts b/packages/sdk/src/client/modules/compute/app/index.ts index f663d9d..afa5dde 100644 --- a/packages/sdk/src/client/modules/compute/app/index.ts +++ b/packages/sdk/src/client/modules/compute/app/index.ts @@ -41,6 +41,11 @@ import { } from "../../../common/contract/caller"; import { withSDKTelemetry } from "../../../common/telemetry/wrapper"; import { UserApiClient } from "../../../common/utils/userapi"; +import { + reconcileReleaseDigest as reconcileReleaseDigestFn, + type ReconcileReleaseDigestOptions, + type ReconcileResult, +} from "../../../common/contract/reconcile"; import type { AppId, @@ -150,6 +155,11 @@ export interface AppModule { }>; executeUpgrade: (prepared: PreparedUpgrade, gas?: GasEstimate) => Promise; watchUpgrade: (appId: AppId, opts?: WatchUpgradeOptions) => Promise; + reconcileReleaseDigest: ( + appId: AppId, + expectedDigest: string, + opts?: ReconcileReleaseDigestOptions, + ) => Promise; // Profile management setProfile: (appId: AppId, profile: AppProfile) => Promise; @@ -408,6 +418,16 @@ export function createAppModule(ctx: AppModuleConfig): AppModule { ); }, + async reconcileReleaseDigest(appId, expectedDigest, opts) { + const userApiClient = new UserApiClient( + environment, + walletClient, + publicClient, + ctx.clientId ? { clientId: ctx.clientId } : undefined, + ); + return reconcileReleaseDigestFn(userApiClient, appId, expectedDigest, opts); + }, + // Profile management async setProfile(appId, profile) { return withSDKTelemetry( diff --git a/packages/sdk/src/client/modules/compute/app/upgrade.ts b/packages/sdk/src/client/modules/compute/app/upgrade.ts index b9151ec..5f7e7e5 100644 --- a/packages/sdk/src/client/modules/compute/app/upgrade.ts +++ b/packages/sdk/src/client/modules/compute/app/upgrade.ts @@ -221,6 +221,7 @@ export async function prepareUpgradeFromVerifiableBuild( data, appId: appID as Address, imageRef: options.imageRef, + imageDigest: options.imageDigest, }, gasEstimate, }; @@ -448,7 +449,7 @@ export async function prepareUpgrade( // 4. Prepare the release (includes build/push if needed) logger.info("Preparing release..."); - const { release, finalImageRef } = await prepareRelease( + const { release, finalImageRef, imageDigest } = await prepareRelease( { dockerfilePath, imageRef, @@ -516,6 +517,7 @@ export async function prepareUpgrade( data, appId: appID, imageRef: finalImageRef, + imageDigest, }, gasEstimate, };