diff --git a/API.md b/API.md index 2a54ba4..5badc36 100644 --- a/API.md +++ b/API.md @@ -579,7 +579,9 @@ All three variants share the following optional base fields: When fee options are returned, `selectFeeOption` receives `FeeOptionWithBalance[]`. Each entry includes the generated `FeeOption` plus the selected wallet's balance -for that fee token when the indexer can load it. +for that fee token when the indexer can load it. Use +`FeeOptionSelector.firstAvailable` to choose the first option the wallet can pay, +or return `option.selection` from a custom selector. --- @@ -1138,8 +1140,13 @@ type FeeOptionSelector = ( feeOptions: FeeOptionWithBalance[] ) => FeeOptionSelection | undefined | Promise +const FeeOptionSelector: { + firstAvailable: FeeOptionSelector +} + type FeeOptionWithBalance = { feeOption: FeeOption + selection: FeeOptionSelection balance?: TokenBalance available?: string availableRaw?: string @@ -1147,7 +1154,11 @@ type FeeOptionWithBalance = { } ``` -When no selector is provided, the SDK uses the first required fee option, or no fee option for sponsored transactions. +When no selector is provided, the SDK uses the first required fee option, or no +fee option for sponsored transactions. `FeeOptionSelector.firstAvailable` uses +enriched balances to skip underfunded fee options and selects the first option +the wallet can pay. For custom selectors, return `option.selection` to select +that fee option. --- diff --git a/README.md b/README.md index f6b8bbd..52c2188 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ pnpm dev:trails-actions-example ## Quick Start ```typescript -import { Networks, OMSClient, WalletType } from '@0xsequence/typescript-sdk' +import { FeeOptionSelector, Networks, OMSClient, WalletType } from '@0xsequence/typescript-sdk' import { parseUnits } from 'viem' const oms = new OMSClient({ @@ -137,17 +137,8 @@ const tx = await oms.wallet.sendTransaction({ network: Networks.polygon, to: '0xRecipient', value: parseUnits('1', 18), // 1 POL - selectFeeOption: (feeOptions) => { - // If this Polygon mainnet transaction is not sponsored, choose a fee token the wallet can pay. - const selectedFeeOption = feeOptions.find(({ feeOption, availableRaw }) => - availableRaw !== undefined && BigInt(availableRaw) >= BigInt(feeOption.value) - ) - if (!selectedFeeOption) { - throw new Error('No fee option has enough balance') - } - - return { token: selectedFeeOption.feeOption.token.symbol } - }, + // If this Polygon mainnet transaction is not sponsored, choose the first fee token the wallet can pay. + selectFeeOption: FeeOptionSelector.firstAvailable, }) console.log(tx.txnHash ?? tx.txnId) ``` @@ -390,7 +381,8 @@ await oms.wallet.sendTransaction({ If WaaS returns fee options, pass a selector to choose one. The selector receives fee options enriched with the current wallet balance for each token when -available. +available. Use `FeeOptionSelector.firstAvailable` to choose the first option the +wallet can pay, or return `option.selection` from a custom selector. ```typescript const tx = await oms.wallet.sendTransaction({ @@ -399,7 +391,7 @@ const tx = await oms.wallet.sendTransaction({ data: '0xa9059cbb000000000000000000000000...', selectFeeOption: async (feeOptions) => { const selected = feeOptions.find(option => option.feeOption.token.symbol === 'USDC') - return selected ? { token: selected.feeOption.token.symbol } : undefined + return selected?.selection }, }) ``` diff --git a/examples/react/src/main.tsx b/examples/react/src/main.tsx index ea0cadc..f4f3921 100644 --- a/examples/react/src/main.tsx +++ b/examples/react/src/main.tsx @@ -338,7 +338,7 @@ function App() { return } - feeSelection.current?.resolve({ token: option.feeOption.token.symbol }) + feeSelection.current?.resolve(option.selection) feeSelection.current = null setFeeOptions([]) setWalletStatus(`Selected ${option.feeOption.token.symbol}. Sending transaction...`) diff --git a/examples/trails-actions/src/App.tsx b/examples/trails-actions/src/App.tsx index beef49f..124c5fe 100644 --- a/examples/trails-actions/src/App.tsx +++ b/examples/trails-actions/src/App.tsx @@ -845,7 +845,7 @@ function App() { } selectedFeeOption.current = option - feeSelection.current?.resolve({ token: option.feeOption.token.symbol }) + feeSelection.current?.resolve(option.selection) feeSelection.current = null setFeeOptions([]) appendLog(`Selected ${option.feeOption.token.symbol}.`) diff --git a/examples/wagmi/src/App.tsx b/examples/wagmi/src/App.tsx index 54f8959..a9292b0 100644 --- a/examples/wagmi/src/App.tsx +++ b/examples/wagmi/src/App.tsx @@ -384,7 +384,7 @@ export function App() { return } - feeOptionSelection.resolveFeeOption({ token: option.feeOption.token.symbol }) + feeOptionSelection.resolveFeeOption(option.selection) setWalletStatus(`Selected ${option.feeOption.token.symbol}. Sending transaction...`) } diff --git a/examples/wagmi/src/feeOptionSelectionBridge.ts b/examples/wagmi/src/feeOptionSelectionBridge.ts index ddf3610..4e72e4f 100644 --- a/examples/wagmi/src/feeOptionSelectionBridge.ts +++ b/examples/wagmi/src/feeOptionSelectionBridge.ts @@ -1,4 +1,4 @@ -import type { FeeOptionSelection, FeeOptionWithBalance } from '@0xsequence/typescript-sdk' +import { FeeOptionSelector, type FeeOptionSelection, type FeeOptionWithBalance } from '@0xsequence/typescript-sdk' export type FeeOptionSelectionRequest = { options: FeeOptionWithBalance[] @@ -25,11 +25,11 @@ export function subscribeToFeeOptionSelection(nextListener: FeeOptionSelectionLi export async function selectFeeOptionWithAppUi(options: FeeOptionWithBalance[]): Promise { if (!listener) { - const payableOption = options.find(canPayFeeOption) - if (!payableOption) { + const selection = FeeOptionSelector.firstAvailable(options) + if (!selection) { throw new Error('No fee option has enough balance.') } - return { token: payableOption.feeOption.token.symbol } + return selection } rejectPendingSelection?.(new Error('Fee option selection was superseded.')) @@ -49,13 +49,3 @@ export async function selectFeeOptionWithAppUi(options: FeeOptionWithBalance[]): }) }) } - -function canPayFeeOption(option: FeeOptionWithBalance): boolean { - if (option.availableRaw === undefined) return false - - try { - return BigInt(option.availableRaw) >= BigInt(option.feeOption.value) - } catch { - return false - } -} diff --git a/packages/oms-wallet-wagmi-connector/README.md b/packages/oms-wallet-wagmi-connector/README.md index 72484c5..73e22c1 100644 --- a/packages/oms-wallet-wagmi-connector/README.md +++ b/packages/oms-wallet-wagmi-connector/README.md @@ -7,7 +7,7 @@ Wagmi connector for an active `@0xsequence/typescript-sdk` OMS client. ```ts import { createConfig, http } from 'wagmi' import { polygon } from 'wagmi/chains' -import { OMSClient, type FeeOptionWithBalance } from '@0xsequence/typescript-sdk' +import { FeeOptionSelector, OMSClient } from '@0xsequence/typescript-sdk' import { omsWalletConnector } from '@0xsequence/oms-wallet-wagmi-connector' const oms = new OMSClient({ @@ -15,17 +15,6 @@ const oms = new OMSClient({ projectId: import.meta.env.VITE_OMS_PROJECT_ID, }) -function selectFirstPayableFeeOption(feeOptions: FeeOptionWithBalance[]) { - const payableOption = feeOptions.find((option) => - option.availableRaw !== undefined && - BigInt(option.availableRaw) >= BigInt(option.feeOption.value) - ) - if (!payableOption) { - throw new Error('No fee option has enough balance.') - } - return { token: payableOption.feeOption.token.symbol } -} - export const wagmiConfig = createConfig({ chains: [polygon], transports: { @@ -37,7 +26,7 @@ export const wagmiConfig = createConfig({ networks: oms.supportedNetworks, initialChainId: polygon.id, transactionOptions: { - selectFeeOption: selectFirstPayableFeeOption, + selectFeeOption: FeeOptionSelector.firstAvailable, }, }), ], @@ -96,13 +85,13 @@ omsWalletConnector({ mode: TransactionMode.Relayer, selectFeeOption: async (feeOptions) => { const usdc = feeOptions.find(option => option.feeOption.token.symbol === 'USDC') - return usdc ? { token: usdc.feeOption.token.symbol } : undefined + return usdc?.selection }, }), }) ``` -The SDK calls `selectFeeOption` after preparing the transaction. The selector receives `FeeOptionWithBalance[]`, including each WaaS fee option and wallet balance data when the indexer can load it. Without a selector, the SDK keeps sponsored transactions fee-free and otherwise chooses the first returned fee option. +The SDK calls `selectFeeOption` after preparing the transaction. The selector receives `FeeOptionWithBalance[]`, including each WaaS fee option and wallet balance data when the indexer can load it. Use `FeeOptionSelector.firstAvailable` to choose the first option the wallet can pay, or return `option.selection` from a custom selector. Without a selector, the SDK keeps sponsored transactions fee-free and otherwise chooses the first returned fee option. For React UI, keep `selectFeeOption` wired in the connector initializer and bridge it into a modal or sheet with app state. The workspace wagmi example shows this as a hook-driven modal; see `examples/wagmi/src/feeOptionSelectionBridge.ts`, `examples/wagmi/src/useFeeOptionSelection.ts`, and the fee option panel in `examples/wagmi/src/App.tsx`. diff --git a/src/clients/walletClient.ts b/src/clients/walletClient.ts index 2bf044c..718ccc5 100644 --- a/src/clients/walletClient.ts +++ b/src/clients/walletClient.ts @@ -63,6 +63,7 @@ import type {Network} from "../networks.js"; import { FeeOptionSelector, FeeOptionWithBalance, + feeOptionSelection, SendContractTransactionParams, SendDataTransactionParams, SendNativeTransactionParams, SendTransactionParams, @@ -1483,17 +1484,25 @@ export class WalletClient { network: Network selectFeeOption?: FeeOptionSelector }): Promise { - if (params.feeOptions.length === 0) { + if (params.sponsored) { return undefined } + if (params.feeOptions.length === 0) { + throw new Error("No fee options available for unsponsored transaction") + } + if (!params.selectFeeOption) { - return this.defaultFeeOptionSelection(params.feeOptions, params.sponsored) + return this.defaultFeeOptionSelection(params.feeOptions) } - return params.selectFeeOption( + const selected = await params.selectFeeOption( await this.enrichFeeOptionsWithBalances(params.network, params.feeOptions), ) + if (!selected) { + throw new Error("No fee option selected for unsponsored transaction") + } + return selected } private async enrichFeeOptionsWithBalances( @@ -1529,6 +1538,7 @@ export class WalletClient { return { feeOption, + selection: feeOptionSelection(feeOption), balance, available: this.formatTokenAmount(balance?.balance, decimals), availableRaw: balance?.balance, @@ -1573,9 +1583,8 @@ export class WalletClient { private defaultFeeOptionSelection( feeOptions: FeeOption[], - sponsored: boolean, - ): FeeOptionSelection | undefined { - return sponsored ? undefined : feeOptions[0] ? {token: feeOptions[0].token.symbol} : undefined + ): FeeOptionSelection { + return feeOptionSelection(feeOptions[0]) } private async waitForTransactionStatus( diff --git a/src/index.ts b/src/index.ts index 16df7bf..11d99bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -88,8 +88,10 @@ export type { export type { FeeOption, FeeOptionSelection, - FeeOptionSelector, FeeOptionWithBalance, SendTransactionResponse, TransactionStatusPollingOptions, } from './types/transactionTypes.js' +export { + FeeOptionSelector, +} from './types/transactionTypes.js' diff --git a/src/types/transactionTypes.ts b/src/types/transactionTypes.ts index 9ee1db5..857b062 100644 --- a/src/types/transactionTypes.ts +++ b/src/types/transactionTypes.ts @@ -17,15 +17,37 @@ export type { export type FeeOptionWithBalance = { feeOption: FeeOption + selection: FeeOptionSelection balance?: TokenBalance available?: string availableRaw?: string decimals?: number } -export type FeeOptionSelector = ( - feeOptions: FeeOptionWithBalance[] -) => FeeOptionSelection | undefined | Promise +export interface FeeOptionSelector { + (feeOptions: FeeOptionWithBalance[]): FeeOptionSelection | undefined | Promise +} + +export namespace FeeOptionSelector { + export const firstAvailable: FeeOptionSelector = (feeOptions) => feeOptions.find(canPayFeeOption)?.selection +} + +export function feeOptionSelection(feeOption: FeeOption): FeeOptionSelection { + const tokenIdentifier = feeOption.token.tokenID?.trim() + return {token: tokenIdentifier && tokenIdentifier.length > 0 ? tokenIdentifier : feeOption.token.symbol} +} + +function canPayFeeOption(option: FeeOptionWithBalance): boolean { + if (option.availableRaw === undefined) { + return false + } + + try { + return BigInt(option.availableRaw) >= BigInt(option.feeOption.value) + } catch { + return false + } +} export type SendTransactionResponse = { txnId: string diff --git a/tests/walletTransactions.test.ts b/tests/walletTransactions.test.ts index b417a39..0926e06 100644 --- a/tests/walletTransactions.test.ts +++ b/tests/walletTransactions.test.ts @@ -5,6 +5,7 @@ import type {CredentialSigner} from "../src/credentialSigner"; import {TransactionStatus} from "../src/generated/waas.gen"; import {Networks} from "../src/networks"; import {MemoryStorageManager} from "../src/storageManager"; +import {FeeOptionSelector} from "../src/types/transactionTypes"; class MockSigner implements CredentialSigner { readonly signingAlgorithm = "ecdsa-p256-sha256"; @@ -145,7 +146,7 @@ describe("WalletClient transactions", () => { selectFeeOption: (feeOptions) => { expect(feeOptions[0].available).toBe("1"); expect(feeOptions[1].available).toBe("2.5"); - return {token: feeOptions[1].feeOption.token.symbol}; + return feeOptions[1].selection; }, }); @@ -156,6 +157,329 @@ describe("WalletClient transactions", () => { }); }); + it("selects the default fee option identifier without balance lookup", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const body = init?.body ? JSON.parse(init.body as string) : undefined; + + if (url.endsWith("/PrepareEthereumTransaction")) { + return jsonResponse({ + txnId: "txn-default-fee", + status: "quoted", + feeOptions: [ + { + token: { + network: "137", + name: "Polygon", + symbol: "POL", + type: "native", + decimals: 18, + logoURL: "", + tokenID: "pol", + }, + value: "100000000000000000", + displayValue: "0.1", + }, + ], + sponsored: false, + expiresAt: "2099-01-01T00:00:00Z", + }); + } + + if (url.endsWith("/GetNativeTokenBalance") || url.endsWith("/GetTokenBalances")) { + throw new Error("default fee selection should not load balances"); + } + + if (url.endsWith("/Execute")) { + expect(body).toEqual({ + txnId: "txn-default-fee", + feeOption: {token: "pol"}, + }); + return jsonResponse({status: "pending"}); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = createWalletWithSession( + new MemoryStorageManager(), + "0x9999999999999999999999999999999999999999", + ); + + await expect(wallet.sendTransaction({ + network: Networks.polygon, + to: "0x1111111111111111111111111111111111111111", + value: 0n, + waitForStatus: false, + })).resolves.toEqual({ + txnId: "txn-default-fee", + status: TransactionStatus.Pending, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("skips fee selection for sponsored transactions", async () => { + const selectFeeOption = vi.fn(() => { + throw new Error("sponsored transactions should not ask for fee selection"); + }); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const body = init?.body ? JSON.parse(init.body as string) : undefined; + + if (url.endsWith("/PrepareEthereumTransaction")) { + return jsonResponse({ + txnId: "txn-sponsored", + status: "quoted", + feeOptions: [ + { + token: { + network: "137", + name: "Polygon", + symbol: "POL", + type: "native", + decimals: 18, + logoURL: "", + tokenID: "pol", + }, + value: "100000000000000000", + displayValue: "0.1", + }, + ], + sponsored: true, + expiresAt: "2099-01-01T00:00:00Z", + }); + } + + if (url.endsWith("/Execute")) { + expect(body).toEqual({txnId: "txn-sponsored"}); + return jsonResponse({status: "pending"}); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = createWalletWithSession( + new MemoryStorageManager(), + "0x9999999999999999999999999999999999999999", + ); + + await expect(wallet.sendTransaction({ + network: Networks.polygon, + to: "0x1111111111111111111111111111111111111111", + value: 0n, + selectFeeOption, + waitForStatus: false, + })).resolves.toEqual({ + txnId: "txn-sponsored", + status: TransactionStatus.Pending, + }); + expect(selectFeeOption).not.toHaveBeenCalled(); + }); + + it("firstAvailable selects the first affordable fee option", async () => { + const usdcAddress = "0x2222222222222222222222222222222222222222"; + const daiAddress = "0x3333333333333333333333333333333333333333"; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const body = init?.body ? JSON.parse(init.body as string) : undefined; + + if (url.endsWith("/PrepareEthereumTransaction")) { + return jsonResponse({ + txnId: "txn-first-available", + status: "quoted", + feeOptions: [ + { + token: { + network: "137", + name: "Dai", + symbol: "DAI", + type: "erc20", + decimals: 18, + logoURL: "", + contractAddress: daiAddress, + tokenID: "dai", + }, + value: "1000", + displayValue: "0.000000000000001", + }, + { + token: { + network: "137", + name: "USD Coin", + symbol: "USDC", + type: "erc20", + decimals: 6, + logoURL: "", + contractAddress: usdcAddress, + tokenID: "usdc", + }, + value: "2000", + displayValue: "0.002", + }, + ], + sponsored: false, + expiresAt: "2099-01-01T00:00:00Z", + }); + } + + if (url.endsWith("/GetTokenBalances")) { + const balance = body.contractAddress === daiAddress ? "100" : "2000"; + return jsonResponse({ + page: {page: 0, pageSize: 40, more: false}, + balances: [{ + contractType: "ERC20", + contractAddress: body.contractAddress, + accountAddress: body.accountAddress, + tokenID: null, + balance, + chainId: 137, + }], + }); + } + + if (url.endsWith("/Execute")) { + expect(body).toEqual({ + txnId: "txn-first-available", + feeOption: {token: "usdc"}, + }); + return jsonResponse({status: "pending"}); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = createWalletWithSession( + new MemoryStorageManager(), + "0x9999999999999999999999999999999999999999", + ); + + await expect(wallet.sendTransaction({ + network: Networks.polygon, + to: "0x1111111111111111111111111111111111111111", + value: 0n, + selectFeeOption: FeeOptionSelector.firstAvailable, + waitForStatus: false, + })).resolves.toEqual({ + txnId: "txn-first-available", + status: TransactionStatus.Pending, + }); + }); + + it("firstAvailable requires an affordable fee option", async () => { + const usdcAddress = "0x2222222222222222222222222222222222222222"; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const body = init?.body ? JSON.parse(init.body as string) : undefined; + + if (url.endsWith("/PrepareEthereumTransaction")) { + return jsonResponse({ + txnId: "txn-no-affordable-fee", + status: "quoted", + feeOptions: [ + { + token: { + network: "137", + name: "USD Coin", + symbol: "USDC", + type: "erc20", + decimals: 6, + logoURL: "", + contractAddress: usdcAddress, + tokenID: "usdc", + }, + value: "1000", + displayValue: "0.001", + }, + ], + sponsored: false, + expiresAt: "2099-01-01T00:00:00Z", + }); + } + + if (url.endsWith("/GetTokenBalances")) { + return jsonResponse({ + page: {page: 0, pageSize: 40, more: false}, + balances: [{ + contractType: "ERC20", + contractAddress: body.contractAddress, + accountAddress: body.accountAddress, + tokenID: null, + balance: "100", + chainId: 137, + }], + }); + } + + if (url.endsWith("/Execute")) { + throw new Error("Execute should not run without an affordable fee option"); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = createWalletWithSession( + new MemoryStorageManager(), + "0x9999999999999999999999999999999999999999", + ); + + await expect(wallet.sendTransaction({ + network: Networks.polygon, + to: "0x1111111111111111111111111111111111111111", + value: 0n, + selectFeeOption: FeeOptionSelector.firstAvailable, + waitForStatus: false, + })).rejects.toMatchObject({ + code: "OMS_VALIDATION_ERROR", + operation: "wallet.sendTransaction", + message: "No fee option selected for unsponsored transaction", + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("requires fee options for unsponsored transactions", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + + if (url.endsWith("/PrepareEthereumTransaction")) { + return jsonResponse({ + txnId: "txn-no-fee-options", + status: "quoted", + feeOptions: [], + sponsored: false, + expiresAt: "2099-01-01T00:00:00Z", + }); + } + + if (url.endsWith("/Execute")) { + throw new Error("Execute should not run without fee options"); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const wallet = createWalletWithSession( + new MemoryStorageManager(), + "0x9999999999999999999999999999999999999999", + ); + + await expect(wallet.sendTransaction({ + network: Networks.polygon, + to: "0x1111111111111111111111111111111111111111", + value: 0n, + waitForStatus: false, + })).rejects.toMatchObject({ + code: "OMS_VALIDATION_ERROR", + operation: "wallet.sendTransaction", + message: "No fee options available for unsponsored transaction", + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it("can skip transaction status polling", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = input.toString(); @@ -165,7 +489,7 @@ describe("WalletClient transactions", () => { txnId: "txn-1", status: "quoted", feeOptions: [], - sponsored: false, + sponsored: true, expiresAt: "2099-01-01T00:00:00Z", }); } @@ -240,7 +564,7 @@ describe("WalletClient transactions", () => { txnId: "txn-1", status: "quoted", feeOptions: [], - sponsored: false, + sponsored: true, expiresAt: "2099-01-01T00:00:00Z", }); }