From 1d46a4c924ec85e5d67006c6c83696a594acce6b Mon Sep 17 00:00:00 2001 From: Spencer Stock Date: Mon, 22 Dec 2025 22:58:30 -0700 Subject: [PATCH 1/3] Add payment status polling after transaction execution --- .../src/interface/payment/pay.test.ts | 231 ++++++++++++++++++ .../account-sdk/src/interface/payment/pay.ts | 34 ++- 2 files changed, 264 insertions(+), 1 deletion(-) diff --git a/packages/account-sdk/src/interface/payment/pay.test.ts b/packages/account-sdk/src/interface/payment/pay.test.ts index 42212d25f..291a99bf7 100644 --- a/packages/account-sdk/src/interface/payment/pay.test.ts +++ b/packages/account-sdk/src/interface/payment/pay.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { pay } from './pay.js'; +import * as getPaymentStatusModule from './getPaymentStatus.js'; import * as sdkManager from './utils/sdkManager.js'; import * as translatePayment from './utils/translatePayment.js'; import * as validation from './utils/validation.js'; @@ -16,6 +17,7 @@ vi.mock('./utils/validation.js', async () => { }); vi.mock('./utils/translatePayment.js'); vi.mock('./utils/sdkManager.js'); +vi.mock('./getPaymentStatus.js'); // Mock telemetry events vi.mock(':core/telemetry/events/payment.js', () => ({ @@ -31,6 +33,13 @@ describe('pay', () => { vi.stubGlobal('crypto', { randomUUID: vi.fn().mockReturnValue('mock-correlation-id'), }); + + // Mock getPaymentStatus to return 'pending' by default (simulating still pending after 2 seconds) + vi.mocked(getPaymentStatusModule.getPaymentStatus).mockResolvedValue({ + status: 'pending', + id: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + message: 'Payment is being processed', + }); }); it('should successfully process a payment', async () => { @@ -550,4 +559,226 @@ describe('pay', () => { errorMessage: 'Unknown error occurred', }); }); + + describe('polling behavior', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should poll for status after initial payment response', async () => { + // Setup mocks + vi.mocked(validation.validateStringAmount).mockReturnValue(undefined); + vi.mocked(translatePayment.translatePaymentToSendCalls).mockReturnValue({ + version: '2.0.0', + chainId: 8453, + calls: [ + { + to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + data: '0xabcdef', + value: '0x0', + }, + ], + capabilities: {}, + }); + vi.mocked(sdkManager.executePaymentWithSDK).mockResolvedValue({ + transactionHash: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + }); + + // Mock getPaymentStatus to return pending + vi.mocked(getPaymentStatusModule.getPaymentStatus).mockResolvedValue({ + status: 'pending', + id: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + message: 'Payment is being processed', + }); + + const paymentPromise = pay({ + amount: '10.50', + to: '0xFe21034794A5a574B94fE4fDfD16e005F1C96e51', + testnet: false, + }); + + // Fast-forward through polling period + await vi.advanceTimersByTimeAsync(2500); + + const payment = await paymentPromise; + + expect(payment.success).toBe(true); + // Verify getPaymentStatus was called multiple times during polling + expect(getPaymentStatusModule.getPaymentStatus).toHaveBeenCalled(); + }); + + it('should exit polling early when payment is completed', async () => { + // Setup mocks + vi.mocked(validation.validateStringAmount).mockReturnValue(undefined); + vi.mocked(translatePayment.translatePaymentToSendCalls).mockReturnValue({ + version: '2.0.0', + chainId: 8453, + calls: [ + { + to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + data: '0xabcdef', + value: '0x0', + }, + ], + capabilities: {}, + }); + vi.mocked(sdkManager.executePaymentWithSDK).mockResolvedValue({ + transactionHash: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + }); + + // Mock getPaymentStatus to return completed immediately + vi.mocked(getPaymentStatusModule.getPaymentStatus).mockResolvedValue({ + status: 'completed', + id: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + message: 'Payment completed successfully', + }); + + const paymentPromise = pay({ + amount: '10.50', + to: '0xFe21034794A5a574B94fE4fDfD16e005F1C96e51', + testnet: false, + }); + + // Fast-forward just enough for one poll + await vi.advanceTimersByTimeAsync(300); + + const payment = await paymentPromise; + + expect(payment.success).toBe(true); + // Should have exited early, not continuing to poll for 2 seconds + expect(getPaymentStatusModule.getPaymentStatus).toHaveBeenCalledTimes(1); + }); + + it('should exit polling early when payment fails', async () => { + // Setup mocks + vi.mocked(validation.validateStringAmount).mockReturnValue(undefined); + vi.mocked(translatePayment.translatePaymentToSendCalls).mockReturnValue({ + version: '2.0.0', + chainId: 8453, + calls: [ + { + to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + data: '0xabcdef', + value: '0x0', + }, + ], + capabilities: {}, + }); + vi.mocked(sdkManager.executePaymentWithSDK).mockResolvedValue({ + transactionHash: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + }); + + // Mock getPaymentStatus to return failed + vi.mocked(getPaymentStatusModule.getPaymentStatus).mockResolvedValue({ + status: 'failed', + id: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + message: 'Payment failed', + reason: 'Insufficient funds', + }); + + const paymentPromise = pay({ + amount: '10.50', + to: '0xFe21034794A5a574B94fE4fDfD16e005F1C96e51', + testnet: false, + }); + + // Fast-forward just enough for one poll + await vi.advanceTimersByTimeAsync(300); + + const payment = await paymentPromise; + + expect(payment.success).toBe(true); + // Should have exited early + expect(getPaymentStatusModule.getPaymentStatus).toHaveBeenCalledTimes(1); + }); + + it('should continue and return original response if polling errors', async () => { + // Setup mocks + vi.mocked(validation.validateStringAmount).mockReturnValue(undefined); + vi.mocked(translatePayment.translatePaymentToSendCalls).mockReturnValue({ + version: '2.0.0', + chainId: 8453, + calls: [ + { + to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + data: '0xabcdef', + value: '0x0', + }, + ], + capabilities: {}, + }); + vi.mocked(sdkManager.executePaymentWithSDK).mockResolvedValue({ + transactionHash: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + }); + + // Mock getPaymentStatus to throw an error + vi.mocked(getPaymentStatusModule.getPaymentStatus).mockRejectedValue( + new Error('Network error') + ); + + const paymentPromise = pay({ + amount: '10.50', + to: '0xFe21034794A5a574B94fE4fDfD16e005F1C96e51', + testnet: false, + }); + + // Fast-forward just enough for one poll + await vi.advanceTimersByTimeAsync(300); + + const payment = await paymentPromise; + + // Payment should still succeed with original response despite polling error + expect(payment.success).toBe(true); + expect(payment.id).toBe( + '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + ); + }); + + it('should disable telemetry for status polling calls', async () => { + // Setup mocks + vi.mocked(validation.validateStringAmount).mockReturnValue(undefined); + vi.mocked(translatePayment.translatePaymentToSendCalls).mockReturnValue({ + version: '2.0.0', + chainId: 8453, + calls: [ + { + to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + data: '0xabcdef', + value: '0x0', + }, + ], + capabilities: {}, + }); + vi.mocked(sdkManager.executePaymentWithSDK).mockResolvedValue({ + transactionHash: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + }); + + vi.mocked(getPaymentStatusModule.getPaymentStatus).mockResolvedValue({ + status: 'completed', + id: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + message: 'Payment completed', + }); + + const paymentPromise = pay({ + amount: '10.50', + to: '0xFe21034794A5a574B94fE4fDfD16e005F1C96e51', + testnet: false, + telemetry: true, + }); + + await vi.advanceTimersByTimeAsync(300); + await paymentPromise; + + // Verify telemetry was disabled for polling calls + expect(getPaymentStatusModule.getPaymentStatus).toHaveBeenCalledWith( + expect.objectContaining({ + telemetry: false, + }) + ); + }); + }); }); diff --git a/packages/account-sdk/src/interface/payment/pay.ts b/packages/account-sdk/src/interface/payment/pay.ts index 2008ac585..5962448c7 100644 --- a/packages/account-sdk/src/interface/payment/pay.ts +++ b/packages/account-sdk/src/interface/payment/pay.ts @@ -3,6 +3,7 @@ import { logPaymentError, logPaymentStarted, } from ':core/telemetry/events/payment.js'; +import { getPaymentStatus } from './getPaymentStatus.js'; import type { PaymentOptions, PaymentResult } from './types.js'; import { executePaymentWithSDK } from './utils/sdkManager.js'; import { translatePaymentToSendCalls } from './utils/translatePayment.js'; @@ -65,6 +66,37 @@ export async function pay(options: PaymentOptions): Promise { telemetry ); + // Step 4: Poll for status updates for up to 2 seconds + const pollingStartTime = Date.now(); + const pollingDurationMs = 2000; + const pollingIntervalMs = 200; // Poll every 200ms + + let latestPayerInfoResponses = executionResult.payerInfoResponses; + + while (Date.now() - pollingStartTime < pollingDurationMs) { + try { + // Wait before polling + await new Promise((resolve) => setTimeout(resolve, pollingIntervalMs)); + + // Check payment status + const status = await getPaymentStatus({ + id: executionResult.transactionHash, + testnet, + telemetry: false, // Disable telemetry for polling to avoid noise + }); + + // Exit early if payment is confirmed or failed + if (status.status === 'completed' || status.status === 'failed') { + break; + } + } catch (pollingError) { + // If polling fails, continue with the original response + // This ensures we don't fail the entire payment due to status check issues + console.warn('[pay] Error during status polling:', pollingError); + break; + } + } + // Log payment completed if (telemetry) { logPaymentCompleted({ amount, testnet, correlationId }); @@ -76,7 +108,7 @@ export async function pay(options: PaymentOptions): Promise { id: executionResult.transactionHash, amount: amount, to: normalizedAddress, - payerInfoResponses: executionResult.payerInfoResponses, + payerInfoResponses: latestPayerInfoResponses, }; } catch (error) { // Extract error message From 0725ec828243c662be208a9c6feda76e9214f064 Mon Sep 17 00:00:00 2001 From: Spencer Stock Date: Mon, 22 Dec 2025 22:58:49 -0700 Subject: [PATCH 2/3] Apply formatting fixes --- packages/account-sdk/src/interface/payment/pay.test.ts | 6 ++---- packages/account-sdk/src/interface/payment/pay.ts | 8 ++++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/account-sdk/src/interface/payment/pay.test.ts b/packages/account-sdk/src/interface/payment/pay.test.ts index 291a99bf7..032603439 100644 --- a/packages/account-sdk/src/interface/payment/pay.test.ts +++ b/packages/account-sdk/src/interface/payment/pay.test.ts @@ -33,7 +33,7 @@ describe('pay', () => { vi.stubGlobal('crypto', { randomUUID: vi.fn().mockReturnValue('mock-correlation-id'), }); - + // Mock getPaymentStatus to return 'pending' by default (simulating still pending after 2 seconds) vi.mocked(getPaymentStatusModule.getPaymentStatus).mockResolvedValue({ status: 'pending', @@ -733,9 +733,7 @@ describe('pay', () => { // Payment should still succeed with original response despite polling error expect(payment.success).toBe(true); - expect(payment.id).toBe( - '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' - ); + expect(payment.id).toBe('0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'); }); it('should disable telemetry for status polling calls', async () => { diff --git a/packages/account-sdk/src/interface/payment/pay.ts b/packages/account-sdk/src/interface/payment/pay.ts index 5962448c7..064a73787 100644 --- a/packages/account-sdk/src/interface/payment/pay.ts +++ b/packages/account-sdk/src/interface/payment/pay.ts @@ -70,21 +70,21 @@ export async function pay(options: PaymentOptions): Promise { const pollingStartTime = Date.now(); const pollingDurationMs = 2000; const pollingIntervalMs = 200; // Poll every 200ms - + let latestPayerInfoResponses = executionResult.payerInfoResponses; - + while (Date.now() - pollingStartTime < pollingDurationMs) { try { // Wait before polling await new Promise((resolve) => setTimeout(resolve, pollingIntervalMs)); - + // Check payment status const status = await getPaymentStatus({ id: executionResult.transactionHash, testnet, telemetry: false, // Disable telemetry for polling to avoid noise }); - + // Exit early if payment is confirmed or failed if (status.status === 'completed' || status.status === 'failed') { break; From 5f241f5f0fe86b19633f9da443e0d7c0b0e63cd1 Mon Sep 17 00:00:00 2001 From: Spencer Stock Date: Tue, 23 Dec 2025 09:55:45 -0700 Subject: [PATCH 3/3] add bundlerUrl to pay() --- .../src/interface/payment/pay.test.ts | 48 +++++++++++++++++++ .../account-sdk/src/interface/payment/pay.ts | 10 ++-- .../src/interface/payment/types.ts | 2 + 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/packages/account-sdk/src/interface/payment/pay.test.ts b/packages/account-sdk/src/interface/payment/pay.test.ts index 032603439..dbb835db8 100644 --- a/packages/account-sdk/src/interface/payment/pay.test.ts +++ b/packages/account-sdk/src/interface/payment/pay.test.ts @@ -778,5 +778,53 @@ describe('pay', () => { }) ); }); + + it('should pass bundlerUrl to getPaymentStatus during polling', async () => { + const customBundlerUrl = 'https://my-custom-bundler.example.com/rpc'; + + // Setup mocks + vi.mocked(validation.validateStringAmount).mockReturnValue(undefined); + vi.mocked(translatePayment.translatePaymentToSendCalls).mockReturnValue({ + version: '2.0.0', + chainId: 8453, + calls: [ + { + to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + data: '0xabcdef', + value: '0x0', + }, + ], + capabilities: {}, + }); + vi.mocked(sdkManager.executePaymentWithSDK).mockResolvedValue({ + transactionHash: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + }); + + vi.mocked(getPaymentStatusModule.getPaymentStatus).mockResolvedValue({ + status: 'completed', + id: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + message: 'Payment completed', + }); + + const paymentPromise = pay({ + amount: '10.50', + to: '0xFe21034794A5a574B94fE4fDfD16e005F1C96e51', + testnet: false, + bundlerUrl: customBundlerUrl, + }); + + await vi.advanceTimersByTimeAsync(300); + await paymentPromise; + + // Verify custom bundlerUrl was passed to getPaymentStatus + expect(getPaymentStatusModule.getPaymentStatus).toHaveBeenCalledWith( + expect.objectContaining({ + id: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + testnet: false, + telemetry: false, + bundlerUrl: customBundlerUrl, + }) + ); + }); }); }); diff --git a/packages/account-sdk/src/interface/payment/pay.ts b/packages/account-sdk/src/interface/payment/pay.ts index 064a73787..75c154039 100644 --- a/packages/account-sdk/src/interface/payment/pay.ts +++ b/packages/account-sdk/src/interface/payment/pay.ts @@ -1,7 +1,7 @@ import { - logPaymentCompleted, - logPaymentError, - logPaymentStarted, + logPaymentCompleted, + logPaymentError, + logPaymentStarted, } from ':core/telemetry/events/payment.js'; import { getPaymentStatus } from './getPaymentStatus.js'; import type { PaymentOptions, PaymentResult } from './types.js'; @@ -17,6 +17,7 @@ import { normalizeAddress, validateStringAmount } from './utils/validation.js'; * @param options.to - Ethereum address to send payment to * @param options.testnet - Whether to use Base Sepolia testnet (default: false) * @param options.payerInfo - Optional payer information configuration for data callbacks + * @param options.bundlerUrl - Optional custom bundler URL to use for payment status polling. Useful for avoiding rate limits on public endpoints. * @returns Promise - Result of the payment transaction * @throws Error if the payment fails * @@ -36,7 +37,7 @@ import { normalizeAddress, validateStringAmount } from './utils/validation.js'; * ``` */ export async function pay(options: PaymentOptions): Promise { - const { amount, to, testnet = false, payerInfo, walletUrl, telemetry = true } = options; + const { amount, to, testnet = false, payerInfo, walletUrl, telemetry = true, bundlerUrl } = options; // Generate correlation ID for this payment request const correlationId = crypto.randomUUID(); @@ -83,6 +84,7 @@ export async function pay(options: PaymentOptions): Promise { id: executionResult.transactionHash, testnet, telemetry: false, // Disable telemetry for polling to avoid noise + bundlerUrl, }); // Exit early if payment is confirmed or failed diff --git a/packages/account-sdk/src/interface/payment/types.ts b/packages/account-sdk/src/interface/payment/types.ts index d8678e483..a5d0757d1 100644 --- a/packages/account-sdk/src/interface/payment/types.ts +++ b/packages/account-sdk/src/interface/payment/types.ts @@ -68,6 +68,8 @@ export interface PaymentOptions { walletUrl?: string; /** Whether to enable telemetry logging. Defaults to true */ telemetry?: boolean; + /** Optional custom bundler URL to use for payment status polling. Useful for avoiding rate limits on public endpoints. */ + bundlerUrl?: string; } /**