Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changelog/wallet-readiness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
wallet-cli: patch
---

Report unavailable wallet balances as unknown instead of zero and include RPC diagnostics in wallet readiness output.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ tempo wallet swap <AMOUNT> <TOKEN_IN> <TOKEN_OUT> --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 <SERVICE_ID>` 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 <amount>` 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. |
Expand Down
22 changes: 15 additions & 7 deletions src/commands/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
};
Expand Down
10 changes: 7 additions & 3 deletions src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
27 changes: 27 additions & 0 deletions test/cli-describe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }[] };
};
}[];
};
};
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<string, unknown> } };
};
};
};
expect(keys.output.properties.keys.items.properties).toHaveProperty("balance_error");
});

it.each([
["long json", ["services", "--json-output", "--schema"]],
["short json", ["services", "-j", "--schema"]],
Expand Down
75 changes: 75 additions & 0 deletions test/identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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(
Expand Down
89 changes: 89 additions & 0 deletions test/wallet-readiness.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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<void>((resolve) => server.close(() => resolve()));
}
}, 30_000);