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
7 changes: 7 additions & 0 deletions .changeset/fix-x402-ambiguous-payment-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"nansen-cli": patch
---

Fix the x402 auto-payment fallback in `src/api.js` generating multiple payment authorizations for the same request after an ambiguous outcome (issue #583). After a signed `Payment-Signature` was transmitted, `_x402Retry` previously collapsed every non-ok response — a clean rejection, a 5xx, an unreadable body — and every transport failure into a single `null`, and callers treated any `null` as "safe to try the next payment option/provider". That meant a 5xx, a timeout, or an unparseable response (any of which could mean the server already received and settled the payment) triggered signing and transmitting a *second* independent payment for the same logical request. Separately, a genuine successful response whose JSON body happened to be `null` was indistinguishable from a rejection, risking a second payment for an already-settled call.

`_x402Retry` now returns a dedicated `X402_PAYMENT_REJECTED` sentinel only for a provably clean rejection (a non-5xx status with a readable body), and throws `NansenError` with the new `PAYMENT_AMBIGUOUS` code for anything else — a transport failure, a 5xx, or an unreadable body on either a rejection or a success. All three fallback call sites (Privy, local wallet, WalletConnect) now check against the sentinel instead of `null`, and re-throw a `PAYMENT_AMBIGUOUS` error immediately instead of silently moving on to the next provider.
125 changes: 125 additions & 0 deletions src/__tests__/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3469,6 +3469,131 @@ describe('NansenAPI', () => {

vi.doUnmock('../walletconnect-x402.js');
});

// Issue #583: an ambiguous outcome after a signed payment was already
// transmitted (a 5xx, a transport failure, or an unreadable body) must
// not be treated as an ordinary rejection — the server may have already
// settled it, so signing and sending another payment would risk paying
// twice for the same logical request.
describe('ambiguous outcomes after transmission (issue #583)', () => {
it('does not fall back to WalletConnect after an ambiguous local-wallet outcome', async () => {
if (LIVE_TEST) return;

const paymentReqs = {
accepts: [{
scheme: 'exact',
asset: '0xUSDC',
payTo: '0xR',
amount: '1',
network: 'base',
extra: { name: 'X', version: '1', chainId: 1 },
}],
};
const paymentHeader = btoa(JSON.stringify(paymentReqs));

const errorResponse = {
ok: false,
status: 402,
json: async () => ({ message: 'Payment required' }),
headers: { get: (h) => h === 'payment-required' ? paymentHeader : null },
};
// The paid retry fails with a 5xx — ambiguous, not a clean rejection.
const ambiguousRetryResponse = {
ok: false,
status: 503,
json: async () => ({ error: 'internal error' }),
};
mockFetch
.mockResolvedValueOnce(errorResponse)
.mockResolvedValueOnce(ambiguousRetryResponse);

const mockHandleX402Payment = vi.fn().mockResolvedValue('walletconnect-sig');
vi.resetModules();
// Skip real wallet/crypto setup: yield one already-built local signature.
vi.doMock('../x402.js', () => ({
createPaymentSignatures: async function* () {
yield { signature: 'local-sig', network: 'eip155:8453', asset: '0xUSDC' };
},
checkX402Balance: vi.fn().mockResolvedValue(null),
}));
vi.doMock('../walletconnect-x402.js', () => ({ handleX402Payment: mockHandleX402Payment }));

const autoPayApi = new NansenAPI('test-key', 'https://api.nansen.ai');

let thrownError;
try {
await autoPayApi.smartMoneyNetflow({});
} catch (err) {
thrownError = err;
}

expect(thrownError).toBeDefined();
expect(thrownError.code).toBe(ErrorCode.PAYMENT_AMBIGUOUS);
// Exactly the initial 402 + the one ambiguous paid retry — no second
// payment attempt via WalletConnect.
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockHandleX402Payment).not.toHaveBeenCalled();

vi.doUnmock('../x402.js');
vi.doUnmock('../walletconnect-x402.js');
});

it('propagates PAYMENT_AMBIGUOUS instead of a generic failure message when the WalletConnect paid retry is ambiguous', async () => {
if (LIVE_TEST) return;

const paymentReqs = {
accepts: [{
scheme: 'exact',
asset: '0xUSDC',
payTo: '0xR',
amount: '1',
network: 'base',
extra: { name: 'X', version: '1', chainId: 1 },
}],
};
const paymentHeader = btoa(JSON.stringify(paymentReqs));

const errorResponse = {
ok: false,
status: 402,
json: async () => ({ message: 'Payment required' }),
headers: { get: (h) => h === 'payment-required' ? paymentHeader : null },
};
const ambiguousRetryResponse = {
ok: false,
status: 503,
json: async () => ({ error: 'internal error' }),
};
mockFetch
.mockResolvedValueOnce(errorResponse)
.mockResolvedValueOnce(ambiguousRetryResponse);

// No local wallet configured (empty temp HOME) — falls straight
// through to WalletConnect, which signs successfully.
const mockHandleX402Payment = vi.fn().mockResolvedValue('walletconnect-sig');
vi.resetModules();
vi.doMock('../walletconnect-x402.js', () => ({ handleX402Payment: mockHandleX402Payment }));

const autoPayApi = new NansenAPI('test-key', 'https://api.nansen.ai');

let thrownError;
try {
await autoPayApi.smartMoneyNetflow({});
} catch (err) {
thrownError = err;
}

expect(thrownError).toBeDefined();
expect(thrownError.code).toBe(ErrorCode.PAYMENT_AMBIGUOUS);
expect(thrownError.message).toMatch(/not attempting another payment/i);
// Only the one paid attempt via WalletConnect — no retry loop, no
// second signature.
expect(mockHandleX402Payment).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledTimes(2);

vi.doUnmock('../walletconnect-x402.js');
});
});
});

