diff --git a/.changelog/wallet-readiness.md b/.changelog/wallet-readiness.md new file mode 100644 index 0000000..a9da4f3 --- /dev/null +++ b/.changelog/wallet-readiness.md @@ -0,0 +1,5 @@ +--- +wallet-cli: patch +--- + +Report unavailable wallet balances as unknown instead of zero and include RPC diagnostics in wallet readiness output. diff --git a/README.md b/README.md index 103891a..4b61fb8 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,8 @@ tempo wallet services --search ai `tempo wallet whoami` separates available funds, active-session `locked` reserves, and `pending_refund` reserves in closing or finalizable sessions. `total` includes all three; pending refunds remain unavailable until withdrawal completes. +If the balance RPC is unavailable, `whoami` reports `ready: false`, `balance.available: null`, `balance.total: null`, and a `balance.error` diagnostic. Wallet and key details remain available, along with locally recorded session reserves. An unavailable key balance is also `null`; key details in `whoami` and `keys` include a `balance_error` diagnostic when the query fails. A successful zero-balance query is reported as zero; `ready` checks wallet/key configuration and balance-query success, not whether a particular purchase is affordable. + Make a paid HTTP request: ```sh diff --git a/SKILL.md b/SKILL.md index 2164e35..937079e 100644 --- a/SKILL.md +++ b/SKILL.md @@ -150,7 +150,7 @@ tempo wallet swap --yes | `tempo: command not found` | CLI not installed | Run `curl -fsSL https://tempo.xyz/install \| bash`, then retry using `"$HOME/.tempo/bin/tempo" ...`. | | "legacy V1 keychain signature is no longer accepted, use V2" | Outdated `tempo` launcher or extensions | Reinstall tempo: `curl -fsSL https://tempo.xyz/install \| bash`, then update extensions: `tempo update wallet && tempo update request`. Log out and back in: `tempo wallet logout --yes && tempo wallet login`. | | "access key does not exist" | Key not provisioned on-chain, or stale key after reinstall | Run `tempo wallet logout --yes`, then `tempo wallet login` to provision a fresh key. | -| `ready=false` or `No wallet configured` | Wallet not logged in | Run `tempo wallet login`, wait for user completion, then rerun `tempo wallet whoami`. | +| `ready=false` or `No wallet configured` | Wallet not logged in, key unavailable, or balance RPC failed | Inspect `tempo wallet whoami`: if `balance.error` is present, check RPC connectivity and `TEMPO_RPC_URL`; otherwise run `tempo wallet login`, wait for user completion, then rerun `tempo wallet whoami`. | | HTTP 422 on first request to a service | Wrong request schema — field names vary across services | Check `tempo wallet services ` for endpoint details, then fetch the endpoint's `docs` URL or the service's `llms.txt` for exact field names and types. | | Balance is 0, insufficient funds, or spending limit exceeded | Wallet needs funding or limit hit | For a limit hit, run `tempo wallet keys update --limit ` with user approval. For insufficient funds, suggest `tempo wallet fund` or the wallet dashboard. If standard funding options are unavailable, check whether the service supports credits and suggest `tempo wallet fund --credits` for eligible services. | | Token balance is 0 but MPP Credits may be available | Credits are separate from token balances | Run `tempo wallet whoami --credits`. If the service shows `supportsCredits: true`, credits can be used for one-time charge payments. | diff --git a/src/commands/identity.ts b/src/commands/identity.ts index 5f0e0da..252b115 100644 --- a/src/commands/identity.ts +++ b/src/commands/identity.ts @@ -424,9 +424,12 @@ export async function currentWhoamiOutput(options: { walletAddress: options.walletAddress, }); return { - ready: Boolean(options.walletAddress && paymentKey), + ready: Boolean(options.walletAddress && paymentKey && balance), wallet: options.walletAddress?.toLowerCase() ?? null, - balance: balanceOutput(balance, sessions, tokenSymbol(token)), + balance: { + ...balanceOutput(balance, sessions, tokenSymbol(token)), + ...(options.walletAddress && !balance ? { error: balanceQueryError } : {}), + }, balances, key: currentKeyOutput({ key, @@ -492,7 +495,8 @@ function currentKeyOutput(options: { balance: options.balance && options.balance.token.toLowerCase() === token.toLowerCase() ? options.balance.formatted - : "0.000000", + : null, + ...(options.walletAddress && !options.balance ? { balance_error: balanceQueryError } : {}), spending_limit: { unlimited: false, limit: limit ? formatMicroUnits(cleanStoredScalar(limit.limit)) : "0.000000", @@ -663,6 +667,11 @@ function formatAccessKeyLimit(value: string | undefined, decimals: number) { } } +const balanceQueryError = { + code: "E_RPC" as const, + message: "Unable to query token balance. Check RPC connectivity and TEMPO_RPC_URL.", +}; + type SessionStats = { active: number; locked: bigint; @@ -739,13 +748,12 @@ function balanceOutput( sessions: SessionStats, fallbackSymbol: string, ) { - const available = balance?.raw ?? 0n; - const total = available + sessions.locked + sessions.pendingRefund; + const total = balance ? balance.raw + sessions.locked + sessions.pendingRefund : null; return { - total: formatTokenUnits(total, 6), + total: total === null ? null : formatTokenUnits(total, 6), locked: formatTokenUnits(sessions.locked, 6), pending_refund: formatTokenUnits(sessions.pendingRefund, 6), - available: balance?.formatted ?? "0.000000", + available: balance?.formatted ?? null, active_sessions: sessions.active, symbol: balance?.symbol ?? fallbackSymbol, }; diff --git a/src/schemas.ts b/src/schemas.ts index 9cd92f9..ef6f569 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -36,12 +36,13 @@ export const whoamiOutput = z.union([ ready: z.boolean(), wallet: z.string().nullable(), balance: z.object({ - total: z.string(), + total: z.string().nullable(), locked: z.string(), pending_refund: z.string(), - available: z.string(), + available: z.string().nullable(), active_sessions: z.number(), symbol: z.string(), + error: z.object({ code: z.literal("E_RPC"), message: z.string() }).optional(), }), balances: z.array( z.object({ @@ -60,6 +61,8 @@ export const whoamiOutput = z.union([ network: z.string(), symbol: z.string(), token: z.string(), + balance: z.string().nullable(), + balance_error: z.object({ code: z.literal("E_RPC"), message: z.string() }).optional(), spending_limit: z.object({ unlimited: z.boolean(), limit: z.string(), @@ -102,7 +105,8 @@ export const keysOutput = z.object({ wallet_address: z.string().nullable(), symbol: z.string(), token: z.string(), - balance: z.string(), + balance: z.string().nullable(), + balance_error: z.object({ code: z.literal("E_RPC"), message: z.string() }).optional(), spending_limit: z.object({ unlimited: z.boolean(), limit: z.string(), diff --git a/test/cli-describe.test.ts b/test/cli-describe.test.ts index 6651636..4b7d002 100644 --- a/test/cli-describe.test.ts +++ b/test/cli-describe.test.ts @@ -73,6 +73,33 @@ describe("generated CLI metadata", () => { expect(schema.options.properties.search.description).toContain("Search by name"); }); + it("advertises nullable wallet balances and RPC diagnostics", async () => { + const whoami = JSON.parse(await walletCli(["whoami", "--schema", "--format", "json"])) as { + output: { + anyOf: { + properties?: { + balance?: { properties: { available: { anyOf: { type: string }[] } } }; + key?: { anyOf: { properties?: Record }[] }; + }; + }[]; + }; + }; + const detailedWhoami = whoami.output.anyOf.find((item) => item.properties?.balance); + expect(detailedWhoami?.properties?.balance?.properties.available.anyOf).toContainEqual({ + type: "null", + }); + expect(detailedWhoami?.properties?.key?.anyOf[0]?.properties).toHaveProperty("balance_error"); + + const keys = JSON.parse(await walletCli(["keys", "list", "--schema", "--format", "json"])) as { + output: { + properties: { + keys: { items: { properties: Record } }; + }; + }; + }; + expect(keys.output.properties.keys.items.properties).toHaveProperty("balance_error"); + }); + it.each([ ["long json", ["services", "--json-output", "--schema"]], ["short json", ["services", "-j", "--schema"]], diff --git a/test/identity.test.ts b/test/identity.test.ts index 5b8dd2e..e9ebf77 100644 --- a/test/identity.test.ts +++ b/test/identity.test.ts @@ -368,6 +368,23 @@ describe("identity commands", () => { expect(result.balance.symbol).toBe("PathUSD"); }); + it("does not report an RPC error or query a balance without a wallet", async () => { + const result = await currentWhoamiOutput({ + walletAddress: null, + chain: 4217, + accessKeys: [], + }); + + expect(result).toMatchObject({ + ready: false, + wallet: null, + balance: { available: null, total: null }, + key: null, + }); + expect(result.balance).not.toHaveProperty("error"); + expect(mocks.readContract).not.toHaveBeenCalled(); + }); + it("whoami reports ready with a wallet", async () => { await useTempHome(); await writeWalletState(walletState()); @@ -458,6 +475,64 @@ describe("identity commands", () => { ]); }); + it("reports RPC failure as an unknown balance and recovers when the RPC returns", async () => { + await useTempHome(); + await writeWalletState(walletState()); + await upsertSessionRecord(identitySession()); + await upsertSessionRecord({ + ...identitySession(), + channel_id: `0x${"b".repeat(64)}`, + state: "closing", + close_requested_at: 1, + }); + mocks.readContract.mockRejectedValue(new Error("RPC unavailable")); + + expect(await whoamiHandler({})).toMatchObject({ + ready: false, + wallet: testWallet.toLowerCase(), + balance: { + total: null, + available: null, + locked: "0.008000", + pending_refund: "0.008000", + active_sessions: 1, + error: { code: "E_RPC" }, + }, + key: { status: "ready", balance: null }, + }); + expect(await keysHandler()).toMatchObject({ + keys: [{ balance: null, balance_error: { code: "E_RPC" } }], + }); + + mocks.readContract.mockResolvedValue(5_000_000n); + const recovered = await whoamiHandler({}); + expect(recovered).toMatchObject({ + ready: true, + balance: { total: "5.016000", available: "5" }, + key: { balance: "5" }, + }); + expect(recovered).not.toHaveProperty("balance.error"); + const recoveredKeys = await keysHandler(); + expect(recoveredKeys).toMatchObject({ keys: [{ balance: "5" }] }); + expect(recoveredKeys.keys[0]).not.toHaveProperty("balance_error"); + }); + + it("distinguishes a successful zero balance from an unavailable balance", async () => { + await useTempHome(); + await writeWalletState(walletState()); + mocks.readContract.mockResolvedValue(0n); + + const result = await whoamiHandler({}); + expect(result).toMatchObject({ + ready: true, + balance: { total: "0.000000", available: "0" }, + key: { balance: "0" }, + }); + expect(result).not.toHaveProperty("balance.error"); + if (!("key" in result) || !result.key) expect.unreachable("expected key details"); + expect(result.key).not.toHaveProperty("balance_error"); + }); + it("whoami reports an expired access key as not ready", async () => { await useTempHome(); await writeWalletState( diff --git a/test/wallet-readiness.test.ts b/test/wallet-readiness.test.ts new file mode 100644 index 0000000..98d9056 --- /dev/null +++ b/test/wallet-readiness.test.ts @@ -0,0 +1,89 @@ +import { execFile } from "node:child_process"; +import { createServer } from "node:http"; +import { promisify } from "node:util"; +import { expect, it } from "vitest"; +import { useTempHome, walletState, writeWalletState } from "./helpers.js"; + +const execFileAsync = promisify(execFile); + +it("reports unknown RPC balances separately from funded and verified zero balances through whoami", async () => { + const home = await useTempHome(); + await writeWalletState(walletState()); + let mode: "funded" | "failed" | "zero" = "funded"; + let calls = 0; + const server = createServer((request, response) => { + if (request.method === "GET") { + response.setHeader("content-type", "application/json"); + response.end("[]"); + return; + } + let body = ""; + request.on("data", (chunk) => { + body += chunk; + }); + request.on("end", () => { + const rpc = JSON.parse(body) as { id: number; method: string }; + expect(rpc.method).toBe("eth_call"); + calls++; + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + jsonrpc: "2.0", + id: rpc.id, + ...(mode === "failed" + ? { error: { code: -32602, message: "Fixture RPC unavailable" } } + : { + result: `0x${(mode === "funded" ? 5_000_000n : 0n).toString(16).padStart(64, "0")}`, + }), + }), + ); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + try { + const address = server.address(); + if (!address || typeof address === "string") throw new Error("No RPC address"); + const whoami = async () => { + const result = await execFileAsync( + process.execPath, + ["--import", "tsx", "src/cli.ts", "whoami", "--format", "json"], + { + env: { + ...process.env, + HOME: home, + TEMPO_AUTH_URL: `http://127.0.0.1:${address.port}`, + TEMPO_RPC_URL: `http://127.0.0.1:${address.port}`, + TEMPO_WALLET_NETWORK: "mainnet", + }, + }, + ); + return JSON.parse(result.stdout); + }; + expect(await whoami()).toMatchObject({ + ready: true, + balance: { available: "5", total: "5.000000" }, + key: { balance: "5" }, + }); + mode = "failed"; + expect(await whoami()).toMatchObject({ + ready: false, + balance: { available: null, total: null, error: { code: "E_RPC" } }, + key: { balance: null }, + }); + mode = "zero"; + const zero = await whoami(); + expect(zero).toMatchObject({ + ready: true, + balance: { available: "0", total: "0.000000" }, + key: { balance: "0" }, + }); + expect(zero.balance).not.toHaveProperty("error"); + expect(calls).toBe(3); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } +}, 30_000);