diff --git a/packages/account-sdk/src/browser-entry.ts b/packages/account-sdk/src/browser-entry.ts index f950f0fab..f83eb92ab 100644 --- a/packages/account-sdk/src/browser-entry.ts +++ b/packages/account-sdk/src/browser-entry.ts @@ -9,6 +9,7 @@ import { base } from './interface/payment/base.browser.js'; import { CHAIN_IDS, TOKENS } from './interface/payment/constants.js'; import { getPaymentStatus } from './interface/payment/getPaymentStatus.js'; import { pay } from './interface/payment/pay.js'; +import { payWithToken } from './interface/payment/payWithToken.js'; import { subscribe } from './interface/payment/subscribe.js'; import type { InfoRequest, @@ -17,6 +18,8 @@ import type { PaymentResult, PaymentStatus, PaymentStatusOptions, + PayWithTokenOptions, + PayWithTokenResult, SubscriptionOptions, SubscriptionResult, } from './interface/payment/types.js'; @@ -50,7 +53,7 @@ export type { export { PACKAGE_VERSION as VERSION } from './core/constants.js'; export { createBaseAccountSDK } from './interface/builder/core/createBaseAccountSDK.js'; export { getCryptoKeyAccount, removeCryptoKey } from './kms/crypto-key/index.js'; -export { base, CHAIN_IDS, getPaymentStatus, pay, subscribe, TOKENS }; +export { base, CHAIN_IDS, getPaymentStatus, pay, payWithToken, subscribe, TOKENS }; export type { InfoRequest, PayerInfo, @@ -58,6 +61,8 @@ export type { PaymentResult, PaymentStatus, PaymentStatusOptions, + PayWithTokenOptions, + PayWithTokenResult, SubscriptionOptions, SubscriptionResult, }; diff --git a/packages/account-sdk/src/core/telemetry/events/payment.ts b/packages/account-sdk/src/core/telemetry/events/payment.ts index 1df3bf4ba..bde62f125 100644 --- a/packages/account-sdk/src/core/telemetry/events/payment.ts +++ b/packages/account-sdk/src/core/telemetry/events/payment.ts @@ -75,6 +75,81 @@ export const logPaymentCompleted = ({ ); }; +export const logPayWithTokenStarted = ({ + token, + chainId, + correlationId, +}: { + token: string; + chainId: number; + correlationId: string | undefined; +}) => { + logEvent( + 'payment.payWithToken.started', + { + action: ActionType.process, + componentType: ComponentType.unknown, + method: 'payWithToken', + correlationId, + signerType: 'base-account', + token, + chainId, + }, + AnalyticsEventImportance.high + ); +}; + +export const logPayWithTokenCompleted = ({ + token, + chainId, + correlationId, +}: { + token: string; + chainId: number; + correlationId: string | undefined; +}) => { + logEvent( + 'payment.payWithToken.completed', + { + action: ActionType.process, + componentType: ComponentType.unknown, + method: 'payWithToken', + correlationId, + signerType: 'base-account', + token, + chainId, + }, + AnalyticsEventImportance.high + ); +}; + +export const logPayWithTokenError = ({ + token, + chainId, + correlationId, + errorMessage, +}: { + token: string; + chainId: number; + correlationId: string | undefined; + errorMessage: string; +}) => { + logEvent( + 'payment.payWithToken.error', + { + action: ActionType.error, + componentType: ComponentType.unknown, + method: 'payWithToken', + correlationId, + signerType: 'base-account', + token, + chainId, + errorMessage, + }, + AnalyticsEventImportance.high + ); +}; + export const logPaymentStatusCheckStarted = ({ testnet, correlationId, diff --git a/packages/account-sdk/src/core/telemetry/logEvent.ts b/packages/account-sdk/src/core/telemetry/logEvent.ts index 66b48423f..c63008a9e 100644 --- a/packages/account-sdk/src/core/telemetry/logEvent.ts +++ b/packages/account-sdk/src/core/telemetry/logEvent.ts @@ -71,6 +71,8 @@ type CCAEventData = { testnet?: boolean; status?: string; periodInDays?: number; + token?: string; + chainId?: number; }; type AnalyticsEventData = { diff --git a/packages/account-sdk/src/index.ts b/packages/account-sdk/src/index.ts index 611749a7f..d526ecc8b 100644 --- a/packages/account-sdk/src/index.ts +++ b/packages/account-sdk/src/index.ts @@ -14,12 +14,14 @@ export { export { PACKAGE_VERSION as VERSION } from './core/constants.js'; +// Payment interface exports export { base, CHAIN_IDS, getPaymentStatus, getSubscriptionStatus, pay, + payWithToken, prepareCharge, subscribe, TOKENS, @@ -41,10 +43,20 @@ export type { PrepareChargeCall, PrepareChargeOptions, PrepareChargeResult, + PrepareRevokeCall, + PrepareRevokeOptions, + PrepareRevokeResult, + RevokeOptions, + RevokeResult, SubscriptionOptions, SubscriptionResult, SubscriptionStatus, SubscriptionStatusOptions, + PayWithTokenOptions, + PayWithTokenResult, + PaymasterOptions, + TokenPaymentSuccess, + TokenInput, } from './interface/payment/index.js'; export { diff --git a/packages/account-sdk/src/interface/payment/README.md b/packages/account-sdk/src/interface/payment/README.md index 8d73e2995..a25dd891c 100644 --- a/packages/account-sdk/src/interface/payment/README.md +++ b/packages/account-sdk/src/interface/payment/README.md @@ -21,17 +21,56 @@ if (payment.success) { } ``` +## Token Payments (`payWithToken`) + +Use `payWithToken` to send any ERC20 token on Base or Base Sepolia by specifying the token and paymaster configuration. + +```typescript +import { payWithToken } from '@base-org/account'; + +const payment = await payWithToken({ + amount: '1000000', // base units (wei) + token: 'USDC', // symbol or contract address + testnet: false, // Use Base mainnet (false) or Base Sepolia (true) + to: '0xFe21034794A5a574B94fE4fDfD16e005F1C96e51', + paymaster: { + url: 'https://paymaster.example.com', + }, + payerInfo: { + requests: [{ type: 'email' }], + }, +}); + +console.log(`Token payment sent! Transaction ID: ${payment.id}`); +``` + +**Token payment notes** + +- `amount` must be provided in the token's smallest unit (e.g., wei). +- `token` can be an address or a supported symbol (USDC, USDT, DAI). +- `testnet` toggles between Base mainnet (false) and Base Sepolia (true). Only Base and Base Sepolia are supported. +- `paymaster` is required. Provide either a `url` for a paymaster service or a precomputed `paymasterAndData`. +- The returned `payment.id` is a transaction hash, just like the `pay()` function. Pass this ID to `getPaymentStatus` along with the same `testnet` value. + ## Checking Payment Status -You can check the status of a payment using the transaction ID returned from the pay function: +You can check the status of a payment using the ID returned from either `pay()` or `payWithToken()`: ```typescript import { getPaymentStatus } from '@base/account-sdk'; -// Check payment status -const status = await getPaymentStatus({ - id: payment.id, - testnet: true +// Assume tokenPayment/usdcPayment are the results from the examples above. + +// Token payments - use the same testnet value as the original payment +const tokenStatus = await getPaymentStatus({ + id: tokenPayment.id, // e.g., "0x1234..." + testnet: false, // Same testnet value used in payWithToken +}); + +// USDC payments via pay() still require a testnet flag. +const usdcStatus = await getPaymentStatus({ + id: usdcPayment.id, + testnet: true, }); switch (status.status) { @@ -50,6 +89,11 @@ switch (status.status) { } ``` +The status object now includes: + +- `tokenAmount`, `tokenAddress`, and `tokenSymbol` for any detected ERC20 transfer. +- `amount` (human-readable) when the token is a whitelisted stablecoin (USDC/USDT/DAI). + ## Information Requests (Data Callbacks) You can request additional information from the user during payment using the `payerInfo` parameter: @@ -144,10 +188,8 @@ The payment result is always a successful payment (errors are thrown as exceptio ### `getPaymentStatus(options: PaymentStatusOptions): Promise` -#### PaymentStatusOptions - -- `id: string` - Transaction ID (userOp hash) to check status for -- `testnet?: boolean` - Whether to check on testnet (Base Sepolia). Defaults to false (mainnet) +- `id: string` - Payment ID to check (transaction hash returned from `pay()` or `payWithToken()`). +- `testnet?: boolean` - Whether the payment was on testnet (Base Sepolia). Must match the value used in the original payment. - `telemetry?: boolean` - Whether to enable telemetry logging (default: true) #### PaymentStatus diff --git a/packages/account-sdk/src/interface/payment/constants.ts b/packages/account-sdk/src/interface/payment/constants.ts index 58c11eb50..c65edb6d4 100644 --- a/packages/account-sdk/src/interface/payment/constants.ts +++ b/packages/account-sdk/src/interface/payment/constants.ts @@ -1,5 +1,16 @@ +import type { Address } from 'viem'; + +/** + * Chain IDs for supported networks (Base only) + */ +export const CHAIN_IDS = { + base: 8453, + baseSepolia: 84532, +} as const; + /** - * Token configuration for supported payment tokens + * Token configuration for USDC-only payment APIs (e.g., pay()). + * For other stables or arbitrary tokens, use payWithToken. */ export const TOKENS = { USDC: { @@ -12,13 +23,74 @@ export const TOKENS = { } as const; /** - * Chain IDs for supported networks + * Canonical placeholder used by wallet providers to represent native ETH */ -export const CHAIN_IDS = { - base: 8453, - baseSepolia: 84532, +export const ETH_PLACEHOLDER_ADDRESS = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' as const; + +/** + * Registry of whitelisted stablecoins that can be referenced by symbol + * when calling token-aware payment APIs (Base and Base Sepolia only). + */ +export const STABLECOIN_WHITELIST = { + USDC: { + symbol: 'USDC', + decimals: 6, + addresses: { + [CHAIN_IDS.base]: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + [CHAIN_IDS.baseSepolia]: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + } satisfies Partial>, + }, + USDT: { + symbol: 'USDT', + decimals: 6, + addresses: { + [CHAIN_IDS.base]: '0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2', + } satisfies Partial>, + }, + DAI: { + symbol: 'DAI', + decimals: 18, + addresses: { + [CHAIN_IDS.base]: '0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb', + } satisfies Partial>, + }, + EURC: { + symbol: 'EURC', + decimals: 6, + addresses: { + [CHAIN_IDS.base]: '0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c', + } satisfies Partial>, + }, } as const; +type StablecoinKey = keyof typeof STABLECOIN_WHITELIST; + +/** + * Lookup map from token address to stablecoin metadata. + */ +export const STABLECOIN_ADDRESS_LOOKUP = Object.entries(STABLECOIN_WHITELIST).reduce< + Record< + string, + { + symbol: StablecoinKey; + decimals: number; + chainId: number; + } + > +>((acc, [symbol, config]) => { + for (const [chainId, address] of Object.entries(config.addresses)) { + if (!address) { + continue; + } + acc[address.toLowerCase()] = { + symbol: symbol as StablecoinKey, + decimals: config.decimals, + chainId: Number(chainId), + }; + } + return acc; +}, {}); + /** * ERC20 transfer function ABI */ diff --git a/packages/account-sdk/src/interface/payment/getPaymentStatus.test.ts b/packages/account-sdk/src/interface/payment/getPaymentStatus.test.ts index 50996a9d5..1d226ed7b 100644 --- a/packages/account-sdk/src/interface/payment/getPaymentStatus.test.ts +++ b/packages/account-sdk/src/interface/payment/getPaymentStatus.test.ts @@ -63,6 +63,9 @@ describe('getPaymentStatus', () => { message: 'Payment completed successfully', sender: '0x4A7c6899cdcB379e284fBFd045462e751da4C7ce', amount: '10', + tokenAmount: '10000000', + tokenAddress: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + tokenSymbol: 'USDC', recipient: '0xf1DdF1fc0310Cb11F0Ca87508207012F4a9CB336', }); @@ -81,6 +84,44 @@ describe('getPaymentStatus', () => { ); }); + it('should use testnet flag to determine network (Base Sepolia when testnet=true)', async () => { + const transactionHash = '0xabc1230000000000000000000000000000000000000000000000000000000000'; + const mockReceipt = { + jsonrpc: '2.0', + id: 1, + result: { + success: true, + receipt: { + transactionHash, + logs: [], + }, + sender: '0x4A7c6899cdcB379e284fBFd045462e751da4C7ce', + }, + }; + + vi.mocked(fetch).mockResolvedValueOnce({ + json: async () => mockReceipt, + } as Response); + + const status = await getPaymentStatus({ + id: transactionHash, + testnet: true, + }); + + expect(status.id).toBe(transactionHash); + expect(fetch).toHaveBeenCalledWith( + 'https://api.developer.coinbase.com/rpc/v1/base-sepolia/S-fOd2n2Oi4fl4e1Crm83XeDXZ7tkg8O', + expect.objectContaining({ + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'eth_getUserOperationReceipt', + params: [transactionHash], + }), + }) + ); + }); + it('should return failed status for failed payment', async () => { const mockReceipt = { jsonrpc: '2.0', @@ -282,6 +323,8 @@ describe('getPaymentStatus', () => { }); expect(status.amount).toBe('10'); + expect(status.tokenAmount).toBe('10000000'); + expect(status.tokenSymbol).toBe('USDC'); expect(status.recipient).toBe('0xf1DdF1fc0310Cb11F0Ca87508207012F4a9CB336'); }); @@ -320,7 +363,7 @@ describe('getPaymentStatus', () => { testnet: false, }) ).rejects.toThrow( - 'Unable to find USDC transfer from sender wallet 0x4A7c6899cdcB379e284fBFd045462e751da4C7ce' + 'Unable to find token transfer from sender wallet 0x4A7c6899cdcB379e284fBFd045462e751da4C7ce' ); }); @@ -367,7 +410,7 @@ describe('getPaymentStatus', () => { testnet: false, }) ).rejects.toThrow( - /Found multiple USDC transfers from sender wallet.*Expected exactly one transfer/ + /Found multiple token transfers from sender wallet.*Expected exactly one transfer/ ); }); @@ -421,6 +464,9 @@ describe('getPaymentStatus', () => { message: 'Payment completed successfully', sender: '0x4A7c6899cdcB379e284fBFd045462e751da4C7ce', amount: '1', // Should pick the 1 USDC from sender, not the 4500 USDC gas payment + tokenAmount: '1000000', + tokenAddress: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + tokenSymbol: 'USDC', recipient: '0xf1DdF1fc0310Cb11F0Ca87508207012F4a9CB336', }); }); diff --git a/packages/account-sdk/src/interface/payment/getPaymentStatus.ts b/packages/account-sdk/src/interface/payment/getPaymentStatus.ts index a887b4269..0a133ce43 100644 --- a/packages/account-sdk/src/interface/payment/getPaymentStatus.ts +++ b/packages/account-sdk/src/interface/payment/getPaymentStatus.ts @@ -6,13 +6,16 @@ import { logPaymentStatusCheckError, logPaymentStatusCheckStarted, } from ':core/telemetry/events/payment.js'; -import { ERC20_TRANSFER_ABI, TOKENS } from './constants.js'; +import { ERC20_TRANSFER_ABI } from './constants.js'; import type { PaymentStatus, PaymentStatusOptions } from './types.js'; +import { getStablecoinMetadataByAddress } from './utils/tokenRegistry.js'; /** * Check the status of a payment transaction using its transaction ID (userOp hash) * * @param options - Payment status check options + * @param options.id - Transaction hash from pay() or payWithToken() + * @param options.testnet - Whether to use testnet (Base Sepolia). Defaults to false (Base mainnet) * @returns Promise - Status information about the payment * @throws Error if unable to connect to the RPC endpoint or if the RPC request fails * @@ -31,8 +34,6 @@ import type { PaymentStatus, PaymentStatusOptions } from './types.js'; * console.error('Unable to check payment status:', error.message) * } * ``` - * - * @note The id is the userOp hash returned from the pay function */ export async function getPaymentStatus(options: PaymentStatusOptions): Promise { const { id, testnet = false, telemetry = true } = options; @@ -40,17 +41,18 @@ export async function getPaymentStatus(options: PaymentStatusOptions): Promise

res.json()); @@ -88,7 +90,7 @@ export async function getPaymentStatus(options: PaymentStatusOptions): Promise

res.json()); @@ -99,7 +101,7 @@ export async function getPaymentStatus(options: PaymentStatusOptions): Promise

= []; - for (let i = 0; i < txReceipt.logs.length; i++) { - const log = txReceipt.logs[i]; - - // Check if this is a USDC log - const logAddressLower = log.address?.toLowerCase(); - const isUsdcLog = logAddressLower === usdcAddress; - - if (isUsdcLog) { - try { - const decoded = decodeEventLog({ - abi: ERC20_TRANSFER_ABI, - data: log.data, - topics: log.topics, - }); - - if (decoded.eventName === 'Transfer' && decoded.args) { - const args = decoded.args as { from: string; to: string; value: bigint }; + for (const log of txReceipt.logs) { + if (!log.address) { + continue; + } - if (args.value && args.to && args.from) { - const formattedAmount = formatUnits(args.value, 6); + try { + const decoded = decodeEventLog({ + abi: ERC20_TRANSFER_ABI, + data: log.data, + topics: log.topics, + }); - usdcTransfers.push({ - from: args.from, - to: args.to, - value: args.value, - formattedAmount, - logIndex: i, - }); - } + if (decoded.eventName === 'Transfer' && decoded.args) { + const args = decoded.args as { from: string; to: string; value: bigint }; + if (args.value && args.to && args.from) { + tokenTransfers.push({ + from: getAddress(args.from), + to: getAddress(args.to), + value: args.value, + contract: getAddress(log.address as Address), + }); } - } catch (_e) { - // Do not fail here - fail when we can't find a single valid transfer } + } catch (_e) { + // Ignore non ERC-20 logs } } - // Now select the correct transfer - // Strategy: Find the transfer from the sender (smart wallet) address - if (usdcTransfers.length > 0 && senderAddress) { - // Look for transfers from the sender address (smart wallet) - // Compare checksummed addresses for consistency - const senderTransfers = usdcTransfers.filter((t) => { + if (tokenTransfers.length > 0 && senderAddress) { + const senderTransfers = tokenTransfers.filter((t) => { try { - return isAddressEqual(t.from as Address, senderAddress!); + return isAddressEqual(t.from, senderAddress); } catch { return false; } }); if (senderTransfers.length === 0) { - // No transfer from the sender wallet was found throw new Error( - `Unable to find USDC transfer from sender wallet ${receipt.result.sender}. ` + - `Found ${usdcTransfers.length} USDC transfer(s) but none originated from the sender wallet.` + `Unable to find token transfer from sender wallet ${receipt.result.sender}. ` + + `Found ${tokenTransfers.length} transfer(s) but none originated from the sender wallet.` ); } + if (senderTransfers.length > 1) { - // Multiple transfers from the sender wallet found const transferDetails = senderTransfers - .map((t) => `${t.formattedAmount} USDC to ${t.to}`) + .map((t) => `${t.value.toString()} wei to ${t.to}`) .join(', '); throw new Error( - `Found multiple USDC transfers from sender wallet ${receipt.result.sender}: ${transferDetails}. Expected exactly one transfer.` + `Found multiple token transfers from sender wallet ${receipt.result.sender}: ${transferDetails}. Expected exactly one transfer.` ); } - // Exactly one transfer from sender found - amount = senderTransfers[0].formattedAmount; - recipient = senderTransfers[0].to; + + const transfer = senderTransfers[0]; + const stablecoinMetadata = getStablecoinMetadataByAddress(transfer.contract); + + tokenAmount = transfer.value.toString(); + tokenAddress = transfer.contract; + tokenSymbol = stablecoinMetadata?.symbol; + recipient = transfer.to; + + if (stablecoinMetadata) { + amount = formatUnits(transfer.value, stablecoinMetadata.decimals); + } } } @@ -220,10 +216,13 @@ export async function getPaymentStatus(options: PaymentStatusOptions): Promise

({ + logPayWithTokenStarted: vi.fn(), + logPayWithTokenCompleted: vi.fn(), + logPayWithTokenError: vi.fn(), +})); + +vi.mock('./utils/validation.js', () => ({ + normalizeAddress: vi.fn((address: string) => address), + validateBaseUnitAmount: vi.fn(() => BigInt(1000)), +})); + +vi.mock('./utils/tokenRegistry.js', () => ({ + resolveTokenAddress: vi.fn(() => ({ + address: '0x0000000000000000000000000000000000000001', + symbol: 'USDC', + decimals: 6, + isNativeEth: false, + })), +})); + +vi.mock('./utils/translateTokenPayment.js', () => ({ + buildTokenPaymentRequest: vi.fn(() => ({ + version: '2.0.0', + chainId: 8453, + calls: [], + capabilities: {}, + })), +})); + +vi.mock('./utils/sdkManager.js', () => ({ + executePaymentWithSDK: vi.fn(async () => ({ + transactionHash: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + payerInfoResponses: { email: 'test@example.com' }, + })), +})); + +describe('payWithToken', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('crypto', { + randomUUID: vi.fn().mockReturnValue('mock-correlation-id'), + }); + }); + + it('should successfully process a token payment', async () => { + const result = await payWithToken({ + amount: '1000', + to: '0xFe21034794A5a574B94fE4fDfD16e005F1C96e51', + token: 'USDC', + testnet: false, + paymaster: { url: 'https://paymaster.example.com' }, + }); + + expect(result).toEqual({ + success: true, + id: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + token: 'USDC', + tokenAddress: '0x0000000000000000000000000000000000000001', + tokenAmount: '1000', + to: '0xFe21034794A5a574B94fE4fDfD16e005F1C96e51', + payerInfoResponses: { email: 'test@example.com' }, + }); + + const { buildTokenPaymentRequest } = await import('./utils/translateTokenPayment.js'); + expect(buildTokenPaymentRequest).toHaveBeenCalledWith( + expect.objectContaining({ + recipient: '0xFe21034794A5a574B94fE4fDfD16e005F1C96e51', + amount: BigInt(1000), + chainId: 8453, + paymaster: { url: 'https://paymaster.example.com' }, + }) + ); + }); + + it('should pass walletUrl to the executor', async () => { + const { executePaymentWithSDK } = await import('./utils/sdkManager.js'); + + await payWithToken({ + amount: '500', + to: '0x0A7c6899cdCb379E284fbFd045462e751dA4C7cE', + token: 'USDT', + testnet: false, + paymaster: { paymasterAndData: '0xdeadbeef' as `0x${string}` }, + walletUrl: 'https://wallet.example.com', + }); + + expect(executePaymentWithSDK).toHaveBeenCalledWith( + expect.any(Object), + false, + 'https://wallet.example.com', + true + ); + }); + + it('should propagate errors and log telemetry when execution fails', async () => { + const { executePaymentWithSDK } = await import('./utils/sdkManager.js'); + const { logPayWithTokenError } = await import(':core/telemetry/events/payment.js'); + + vi.mocked(executePaymentWithSDK).mockRejectedValueOnce(new Error('execution reverted')); + + await expect( + payWithToken({ + amount: '1', + to: '0x000000000000000000000000000000000000dead', + token: 'USDC', + testnet: false, + paymaster: { url: 'https://paymaster.example.com' }, + }) + ).rejects.toThrow('execution reverted'); + + expect(logPayWithTokenError).toHaveBeenCalledWith( + expect.objectContaining({ + token: 'USDC', + errorMessage: 'execution reverted', + }) + ); + }); +}); diff --git a/packages/account-sdk/src/interface/payment/payWithToken.ts b/packages/account-sdk/src/interface/payment/payWithToken.ts new file mode 100644 index 000000000..c6c73b723 --- /dev/null +++ b/packages/account-sdk/src/interface/payment/payWithToken.ts @@ -0,0 +1,92 @@ +import { + logPayWithTokenCompleted, + logPayWithTokenError, + logPayWithTokenStarted, +} from ':core/telemetry/events/payment.js'; +import { CHAIN_IDS } from './constants.js'; +import type { PayWithTokenOptions, PayWithTokenResult } from './types.js'; +import { executePaymentWithSDK } from './utils/sdkManager.js'; +import { resolveTokenAddress } from './utils/tokenRegistry.js'; +import { buildTokenPaymentRequest } from './utils/translateTokenPayment.js'; +import { normalizeAddress, validateBaseUnitAmount } from './utils/validation.js'; + +/** + * Pay a specified address with any ERC20 token using an ephemeral smart wallet. + * + * @param options - Payment options + * @returns Promise + */ +export async function payWithToken(options: PayWithTokenOptions): Promise { + const { + amount, + to, + token, + testnet = false, + paymaster, + payerInfo, + walletUrl, + telemetry = true, + } = options; + + const correlationId = crypto.randomUUID(); + const network = testnet ? 'baseSepolia' : 'base'; + const chainId = CHAIN_IDS[network]; + const normalizedRecipient = normalizeAddress(to); + const amountInWei = validateBaseUnitAmount(amount); + const resolvedToken = resolveTokenAddress(token, chainId); + const tokenLabel = resolvedToken.symbol ?? resolvedToken.address; + + if (telemetry) { + logPayWithTokenStarted({ + token: tokenLabel, + chainId, + correlationId, + }); + } + + try { + const requestParams = buildTokenPaymentRequest({ + recipient: normalizedRecipient, + amount: amountInWei, + chainId, + token: resolvedToken, + payerInfo, + paymaster, + }); + + const executionResult = await executePaymentWithSDK( + requestParams, + testnet, + walletUrl, + telemetry + ); + + if (telemetry) { + logPayWithTokenCompleted({ + token: tokenLabel, + chainId, + correlationId, + }); + } + + return { + success: true, + id: executionResult.transactionHash, + token: tokenLabel, + tokenAddress: resolvedToken.address, + tokenAmount: amountInWei.toString(), + to: normalizedRecipient, + payerInfoResponses: executionResult.payerInfoResponses, + }; + } catch (error) { + if (telemetry) { + logPayWithTokenError({ + token: tokenLabel, + chainId, + correlationId, + errorMessage: error instanceof Error ? error.message : 'Unknown error occurred', + }); + } + throw error; + } +} diff --git a/packages/account-sdk/src/interface/payment/types.ts b/packages/account-sdk/src/interface/payment/types.ts index af021d9c2..f23a0fe5e 100644 --- a/packages/account-sdk/src/interface/payment/types.ts +++ b/packages/account-sdk/src/interface/payment/types.ts @@ -53,6 +53,11 @@ export interface PayerInfo { callbackURL?: string; } +/** + * Input supported for token parameters. Accepts either a contract address or a supported symbol. + */ +export type TokenInput = string; + /** * Options for making a payment */ @@ -71,25 +76,81 @@ export interface PaymentOptions { } /** - * Successful payment result + * Paymaster configuration for token-based payments. */ -export interface PaymentSuccess { +export interface PaymasterOptions { + /** URL for the paymaster service (sponsor) */ + url?: string; + /** Optional contextual data to send to the paymaster service */ + context?: Record; + /** Direct paymasterAndData value for pre-signed operations */ + paymasterAndData?: Hex; +} + +/** + * Options for making a token-denominated payment. + */ +export interface PayWithTokenOptions { + /** Amount to send in the token's smallest unit (e.g., wei) */ + amount: string; + /** Recipient address */ + to: string; + /** Token to transfer (address or whitelisted symbol) */ + token: TokenInput; + /** Whether to use testnet (Base Sepolia). Defaults to false (mainnet) */ + testnet?: boolean; + /** Paymaster configuration (required) */ + paymaster: PaymasterOptions; + /** Optional payer information configuration for data callbacks */ + payerInfo?: PayerInfo; + /** Optional wallet URL override */ + walletUrl?: string; + /** Whether to enable telemetry logging. Defaults to true */ + telemetry?: boolean; +} + +/** + * Base shape shared by all successful payment responses. + */ +export interface BasePaymentSuccess { success: true; /** Transaction ID (hash) of the payment */ id: string; - /** The amount that was sent */ - amount: string; /** The address that received the payment */ to: Address; /** Optional responses from information requests */ payerInfoResponses?: PayerInfoResponses; } +/** + * Successful payment result for USDC payments. + */ +export interface PaymentSuccess extends BasePaymentSuccess { + /** The amount that was sent (in USDC) */ + amount: string; +} + /** * Result of a payment transaction */ export type PaymentResult = PaymentSuccess; +/** + * Successful payment result for token payments. + */ +export interface TokenPaymentSuccess extends BasePaymentSuccess { + /** Transaction ID (hash) of the payment */ + id: string; + /** Token amount transferred in base units (wei) */ + tokenAmount: string; + /** Token contract address or native placeholder */ + tokenAddress: Address; + /** Optional token shorthand (symbol or user-provided value) */ + token?: string; +} + +export type PayWithTokenResult = TokenPaymentSuccess; + /** * Options for checking payment status */ @@ -123,6 +184,12 @@ export interface PaymentStatus { sender?: string; /** Amount sent (present for completed transactions, parsed from logs) */ amount?: string; + /** Token amount in base units (present when transfer is detected) */ + tokenAmount?: string; + /** Token contract address (present when transfer is detected) */ + tokenAddress?: Address; + /** Token shorthand (symbol if recognized, otherwise undefined) */ + tokenSymbol?: string; /** Recipient address (present for completed transactions, parsed from logs) */ recipient?: string; /** Reason for transaction failure (present for failed status - describes why the transaction failed on-chain) */ diff --git a/packages/account-sdk/src/interface/payment/utils/sdkManager.ts b/packages/account-sdk/src/interface/payment/utils/sdkManager.ts index d020d8d83..803681247 100644 --- a/packages/account-sdk/src/interface/payment/utils/sdkManager.ts +++ b/packages/account-sdk/src/interface/payment/utils/sdkManager.ts @@ -6,7 +6,7 @@ import type { PayerInfoResponses } from '../types.js'; /** * Type for wallet_sendCalls request parameters */ -type WalletSendCallsRequestParams = { +export type WalletSendCallsRequestParams = { version: string; chainId: number; calls: Array<{ diff --git a/packages/account-sdk/src/interface/payment/utils/tokenRegistry.ts b/packages/account-sdk/src/interface/payment/utils/tokenRegistry.ts new file mode 100644 index 000000000..9ded03689 --- /dev/null +++ b/packages/account-sdk/src/interface/payment/utils/tokenRegistry.ts @@ -0,0 +1,88 @@ +import { getAddress, isAddress, type Address } from 'viem'; + +import { + ETH_PLACEHOLDER_ADDRESS, + STABLECOIN_ADDRESS_LOOKUP, + STABLECOIN_WHITELIST, +} from '../constants.js'; +import type { TokenInput } from '../types.js'; + +export interface ResolvedToken { + address: Address; + symbol?: string; + decimals?: number; + isNativeEth: boolean; +} + +const ETH_PLACEHOLDER_LOWER = ETH_PLACEHOLDER_ADDRESS.toLowerCase(); +const SUPPORTED_STABLECOIN_SYMBOLS = Object.keys(STABLECOIN_WHITELIST); + +/** + * Checks whether a string represents the native ETH placeholder. + */ +export function isEthPlaceholder(value: string): boolean { + return value.trim().toLowerCase() === ETH_PLACEHOLDER_LOWER; +} + +/** + * Resolves a token input (symbol or address) into a concrete contract address. + */ +export function resolveTokenAddress(token: TokenInput, chainId: number): ResolvedToken { + if (typeof token !== 'string' || token.trim().length === 0) { + throw new Error('Token is required'); + } + + const trimmed = token.trim(); + + if (isEthPlaceholder(trimmed)) { + return { + address: getAddress(ETH_PLACEHOLDER_ADDRESS), + symbol: 'ETH', + decimals: 18, + isNativeEth: true, + }; + } + + if (isAddress(trimmed)) { + return { + address: getAddress(trimmed), + isNativeEth: isEthPlaceholder(trimmed), + }; + } + + const normalizedSymbol = trimmed.toUpperCase(); + if (normalizedSymbol in STABLECOIN_WHITELIST) { + const stablecoin = STABLECOIN_WHITELIST[normalizedSymbol as keyof typeof STABLECOIN_WHITELIST]; + const address = stablecoin.addresses[chainId as keyof typeof stablecoin.addresses]; + + if (!address) { + throw new Error( + `Token ${normalizedSymbol} is not whitelisted on chain ${chainId}. Provide a contract address instead.` + ); + } + + return { + address: getAddress(address), + symbol: stablecoin.symbol, + decimals: stablecoin.decimals, + isNativeEth: false, + }; + } + + throw new Error( + `Unknown token "${token}". Provide a contract address or one of the supported symbols: ${SUPPORTED_STABLECOIN_SYMBOLS.join( + ', ' + )}, ETH` + ); +} + +/** + * Returns metadata for a whitelisted stablecoin by address, if available. + */ +export function getStablecoinMetadataByAddress(address?: string) { + if (!address) { + return undefined; + } + + return STABLECOIN_ADDRESS_LOOKUP[address.toLowerCase()]; +} diff --git a/packages/account-sdk/src/interface/payment/utils/translateTokenPayment.ts b/packages/account-sdk/src/interface/payment/utils/translateTokenPayment.ts new file mode 100644 index 000000000..59c3b8563 --- /dev/null +++ b/packages/account-sdk/src/interface/payment/utils/translateTokenPayment.ts @@ -0,0 +1,100 @@ +import { encodeFunctionData, toHex, type Address, type Hex } from 'viem'; + +import { ERC20_TRANSFER_ABI } from '../constants.js'; +import type { PayerInfo, PaymasterOptions } from '../types.js'; +import type { WalletSendCallsRequestParams } from './sdkManager.js'; +import type { ResolvedToken } from './tokenRegistry.js'; + +function buildDataCallbackCapability(payerInfo?: PayerInfo) { + if (!payerInfo || payerInfo.requests.length === 0) { + return undefined; + } + + return { + requests: payerInfo.requests.map((request) => ({ + type: request.type, + optional: request.optional ?? false, + })), + ...(payerInfo.callbackURL && { callbackURL: payerInfo.callbackURL }), + }; +} + +function buildPaymasterCapability(paymaster: PaymasterOptions | undefined) { + if (!paymaster) { + throw new Error('paymaster configuration is required'); + } + + const capability: Record = {}; + + if (paymaster.url) { + capability.url = paymaster.url; + } + + if (paymaster.context) { + capability.context = paymaster.context; + } + + if (paymaster.paymasterAndData) { + capability.paymasterAndData = paymaster.paymasterAndData; + } + + if (Object.keys(capability).length === 0) { + throw new Error('paymaster configuration must include either a url or paymasterAndData value'); + } + + return capability; +} + +export function buildTokenPaymentRequest({ + recipient, + amount, + chainId, + token, + payerInfo, + paymaster, +}: { + recipient: Address; + amount: bigint; + chainId: number; + token: ResolvedToken; + payerInfo?: PayerInfo; + paymaster: PaymasterOptions; +}): WalletSendCallsRequestParams { + const capabilities: Record = {}; + + const paymasterCapability = buildPaymasterCapability(paymaster); + capabilities.paymasterService = paymasterCapability; + + const dataCallbackCapability = buildDataCallbackCapability(payerInfo); + if (dataCallbackCapability) { + capabilities.dataCallback = dataCallbackCapability; + } + + const calls = + token.isNativeEth === true + ? ([ + { + to: recipient as Hex, + data: '0x' as Hex, + value: toHex(amount), + }, + ] as const) + : ([ + { + to: token.address as Hex, + data: encodeFunctionData({ + abi: ERC20_TRANSFER_ABI, + functionName: 'transfer', + args: [recipient, amount], + }), + value: '0x0' as Hex, + }, + ] as const); + + return { + version: '2.0.0', + chainId, + calls: [...calls], + capabilities, + }; +} diff --git a/packages/account-sdk/src/interface/payment/utils/validation.ts b/packages/account-sdk/src/interface/payment/utils/validation.ts index 6169cdb1f..833ccbf5d 100644 --- a/packages/account-sdk/src/interface/payment/utils/validation.ts +++ b/packages/account-sdk/src/interface/payment/utils/validation.ts @@ -50,3 +50,30 @@ export function normalizeAddress(address: string): Address { throw new Error('Invalid address: must be a valid Ethereum address'); } } + +/** + * Validates that a base-unit amount (wei) is provided as a positive integer string + * @param amount - Amount expressed in smallest unit (e.g., wei) + * @returns bigint representation of the amount + */ +export function validateBaseUnitAmount(amount: string): bigint { + if (typeof amount !== 'string') { + throw new Error('Invalid amount: must be provided as a string'); + } + + const trimmed = amount.trim(); + if (trimmed.length === 0) { + throw new Error('Invalid amount: value is required'); + } + + if (!/^\d+$/.test(trimmed)) { + throw new Error('Invalid amount: payWithToken expects an integer amount in wei'); + } + + const parsed = BigInt(trimmed); + if (parsed <= BigInt(0)) { + throw new Error('Invalid amount: must be greater than 0'); + } + + return parsed; +}