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
6 changes: 6 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ export class X402PaymentClient {
anchorMode: AnchorMode.Any,
...(details.nonce !== undefined && { nonce: details.nonce }),
...(details.fee !== undefined && { fee: details.fee }),
...(details.sponsored && { sponsored: true, fee: 0n }),
};

// Create transaction (signed but not broadcast)
Expand Down Expand Up @@ -220,6 +221,7 @@ export class X402PaymentClient {
postConditionMode: PostConditionMode.Allow,
...(details.nonce !== undefined && { nonce: details.nonce }),
...(details.fee !== undefined && { fee: details.fee }),
...(details.sponsored && { sponsored: true, fee: 0n }),
};

// Create transaction (signed but not broadcast)
Expand Down Expand Up @@ -262,6 +264,7 @@ export class X402PaymentClient {
anchorMode: AnchorMode.Any,
...(details.nonce !== undefined && { nonce: details.nonce }),
...(details.fee !== undefined && { fee: details.fee }),
...(details.sponsored && { sponsored: true, fee: 0n }),
};

// Create transaction
Expand Down Expand Up @@ -344,6 +347,7 @@ export class X402PaymentClient {
postConditionMode: PostConditionMode.Allow,
...(details.nonce !== undefined && { nonce: details.nonce }),
...(details.fee !== undefined && { fee: details.fee }),
...(details.sponsored && { sponsored: true, fee: 0n }),
};

// Create transaction
Expand Down Expand Up @@ -426,6 +430,7 @@ export class X402PaymentClient {
postConditionMode: PostConditionMode.Allow,
...(details.nonce !== undefined && { nonce: details.nonce }),
...(details.fee !== undefined && { fee: details.fee }),
...(details.sponsored && { sponsored: true, fee: 0n }),
};

// Create transaction (signed but not broadcast)
Expand Down Expand Up @@ -493,6 +498,7 @@ export class X402PaymentClient {
postConditionMode: PostConditionMode.Allow,
...(details.nonce !== undefined && { nonce: details.nonce }),
...(details.fee !== undefined && { fee: details.fee }),
...(details.sponsored && { sponsored: true, fee: 0n }),
};

// Create transaction
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export {
decodeXPaymentResponse,
encodeXPaymentResponse,
} from './interceptor';
export type { SignPaymentOptions, PaymentInterceptorConfig } from './interceptor';

// Legacy client (class-based)
export { X402PaymentClient } from './client';
Expand Down
48 changes: 42 additions & 6 deletions src/interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,18 +75,28 @@ function getNetworkInstance(network: NetworkType): StacksNetwork {
return network === 'mainnet' ? new StacksMainnet() : new StacksTestnet();
}

/**
* Options for payment signing
*/
export interface SignPaymentOptions {
/** Build as sponsored transaction (for gasless relay) */
sponsored?: boolean;
}

/**
* Sign a payment transaction based on x402 payment request
* Returns the signed transaction hex (does not broadcast)
*/
async function signPayment(
paymentRequest: X402PaymentRequired,
account: StacksAccount
account: StacksAccount,
options: SignPaymentOptions = {}
): Promise<string> {
const amount = BigInt(paymentRequest.maxAmountRequired);
const tokenType = paymentRequest.tokenType || 'STX';
const network = getNetworkInstance(paymentRequest.network);
const memo = paymentRequest.nonce.substring(0, 34); // Max 34 bytes for Stacks memo
const { sponsored } = options;

if (tokenType === 'sBTC' || tokenType === 'USDCx') {
// sBTC or USDCx transfer (SIP-010 contract call)
Expand All @@ -112,6 +122,7 @@ async function signPayment(
network,
anchorMode: AnchorMode.Any,
postConditionMode: PostConditionMode.Allow,
...(sponsored && { sponsored: true, fee: 0n }),
});

// Convert Uint8Array to hex string
Expand All @@ -126,6 +137,7 @@ async function signPayment(
network,
memo,
anchorMode: AnchorMode.Any,
...(sponsored && { sponsored: true, fee: 0n }),
});

// Convert Uint8Array to hex string
Expand Down Expand Up @@ -156,6 +168,14 @@ function isValidPaymentRequest(data: unknown): data is X402PaymentRequired {
// Track which requests have already had payment attempted
const paymentAttempted = new WeakSet<InternalAxiosRequestConfig>();

/**
* Configuration options for the payment interceptor
*/
export interface PaymentInterceptorConfig {
/** Build transactions as sponsored (for gasless relay) */
sponsored?: boolean;
}

/**
* Wrap an axios instance with automatic x402 payment handling
* Similar to x402-axios's withPaymentInterceptor
Expand All @@ -175,11 +195,19 @@ const paymentAttempted = new WeakSet<InternalAxiosRequestConfig>();
* // Use normally - 402 handling is automatic
* const response = await api.get('/premium-data');
* console.log(response.data);
*
* // For gasless transactions via sponsor relay:
* const gaslessApi = withPaymentInterceptor(
* axios.create({ baseURL: 'https://sponsor-relay.example.com' }),
* account,
* { sponsored: true }
* );
* ```
*/
export function withPaymentInterceptor(
axiosInstance: AxiosInstance,
account: StacksAccount
account: StacksAccount,
config: PaymentInterceptorConfig = {}
): AxiosInstance {
// Response interceptor to handle 402 Payment Required
axiosInstance.interceptors.response.use(
Expand Down Expand Up @@ -218,7 +246,9 @@ export function withPaymentInterceptor(

try {
// Sign the payment (don't broadcast - server will do that)
const signedTransaction = await signPayment(paymentRequest, account);
const signedTransaction = await signPayment(paymentRequest, account, {
sponsored: config.sponsored,
});

// Retry the request with the signed payment
originalRequest.headers = originalRequest.headers || {};
Expand Down Expand Up @@ -250,14 +280,20 @@ export function withPaymentInterceptor(
* const api = createPaymentClient(account, { baseURL: 'https://api.example.com' });
*
* const response = await api.get('/premium-data');
*
* // For gasless transactions via sponsor relay:
* const gaslessApi = createPaymentClient(account, {
* baseURL: 'https://sponsor-relay.example.com'
* }, { sponsored: true });
* ```
*/
export function createPaymentClient(
account: StacksAccount,
config?: Parameters<typeof import('axios').default.create>[0]
axiosConfig?: Parameters<typeof import('axios').default.create>[0],
paymentConfig?: PaymentInterceptorConfig
): AxiosInstance {
// Dynamic import to avoid requiring axios at module load time
const axios = require('axios');
const instance = axios.create(config);
return withPaymentInterceptor(instance, account);
const instance = axios.create(axiosConfig);
return withPaymentInterceptor(instance, account, paymentConfig);
}
3 changes: 3 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ export interface PaymentDetails {
/** Optional fee (auto-estimated if not provided) */
fee?: bigint;

/** Build as sponsored transaction (for gasless relay) */
sponsored?: boolean;

/** Token type (defaults to STX) */
tokenType?: TokenType;

Expand Down