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/payment-safety.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
wallet-cli: patch
---

Enforce payment caps and network selection before credential creation, validate request options, and preview decoded payment quotes without paying.
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,17 @@ tempo request --dry-run https://example.mpp.tempo.xyz/v1/resource
tempo request https://example.mpp.tempo.xyz/v1/resource
```

On HTTP 402, `--dry-run` prints a JSON quote with the selected amount, token, chain and
budget assessment, without opening the wallet or paying. `--max-spend` rejects an offer
above the cap in both preview and execution; invalid amounts fail before the HTTP request.
For reusable sessions, execution also checks cumulative spending against the cap.
Capped recurring subscriptions are rejected because a per-period authorization cannot enforce
a cumulative cap.

`--network` overrides `TEMPO_WALLET_NETWORK`; the default is mainnet. `tempo`/`mainnet`
and `tempo-moderato`/`moderato`/`testnet` are aliases. Unknown networks and payment
challenges for a different chain are rejected before payment.

When a server offers both reusable sessions and one-time charges, choose an intent explicitly:

```sh
Expand Down
245 changes: 181 additions & 64 deletions src/commands/request.ts

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion src/shared/network.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { createPublicClient, http, type Address } from "viem";
import { Chain } from "viem/tempo";

import { usageError } from "./errors.js";

import { mainnetEscrow, moderatoEscrow, moderatoToken, usdcToken } from "./constants.js";

export function chainId(network: string | undefined) {
return isTestnet(network) ? 42431 : 4217;
}

export function isTestnet(network: string | undefined) {
return network === "testnet" || process.env.TEMPO_WALLET_NETWORK === "testnet";
return normalizeNetwork(network ?? process.env.TEMPO_WALLET_NETWORK ?? "mainnet") === "testnet";
}

export function networkName(chain: number | null) {
Expand Down Expand Up @@ -51,3 +53,9 @@ export function tokenSymbol(token: string) {
}

export const appUrl = process.env.TEMPO_AUTH_URL ?? "https://wallet.tempo.xyz";

export function normalizeNetwork(value: string) {
if (value === "testnet" || value === "tempo-moderato" || value === "moderato") return "testnet";
if (value === "mainnet" || value === "tempo") return "mainnet";
throw usageError(`Unsupported network: ${value}`);
}
13 changes: 13 additions & 0 deletions test/network.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,16 @@ describe("network selection", () => {
expect(chainId(undefined)).toBe(42431);
});
});

it.each(["mainnet", "tempo"])("explicit %s overrides testnet environment", (network) => {
process.env.TEMPO_WALLET_NETWORK = "testnet";
expect(chainId(network)).toBe(4217);
});
it.each(["testnet", "tempo-moderato", "moderato"])("resolves %s consistently", (network) => {
expect(chainId(network)).toBe(42431);
process.env.TEMPO_WALLET_NETWORK = network;
expect(chainId(undefined)).toBe(42431);
});
it("rejects unknown networks", () => {
expect(() => chainId("typo")).toThrow("Unsupported network");
});
204 changes: 204 additions & 0 deletions test/request-safety.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { execFile } from "node:child_process";
import { createServer } from "node:http";
import { promisify } from "node:util";
import { Challenge, Credential } from "mppx";
import { afterEach, expect, it } from "vitest";

const exec = promisify(execFile);
const servers: ReturnType<typeof createServer>[] = [];
afterEach(async () => {
await Promise.all(
servers.splice(0).map(
(server) =>
new Promise<void>((resolve) => {
server.closeAllConnections();
server.close(() => resolve());
}),
),
);
});

async function fixture(
intent = "charge",
chain: number | undefined = 4217,
requestOverrides: Record<string, unknown> = {},
) {
let requests = 0;
let authenticated = 0;
let rpc = 0;
const challenge = Challenge.from({
id: "safety",
realm: "local",
method: "tempo",
intent,
request: {
amount: "6000",
currency: "0x20c0000000000000000000000000000000000000",
recipient: "0x0000000000000000000000000000000000000001",
methodDetails: chain ? { chainId: chain } : {},
...requestOverrides,
},
});
const server = createServer((req, res) => {
if (req.url === "/rpc") {
rpc++;
res.end("{}");
return;
}
requests++;
if (req.headers.authorization) {
authenticated++;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify(Credential.deserialize(req.headers.authorization).payload));
return;
}
res.writeHead(402, { "www-authenticate": Challenge.serialize(challenge) });
res.end("Payment Required");
});
servers.push(server);
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("No address");
const url = `http://127.0.0.1:${address.port}`;
const run = async (...args: string[]) => {
try {
return {
...(await exec(process.execPath, ["--import", "tsx", "src/request-cli.ts", url, ...args], {
env: {
...process.env,
TEMPO_RPC_URL: `${url}/rpc`,
TEMPO_WALLET_NETWORK: "mainnet",
TEMPO_PRIVATE_KEY: `0x${"1".repeat(64)}`,
},
})),
code: 0,
};
} catch (error) {
return error as { stdout: string; stderr: string; code: number };
}
};
return { run, counts: () => ({ requests, authenticated, rpc }) };
}

