Skip to content
Open
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/reject-malformed-mpp-challenge-request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
wallet-cli: patch
---

Fixed `tempo wallet transfer --credits` to report a malformed `request` parameter in an MPP challenge as `E_USAGE`. The parameter was passed straight to `JSON.parse`, so a challenge whose `request` was not base64url-encoded JSON escaped as an uncaught `SyntaxError` and exited 1 under `E_RUNTIME` instead of the exit code 2 every other invalid-challenge check uses. A `request` that decodes to valid JSON that is not an object is rejected the same way, rather than degrading to an empty request and failing later on a missing currency.
20 changes: 17 additions & 3 deletions src/commands/transfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Actions } from "viem/tempo";
import { version } from "../shared/constants.js";
import { networkError, usageError } from "../shared/errors.js";
import { appUrl, chainId, tokenAddress, tokenDecimals, tokenSymbol } from "../shared/network.js";
import { decodeBase64UrlJson, getRecord, stringValue } from "../shared/utils.js";
import { getRecord, stringValue } from "../shared/utils.js";
import { createProvider } from "../provider.js";
import { loadWalletState } from "../wallet/store.js";

Expand Down Expand Up @@ -293,10 +293,13 @@ function parseMppChallenge(input: string) {
throw usageError("Invalid configuration: invalid MPP challenge: Expected 'Payment' scheme.");

const params = parseAuthParams(header.slice(paymentIndex + "Payment".length));
const request = params.request ? decodeBase64UrlJson(params.request) : null;
if (!request)
if (!params.request)
throw usageError("Invalid configuration: invalid MPP challenge: Missing request parameter.");

const request = mppChallengeRequest(params.request);
if (!request)
throw usageError("Invalid configuration: invalid MPP challenge: Malformed request parameter.");

return {
id: params.id ?? "",
realm: params.realm ?? "",
Expand All @@ -307,6 +310,17 @@ function parseMppChallenge(input: string) {
};
}

function mppChallengeRequest(value: string) {
let decoded: unknown;
try {
decoded = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
} catch {
return null;
}
if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) return null;
return decoded as Record<string, unknown>;
}

function mppHeaderValue(input: string) {
const trimmed = input.trim();
for (const line of trimmed.split(/\r?\n/)) {
Expand Down
74 changes: 73 additions & 1 deletion test/transfer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@ function buildMppChallenge(amount: string, overrides: Record<string, unknown> =
...overrides,
};
const encoded = Buffer.from(JSON.stringify(request), "utf8").toString("base64url");
return `Payment realm="example", method="tempo", intent="charge", id="abc123", request="${encoded}"`;
return buildMppChallengeWithRequest(`request="${encoded}"`);
}

function buildMppChallengeWithRequest(requestParam: string) {
return `Payment realm="example", method="tempo", intent="charge", id="abc123"${
requestParam ? `, ${requestParam}` : ""
}`;
}

describe("transferTokens", () => {
Expand Down Expand Up @@ -182,6 +188,72 @@ describe("transferCredits", () => {
);
});

it("throws E_USAGE for an MPP challenge request parameter that is not valid JSON", async () => {
await useTempHome();
await writeWalletState(walletState());

const encoded = Buffer.from("not-json", "utf8").toString("base64url");
const error = await transferCredits({
options: {
"dry-run": true,
"mpp-challenge": buildMppChallengeWithRequest(`request="${encoded}"`),
},
}).catch((err: unknown) => err);

expectUsageError(
error,
"Invalid configuration: invalid MPP challenge: Malformed request parameter.",
);
});

it("throws E_USAGE for an MPP challenge request parameter that is not base64url", async () => {
await useTempHome();
await writeWalletState(walletState());

const error = await transferCredits({
options: { "dry-run": true, "mpp-challenge": buildMppChallengeWithRequest('request="!!!!"') },
}).catch((err: unknown) => err);

expectUsageError(
error,
"Invalid configuration: invalid MPP challenge: Malformed request parameter.",
);
});

it("throws E_USAGE for an MPP challenge request parameter that is not a JSON object", async () => {
await useTempHome();
await writeWalletState(walletState());

for (const payload of ["null", "[]", "5", '"amount"']) {
const encoded = Buffer.from(payload, "utf8").toString("base64url");
const error = await transferCredits({
options: {
"dry-run": true,
"mpp-challenge": buildMppChallengeWithRequest(`request="${encoded}"`),
},
}).catch((err: unknown) => err);

expectUsageError(
error,
"Invalid configuration: invalid MPP challenge: Malformed request parameter.",
);
}
});

it("throws E_USAGE for an MPP challenge with no request parameter", async () => {
await useTempHome();
await writeWalletState(walletState());

const error = await transferCredits({
options: { "dry-run": true, "mpp-challenge": buildMppChallengeWithRequest("") },
}).catch((err: unknown) => err);

expectUsageError(
error,
"Invalid configuration: invalid MPP challenge: Missing request parameter.",
);
});

it("throws E_USAGE for a non-zero ETH value", async () => {
await useTempHome();
await writeWalletState(walletState());
Expand Down