diff --git a/packages/checkout/sdk/src/errors/checkoutError.ts b/packages/checkout/sdk/src/errors/checkoutError.ts index 43c64cc31c..655f0f96e9 100644 --- a/packages/checkout/sdk/src/errors/checkoutError.ts +++ b/packages/checkout/sdk/src/errors/checkoutError.ts @@ -37,6 +37,7 @@ export enum CheckoutErrorType { ORDER_FEE_ERROR = 'ORDER_FEE_ERROR', ITEM_REQUIREMENTS_ERROR = 'ITEM_REQUIREMENTS_ERROR', API_ERROR = 'API_ERROR', + ORDER_EXPIRED_ERROR = 'ORDER_EXPIRED_ERROR', } /** diff --git a/packages/checkout/sdk/src/smartCheckout/buy/buy.test.ts b/packages/checkout/sdk/src/smartCheckout/buy/buy.test.ts index 45f8909d8f..7f604c23d2 100644 --- a/packages/checkout/sdk/src/smartCheckout/buy/buy.test.ts +++ b/packages/checkout/sdk/src/smartCheckout/buy/buy.test.ts @@ -497,60 +497,65 @@ describe('buy', () => { }, ); - it('should call smart checkout with item requirements and gas limit', async () => { - (getUnsignedERC20ApprovalTransactions as jest.Mock).mockResolvedValue([{ from: '0xAPPROVAL' }]); - (getUnsignedFulfillmentTransactions as jest.Mock).mockResolvedValue([]); - (smartCheckout as jest.Mock).mockResolvedValue({}); - (createOrderbookInstance as jest.Mock).mockReturnValue({ - getListing: jest.fn().mockResolvedValue({ - result: { - buy: [ - { - type: 'NATIVE', - amount: '1000000000000000000', - }, - ], - fees: [ - { - amount: '1000000000000000000', - }, - ], - }, - }), - config: jest.fn().mockReturnValue({ - seaportContractAddress, - }), - fulfillOrder: jest.fn().mockRejectedValue({}), - }); + it( + 'should call smart checkout with item requirements and gas limit if fulfillOrder errors with balance error', + async () => { + (getUnsignedERC20ApprovalTransactions as jest.Mock).mockResolvedValue([{ from: '0xAPPROVAL' }]); + (getUnsignedFulfillmentTransactions as jest.Mock).mockResolvedValue([]); + (smartCheckout as jest.Mock).mockResolvedValue({}); + (createOrderbookInstance as jest.Mock).mockReturnValue({ + getListing: jest.fn().mockResolvedValue({ + result: { + buy: [ + { + type: 'NATIVE', + amount: '1000000000000000000', + }, + ], + fees: [ + { + amount: '1000000000000000000', + }, + ], + }, + }), + config: jest.fn().mockReturnValue({ + seaportContractAddress, + }), + fulfillOrder: jest.fn().mockRejectedValue( + new Error('The fulfiller does not have the balances needed to fulfill.'), + ), + }); - const order:BuyOrder = { - id: '1', - takerFees: [{ amount: { percentageDecimal: 0.01 }, recipient: '0xFEERECIPIENT' }], - }; - const itemRequirements = [ - { - type: ItemType.NATIVE, - amount: BigNumber.from('2000000000000000000'), - }, - ]; - const gasAmount: GasAmount = { - type: TransactionOrGasType.GAS, - gasToken: { - type: GasTokenType.NATIVE, - limit: BigNumber.from(gasLimit), - }, - }; + const order:BuyOrder = { + id: '1', + takerFees: [{ amount: { percentageDecimal: 0.01 }, recipient: '0xFEERECIPIENT' }], + }; + const itemRequirements = [ + { + type: ItemType.NATIVE, + amount: BigNumber.from('2000000000000000000'), + }, + ]; + const gasAmount: GasAmount = { + type: TransactionOrGasType.GAS, + gasToken: { + type: GasTokenType.NATIVE, + limit: BigNumber.from(gasLimit), + }, + }; - await buy(config, mockProvider, [order]); - expect(smartCheckout).toBeCalledWith( - config, - mockProvider, - itemRequirements, - gasAmount, - ); - }); + await buy(config, mockProvider, [order]); + expect(smartCheckout).toBeCalledWith( + config, + mockProvider, + itemRequirements, + gasAmount, + ); + }, + ); - it('should call smart checkout with an erc20 requirement', async () => { + it('should call smart checkout with an erc20 requirement if fulfillOrder errors with balance error', async () => { (getUnsignedERC20ApprovalTransactions as jest.Mock).mockResolvedValue([{ from: '0xAPPROVAL' }]); (getUnsignedFulfillmentTransactions as jest.Mock).mockResolvedValue([]); (createOrderbookInstance as jest.Mock).mockReturnValue({ @@ -573,7 +578,9 @@ describe('buy', () => { config: jest.fn().mockReturnValue({ seaportContractAddress, }), - fulfillOrder: jest.fn().mockRejectedValue({}), + fulfillOrder: jest.fn().mockRejectedValue( + new Error('The fulfiller does not have the balances needed to fulfill.'), + ), }); const smartCheckoutResult = { sufficient: true, @@ -1006,7 +1013,9 @@ describe('buy', () => { config: jest.fn().mockReturnValue({ seaportContractAddress, }), - fulfillOrder: jest.fn().mockRejectedValue({}), + fulfillOrder: jest.fn().mockReturnValue({ + actions: [], + }), }); const order = { @@ -1049,7 +1058,9 @@ describe('buy', () => { config: jest.fn().mockReturnValue({ seaportContractAddress, }), - fulfillOrder: jest.fn().mockRejectedValue({}), + fulfillOrder: jest.fn().mockReturnValue({ + actions: [], + }), }); const order = { @@ -1072,12 +1083,31 @@ describe('buy', () => { expect(data).toEqual({ orderId: '1' }); }); - it('should throw error if orderbook returns error', async () => { + it('should throw expired error if orderbook fulfillOrder returns expired error', async () => { (createOrderbookInstance as jest.Mock).mockReturnValue({ - getListing: jest.fn().mockRejectedValue(new Error('error from orderbook')), + getListing: jest.fn().mockResolvedValue({ + result: { + buy: [ + { + type: 'NATIVE', + amount: '1', + }, + ], + fees: [ + { + amount: '1', + }, + ], + }, + }), + config: jest.fn().mockReturnValue({ + seaportContractAddress, + }), + fulfillOrder: jest.fn().mockRejectedValue(new Error( + 'Unable to prepare fulfillment date: order is not active: 1, actual status EXPIRED', + )), }); - const provider = {} as any; const order = { id: '1', takerFees: [], @@ -1088,15 +1118,64 @@ describe('buy', () => { let data; try { - await buy(config, provider, [order]); + await buy(config, mockProvider, [order]); + } catch (err: any) { + message = err.message; + type = err.type; + data = err.data; + } + + expect(message).toEqual('Order is expired'); + expect(type).toEqual(CheckoutErrorType.ORDER_EXPIRED_ERROR); + expect(data).toEqual({ + orderId: '1', + }); + }); + + it('should throw error if orderbook fulfillOrder returns error other than expired or balances', async () => { + (createOrderbookInstance as jest.Mock).mockReturnValue({ + getListing: jest.fn().mockResolvedValue({ + result: { + buy: [ + { + type: 'NATIVE', + amount: '1', + }, + ], + fees: [ + { + amount: '1', + }, + ], + }, + }), + config: jest.fn().mockReturnValue({ + seaportContractAddress, + }), + fulfillOrder: jest.fn().mockRejectedValue(new Error( + 'error from orderbook', + )), + }); + + const order = { + id: '1', + takerFees: [], + }; + + let message; + let type; + let data; + + try { + await buy(config, mockProvider, [order]); } catch (err: any) { message = err.message; type = err.type; data = err.data; } - expect(message).toEqual('An error occurred while getting the order listing'); - expect(type).toEqual(CheckoutErrorType.GET_ORDER_LISTING_ERROR); + expect(message).toEqual('Error occurred while trying to fulfill the order'); + expect(type).toEqual(CheckoutErrorType.FULFILL_ORDER_LISTING_ERROR); expect(data).toEqual({ orderId: '1', message: 'error from orderbook', diff --git a/packages/checkout/sdk/src/smartCheckout/buy/buy.ts b/packages/checkout/sdk/src/smartCheckout/buy/buy.ts index 9a3855f6c7..70080beade 100644 --- a/packages/checkout/sdk/src/smartCheckout/buy/buy.ts +++ b/packages/checkout/sdk/src/smartCheckout/buy/buy.ts @@ -10,6 +10,7 @@ import { FeeValue, Action, FulfillOrderResponse, + OrderStatusName, } from '@imtbl/orderbook'; import * as instance from '../../instance'; import { CheckoutConfiguration } from '../../config'; @@ -155,7 +156,7 @@ export const buy = async ( let unsignedFulfillmentTransactions: TransactionRequest[] = []; let orderActions: Action[] = []; - const fulfillOrderStartTime = new Date().getTime(); + const fulfillOrderStartTime = performance.now(); try { const fulfillerAddress = await measureAsyncExecution( config, @@ -174,9 +175,25 @@ export const buy = async ( getUnsignedERC20ApprovalTransactions(actions), ); } catch (err: any) { - // Silently ignore error as this is usually thrown if user does not have enough balance - const elapsedTimeInSeconds = (new Date().getTime() - fulfillOrderStartTime) / 1000; + const elapsedTimeInSeconds = (performance.now() - fulfillOrderStartTime) / 1000; debugLogger(config, 'Time to call fulfillOrder from the orderbook', elapsedTimeInSeconds); + + if (err.message.includes(OrderStatusName.EXPIRED)) { + throw new CheckoutError('Order is expired', CheckoutErrorType.ORDER_EXPIRED_ERROR, { orderId: id }); + } + + // The balances error will be handled by bulk order fulfillment but for now we + // need to assert on this string to check that the error is not a balances error + if (!err.message.includes('The fulfiller does not have the balances needed to fulfill')) { + throw new CheckoutError( + 'Error occurred while trying to fulfill the order', + CheckoutErrorType.FULFILL_ORDER_LISTING_ERROR, + { + orderId: id, + message: err.message, + }, + ); + } } try {