it.each([
["--retries", "-1"],
["--retries", "1.5"],
["--timeout", "-1"],
["--max-spend", "banana"],
["--network", "typo"],
])("rejects %s %s before HTTP through the actual CLI", async (flag, value) => {
const server = await fixture();
const result = await server.run(flag, value);
expect(result.code).not.toBe(0);
expect(`${result.stdout}${result.stderr}`).toContain("E_USAGE");
expect(server.counts()).toEqual({ requests: 0, authenticated: 0, rpc: 0 });
});

it.each([
["--max-spend", "0.001", "max spend exceeded"],
["--network", "tempo-moderato", "network mismatch"],
])("rejects %s before wallet or RPC access", async (flag, value, message) => {
const server = await fixture();
const result = await server.run(flag, value);
expect(result.code).not.toBe(0);
expect(`${result.stdout}${result.stderr}`).toContain(message);
expect(server.counts()).toEqual({ requests: 1, authenticated: 0, rpc: 0 });
});

it("dry-run decodes and validates a quote without accessing the wallet", async () => {
const server = await fixture();
const result = await server.run(
"--dry-run",
"--max-spend",
"0.006",
"--timeout",
"0.5",
"--connect-timeout",
"0.2",
);
expect(result.code).toBe(0);
expect(JSON.parse(result.stdout)).toMatchObject({
amount: "0.006",
chain_id: 4217,
within_budget: true,
});
const rejected = await server.run("--dry-run", "--max-spend", "0.001");
expect(rejected.code).not.toBe(0);
expect(server.counts()).toEqual({ requests: 2, authenticated: 0, rpc: 0 });
});

it("rejects capped recurring subscriptions before RPC or authorization", async () => {
const server = await fixture("subscription");
const result = await server.run("--max-spend", "0.006");
expect(result.code).not.toBe(0);
expect(`${result.stdout}${result.stderr}`).toContain("cannot enforce cumulative spending");
expect(server.counts()).toEqual({ requests: 1, authenticated: 0, rpc: 0 });
});

it("authenticates a zero-amount charge without a recipient or RPC access", async () => {
const server = await fixture("charge", 4217, { amount: "0", recipient: undefined });
const quote = await server.run("--dry-run", "--max-spend", "0");
expect(quote.code).toBe(0);
expect(JSON.parse(quote.stdout)).toMatchObject({ amount: "0", within_budget: true });
expect(server.counts()).toEqual({ requests: 1, authenticated: 0, rpc: 0 });

const result = await server.run("--max-spend", "0");
expect(result.code).toBe(0);
expect(JSON.parse(result.stdout)).toMatchObject({
type: "proof",
signature: expect.stringMatching(/^0x[0-9a-f]+$/i),
});
expect(server.counts()).toEqual({ requests: 3, authenticated: 1, rpc: 0 });
});

it.each([
["charge", { recipient: undefined }, "valid recipient address"],
["charge", { recipient: "invalid" }, "valid recipient address"],
["charge", { amount: "0", currency: "invalid", recipient: undefined }, "valid currency address"],
[
"session",
{ amount: "0", recipient: undefined, methodDetails: { chainId: 4217, sessionProtocol: "v2" } },
"valid recipient address",
],
["charge", { currency: "invalid" }, "valid currency address"],
[
"session",
{ recipient: undefined, methodDetails: { chainId: 4217, sessionProtocol: "v2" } },
"valid recipient address",
],
[
"session",
{
methodDetails: {
chainId: 4217,
escrowContract: "0x0000000000000000000000000000000000000bad",
sessionProtocol: "v2",
},
},
"Unsupported Tempo session escrow",
],
[
"session",
{
methodDetails: {
chainId: 4217,
escrow: "0x0000000000000000000000000000000000000bad",
sessionProtocol: "v2",
},
},
"Unsupported Tempo session escrow",
],
["charge", { amount: undefined }, "valid amount"],
["charge", { amount: "not-a-number" }, "valid amount"],
] as const)(
"rejects malformed %s offers before wallet access",
async (intent, request, message) => {
const server = await fixture(intent, 4217, request);
for (const args of [["--dry-run"], []]) {
const result = await server.run(...args);
expect(result.code).not.toBe(0);
expect(`${result.stdout}${result.stderr}`).toContain(message);
}
expect(server.counts()).toEqual({ requests: 2, authenticated: 0, rpc: 0 });
},
);
Loading