// =================== Smart Alert Endpoints ===================
Expand Down
105 changes: 94 additions & 11 deletions src/__tests__/x402-api-retry.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,21 @@
* non-JSON body may not propagate cleanly through all runtime environments
* without an explicit await in the async function body.
*
* Issue #583 — after a signed payment was transmitted, a non-ok response no
* longer collapses everything (5xx, transport failures, unreadable bodies,
* clean rejections) into a single `null`. Only a readable, legible rejection
* body on a non-5xx status returns the X402_PAYMENT_REJECTED sentinel (safe
* to try another payment option); anything else throws NansenError with code
* PAYMENT_AMBIGUOUS, because the server may have already settled the payment
* and trying another option would risk paying twice. The sentinel is also
* distinct from a genuine successful response whose JSON body is `null`.
*
* These tests pin the contract so future changes to _x402Retry are caught
* before they reach CI.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { NansenAPI, RESPONSE_META } from '../api.js';
import { NansenAPI, RESPONSE_META, X402_PAYMENT_REJECTED, ErrorCode, NansenError } from '../api.js';

function makeApi() {
return new NansenAPI('test-key', 'https://api.nansen.ai');
Expand All @@ -35,25 +44,84 @@ describe('NansenAPI._x402Retry', () => {
vi.unstubAllGlobals();
});

it('returns null when the paid response is not ok', async () => {
// Core contract: a rejected payment (non-ok retry) yields null so the
// caller can fall through to the next payment option.
it('returns the X402_PAYMENT_REJECTED sentinel for a readable, non-5xx rejection', async () => {
// Core contract: a rejection the server can prove (a legible, non-5xx
// body) is safe to treat as "try the next payment option".
mockFetch.mockResolvedValue({
ok: false,
status: 402,
json: async () => ({ error: 'payment rejected' }),
});

const api = makeApi();
const result = await api._x402Retry(
'test-sig', null, null, 'https://api.nansen.ai/test', {},
);
expect(result).toBe(X402_PAYMENT_REJECTED);
});

it('throws PAYMENT_AMBIGUOUS on a 5xx after the payment was transmitted (issue #583)', async () => {
// A 5xx doesn't prove the payment was rejected — the server could have
// settled it before failing to respond. Must not be treated the same as
// a clean rejection, or the caller would sign and send another payment.
mockFetch.mockResolvedValue({
ok: false,
status: 503,
json: async () => ({ error: 'internal error' }),
});

const api = makeApi();
await expect(
api._x402Retry('test-sig', null, null, 'https://api.nansen.ai/test', {}),
).rejects.toMatchObject({ code: ErrorCode.PAYMENT_AMBIGUOUS });
});

it('throws PAYMENT_AMBIGUOUS when a non-5xx rejection body is unreadable (issue #583)', async () => {
// A 4xx we can't even parse doesn't prove a clean rejection either.
mockFetch.mockResolvedValue({
ok: false,
status: 402,
json: vi.fn().mockRejectedValue(new SyntaxError('Unexpected token < in JSON')),
});

const api = makeApi();
await expect(
api._x402Retry('test-sig', null, null, 'https://api.nansen.ai/test', {}),
).rejects.toMatchObject({ code: ErrorCode.PAYMENT_AMBIGUOUS });
});

it('throws PAYMENT_AMBIGUOUS when the fetch itself fails after transmission (issue #583)', async () => {
// The signature was already on the wire when the connection dropped —
// the server may have received and settled it.
mockFetch.mockRejectedValue(new TypeError('fetch failed'));

const api = makeApi();
await expect(
api._x402Retry('test-sig', null, null, 'https://api.nansen.ai/test', {}),
).rejects.toMatchObject({ code: ErrorCode.PAYMENT_AMBIGUOUS });
});

it('returns a genuine null success body as null, not the rejection sentinel (issue #583)', async () => {
// A successful (ok) response whose JSON body happens to be `null` must
// not be confused with a clean rejection — that would make the caller
// sign and transmit a second payment for an already-settled request.
mockFetch.mockResolvedValue({
ok: true,
json: async () => null,
headers: new Map(),
});

const api = makeApi();
const result = await api._x402Retry(
'test-sig', null, null, 'https://api.nansen.ai/test', {},
);
expect(result).toBeNull();
expect(result).not.toBe(X402_PAYMENT_REJECTED);
});

it('returns the parsed JSON body with response metadata when the paid response is ok', async () => {
// Regression for e918bdd: the resolved JSON value (not a Promise) is
// returned so callers can use strict !== null to detect success.
// returned so callers can use strict !== sentinel to detect success.
const responseData = { data: { token: 'ETH', value: 1234 } };
mockFetch.mockResolvedValue({
ok: true,
Expand All @@ -79,7 +147,7 @@ describe('NansenAPI._x402Retry', () => {
it('returns a falsy-but-valid JSON body as-is (regression for 3c25a0d)', async () => {
// Before 3c25a0d the callers used `if (result)` — a valid response of
// `false` would be misread as payment failure and the request would fall
// through to the next payment option. The fix uses `!== null` instead.
// through to the next payment option. The fix uses `!== sentinel` instead.
mockFetch.mockResolvedValue({
ok: true,
json: async () => false,
Expand All @@ -89,7 +157,7 @@ describe('NansenAPI._x402Retry', () => {
const result = await api._x402Retry(
'test-sig', null, null, 'https://api.nansen.ai/test', {},
);
// false is a valid (if unusual) API response — must not be treated as null
// false is a valid (if unusual) API response — must not be treated as rejected
expect(result).toBe(false);
});

Expand Down Expand Up @@ -165,9 +233,11 @@ describe('NansenAPI._x402Retry', () => {
expect(requestInit.headers['Content-Type']).toBe('application/json');
});

it('propagates json() rejection when the paid response body is not valid JSON', async () => {
// Regression for e918bdd: the explicit await ensures a parsing failure
// surfaces as a clean rejection from _x402Retry rather than being lost.
it('throws PAYMENT_AMBIGUOUS when an ok response body is not valid JSON (issue #583)', async () => {
// Regression for e918bdd, updated for #583: the payment was accepted
// (ok) — it settled — so a parse failure now surfaces as PAYMENT_AMBIGUOUS
// rather than a bare SyntaxError, and must not be treated as a rejection
// (that would risk paying again for an already-settled request).
mockFetch.mockResolvedValue({
ok: true,
json: vi.fn().mockRejectedValue(new SyntaxError('Unexpected token < in JSON')),
Expand All @@ -176,6 +246,19 @@ describe('NansenAPI._x402Retry', () => {
const api = makeApi();
await expect(
api._x402Retry('test-sig', null, null, 'https://api.nansen.ai/test', {}),
).rejects.toThrow('Unexpected token');
).rejects.toMatchObject({ code: ErrorCode.PAYMENT_AMBIGUOUS });
});

it('every PAYMENT_AMBIGUOUS error is a NansenError with an actionable message', async () => {
mockFetch.mockRejectedValue(new TypeError('network down'));
const api = makeApi();
try {
await api._x402Retry('test-sig', null, null, 'https://api.nansen.ai/test', {});
expect.unreachable('should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(NansenError);
expect(err.code).toBe(ErrorCode.PAYMENT_AMBIGUOUS);
expect(err.message).toMatch(/not attempting another payment/i);
}
});
});
Loading