Skip to content
Open
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
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,10 @@ import {
getDefaultSBTCContract,
networkToCAIP2,
caip2ToNetwork,
createFacilitatorNonce,
createFacilitatorMemo,
isFacilitatorMemo,
parsePaymentMemo,
} from 'x402-stacks';

// Convert amounts
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions jest.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/test'],
moduleFileExtensions: ['ts', 'js', 'json'],
clearMocks: true,
};
17 changes: 10 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "x402-stacks",
"version": "2.0.1",
"version": "2.0.3",
"description": "TypeScript library for implementing x402 payment protocol on Stacks blockchain",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand All @@ -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": [
Expand Down Expand Up @@ -38,15 +38,18 @@
"dependencies": {
"@stacks/transactions": "^6.13.0",
"@stacks/network": "^6.13.0",
"axios": "^1.6.0"
"axios": "1.15.2"
},
"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"
Expand Down
1 change: 1 addition & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
TokenType,
} from './types';


/**
* Payment client for making x402 payments on Stacks
*/
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@ export {
formatPaymentAmount,
parsePaymentMemo,
createPaymentMemo,
createFacilitatorNonce,
createFacilitatorMemo,
isFacilitatorMemo,
estimateFee,

// Timing utilities
Expand Down
5 changes: 2 additions & 3 deletions src/interceptor-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 57 additions & 8 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,50 @@
* 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)
&& FACILITATOR_NONCE_PATTERN.test(memo.substring(FACILITATOR_MEMO_PREFIX.length));
}
Comment on lines +45 to +48
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The isFacilitatorMemo function creates a new RegExp object on every invocation. Since the prefix is a constant and the nonce pattern is already defined as a constant (FACILITATOR_NONCE_PATTERN), it is more efficient to reuse the existing pattern or perform a string prefix check followed by the nonce validation. This avoids unnecessary object allocation and compilation overhead, which is beneficial if this utility is used in performance-sensitive areas like transaction scanning.

Suggested change
export function isFacilitatorMemo(memo: string): boolean {
return new RegExp(`^${FACILITATOR_MEMO_PREFIX}[A-Za-z0-9_-]{24}$`).test(memo);
}
export function isFacilitatorMemo(memo: string): boolean {
return memo.startsWith(FACILITATOR_MEMO_PREFIX) && FACILITATOR_NONCE_PATTERN.test(memo.substring(FACILITATOR_MEMO_PREFIX.length));
}


/**
* Convert microSTX to STX
*/
Expand Down Expand Up @@ -162,27 +201,37 @@ export function parsePaymentMemo(memo: string): {
custom?: Record<string, string>;
} = {};

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('=')) {
if (!FACILITATOR_NONCE_PATTERN.test(content)) {
return result;
}

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') {
result.resource = value;
} else if (key === 'nonce') {
result.nonce = value;
} else {
if (!result.custom) {
result.custom = {};
}
result.custom = result.custom || {};
result.custom[key] = value;
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/verifier-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down
120 changes: 120 additions & 0 deletions test/interceptor-v2.memo.test.ts
Original file line number Diff line number Diff line change
@@ -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<any>;

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();
});
});
Loading