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; }