diff --git a/packages/account-sdk/src/interface/payment/pay.test.ts b/packages/account-sdk/src/interface/payment/pay.test.ts index 42212d25f..dbb835db8 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,272 @@ 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, + }) + ); + }); + + 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 2008ac585..75c154039 100644 --- a/packages/account-sdk/src/interface/payment/pay.ts +++ b/packages/account-sdk/src/interface/payment/pay.ts @@ -1,8 +1,9 @@ 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'; import { executePaymentWithSDK } from './utils/sdkManager.js'; import { translatePaymentToSendCalls } from './utils/translatePayment.js'; @@ -16,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 * @@ -35,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(); @@ -65,6 +67,38 @@ 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 + bundlerUrl, + }); + + // 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 +110,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 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; } /**