Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions packages/checkout/sdk/src/smartCheckout/buy/buy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
13 changes: 9 additions & 4 deletions packages/checkout/sdk/src/smartCheckout/buy/buy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
SmartCheckoutResult, BuyOverrides,
} from '../../types/smartCheckout';
import { smartCheckout } from '..';
import { isPassportProvider } from '../routing';
import {
getUnsignedERC20ApprovalTransactions,
getUnsignedFulfillmentTransactions,
Expand Down Expand Up @@ -268,17 +269,21 @@ 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<SmartCheckoutResult>(
config,
'Total time running smart checkout',
smartCheckout(
config,
provider,
itemRequirements,
getTransactionOrGas(
gasLimit,
unsignedFulfillmentTransactions,
),
transactionOrGas,
),
);

Expand Down
120 changes: 120 additions & 0 deletions packages/checkout/sdk/src/smartCheckout/gas/gasCalculator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
41 changes: 24 additions & 17 deletions packages/checkout/sdk/src/smartCheckout/gas/gasCalculator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ItemRequirement | null> => {
const estimateGasPromises = [];
let totalGas = BigInt(0);
const estimateGasPromises: Promise<bigint>[] = [];

// Get all the gas estimate promises for the approval transactions
for (const item of insufficientItems) {
Expand All @@ -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);
};
Loading