Skip to content

Commit bfdfeb0

Browse files
vvillait88claude
andauthored
Never map a Solana confirmation timeout to regenerate/pay-again (#105)
## Summary Fixes a live double-charge on the Solana MPP rail, shared by all three hosted stores. `@solana/mpp`'s `verifyTransaction` broadcasts the transfer (funds move, a signature is minted) and then awaits confirmation in the same synchronous `verify()` call, with a hardcoded 30s window not exposed through `charge()`. Under load that window can expire before the network surfaces confirmation, even on a healthy production RPC (verified: Helius `getHealth` ok in ~70ms during the failure), so `verify()` throws `Transaction confirmation timeout` on a transfer that may already have landed. `Checkout.handleMppx` mapped every unclassified mppx failure to `payment_proof_invalid` + `regenerate_payment_credential`, so the merchant told the agent to pay again for money that already left the wallet. Observed live 2026-08-12 on scaledown and fullenrich: an on-chain balance delta with no service delivered and a regenerate 402 in hand. `classifyMppxFailure` now recognizes the confirmation-timeout reason and returns `payment_pending_confirmation` (HTTP 504, `action: check_settlement_before_retry`) with a message telling the buyer to verify settlement before retrying. 504 rather than 402 is deliberate: x402/MPP clients version-route on status and a 402 auto-triggers a re-pay retry, which is the double-charge this guards. A confirmation timeout cannot be reliably distinguished from never-landed (the recovery `getSignatureStatuses` with `searchTransactionHistory` lags too), so the response surfaces the ambiguity rather than asserting success or failure. ## Type of change - [x] Bug fix (no breaking change) - [ ] New feature (no breaking change) - [ ] Breaking change (existing callers must update) - [ ] Docs, tests, or internal maintenance only ## Public API No signature changes. Behavior change on one error path only: a Solana MPP settle whose confirmation times out now returns HTTP 504 `payment_pending_confirmation` instead of a 402 `payment_proof_invalid` / `regenerate_payment_credential`. Callers that branched on that 402 to auto-repay should treat 504 as "submitted, unconfirmed, do not blindly resubmit." ## Test plan lint (0 warnings), typecheck including examples, full suite 1808 passing, build clean. New coverage: the classifier returns 504 `payment_pending_confirmation` (never 402, never `regenerate_payment_credential`) with a do-not-double-pay message on both the plain and status-recovery-failed variants; a Checkout-level test proves a captured `Transaction confirmation timeout` yields 504 rather than the regenerate 402. ## Checklist - [x] Tests cover the new behavior, and the suite passes locally - [x] Lint, format, and type checks pass - [x] Docs and README examples updated if the public surface changed - [x] No secrets, credentials, or personal data in the diff or the tests Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 6e4c982 commit bfdfeb0

3 files changed

Lines changed: 100 additions & 0 deletions

File tree

src/payment/mppx_failures.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,43 @@ const TEMPO_KEY_NOT_REGISTERED: ClassifiedMppxFailure = {
3939
extra: { upstream_error: 'KeyNotFound', chain: 'tempo' },
4040
};
4141

42+
/**
43+
* The DANGEROUS one, and why this classifier is not just a nicety.
44+
*
45+
* `@solana/mpp` verifies a `transaction`-payload credential by BROADCASTING it
46+
* (`sendTransaction`, so funds move and a signature is minted) and THEN
47+
* awaiting confirmation in the same synchronous verify() call. When
48+
* confirmation times out (routine under load, since Solana status propagation
49+
* can lag the library's fixed 30s window even on a production RPC), verify()
50+
* throws `Transaction confirmation timeout` on a transaction that MAY HAVE
51+
* ALREADY LANDED. Left unclassified, that maps to the generic
52+
* `payment_proof_invalid` + `regenerate_payment_credential`: the merchant
53+
* tells the agent its payment was rejected and to pay AGAIN, for money that
54+
* already left the wallet. Observed live 2026-08-12 (an on-chain balance delta
55+
* with no service delivered and a "regenerate" 402 in hand).
56+
*
57+
* A confirmation timeout cannot be reliably told apart from "never landed"
58+
* (the recovery `getSignatureStatuses` with searchTransactionHistory lags
59+
* too), so the honest response is neither "success" nor "invalid, repay". It
60+
* is 504 with an explicit do-not-blindly-resubmit instruction: the payment was
61+
* submitted, its on-chain state is unconfirmed, and the buyer must check
62+
* whether it settled before paying a second time. x402/MPP clients version-
63+
* route on status; 504 (unlike 402) does not trigger an automatic
64+
* re-pay-with-new-credential retry, which is the whole point.
65+
*/
66+
const SOLANA_CONFIRMATION_TIMEOUT: ClassifiedMppxFailure = {
67+
code: 'payment_pending_confirmation',
68+
status: 504,
69+
message:
70+
'Payment was submitted on-chain but its confirmation timed out. It may have settled. Do NOT resubmit without checking first, or you risk paying twice.',
71+
nextSteps: {
72+
action: 'check_settlement_before_retry',
73+
user_message:
74+
'Your payment was broadcast to the network but confirmation timed out, so it is unconfirmed rather than failed. Check your wallet balance and the recipient before retrying: if the balance decreased, the payment likely landed and you should NOT pay again — wait for the merchant to reconcile or contact support. Only resubmit if the funds are still in your wallet.',
75+
},
76+
extra: { chain: 'solana', broadcast: true },
77+
};
78+
4279
/** Classify a failure reason against known patterns.
4380
*
4481
* Returns `null` when the reason is unrecognized — callers fall back to
@@ -52,5 +89,10 @@ export function classifyMppxFailure(reason: string | null | undefined): Classifi
5289
if (lower.includes('keychain validation failed') || lower.includes('keynotfound')) {
5390
return TEMPO_KEY_NOT_REGISTERED;
5491
}
92+
// A broadcast Solana transfer whose confirmation timed out: money may have
93+
// moved, so this must never fall through to `regenerate_payment_credential`.
94+
if (lower.includes('confirmation timeout') || lower.includes('confirmation timed out')) {
95+
return SOLANA_CONFIRMATION_TIMEOUT;
96+
}
5597
return null;
5698
}

tests/checkout.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,33 @@ describe('Checkout — composeMppx hook', () => {
256256
expect(result.settlePhase).toBe('verify_failed');
257257
});
258258

259+
it('a Solana confirmation timeout does NOT become a regenerate/pay-again 402', async () => {
260+
// The dangerous case: @solana/mpp broadcast the transfer (money moved),
261+
// then confirmation timed out and verify() threw. This must NOT reach the
262+
// agent as `payment_proof_invalid` + regenerate, or a compliant client
263+
// pays twice.
264+
const composeMppx = vi.fn(
265+
async (): Promise<MppxComposeOutcome> => {
266+
console.error('mppx: internal verification error', {
267+
message: 'Transaction confirmation timeout',
268+
});
269+
return { status: 402, headers: { 'www-authenticate': 'Payment id="ord_x"' } };
270+
},
271+
);
272+
const checkout = new Checkout({
273+
rails: { solanaMpp: { recipient: 'SoLanaRecipient1111111111111111111111111111' } as never },
274+
url: 'https://api.example/purchase',
275+
computePricing: () => ({ amountUsd: 1 }),
276+
composeMppx,
277+
});
278+
const result = await checkout.handle(req({ headers: { authorization: `Payment ${FAKE_MPP_CRED}` } }));
279+
expect(result.status).toBe(504);
280+
const err = result.body.error as Record<string, unknown>;
281+
expect(err.code).toBe('payment_pending_confirmation');
282+
expect(err.code).not.toBe('payment_proof_invalid');
283+
expect((result.body.next_steps as Record<string, unknown>).action).not.toBe('regenerate_payment_credential');
284+
});
285+
259286
it('discovery-leg compose_mppx layers fresh WWW-Auth into the 402', async () => {
260287
const composeMppx = vi.fn(
261288
async (): Promise<MppxComposeOutcome> => ({

tests/payment/mppx_failures.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,37 @@ describe('classifyMppxFailure', () => {
1414
expect(classifyMppxFailure('Transaction reverted: ERC20: transfer amount exceeds balance')).toBeNull();
1515
});
1616

17+
describe('Solana confirmation timeout (broadcast-then-unconfirmed)', () => {
18+
it('classifies a confirmation timeout as pending, NOT as regenerate/pay-again', () => {
19+
const out = classifyMppxFailure('Transaction confirmation timeout');
20+
expect(out).not.toBeNull();
21+
expect(out?.code).toBe('payment_pending_confirmation');
22+
// 504, not 402: a 402 would trigger x402 clients to auto-repay, which is
23+
// exactly the double-charge this guards against.
24+
expect(out?.status).toBe(504);
25+
expect(out?.status).not.toBe(402);
26+
// The action must never tell the agent to regenerate the credential.
27+
expect(out?.nextSteps.action).not.toBe('regenerate_payment_credential');
28+
expect(out?.nextSteps.action).toBe('check_settlement_before_retry');
29+
expect(out?.extra?.chain).toBe('solana');
30+
expect(out?.extra?.broadcast).toBe(true);
31+
});
32+
33+
it('matches the status-recovery-failed variant too', () => {
34+
const out = classifyMppxFailure(
35+
'Transaction confirmation timeout (status recovery failed: RPC error)',
36+
);
37+
expect(out?.code).toBe('payment_pending_confirmation');
38+
});
39+
40+
it('warns the buyer not to pay twice and to check the balance first', () => {
41+
const msg = classifyMppxFailure('Transaction confirmation timeout')!.nextSteps.user_message;
42+
expect(msg.toLowerCase()).toContain('confirmation timed out');
43+
expect(msg.toLowerCase()).toMatch(/check your (wallet )?balance/);
44+
expect(msg.toLowerCase()).toMatch(/not pay again|only resubmit/);
45+
});
46+
});
47+
1748
it('classifies Tempo keychain rejection by literal pattern', () => {
1849
const out = classifyMppxFailure(
1950
'RPC Request failed. (keychain validation failed: AccountKeychainError(KeyNotFound(KeyNotFound)))',

0 commit comments

Comments
 (0)