From 61271497045f0a556aae8cb88a16f4754ba8135f Mon Sep 17 00:00:00 2001 From: bdj Date: Thu, 16 Apr 2026 12:26:26 -0700 Subject: [PATCH] fix: allow null txHash in SettleResult for already-settled retries Auth is adding a carve-out on /settle/x402 that returns 200 with { alreadySettled: true, txHash: null, ... } when the CDP facilitator rejects a retry with "duplicate transaction" (see auth PR for details). The SDK's SettleResult.txHash was typed as string (non-nullable), which would be a lie the moment that auth change ships. Loosen the type to string | null, add an optional alreadySettled flag, and update the two log interpolations in protocol.ts and atxpExpress.ts to render "" when txHash is null instead of the word "null". Behavior on the happy path (txHash: string) is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/atxp-express/src/atxpExpress.ts | 2 +- packages/atxp-server/src/protocol.test.ts | 25 +++++++++++++++++++++++ packages/atxp-server/src/protocol.ts | 10 +++++++-- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/atxp-express/src/atxpExpress.ts b/packages/atxp-express/src/atxpExpress.ts index d8252d5..06091ab 100644 --- a/packages/atxp-express/src/atxpExpress.ts +++ b/packages/atxp-express/src/atxpExpress.ts @@ -150,7 +150,7 @@ export function atxpExpress(args: ATXPArgs): Router { detected.credential, context as Parameters[2], ); - logger.info(`Settled ${detected.protocol} in middleware: txHash=${result.txHash}, amount=${result.settledAmount}`); + logger.info(`Settled ${detected.protocol} in middleware: txHash=${result.txHash ?? ''}, amount=${result.settledAmount}`); } catch (error) { logger.error(`Middleware settlement failed for ${detected.protocol}: ${error instanceof Error ? error.message : String(error)}`); // Don't store the credential — it's already consumed/invalid. diff --git a/packages/atxp-server/src/protocol.test.ts b/packages/atxp-server/src/protocol.test.ts index 30075eb..10c62cb 100644 --- a/packages/atxp-server/src/protocol.test.ts +++ b/packages/atxp-server/src/protocol.test.ts @@ -319,6 +319,31 @@ describe('ProtocolSettlement', () => { await expect(settlement.settle('x402', 'cred')).rejects.toThrow('Settlement failed for x402: 500'); }); + it('should pass through null txHash when auth reports already-settled', async () => { + // The auth server returns { txHash: null, alreadySettled: true, ... } on + // retries of an already-settled payload. The SDK should surface it as-is + // rather than crashing or forcing the type to string. + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + txHash: null, + settledAmount: '201000', + alreadySettled: true, + network: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + payer: '3FnrCCfHhZhEyeQd5Q69B1faqLvdHoG3WxUesZBBJ7M2', + sourceAccountId: 'atxp:atxp_acct_6qB245zVIJeSiIHi8xPmY', + }), + }); + + const payload = { signature: '0xabc' }; + const credential = Buffer.from(JSON.stringify(payload)).toString('base64'); + const result = await settlement.settle('x402', credential, { paymentRequirements: { network: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' } }); + + expect(result.txHash).toBeNull(); + expect(result.settledAmount).toBe('201000'); + expect(result.alreadySettled).toBe(true); + }); + describe('X402 multi-chain accept routing', () => { const multiChainReqs = { x402Version: 2, diff --git a/packages/atxp-server/src/protocol.ts b/packages/atxp-server/src/protocol.ts index ff8edef..ae6a469 100644 --- a/packages/atxp-server/src/protocol.ts +++ b/packages/atxp-server/src/protocol.ts @@ -106,10 +106,16 @@ export type VerifyResult = { /** * Result of settling a payment. + * + * `txHash` is null when the auth server reports the payment was already settled + * by a prior call (HTTP retry after a successful settle). The original tx hash + * is not carried in that response; callers that need it must look it up by + * other means (payer / amount / destination). */ export type SettleResult = { - txHash: string; + txHash: string | null; settledAmount: string; + alreadySettled?: boolean; }; /** @@ -230,7 +236,7 @@ export class ProtocolSettlement { } const result = await response.json() as SettleResult; - this.logger.info(`Settled ${protocol}: txHash=${result.txHash}, amount=${result.settledAmount}`); + this.logger.info(`Settled ${protocol}: txHash=${result.txHash ?? ''}, amount=${result.settledAmount}`); return result; }