From 3c833471924323d4182a5c1286882e8f08ef8131 Mon Sep 17 00:00:00 2001 From: Allan Almeida Date: Wed, 30 Sep 2026 09:38:45 +1000 Subject: [PATCH 1/2] fix(checkout): price gas units before using them as a balance requirement gasCalculator summed the estimateGas results, which are gas units, and returned the sum as a wei amount for the native balance check. 200,000 gas became 200,000 wei, so the gas half of the affordability check could never fail and a wallet holding only the item price passed, then failed at submission with an out-of-funds error. The units are now multiplied by the current gas price, the same price the caller-supplied gas-limit branch already used, before they become a requirement. Co-Authored-By: Claude Fable 5.1 --- .../smartCheckout/gas/gasCalculator.test.ts | 120 ++++++++++++++++++ .../src/smartCheckout/gas/gasCalculator.ts | 41 +++--- 2 files changed, 144 insertions(+), 17 deletions(-) diff --git a/packages/checkout/sdk/src/smartCheckout/gas/gasCalculator.test.ts b/packages/checkout/sdk/src/smartCheckout/gas/gasCalculator.test.ts index 20b8dad379..8344f4be21 100644 --- a/packages/checkout/sdk/src/smartCheckout/gas/gasCalculator.test.ts +++ b/packages/checkout/sdk/src/smartCheckout/gas/gasCalculator.test.ts @@ -6,9 +6,16 @@ import { CheckoutErrorType } from '../..'; describe('gasCalculator', () => { describe('gasCalculator', () => { + const oneWeiFeeData = { + maxFeePerGas: 1n, + maxPriorityFeePerGas: 1n, + gasPrice: null, + }; + it('should return gas for transaction', async () => { const mockProvider = { estimateGas: jest.fn().mockResolvedValue(100000n), + getFeeData: jest.fn().mockResolvedValue(oneWeiFeeData), } as unknown as WrappedBrowserProvider; const item = await gasCalculator( @@ -32,6 +39,7 @@ describe('gasCalculator', () => { it('should return the total gas required for approvals and transaction', async () => { const mockProvider = { estimateGas: jest.fn().mockResolvedValue(100000n), + getFeeData: jest.fn().mockResolvedValue(oneWeiFeeData), } as unknown as WrappedBrowserProvider; const item = await gasCalculator( @@ -188,9 +196,121 @@ describe('gasCalculator', () => { }); }); + it('should price the gas units at the current gas price', async () => { + // 10 gwei on a legacy (non-1559) fee response + const gasPrice = 10_000_000_000n; + const mockProvider = { + estimateGas: jest.fn().mockResolvedValue(21_000n), + getFeeData: jest.fn().mockResolvedValue({ + maxFeePerGas: null, + maxPriorityFeePerGas: null, + gasPrice, + }), + } as unknown as WrappedBrowserProvider; + + const item = await gasCalculator( + mockProvider, + [ + { + sufficient: false, + type: ItemType.ERC20, + delta: BigInt(1), + itemRequirement: { + type: ItemType.ERC20, + tokenAddress: '0xERC20', + amount: BigInt(1), + spenderAddress: '0xSEAPORT', + isFee: false, + }, + approvalTransaction: { from: '0xADDRESS', data: '0xDATA', to: '0xSEAPORT' }, + }, + ], + { + type: TransactionOrGasType.TRANSACTION, + transaction: { + from: '0xADDRESS', + }, + }, + ); + + // approval (21,000) + fulfilment (21,000) gas units, each priced at 10 gwei + expect(item).toEqual({ + type: ItemType.NATIVE, + amount: 42_000n * gasPrice, + isFee: true, + }); + }); + + it('should price a caller-supplied gas limit and the approvals at the same gas price', async () => { + const gasPrice = 10_000_000_000n; + const mockProvider = { + estimateGas: jest.fn().mockResolvedValue(50_000n), + getFeeData: jest.fn().mockResolvedValue({ + maxFeePerGas: null, + maxPriorityFeePerGas: null, + gasPrice, + }), + } as unknown as WrappedBrowserProvider; + + const item = await gasCalculator( + mockProvider, + [ + { + sufficient: false, + type: ItemType.ERC721, + itemRequirement: { + type: ItemType.ERC721, + contractAddress: '0xERC721', + id: '0', + spenderAddress: '0xSEAPORT', + }, + approvalTransaction: { from: '0xADDRESS', data: '0xDATA', to: '0xSEAPORT' }, + }, + ], + { + type: TransactionOrGasType.GAS, + gasToken: { + type: GasTokenType.NATIVE, + limit: 150_000n, + }, + }, + ); + + expect(item).toEqual({ + type: ItemType.NATIVE, + amount: 200_000n * gasPrice, + isFee: true, + }); + }); + + it('should return null when the node gives no gas price', async () => { + const mockProvider = { + estimateGas: jest.fn().mockResolvedValue(100000n), + getFeeData: jest.fn().mockResolvedValue({ + maxFeePerGas: null, + maxPriorityFeePerGas: null, + gasPrice: null, + }), + } as unknown as WrappedBrowserProvider; + + const item = await gasCalculator( + mockProvider, + [], + { + type: TransactionOrGasType.TRANSACTION, + transaction: { + from: '0xADDRESS', + }, + }, + ); + + expect(item).toBeNull(); + }); + it('should return null if no gas required', async () => { const mockProvider = { estimateGas: jest.fn().mockResolvedValue(0n), + getFeeData: jest.fn().mockResolvedValue(oneWeiFeeData), } as unknown as WrappedBrowserProvider; const item = await gasCalculator( diff --git a/packages/checkout/sdk/src/smartCheckout/gas/gasCalculator.ts b/packages/checkout/sdk/src/smartCheckout/gas/gasCalculator.ts index 0ed34c2e0c..ad5640d588 100644 --- a/packages/checkout/sdk/src/smartCheckout/gas/gasCalculator.ts +++ b/packages/checkout/sdk/src/smartCheckout/gas/gasCalculator.ts @@ -44,13 +44,18 @@ export const getGasItemRequirement = ( }; }; +/** + * Works out what the wallet must hold to pay for gas: every approval the + * fulfilment needs plus the fulfilment itself (or a caller-supplied gas limit), + * priced at the current gas price. estimateGas answers in gas units, so the + * units are converted to a cost before they become a balance requirement. + */ export const gasCalculator = async ( provider: WrappedBrowserProvider, insufficientItems: (InsufficientERC20 | InsufficientERC721 | InsufficientERC1155)[], transactionOrGas: FulfillmentTransaction | GasAmount, ): Promise => { - const estimateGasPromises = []; - let totalGas = BigInt(0); + const estimateGasPromises: Promise[] = []; // Get all the gas estimate promises for the approval transactions for (const item of insufficientItems) { @@ -59,24 +64,26 @@ export const gasCalculator = async ( } // If the transaction is a fulfillment transaction get the estimate gas promise - // Otherwise use the gas amount with the limit to estimate the gas + // Otherwise the caller has supplied the gas limit directly if (transactionOrGas.type === TransactionOrGasType.TRANSACTION) { estimateGasPromises.push(estimateGas(provider, transactionOrGas.transaction)); - } else { - const feeData = await provider.getFeeData(); - const gasPrice = getGasPriceInWei(feeData); - if (gasPrice !== null) { - const gas = gasPrice * transactionOrGas.gasToken.limit; - if (gas) totalGas += gas; - } } - // Get the gas estimates for all the transactions and calculate the total gas - const gasEstimatePromises = await Promise.all(estimateGasPromises); - gasEstimatePromises.forEach((gasEstimate) => { - totalGas += gasEstimate; - }); + const [feeData, gasEstimates] = await Promise.all([ + provider.getFeeData(), + Promise.all(estimateGasPromises), + ]); - if (totalGas === 0n) return null; - return getGasItemRequirement(totalGas, transactionOrGas); + // Without a price the gas units cannot be turned into a balance requirement + const gasPrice = getGasPriceInWei(feeData); + if (gasPrice === null) return null; + + let totalGasUnits = gasEstimates.reduce((sum, gasEstimate) => sum + gasEstimate, BigInt(0)); + if (transactionOrGas.type === TransactionOrGasType.GAS) { + totalGasUnits += transactionOrGas.gasToken.limit; + } + + const totalGasCost = totalGasUnits * gasPrice; + if (totalGasCost === BigInt(0)) return null; + return getGasItemRequirement(totalGasCost, transactionOrGas); }; From 4903e746874bf201f03b3907a7d6496725e10aaf Mon Sep 17 00:00:00 2001 From: Allan Almeida Date: Wed, 30 Sep 2026 10:21:56 +1000 Subject: [PATCH 2/2] fix(checkout): run buy's Smart Checkout without a gas requirement for Passport wallets Now that the gas requirement is priced correctly it would ask a Passport wallet to hold native IMX for gas the relayer prices and collects instead. buy still runs Smart Checkout for Passport so the item price is checked and top-up routes offered, but passes no transaction or gas limit, which skips the gas requirement. sell already skipped Smart Checkout entirely for Passport; buy cannot, because buying needs funds. Co-Authored-By: Claude Fable 5.1 --- .../sdk/src/smartCheckout/buy/buy.test.ts | 45 +++++++++++++++++++ .../checkout/sdk/src/smartCheckout/buy/buy.ts | 13 ++++-- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/packages/checkout/sdk/src/smartCheckout/buy/buy.test.ts b/packages/checkout/sdk/src/smartCheckout/buy/buy.test.ts index ad809e722e..2385786079 100644 --- a/packages/checkout/sdk/src/smartCheckout/buy/buy.test.ts +++ b/packages/checkout/sdk/src/smartCheckout/buy/buy.test.ts @@ -189,6 +189,51 @@ describe('buy', () => { ); }); + it('should call smart checkout without a gas requirement when the provider is Passport', async () => { + const passportProvider = { + ...mockProvider, + ethereumProvider: { isPassport: true }, + } as unknown as WrappedBrowserProvider; + const smartCheckoutResult = { sufficient: true, transactionRequirements: [] }; + (smartCheckout as jest.Mock).mockResolvedValue(smartCheckoutResult); + (createOrderbookInstance as jest.Mock).mockReturnValue({ + getListing: jest.fn().mockResolvedValue({ + result: { + buy: [{ type: 'NATIVE', amount: '1000000000000000000' }], + sell: [{ type: 'ERC721', amount: '1' }], + fees: [{ amount: '1000000000000000000' }], + }, + }), + config: jest.fn().mockReturnValue({ seaportContractAddress }), + fulfillOrder: jest.fn().mockReturnValue({ + actions: [ + { + type: ActionType.TRANSACTION, + purpose: TransactionPurpose.FULFILL_ORDER, + buildTransaction: jest.fn().mockResolvedValue({ from: '0xTRANSACTION' } as PreparedTransactionRequest), + }, + ], + }), + }); + (getUnsignedERC20ApprovalTransactions as jest.Mock).mockResolvedValue([]); + (getUnsignedFulfillmentTransactions as jest.Mock).mockResolvedValue([{ from: '0xTRANSACTION' }]); + (signApprovalTransactions as jest.Mock).mockResolvedValue({ type: SignTransactionStatusType.SUCCESS }); + (signFulfillmentTransactions as jest.Mock).mockResolvedValue({ type: SignTransactionStatusType.SUCCESS }); + + const order: BuyOrder = { id: '1', takerFees: [] }; + const buyResult = await buy(config, passportProvider, [order]); + + // Item price is still checked; the native gas requirement is not, because the + // relayer prices gas for Passport wallets. + expect(smartCheckout).toBeCalledWith( + config, + passportProvider, + [{ type: ItemType.NATIVE, amount: BigInt('2000000000000000000'), isFee: false }], + undefined, + ); + expect(buyResult).toEqual({ status: CheckoutStatus.SUCCESS, smartCheckoutResult }); + }); + // eslint-disable-next-line max-len it('should call smart checkout with item requirements and execute transactions for ERC20 fulfillment - ERC721 order', async () => { const smartCheckoutResult = { diff --git a/packages/checkout/sdk/src/smartCheckout/buy/buy.ts b/packages/checkout/sdk/src/smartCheckout/buy/buy.ts index 2c9984797f..0842c21faa 100644 --- a/packages/checkout/sdk/src/smartCheckout/buy/buy.ts +++ b/packages/checkout/sdk/src/smartCheckout/buy/buy.ts @@ -26,6 +26,7 @@ import { SmartCheckoutResult, BuyOverrides, } from '../../types/smartCheckout'; import { smartCheckout } from '..'; +import { isPassportProvider } from '../routing'; import { getUnsignedERC20ApprovalTransactions, getUnsignedFulfillmentTransactions, @@ -268,6 +269,13 @@ export const buy = async ( getItemRequirement(type, contractAddress, amount, spenderAddress), ]; + // A Passport wallet does not pay gas from its native balance: the relayer prices + // and collects the fee. Checking the item price still matters, so Smart Checkout + // runs, but without a gas requirement. + const transactionOrGas = isPassportProvider(provider) + ? undefined + : getTransactionOrGas(gasLimit, unsignedFulfillmentTransactions); + const smartCheckoutResult = await measureAsyncExecution( config, 'Total time running smart checkout', @@ -275,10 +283,7 @@ export const buy = async ( config, provider, itemRequirements, - getTransactionOrGas( - gasLimit, - unsignedFulfillmentTransactions, - ), + transactionOrGas, ), );