From be5db6fa4216698e38b7aa020c6953fd2027325f Mon Sep 17 00:00:00 2001 From: Antonio Date: Mon, 20 Apr 2026 20:05:27 -0600 Subject: [PATCH 1/7] fix: restore legacy v1 memo behavior --- src/client.ts | 1 + src/interceptor.ts | 1 + src/middleware.ts | 2 +- test/v1-facilitator-memo.test.ts | 118 +++++++++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 test/v1-facilitator-memo.test.ts diff --git a/src/client.ts b/src/client.ts index 659b0e4..ef711eb 100644 --- a/src/client.ts +++ b/src/client.ts @@ -30,6 +30,7 @@ import { TokenType, } from './types'; + /** * Payment client for making x402 payments on Stacks */ diff --git a/src/interceptor.ts b/src/interceptor.ts index e6fc18d..e044e4c 100644 --- a/src/interceptor.ts +++ b/src/interceptor.ts @@ -26,6 +26,7 @@ import { NetworkType, } from './types'; + /** * Create a Stacks account from a private key (V1) * @deprecated Use privateKeyToAccount from the main exports instead diff --git a/src/middleware.ts b/src/middleware.ts index 00b2740..667f977 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -4,13 +4,13 @@ * Note: For new projects, use paymentMiddleware from middleware-v2.ts */ +import { randomBytes } from 'crypto'; import { Request, Response, NextFunction } from 'express'; import { X402PaymentVerifierV1, SettleOptionsV1 } from './verifier'; import { X402MiddlewareConfig, X402PaymentRequired, } from './types'; -import { randomBytes } from 'crypto'; /** * Express middleware for x402 V1 payment requirements diff --git a/test/v1-facilitator-memo.test.ts b/test/v1-facilitator-memo.test.ts new file mode 100644 index 0000000..59b7ae7 --- /dev/null +++ b/test/v1-facilitator-memo.test.ts @@ -0,0 +1,118 @@ +import type { AxiosInstance } from 'axios'; +import { paymentMiddlewareV1, wrapAxiosWithPaymentV1, X402PaymentClient } from '../src'; + +const mockMakeSTXTokenTransfer = jest.fn(); + +jest.mock('@stacks/transactions', () => ({ + makeSTXTokenTransfer: (opts: any) => mockMakeSTXTokenTransfer(opts), + makeContractCall: jest.fn(), + broadcastTransaction: jest.fn(), + AnchorMode: { Any: 3 }, + PostConditionMode: { Allow: 1 }, + TxBroadcastResult: {}, + uintCV: (value: string) => value, + principalCV: (value: string) => value, + someCV: (value: any) => ({ type: 'some', value }), + noneCV: () => ({ type: 'none' }), + bufferCVFromString: (value: string) => value, + getAddressFromPrivateKey: () => 'ST2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7', + TransactionVersion: { Mainnet: 0, Testnet: 1 }, +})); + +jest.mock('@stacks/network', () => ({ + StacksMainnet: class { url = 'https://stacks-node-api.mainnet.stacks.co'; }, + StacksTestnet: class { url = 'https://stacks-node-api.testnet.stacks.co'; }, +})); + +function createV1AxiosHarness() { + let rejected!: (error: any) => Promise; + + const instance = { + interceptors: { + response: { + use: (_fulfilled: unknown, onRejected: typeof rejected) => { + rejected = onRejected; + return 0; + }, + }, + }, + request: jest.fn().mockResolvedValue({ status: 200, data: { ok: true } }), + } as unknown as AxiosInstance & { request: jest.Mock }; + + wrapAxiosWithPaymentV1(instance, { + address: 'ST2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7', + privateKey: '1'.repeat(64), + network: 'testnet', + }); + + return { rejected }; +} + +describe('legacy V1 memo flow', () => { + beforeEach(() => { + mockMakeSTXTokenTransfer.mockResolvedValue({ serialize: () => Uint8Array.from([0xaa, 0xbb]) }); + }); + + it('returns a 32-character hex nonce in V1 402 responses', async () => { + const middleware = paymentMiddlewareV1({ + amount: 1000n, + address: 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM', + network: 'testnet', + }); + + const req = { headers: {}, query: {}, body: {}, path: '/premium', method: 'GET' } as any; + const res = { status: jest.fn().mockReturnThis(), json: jest.fn(), setHeader: jest.fn() } as any; + + await middleware(req, res, jest.fn()); + + expect(res.status).toHaveBeenCalledWith(402); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + nonce: expect.stringMatching(/^[0-9a-f]{32}$/), + })); + }); + + it('keeps the V1 axios-interceptor memo as the raw nonce', async () => { + const { rejected } = createV1AxiosHarness(); + + await rejected({ + config: { headers: {} }, + response: { + status: 402, + data: { + maxAmountRequired: '1000', + resource: '/premium', + payTo: 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM', + network: 'testnet', + nonce: '0123456789abcdef0123456789abcdef', + expiresAt: new Date(Date.now() + 300000).toISOString(), + }, + }, + }); + + expect(mockMakeSTXTokenTransfer).toHaveBeenCalledWith(expect.objectContaining({ + memo: '0123456789abcdef0123456789abcdef', + })); + }); + + it('truncates long V1 client nonces without adding an x402 prefix', async () => { + const client = new X402PaymentClient({ + privateKey: '1'.repeat(64), + network: 'testnet', + }); + + const longNonce = '0123456789abcdef0123456789abcdefzz'; + + await client.signPayment({ + maxAmountRequired: '1000', + resource: '/premium', + payTo: 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM', + network: 'testnet', + nonce: longNonce, + expiresAt: new Date(Date.now() + 300000).toISOString(), + }); + + expect(mockMakeSTXTokenTransfer).toHaveBeenCalledWith(expect.objectContaining({ + memo: longNonce.substring(0, 34), + })); + }); +}); From 823f8576f5570359bf975e227a3f137c95217b53 Mon Sep 17 00:00:00 2001 From: Antonio Date: Mon, 20 Apr 2026 20:06:19 -0600 Subject: [PATCH 2/7] docs: clarify facilitator memo as v2-only --- README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/README.md b/README.md index c9ffaf7..e27796c 100644 --- a/README.md +++ b/README.md @@ -317,6 +317,10 @@ import { getDefaultSBTCContract, networkToCAIP2, caip2ToNetwork, + createFacilitatorNonce, + createFacilitatorMemo, + isFacilitatorMemo, + parsePaymentMemo, } from 'x402-stacks'; // Convert amounts @@ -425,6 +429,31 @@ app.get('/api/market-data/:tier', ); ``` +## Facilitator Memo Convention (V2) + +When the SDK signs a facilitator-bound transaction in the default V2 flow, it writes the memo before signing using: + +```text +x402:<24-char-base64url-nonce> +``` + +- STX transfers store the memo in the transaction memo field. +- sBTC and USDCx transfers store the same value in the SIP-010 optional memo argument. +- Legacy V1 flows keep their existing memo behavior and do not use this convention automatically. +- The prefix is intended for analytics and transaction attribution only. +- The prefix is not cryptographic proof that the facilitator handled the payment. + +The SDK exports the following helpers from the root module: + +```ts +import { + createFacilitatorNonce, + createFacilitatorMemo, + isFacilitatorMemo, + parsePaymentMemo, +} from 'x402-stacks'; +``` + ## sBTC Support x402-stacks supports **sBTC** (Bitcoin on Stacks) for payments in addition to STX! sBTC is a 1:1 Bitcoin-backed asset on Stacks, allowing users to pay with Bitcoin while leveraging Stacks' fast settlement. From 74f9cedceab534d8dd1f063592ad7d7b5fdfbdd4 Mon Sep 17 00:00:00 2001 From: Antonio Date: Mon, 20 Apr 2026 20:07:07 -0600 Subject: [PATCH 3/7] release: scope facilitator memo to v2 in 2.0.2 --- package.json | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 68a12ec..f8cca7a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "x402-stacks", - "version": "2.0.1", + "version": "2.0.2", "description": "TypeScript library for implementing x402 payment protocol on Stacks blockchain", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -9,7 +9,7 @@ "dev": "tsc --watch", "dev:server": "ts-node examples/server.ts", "dev:client": "ts-node examples/client.ts", - "test": "jest", + "test": "jest --runInBand", "prepublishOnly": "npm run build" }, "keywords": [ @@ -41,12 +41,15 @@ "axios": "^1.6.0" }, "devDependencies": { - "@types/node": "^20.0.0", "@types/express": "^4.17.0", - "typescript": "^5.3.0", - "ts-node": "^10.9.0", + "@types/jest": "^29.5.14", + "@types/node": "^20.0.0", + "dotenv": "^16.3.0", "express": "^4.18.0", - "dotenv": "^16.3.0" + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "ts-node": "^10.9.0", + "typescript": "^5.3.0" }, "peerDependencies": { "express": "^4.18.0" From ad88e88a07ae1da935ee4ec4c35181b9a84b2716 Mon Sep 17 00:00:00 2001 From: Antonio Date: Mon, 20 Apr 2026 21:13:13 -0600 Subject: [PATCH 4/7] feat: add v2 facilitator memo helpers --- jest.config.cjs | 7 ++ src/index.ts | 3 + src/interceptor-v2.ts | 5 +- src/utils.ts | 60 ++++++++++++-- test/interceptor-v2.memo.test.ts | 120 ++++++++++++++++++++++++++++ test/utils/facilitator-memo.test.ts | 48 +++++++++++ 6 files changed, 232 insertions(+), 11 deletions(-) create mode 100644 jest.config.cjs create mode 100644 test/interceptor-v2.memo.test.ts create mode 100644 test/utils/facilitator-memo.test.ts diff --git a/jest.config.cjs b/jest.config.cjs new file mode 100644 index 0000000..8da1b8f --- /dev/null +++ b/jest.config.cjs @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/test'], + moduleFileExtensions: ['ts', 'js', 'json'], + clearMocks: true, +}; diff --git a/src/index.ts b/src/index.ts index ebebbf1..e56bc6a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -172,6 +172,9 @@ export { formatPaymentAmount, parsePaymentMemo, createPaymentMemo, + createFacilitatorNonce, + createFacilitatorMemo, + isFacilitatorMemo, estimateFee, // Timing utilities diff --git a/src/interceptor-v2.ts b/src/interceptor-v2.ts index 95fb8c1..561e976 100644 --- a/src/interceptor-v2.ts +++ b/src/interceptor-v2.ts @@ -28,7 +28,7 @@ import { STACKS_NETWORKS, NetworkV2, } from './types-v2'; -import { networkFromCAIP2, assetFromV2 } from './utils'; +import { networkFromCAIP2, assetFromV2, createFacilitatorMemo, createFacilitatorNonce } from './utils'; /** * Create a Stacks account from a private key @@ -133,8 +133,7 @@ async function signPaymentV2( const network = getNetworkInstanceFromCAIP2(paymentRequirements.network); const v1Network = networkFromCAIP2(paymentRequirements.network); - // Generate a short memo (max 34 bytes for Stacks) - const memo = `x402:${Date.now().toString(36)}`.substring(0, 34); + const memo = createFacilitatorMemo(createFacilitatorNonce()); if (tokenType === 'sBTC' || tokenType === 'USDCx') { // SIP-010 token transfer diff --git a/src/utils.ts b/src/utils.ts index e866d9f..52d4f40 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,11 +3,49 @@ * Helper functions for working with x402 payments on Stacks */ +import { createHash, randomBytes } from 'crypto'; import { makeRandomPrivKey, getPublicKey, publicKeyToAddress, AddressVersion } from '@stacks/transactions'; import { StacksMainnet, StacksTestnet } from '@stacks/network'; import { NetworkType, TokenType, TokenContract } from './types'; import { NetworkV2, STACKS_NETWORKS } from './types-v2'; +const FACILITATOR_MEMO_PREFIX = 'x402:'; +const FACILITATOR_NONCE_BYTES = 18; +const STACKS_MEMO_MAX_BYTES = 34; +const FACILITATOR_NONCE_PATTERN = /^[A-Za-z0-9_-]{24}$/; + +function normalizeFacilitatorNonce(nonce: string): string { + if (FACILITATOR_NONCE_PATTERN.test(nonce)) { + return nonce; + } + + return createHash('sha256') + .update(nonce) + .digest() + .subarray(0, FACILITATOR_NONCE_BYTES) + .toString('base64url'); +} + +export function createFacilitatorNonce( + generator: (size: number) => Buffer = randomBytes +): string { + return generator(FACILITATOR_NONCE_BYTES).toString('base64url'); +} + +export function createFacilitatorMemo(nonce: string): string { + const memo = `${FACILITATOR_MEMO_PREFIX}${normalizeFacilitatorNonce(nonce)}`; + + if (Buffer.byteLength(memo, 'utf8') > STACKS_MEMO_MAX_BYTES) { + throw new Error('Facilitator memo exceeds the 34-byte Stacks memo limit'); + } + + return memo; +} + +export function isFacilitatorMemo(memo: string): boolean { + return memo.startsWith(FACILITATOR_MEMO_PREFIX); +} + /** * Convert microSTX to STX */ @@ -162,17 +200,25 @@ export function parsePaymentMemo(memo: string): { custom?: Record; } = {}; - if (!memo.startsWith('x402:')) { + if (!memo.startsWith(FACILITATOR_MEMO_PREFIX)) { return result; } - // Remove x402: prefix - const content = memo.substring(5); + const content = memo.substring(FACILITATOR_MEMO_PREFIX.length); + + if (!content.includes('=')) { + result.nonce = content; + return result; + } - // Split by comma const parts = content.split(','); - for (const part of parts) { + for (const [index, part] of parts.entries()) { + if (!part.includes('=') && index === 0) { + result.resource = part; + continue; + } + const [key, value] = part.split('='); if (key && value) { if (key === 'resource') { @@ -180,9 +226,7 @@ export function parsePaymentMemo(memo: string): { } else if (key === 'nonce') { result.nonce = value; } else { - if (!result.custom) { - result.custom = {}; - } + result.custom = result.custom || {}; result.custom[key] = value; } } diff --git a/test/interceptor-v2.memo.test.ts b/test/interceptor-v2.memo.test.ts new file mode 100644 index 0000000..a82c267 --- /dev/null +++ b/test/interceptor-v2.memo.test.ts @@ -0,0 +1,120 @@ +jest.mock('@stacks/network', () => ({ + StacksMainnet: class { url = 'https://stacks-node-api.mainnet.stacks.co'; }, + StacksTestnet: class { url = 'https://stacks-node-api.testnet.stacks.co'; }, +})); + +import type { AxiosInstance } from 'axios'; +import { wrapAxiosWithPayment, X402_HEADERS } from '../src'; + +const mockMakeSTXTokenTransfer = jest.fn(); +const mockMakeContractCall = jest.fn(); +const mockBufferCVFromString = jest.fn((value: string) => value); +const mockSomeCV = jest.fn((value: unknown) => ({ type: 'some', value })); + +jest.mock('@stacks/transactions', () => ({ + makeSTXTokenTransfer: (opts: any) => mockMakeSTXTokenTransfer(opts), + makeContractCall: (opts: any) => mockMakeContractCall(opts), + AnchorMode: { Any: 3 }, + PostConditionMode: { Allow: 1 }, + uintCV: (value: string) => value, + principalCV: (value: string) => value, + someCV: (value: any) => mockSomeCV(value), + noneCV: () => ({ type: 'none' }), + bufferCVFromString: (value: string) => mockBufferCVFromString(value), + getAddressFromPrivateKey: () => 'ST2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7', + TransactionVersion: { Mainnet: 0, Testnet: 1 }, +})); + +function createAxiosHarness() { + let rejected!: (error: any) => Promise; + + const instance = { + interceptors: { + response: { + use: (_fulfilled: unknown, onRejected: typeof rejected) => { + rejected = onRejected; + return 0; + }, + }, + }, + request: jest.fn().mockResolvedValue({ status: 200, data: { ok: true } }), + } as unknown as AxiosInstance & { request: jest.Mock }; + + wrapAxiosWithPayment(instance, { + address: 'ST2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7', + privateKey: '1'.repeat(64), + network: 'testnet', + }); + + return { instance, rejected }; +} + +describe('wrapAxiosWithPayment facilitator memo', () => { + beforeEach(() => { + mockMakeSTXTokenTransfer.mockResolvedValue({ serialize: () => Uint8Array.from([0xde, 0xad]) }); + mockMakeContractCall.mockResolvedValue({ serialize: () => Uint8Array.from([0xca, 0xfe]) }); + }); + + it('signs STX retries with an x402-prefixed memo', async () => { + const { rejected } = createAxiosHarness(); + + const paymentRequired = { + x402Version: 2, + resource: { url: 'https://api.example.com/premium' }, + accepts: [{ + scheme: 'exact', + network: 'stacks:2147483648', + amount: '1000', + asset: 'STX', + payTo: 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM', + maxTimeoutSeconds: 300, + }], + }; + + await rejected({ + config: { headers: {} }, + response: { + status: 402, + headers: { + [X402_HEADERS.PAYMENT_REQUIRED]: Buffer.from(JSON.stringify(paymentRequired)).toString('base64'), + }, + data: null, + }, + }); + + expect(mockMakeSTXTokenTransfer).toHaveBeenCalledWith(expect.objectContaining({ + memo: expect.stringMatching(/^x402:[A-Za-z0-9_-]{24}$/), + })); + }); + + it('passes the same memo pattern into SIP-010 contract calls', async () => { + const { rejected } = createAxiosHarness(); + + const paymentRequired = { + x402Version: 2, + resource: { url: 'https://api.example.com/premium' }, + accepts: [{ + scheme: 'exact', + network: 'stacks:2147483648', + amount: '1000', + asset: 'SBTC', + payTo: 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM', + maxTimeoutSeconds: 300, + }], + }; + + await rejected({ + config: { headers: {} }, + response: { + status: 402, + headers: { + [X402_HEADERS.PAYMENT_REQUIRED]: Buffer.from(JSON.stringify(paymentRequired)).toString('base64'), + }, + data: null, + }, + }); + + expect(mockBufferCVFromString).toHaveBeenCalledWith(expect.stringMatching(/^x402:[A-Za-z0-9_-]{24}$/)); + expect(mockSomeCV).toHaveBeenCalled(); + }); +}); diff --git a/test/utils/facilitator-memo.test.ts b/test/utils/facilitator-memo.test.ts new file mode 100644 index 0000000..6024cfa --- /dev/null +++ b/test/utils/facilitator-memo.test.ts @@ -0,0 +1,48 @@ +import { + createFacilitatorMemo, + createFacilitatorNonce, + isFacilitatorMemo, + parsePaymentMemo, +} from '../../src/utils'; + +describe('facilitator memo helpers', () => { + it('creates a 24-character base64url nonce', () => { + const nonce = createFacilitatorNonce(() => Buffer.alloc(18, 1)); + + expect(nonce).toHaveLength(24); + expect(nonce).toMatch(/^[A-Za-z0-9_-]{24}$/); + }); + + it('creates an x402 memo that fits the Stacks memo limit', () => { + const memo = createFacilitatorMemo('Ab3Kx9mPqR2sT5vW8yZ1aB3K'); + + expect(memo).toBe('x402:Ab3Kx9mPqR2sT5vW8yZ1aB3K'); + expect(Buffer.byteLength(memo, 'utf8')).toBeLessThanOrEqual(34); + }); + + it('normalizes legacy nonces into the facilitator memo format', () => { + const legacyNonce = '0123456789abcdef0123456789abcdef'; + const memo = createFacilitatorMemo(legacyNonce); + + expect(memo).toMatch(/^x402:[A-Za-z0-9_-]{24}$/); + expect(createFacilitatorMemo(legacyNonce)).toBe(memo); + }); + + it('parses the facilitator nonce from the compact memo format', () => { + expect(parsePaymentMemo('x402:Ab3Kx9mPqR2sT5vW8yZ1aB3K')).toEqual({ + nonce: 'Ab3Kx9mPqR2sT5vW8yZ1aB3K', + }); + }); + + it('preserves parsing for legacy createPaymentMemo output', () => { + expect(parsePaymentMemo('x402:/premium,nonce=Ab3Kx9mPqR2sT5vW8yZ1aB3K')).toEqual({ + resource: '/premium', + nonce: 'Ab3Kx9mPqR2sT5vW8yZ1aB3K', + }); + }); + + it('recognizes facilitator memos by prefix', () => { + expect(isFacilitatorMemo('x402:Ab3Kx9mPqR2sT5vW8yZ1aB3K')).toBe(true); + expect(isFacilitatorMemo('nonce=abc123')).toBe(false); + }); +}); From e2d8ae5e2e8de1963c985e91ad77c05c817d0bf2 Mon Sep 17 00:00:00 2001 From: Antonio Date: Mon, 20 Apr 2026 21:16:20 -0600 Subject: [PATCH 5/7] fix: narrow facilitator memo helper detection --- src/utils.ts | 6 +++++- test/utils/facilitator-memo.test.ts | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/utils.ts b/src/utils.ts index 52d4f40..1c37179 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -43,7 +43,7 @@ export function createFacilitatorMemo(nonce: string): string { } export function isFacilitatorMemo(memo: string): boolean { - return memo.startsWith(FACILITATOR_MEMO_PREFIX); + return new RegExp(`^${FACILITATOR_MEMO_PREFIX}[A-Za-z0-9_-]{24}$`).test(memo); } /** @@ -207,6 +207,10 @@ export function parsePaymentMemo(memo: string): { const content = memo.substring(FACILITATOR_MEMO_PREFIX.length); if (!content.includes('=')) { + if (!FACILITATOR_NONCE_PATTERN.test(content)) { + return result; + } + result.nonce = content; return result; } diff --git a/test/utils/facilitator-memo.test.ts b/test/utils/facilitator-memo.test.ts index 6024cfa..80829c1 100644 --- a/test/utils/facilitator-memo.test.ts +++ b/test/utils/facilitator-memo.test.ts @@ -34,6 +34,10 @@ describe('facilitator memo helpers', () => { }); }); + it('rejects malformed compact facilitator memos', () => { + expect(parsePaymentMemo('x402:not-a-valid-nonce')).toEqual({}); + }); + it('preserves parsing for legacy createPaymentMemo output', () => { expect(parsePaymentMemo('x402:/premium,nonce=Ab3Kx9mPqR2sT5vW8yZ1aB3K')).toEqual({ resource: '/premium', @@ -43,6 +47,7 @@ describe('facilitator memo helpers', () => { it('recognizes facilitator memos by prefix', () => { expect(isFacilitatorMemo('x402:Ab3Kx9mPqR2sT5vW8yZ1aB3K')).toBe(true); + expect(isFacilitatorMemo('x402:/premium,nonce=Ab3Kx9mPqR2sT5vW8yZ1aB3K')).toBe(false); expect(isFacilitatorMemo('nonce=abc123')).toBe(false); }); }); From 1549e5b22e1b7a111e1ed639f37b009f37f3d31e Mon Sep 17 00:00:00 2001 From: Antonio Date: Mon, 20 Apr 2026 21:33:40 -0600 Subject: [PATCH 6/7] perf: reuse facilitator memo pattern --- src/utils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utils.ts b/src/utils.ts index 1c37179..afd92df 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -43,7 +43,8 @@ export function createFacilitatorMemo(nonce: string): string { } export function isFacilitatorMemo(memo: string): boolean { - return new RegExp(`^${FACILITATOR_MEMO_PREFIX}[A-Za-z0-9_-]{24}$`).test(memo); + return memo.startsWith(FACILITATOR_MEMO_PREFIX) + && FACILITATOR_NONCE_PATTERN.test(memo.substring(FACILITATOR_MEMO_PREFIX.length)); } /** From afbfa636820414a7afd821c6e262054c783cdb5f Mon Sep 17 00:00:00 2001 From: Antonio Date: Mon, 4 May 2026 22:40:41 -0600 Subject: [PATCH 7/7] release: publish 2.0.3 --- package.json | 4 ++-- src/verifier-v2.ts | 2 +- test/verifier-v2.timeout.test.ts | 29 +++++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 test/verifier-v2.timeout.test.ts diff --git a/package.json b/package.json index f8cca7a..488f1ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "x402-stacks", - "version": "2.0.2", + "version": "2.0.3", "description": "TypeScript library for implementing x402 payment protocol on Stacks blockchain", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -38,7 +38,7 @@ "dependencies": { "@stacks/transactions": "^6.13.0", "@stacks/network": "^6.13.0", - "axios": "^1.6.0" + "axios": "1.15.2" }, "devDependencies": { "@types/express": "^4.17.0", diff --git a/src/verifier-v2.ts b/src/verifier-v2.ts index 7003b4d..16e1287 100644 --- a/src/verifier-v2.ts +++ b/src/verifier-v2.ts @@ -44,7 +44,7 @@ export class X402PaymentVerifier { this.facilitatorUrl = facilitatorUrl.replace(/\/$/, ''); // Remove trailing slash this.httpClient = axios.create({ - timeout: 30000, // V2 may need longer timeout for settlement + timeout: 50000, // V2 settlement can take longer while facilitator confirms headers: { 'Content-Type': 'application/json', }, diff --git a/test/verifier-v2.timeout.test.ts b/test/verifier-v2.timeout.test.ts new file mode 100644 index 0000000..029bc8c --- /dev/null +++ b/test/verifier-v2.timeout.test.ts @@ -0,0 +1,29 @@ +const mockAxiosCreate = jest.fn((_config: unknown) => ({ + post: jest.fn(), + get: jest.fn(), +})); + +jest.mock('axios', () => ({ + __esModule: true, + default: { + create: (config: unknown) => mockAxiosCreate(config), + isAxiosError: jest.fn(() => false), + }, + isAxiosError: jest.fn(() => false), +})); + +import { X402PaymentVerifier } from '../src/verifier-v2'; + +describe('X402PaymentVerifier V2 timeout', () => { + beforeEach(() => { + mockAxiosCreate.mockClear(); + }); + + it('waits up to 50 seconds for facilitator responses', () => { + new X402PaymentVerifier('https://facilitator.example.com'); + + expect(mockAxiosCreate).toHaveBeenCalledWith(expect.objectContaining({ + timeout: 50000, + })); + }); +});