From 5c44b7d0ddae7bbd4f91085060bcdf52899c7501 Mon Sep 17 00:00:00 2001 From: markus <55011443+mdymalla@users.noreply.github.com> Date: Tue, 1 Oct 2024 16:15:23 +1000 Subject: [PATCH] TD-1713: Implement collection bids SDK support (#2246) --- .github/workflows/pr.yaml | 1 + .../orderbook/src/api-client/api-client.ts | 83 +++- .../sdk/models/CreateBidRequestBody.ts | 7 +- .../models/CreateCollectionBidRequestBody.ts | 4 +- .../orderbook/src/openapi/sdk/models/Trade.ts | 16 + .../src/openapi/sdk/services/OrdersService.ts | 8 +- packages/orderbook/src/orderbook.ts | 126 +++++- .../src/seaport/map-to-immutable-order.ts | 41 +- packages/orderbook/src/seaport/seaport.ts | 30 +- packages/orderbook/src/types.ts | 43 ++ tests/func-tests/zkevm/jest.config.ts | 2 +- .../func-tests/zkevm/specs/orderbook.spec.ts | 397 +++++++++++++++++- .../zkevm/utils/orderbook/collection-bid.ts | 51 +++ yarn.lock | 4 +- 14 files changed, 762 insertions(+), 51 deletions(-) create mode 100644 tests/func-tests/zkevm/utils/orderbook/collection-bid.ts diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 008d441210..1a9cb61062 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -96,6 +96,7 @@ jobs: # zkevm envs ZKEVM_ORDERBOOK_BANKER: ${{ secrets.ZKEVM_ORDERBOOK_BANKER }} + ZKEVM_ORDERBOOK_ERC20: "0x70dCEF6C22F50497eafc77D252E8E175af21bF75" ZKEVM_ORDERBOOK_ERC721: "0xBE8B131f39825282Ace9eFf99C0Bb14972417b49" ZKEVM_ORDERBOOK_ERC1155: "0x2efB9B7810B1d1520c0822aa20F1889ABd2c2146" SEAPORT_CONTRACT_ADDRESS: "0x7d117aA8BD6D31c4fa91722f246388f38ab1942c" diff --git a/packages/orderbook/src/api-client/api-client.ts b/packages/orderbook/src/api-client/api-client.ts index 571830f7de..d0fdc6d045 100644 --- a/packages/orderbook/src/api-client/api-client.ts +++ b/packages/orderbook/src/api-client/api-client.ts @@ -1,6 +1,7 @@ import { BidResult, CancelOrdersResult, + CollectionBidResult, Fee, ListBidsResult, ListingResult, @@ -13,11 +14,17 @@ import { FulfillableOrder } from '../openapi/sdk/models/FulfillableOrder'; import { FulfillmentDataRequest } from '../openapi/sdk/models/FulfillmentDataRequest'; import { UnfulfillableOrder } from '../openapi/sdk/models/UnfulfillableOrder'; import { ItemType, SEAPORT_CONTRACT_VERSION_V1_5 } from '../seaport'; -import { mapSeaportItemToImmutableItem, mapSeaportOrderTypeToImmutableProtocolDataOrderType } from '../seaport/map-to-immutable-order'; +import { + mapSeaportItemToImmutableAssetCollectionItem, + mapSeaportItemToImmutableERC20Item, + mapSeaportItemToImmutableItem, + mapSeaportOrderTypeToImmutableProtocolDataOrderType, +} from '../seaport/map-to-immutable-order'; import { CreateBidParams, CreateListingParams, ListBidsParams, + ListCollectionBidsParams, ListListingsParams, ListTradesParams, } from '../types'; @@ -57,6 +64,13 @@ export class ImmutableApiClient { }); } + async getCollectionBid(collectionBidId: string): Promise { + return this.orderbookService.getCollectionBid({ + chainName: this.chainName, + collectionBidId, + }); + } + async getTrade(tradeId: string): Promise { return this.orderbookService.getTrade({ chainName: this.chainName, @@ -82,6 +96,15 @@ export class ImmutableApiClient { }); } + async listCollectionBids( + listOrderParams: ListCollectionBidsParams, + ): Promise { + return this.orderbookService.listCollectionBids({ + chainName: this.chainName, + ...listOrderParams, + }); + } + async listTrades( listTradesParams: ListTradesParams, ): Promise { @@ -205,7 +228,63 @@ export class ImmutableApiClient { counter: orderComponents.counter.toString(), }, salt: orderComponents.salt, - sell: orderComponents.offer.map(mapSeaportItemToImmutableItem), + sell: orderComponents.offer.map(mapSeaportItemToImmutableERC20Item), + signature: orderSignature, + start_at: new Date( + parseInt(`${orderComponents.startTime.toString()}000`, 10), + ).toISOString(), + }, + }); + } + + async createCollectionBid({ + orderHash, + orderComponents, + orderSignature, + makerFees, + }: CreateBidParams): Promise { + if (orderComponents.offer.length !== 1) { + throw new Error('Only one item can be listed for a collection bid'); + } + + if (orderComponents.consideration.length !== 1) { + throw new Error('Only one item can be used as currency for a collection bid'); + } + + if (ItemType.ERC20 !== orderComponents.offer[0].itemType) { + throw new Error('Only ERC20 tokens can be used as the currency item in a collection bid'); + } + + if (![ItemType.ERC721_WITH_CRITERIA, ItemType.ERC1155_WITH_CRITERIA] + .includes(orderComponents.consideration[0].itemType) + ) { + throw new Error('Only ERC721 / ERC1155 collection based tokens can be bid against'); + } + + return this.orderbookService.createCollectionBid({ + chainName: this.chainName, + requestBody: { + account_address: orderComponents.offerer, + buy: orderComponents.consideration.map(mapSeaportItemToImmutableAssetCollectionItem), + fees: makerFees.map((f) => ({ + type: Fee.type.MAKER_ECOSYSTEM, + amount: f.amount, + recipient_address: f.recipientAddress, + })), + end_at: new Date( + parseInt(`${orderComponents.endTime.toString()}000`, 10), + ).toISOString(), + order_hash: orderHash, + protocol_data: { + order_type: + mapSeaportOrderTypeToImmutableProtocolDataOrderType(orderComponents.orderType), + zone_address: orderComponents.zone, + seaport_address: this.seaportAddress, + seaport_version: SEAPORT_CONTRACT_VERSION_V1_5, + counter: orderComponents.counter.toString(), + }, + salt: orderComponents.salt, + sell: orderComponents.offer.map(mapSeaportItemToImmutableERC20Item), signature: orderSignature, start_at: new Date( parseInt(`${orderComponents.startTime.toString()}000`, 10), diff --git a/packages/orderbook/src/openapi/sdk/models/CreateBidRequestBody.ts b/packages/orderbook/src/openapi/sdk/models/CreateBidRequestBody.ts index 4bbb97fd1d..9047b86696 100644 --- a/packages/orderbook/src/openapi/sdk/models/CreateBidRequestBody.ts +++ b/packages/orderbook/src/openapi/sdk/models/CreateBidRequestBody.ts @@ -2,6 +2,7 @@ /* tslint:disable */ /* eslint-disable */ +import type { ERC20Item } from './ERC20Item'; import type { Fee } from './Fee'; import type { Item } from './Item'; import type { ProtocolData } from './ProtocolData'; @@ -10,7 +11,7 @@ export type CreateBidRequestBody = { account_address: string; order_hash: string; /** - * Buy item for listing should either be ERC721 or ERC1155 item + * Buy item for bid should either be ERC721 or ERC1155 item */ buy: Array; /** @@ -27,9 +28,9 @@ export type CreateBidRequestBody = { */ salt: string; /** - * Sell item for listing should be an ERC20 item + * Sell item for bid should be an ERC20 item */ - sell: Array; + sell: Array; /** * Digital signature generated by the user for the specific Order */ diff --git a/packages/orderbook/src/openapi/sdk/models/CreateCollectionBidRequestBody.ts b/packages/orderbook/src/openapi/sdk/models/CreateCollectionBidRequestBody.ts index 2058cf4380..3a3a9f38f0 100644 --- a/packages/orderbook/src/openapi/sdk/models/CreateCollectionBidRequestBody.ts +++ b/packages/orderbook/src/openapi/sdk/models/CreateCollectionBidRequestBody.ts @@ -11,7 +11,7 @@ export type CreateCollectionBidRequestBody = { account_address: string; order_hash: string; /** - * Buy item for listing should either be ERC721 or ERC1155 collection item + * Buy item for collection bid should either be ERC721 or ERC1155 collection item */ buy: Array; /** @@ -28,7 +28,7 @@ export type CreateCollectionBidRequestBody = { */ salt: string; /** - * Sell item for listing should be an ERC20 item + * Sell item for collection bid should be an ERC20 item */ sell: Array; /** diff --git a/packages/orderbook/src/openapi/sdk/models/Trade.ts b/packages/orderbook/src/openapi/sdk/models/Trade.ts index 7cb5143ed3..9ebfbd68cc 100644 --- a/packages/orderbook/src/openapi/sdk/models/Trade.ts +++ b/packages/orderbook/src/openapi/sdk/models/Trade.ts @@ -8,9 +8,19 @@ import type { Item } from './Item'; import type { TradeBlockchainMetadata } from './TradeBlockchainMetadata'; export type Trade = { + /** + * Buy items are transferred from the taker to the maker. + */ buy: Array; + /** + * Deprecated. Use maker and taker addresses instead of buyer and seller addresses. + */ buyer_address: string; + /** + * Deprecated. Use fees instead. The taker always pays the fees. + */ buyer_fees: Array; + fees: Array; chain: Chain; order_id: string; blockchain_metadata: TradeBlockchainMetadata; @@ -22,7 +32,13 @@ export type Trade = { * Global Trade identifier */ id: string; + /** + * Sell items are transferred from the maker to the taker. + */ sell: Array; + /** + * Deprecated. Use maker and taker addresses instead of buyer and seller addresses. + */ seller_address: string; maker_address: string; taker_address: string; diff --git a/packages/orderbook/src/openapi/sdk/services/OrdersService.ts b/packages/orderbook/src/openapi/sdk/services/OrdersService.ts index 87b3362512..e5405b41e5 100644 --- a/packages/orderbook/src/openapi/sdk/services/OrdersService.ts +++ b/packages/orderbook/src/openapi/sdk/services/OrdersService.ts @@ -190,8 +190,8 @@ export class OrdersService { } /** - * List a paginated array of bids with optional filter parameters - * List a paginated array of bids with optional filter parameters + * List all bids + * List all bids * @returns ListBidsResult OK response. * @throws ApiError */ @@ -313,8 +313,8 @@ export class OrdersService { } /** - * List a paginated array of collection bids with optional filter parameters - * List a paginated array of collection bids with optional filter parameters + * List all collection bids + * List all collection bids * @returns ListCollectionBidsResult OK response. * @throws ApiError */ diff --git a/packages/orderbook/src/orderbook.ts b/packages/orderbook/src/orderbook.ts index 515fae687c..39c0d237a4 100644 --- a/packages/orderbook/src/orderbook.ts +++ b/packages/orderbook/src/orderbook.ts @@ -9,12 +9,15 @@ import { } from './config/config'; import { mapBidFromOpenApiOrder, + mapCollectionBidFromOpenApiOrder, mapFromOpenApiPage, mapFromOpenApiTrade, mapListingFromOpenApiOrder, mapOrderFromOpenApiOrder, } from './openapi/mapper'; -import { ApiError, CancelOrdersResult, Fee as OpenApiFee } from './openapi/sdk'; +import { + ApiError, CancelOrdersResult, FulfillmentDataRequest, Fee as OpenApiFee, +} from './openapi/sdk'; import { Seaport } from './seaport'; import { getBulkSeaportOrderSignatures } from './seaport/components'; import { SeaportLibFactory } from './seaport/seaport-lib-factory'; @@ -23,7 +26,9 @@ import { ActionType, BidResult, CancelOrdersOnChainResponse, + CollectionBidResult, CreateBidParams, + CreateCollectionBidParams, CreateListingParams, FeeValue, FulfillBulkOrdersResponse, @@ -43,6 +48,8 @@ import { PrepareBulkListingsParams, PrepareBulkListingsResponse, PrepareCancelOrdersResponse, + PrepareCollectionBidParams, + PrepareCollectionBidResponse, PrepareListingParams, PrepareListingResponse, SignablePurpose, @@ -146,6 +153,18 @@ export class Orderbook { }; } + /** + * Get a collection bid by ID + * @param {string} collectionBidId - The collectionBidId to find. + * @return {CollectionBidResult} The returned collection bid result. + */ + async getCollectionBid(collectionBidId: string): Promise { + const apiCollectionBid = await this.apiClient.getCollectionBid(collectionBidId); + return { + result: mapCollectionBidFromOpenApiOrder(apiCollectionBid.result), + }; + } + /** * Get a trade by ID * @param {string} tradeId - The tradeId to find. @@ -431,9 +450,7 @@ export class Orderbook { async createListing( createListingParams: CreateListingParams, ): Promise { - const apiListingResponse = await this.apiClient.createListing({ - ...createListingParams, - }); + const apiListingResponse = await this.apiClient.createListing(createListingParams); return { result: mapListingFromOpenApiOrder(apiListingResponse.result), @@ -475,15 +492,59 @@ export class Orderbook { async createBid( createBidParams: CreateBidParams, ): Promise { - const apiBidResponse = await this.apiClient.createBid({ - ...createBidParams, - }); + const apiBidResponse = await this.apiClient.createBid(createBidParams); return { result: mapBidFromOpenApiOrder(apiBidResponse.result), }; } + /** + * Get required transactions and messages for signing prior to creating a collection bid + * through the {@linkcode createCollectionBid} method + * @param {PrepareCollectionBidParams} - Details about the collection bid to be created. + * @return {PrepareCollectionBidResponse} PrepareCollectionBidResponse includes + * the unsigned approval transaction, the typed order message for signing and + * the order components that can be submitted to {@linkcode createCollectionBid} with a signature. + */ + async prepareCollectionBid({ + makerAddress, + sell, + buy, + orderStart, + orderExpiry, + }: PrepareCollectionBidParams): Promise { + return this.seaport.prepareSeaportOrder( + makerAddress, + sell, + buy, + true, + // Default order start to now + orderStart || new Date(), + // Default order expiry to 2 years from now + orderExpiry || Orderbook.defaultOrderExpiry(), + ); + } + + /** + * Create a collection bid + * @param {CreateCollectionBidParams} createCollectionBidParams create a collection bid + * with the given params. + * @return {CollectionBidResult} The result of the collection bid created + * in the Immutable services. + */ + async createCollectionBid( + createCollectionBidParams: CreateCollectionBidParams, + ): Promise { + const apiCollectionBidResponse = await this.apiClient.createCollectionBid( + createCollectionBidParams, + ); + + return { + result: mapCollectionBidFromOpenApiOrder(apiCollectionBidResponse.result), + }; + } + /** * Get unsigned transactions that can be submitted to fulfil an open order. If the approval * transaction exists it must be signed and submitted to the chain before the fulfilment @@ -500,18 +561,26 @@ export class Orderbook { takerAddress: string, takerFees: FeeValue[], amountToFill?: string, + tokenId?: string, ): Promise { - const fulfillmentDataRes = await this.apiClient.fulfillmentData([ - { - order_id: orderId, - taker_address: takerAddress, - fees: takerFees.map((fee) => ({ - type: OpenApiFee.type.TAKER_ECOSYSTEM, - amount: fee.amount, - recipient_address: fee.recipientAddress, - })), - }, - ]); + const fulfillmentDataParams: FulfillmentDataRequest = { + order_id: orderId, + taker_address: takerAddress, + fees: takerFees.map((fee) => ({ + type: OpenApiFee.type.TAKER_ECOSYSTEM, + amount: fee.amount, + recipient_address: fee.recipientAddress, + })), + }; + + const considerationCriteria = tokenId + ? [{ identifier: tokenId, proof: [] }] + : undefined; + + // if token ID is present we can assume it is a criteria based order for now + if (tokenId) fulfillmentDataParams.token_id = tokenId; + + const fulfillmentDataRes = await this.apiClient.fulfillmentData([fulfillmentDataParams]); if (fulfillmentDataRes.result.unfulfillable_orders?.length > 0) { throw new Error( @@ -530,7 +599,13 @@ export class Orderbook { ); } - return this.seaport.fulfillOrder(orderResult, takerAddress, extraData, amountToFill); + return this.seaport.fulfillOrder( + orderResult, + takerAddress, + extraData, + amountToFill, + considerationCriteria, + ); } /** @@ -721,9 +796,18 @@ export class Orderbook { })), ); + const collectionBidResultsPromises = Promise.all( + orderIds.map((id) => this.apiClient.getCollectionBid(id).catch((e: ApiError) => { + if (e.status === 404) { + return undefined; + } + throw e; + })), + ); + const orders = [ - await Promise.all([listingResultsPromises, bidResultsPromises]), - ].flat(2).filter((r) => r !== undefined).map((r) => r.result); + await Promise.all([listingResultsPromises, bidResultsPromises, collectionBidResultsPromises]), + ].flat(2).filter((r) => r !== undefined).map((f) => f.result); if (orders.length !== orderIds.length) { const notFoundOrderIds = orderIds.filter((oi) => !orders.some((o) => o.id === oi)); diff --git a/packages/orderbook/src/seaport/map-to-immutable-order.ts b/packages/orderbook/src/seaport/map-to-immutable-order.ts index 2651686400..29362936df 100644 --- a/packages/orderbook/src/seaport/map-to-immutable-order.ts +++ b/packages/orderbook/src/seaport/map-to-immutable-order.ts @@ -1,6 +1,8 @@ import { ConsiderationItem, OfferItem } from '@opensea/seaport-js/lib/types'; import { ItemType, OrderType } from '@opensea/seaport-js/lib/constants'; -import { Item, ProtocolData } from '../openapi/sdk'; +import { + AssetCollectionItem, ERC20Item, Item, ProtocolData, +} from '../openapi/sdk'; import { exhaustiveSwitch } from '../utils'; export function mapSeaportItemToImmutableItem(item: OfferItem | ConsiderationItem): Item { @@ -46,6 +48,43 @@ export function mapSeaportItemToImmutableItem(item: OfferItem | ConsiderationIte } } +export function mapSeaportItemToImmutableERC20Item(item: OfferItem | ConsiderationItem): ERC20Item { + if (item.itemType !== ItemType.ERC20) { + throw new Error(`Expected ERC20 item, got ${item.itemType}`); + } + return { + type: 'ERC20', + contract_address: item.token, + amount: item.startAmount, + }; +} + +export function mapSeaportItemToImmutableAssetCollectionItem( + item: OfferItem | ConsiderationItem, +): AssetCollectionItem { + switch (item.itemType) { + case ItemType.ERC721_WITH_CRITERIA: + return { + type: 'ERC721_COLLECTION', + contract_address: item.token, + amount: item.startAmount, + }; + case ItemType.ERC1155_WITH_CRITERIA: + return { + type: 'ERC1155_COLLECTION', + contract_address: item.token, + amount: item.startAmount, + }; + case ItemType.ERC20: + case ItemType.NATIVE: + case ItemType.ERC721: + case ItemType.ERC1155: + throw new Error(`Unsupported item type ${item.itemType}`); + default: + return exhaustiveSwitch(item.itemType); + } +} + export function mapSeaportOrderTypeToImmutableProtocolDataOrderType(ot: OrderType) { switch (ot) { case OrderType.FULL_RESTRICTED: diff --git a/packages/orderbook/src/seaport/seaport.ts b/packages/orderbook/src/seaport/seaport.ts index 1f68c40b21..00d188b3df 100644 --- a/packages/orderbook/src/seaport/seaport.ts +++ b/packages/orderbook/src/seaport/seaport.ts @@ -5,6 +5,7 @@ import type { CreateInputItem, CreateOrderAction, ExchangeAction, + InputCriteria, OrderComponents, OrderUseCase, } from '@opensea/seaport-js/lib/types'; @@ -109,16 +110,20 @@ function mapImmutableSdkItemToSeaportSdkConsiderationInputItem( }; case 'ERC721_COLLECTION': return { + // seaport will handle mapping an ERC721 item with no identifier to a criteria based item itemType: ItemType.ERC721, token: item.contractAddress, amount: item.amount, + identifiers: [], recipient, }; case 'ERC1155_COLLECTION': return { + // seaport will handle mapping an ERC1155 item with no identifier to a criteria based item itemType: ItemType.ERC1155, token: item.contractAddress, amount: item.amount, + identifiers: [], recipient, }; default: @@ -265,24 +270,27 @@ export class Seaport { account: string, extraData: string, unitsToFill?: string, + considerationCriteria?: InputCriteria[], ): Promise { const { orderComponents, tips } = mapImmutableOrderToSeaportOrderComponents(order); const seaportLib = this.getSeaportLib(order); const chainID = (await this.provider.getNetwork()).chainId; + const fulfilmentOrderDetails: Parameters[0]['fulfillOrderDetails'][0] = { + order: { + parameters: orderComponents, + signature: order.signature, + }, + unitsToFill, + extraData, + tips, + }; + + if (considerationCriteria) fulfilmentOrderDetails.considerationCriteria = considerationCriteria; + const { actions: seaportActions } = await seaportLib.fulfillOrders({ accountAddress: account, - fulfillOrderDetails: [ - { - order: { - parameters: orderComponents, - signature: order.signature, - }, - unitsToFill, - extraData, - tips, - }, - ], + fulfillOrderDetails: [fulfilmentOrderDetails], }); const fulfillmentActions: TransactionAction[] = []; diff --git a/packages/orderbook/src/types.ts b/packages/orderbook/src/types.ts index f6a9035ebc..81dbc62a79 100644 --- a/packages/orderbook/src/types.ts +++ b/packages/orderbook/src/types.ts @@ -273,6 +273,49 @@ export interface BulkBidsResult { }[]; } +/* Collection Bid Ops */ + +// Expose the list order filtering and ordering directly from the openAPI SDK, except +// chainName is omitted as its configured as a part of the client +export type ListCollectionBidsParams = Omit< +Parameters[0], +'chainName' +>; + +export interface CollectionBidResult { + result: CollectionBid; +} + +export interface ListCollectionBidsResult { + page: Page; + result: CollectionBid[]; +} + +export interface PrepareCollectionBidParams { + makerAddress: string; + sell: ERC20Item; + buy: ERC721CollectionItem | ERC1155CollectionItem; + orderStart?: Date; + orderExpiry?: Date; +} + +export type PrepareCollectionBidResponse = PrepareOrderResponse; + +export interface CreateCollectionBidParams { + orderComponents: OrderComponents; + orderHash: string; + orderSignature: string; + makerFees: FeeValue[]; +} + +export interface BulkCollectionBidsResult { + result: { + success: boolean; + orderHash: string; + order?: CollectionBid; + }[]; +} + /* Fulfilment Ops */ export interface FulfillmentOrder { diff --git a/tests/func-tests/zkevm/jest.config.ts b/tests/func-tests/zkevm/jest.config.ts index 9330dcbc4a..ade1a9c7af 100644 --- a/tests/func-tests/zkevm/jest.config.ts +++ b/tests/func-tests/zkevm/jest.config.ts @@ -9,7 +9,7 @@ const config: Config = { rootDir: ".", testMatch:["**/*.steps.ts", "**/*.spec.ts"], testTimeout: 120000, - roots: ["step-definitions"], + roots: ["step-definitions", "specs"], moduleDirectories: ["node_modules", ""], moduleNameMapper: { "@imtbl/sdk/provider": "/../../../node_modules/@imtbl/sdk/dist/provider", diff --git a/tests/func-tests/zkevm/specs/orderbook.spec.ts b/tests/func-tests/zkevm/specs/orderbook.spec.ts index 1b76dc0371..ba3f498528 100644 --- a/tests/func-tests/zkevm/specs/orderbook.spec.ts +++ b/tests/func-tests/zkevm/specs/orderbook.spec.ts @@ -19,6 +19,7 @@ import { waitForBidToBeOfStatus } from "../utils/orderbook/bid"; import { GAS_OVERRIDES } from "../utils/orderbook/gas"; import { waitForListingToBeOfStatus } from "../utils/orderbook/listing"; import { RetryProvider } from "../utils/orderbook/retry-provider"; +import { waitForCollectionBidToBeOfStatus } from "../utils/orderbook/collection-bid"; describe("Orderbook", () => { const imxForApproval = 0.03 * 1e18; @@ -342,9 +343,356 @@ describe("Orderbook", () => { }); }); - it("create and cancel listing & bid", async () => { + describe.skip("create and fulfill ERC721 collection bid", () => { + it("fulfill fully", async () => { + const erc721TokenId = getRandomTokenId(); + + // maker funds + await withBankerRetry(async () => { + await ( + await erc20Contract.mint(maker.address, 100, GAS_OVERRIDES) + ).wait(1); + }); + await withBankerRetry(async () => { + await ( + await banker.sendTransaction({ + to: maker.address, + value: `${imxForApproval}`, + ...GAS_OVERRIDES, + }) + ).wait(1); + }); + + // taker funds + await withBankerRetry(async () => { + await ( + await erc721Contract.mint(taker.address, erc721TokenId, GAS_OVERRIDES) + ).wait(1); + }); + await withBankerRetry(async () => { + await ( + await banker.sendTransaction({ + to: taker.address, + value: `${imxForApproval + imxForFulfillment}`, + ...GAS_OVERRIDES, + }) + ).wait(1); + }); + + const { actions, orderComponents, orderHash } = await orderBookSdk.prepareCollectionBid({ + makerAddress: maker.address, + sell: { + type: "ERC20", + contractAddress: erc20Contract.address, + amount: "100", + }, + buy: { + type: "ERC721_COLLECTION", + contractAddress: erc721Contract.address, + amount: "1", + }, + orderStart: new Date(2000, 1, 15), + }); + + const signatures = await actionAll(actions, maker); + + const { result } = await orderBookSdk.createCollectionBid({ + orderComponents, + orderHash, + orderSignature: signatures[0], + makerFees: [], + }); + + await waitForCollectionBidToBeOfStatus(orderBookSdk, result.id, { + name: orderbook.OrderStatusName.ACTIVE, + }); + + const { actions: fulfillActions } = await orderBookSdk.fulfillOrder( + result.id, + taker.address, + [], + "1", + erc721TokenId + ); + await actionAll(fulfillActions, taker); + + await waitForCollectionBidToBeOfStatus(orderBookSdk, result.id, { + name: orderbook.OrderStatusName.FILLED, + }); + }) + + it.skip("fulfill partially", async () => { + const erc721TokenId = getRandomTokenId(); + + // maker funds + await withBankerRetry(async () => { + await ( + await erc20Contract.mint(maker.address, 100, GAS_OVERRIDES) + ).wait(1); + }); + await withBankerRetry(async () => { + await ( + await banker.sendTransaction({ + to: maker.address, + value: `${imxForApproval}`, + ...GAS_OVERRIDES, + }) + ).wait(1); + }); + + // taker funds + await withBankerRetry(async () => { + await ( + await erc721Contract.mint(taker.address, erc721TokenId, GAS_OVERRIDES) + ).wait(1); + }); + await withBankerRetry(async () => { + await ( + await banker.sendTransaction({ + to: taker.address, + value: `${imxForApproval + imxForFulfillment}`, + ...GAS_OVERRIDES, + }) + ).wait(1); + }); + + // create a collection bid to receive an amount greather than 1 + const { actions, orderComponents, orderHash } = await orderBookSdk.prepareCollectionBid({ + makerAddress: maker.address, + sell: { + type: "ERC20", + contractAddress: erc20Contract.address, + amount: "100", + }, + buy: { + type: "ERC721_COLLECTION", + contractAddress: erc721Contract.address, + amount: "2", + }, + orderStart: new Date(2000, 1, 15), + }) + + const signatures = await actionAll(actions, maker); + + const { result } = await orderBookSdk.createCollectionBid({ + orderComponents, + orderHash, + orderSignature: signatures[0], + makerFees: [], + }); + + await waitForCollectionBidToBeOfStatus(orderBookSdk, result.id, { + name: orderbook.OrderStatusName.ACTIVE, + }); + + // fulfill partially with 1 ERC721 token (1/2) + const { actions: fulfillActions } = await orderBookSdk.fulfillOrder( + result.id, + taker.address, + [], + "1", + erc721TokenId + ) + + await actionAll(fulfillActions, taker) + + await waitForCollectionBidToBeOfStatus( + orderBookSdk, + result.id, + { + name: orderbook.OrderStatusName.ACTIVE + }, + { + numerator: 1, + denominator: 2 + } + ); + }) + }) + + describe.skip("create and fulfill ERC1155 collection bid", () => { + it("fulfill fully", async () => { + const erc1155TokenId = getRandomTokenId(); + + // maker funds + await withBankerRetry(async () => { + await ( + await erc20Contract.mint(maker.address, 100, GAS_OVERRIDES) + ).wait(1); + }); + await withBankerRetry(async () => { + await ( + await banker.sendTransaction({ + to: maker.address, + value: `${imxForApproval}`, + ...GAS_OVERRIDES, + }) + ).wait(1); + }); + + // taker funds + await withBankerRetry(async () => { + await ( + await erc1155Contract.safeMint( + taker.address, + erc1155TokenId, + 50, + "0x", + GAS_OVERRIDES + ) + ).wait(1); + }); + await withBankerRetry(async () => { + await ( + await banker.sendTransaction({ + to: taker.address, + value: `${imxForApproval + imxForFulfillment}`, + ...GAS_OVERRIDES, + }) + ).wait(1); + }); + + const { actions, orderComponents, orderHash } = await orderBookSdk.prepareCollectionBid({ + makerAddress: maker.address, + sell: { + type: "ERC20", + contractAddress: erc20Contract.address, + amount: "100", + }, + buy: { + type: "ERC1155_COLLECTION", + contractAddress: erc1155Contract.address, + amount: "50", + }, + orderStart: new Date(2000, 1, 15), + }); + + const signatures = await actionAll(actions, maker); + + const { result } = await orderBookSdk.createCollectionBid({ + orderComponents, + orderHash, + orderSignature: signatures[0], + makerFees: [], + }); + + await waitForCollectionBidToBeOfStatus(orderBookSdk, result.id, { + name: orderbook.OrderStatusName.ACTIVE, + }); + + const { actions: fulfillActions } = await orderBookSdk.fulfillOrder( + result.id, + taker.address, + [], + "50", + erc1155TokenId + ); + await actionAll(fulfillActions, taker); + + await waitForCollectionBidToBeOfStatus(orderBookSdk, result.id, { + name: orderbook.OrderStatusName.ACTIVE, + }); + }) + + it("fulfill partially", async () => { + const erc1155TokenId = getRandomTokenId(); + + // maker funds + await withBankerRetry(async () => { + await ( + await erc20Contract.mint(maker.address, 100, GAS_OVERRIDES) + ).wait(1); + }); + await withBankerRetry(async () => { + await ( + await banker.sendTransaction({ + to: maker.address, + value: `${imxForApproval}`, + ...GAS_OVERRIDES, + }) + ).wait(1); + }); + + // taker funds + await withBankerRetry(async () => { + await ( + await erc1155Contract.safeMint( + taker.address, + erc1155TokenId, + 50, + "0x", + GAS_OVERRIDES + ) + ).wait(1); + }); + await withBankerRetry(async () => { + await ( + await banker.sendTransaction({ + to: taker.address, + value: `${imxForApproval + imxForFulfillment}`, + ...GAS_OVERRIDES, + }) + ).wait(1); + }); + + // create a collection bid to receive an amount greather than 1 + const { actions, orderComponents, orderHash } = await orderBookSdk.prepareCollectionBid({ + makerAddress: maker.address, + sell: { + type: "ERC20", + contractAddress: erc20Contract.address, + amount: "100", + }, + buy: { + type: "ERC1155_COLLECTION", + contractAddress: erc1155Contract.address, + amount: "50", + }, + orderStart: new Date(2000, 1, 15), + }); + + const signatures = await actionAll(actions, maker); + + const { result } = await orderBookSdk.createCollectionBid({ + orderComponents, + orderHash, + orderSignature: signatures[0], + makerFees: [], + }); + + await waitForCollectionBidToBeOfStatus(orderBookSdk, result.id, { + name: orderbook.OrderStatusName.ACTIVE, + }); + + // fulfill partially with 10 ERC1155 (10/50) + const { actions: fulfillActions } = await orderBookSdk.fulfillOrder( + result.id, + taker.address, + [], + "10", + erc1155TokenId + ) + + await actionAll(fulfillActions, taker) + + await waitForCollectionBidToBeOfStatus( + orderBookSdk, + result.id, + { + name: orderbook.OrderStatusName.ACTIVE + }, + { + numerator: 10, + denominator: 50 + } + ); + }) + }) + + it.skip("create and cancel listing, bid, and collection bid", async () => { const erc721TokenIdForListing = getRandomTokenId(); const erc721TokenIdForBid = getRandomTokenId(); + const erc1155TokenIdForCollectionBid = getRandomTokenId(); // maker funds await withBankerRetry(async () => { @@ -358,7 +706,7 @@ describe("Orderbook", () => { [ { to: maker.address, - tokenIds: [erc721TokenIdForListing, erc721TokenIdForBid], + tokenIds: [erc721TokenIdForListing, erc721TokenIdForBid, erc1155TokenIdForCollectionBid], }, ], GAS_OVERRIDES @@ -445,20 +793,56 @@ describe("Orderbook", () => { return result.id; })(); + // collection bid + const collectionBidId = await (async () => { + const { + actions: collectionBidCreateActions, + orderComponents, + orderHash, + } = await orderBookSdk.prepareCollectionBid({ + makerAddress: maker.address, + sell: { + type: "ERC20", + contractAddress: erc20Contract.address, + amount: "100", + }, + buy: { + type: "ERC1155_COLLECTION", + contractAddress: erc1155Contract.address, + amount: "50", + }, + }); + + const signatures = await actionAll(collectionBidCreateActions, maker); + const { result } = await orderBookSdk.createCollectionBid({ + orderComponents, + orderHash, + orderSignature: signatures[0], + makerFees: [], + }); + + await waitForCollectionBidToBeOfStatus(orderBookSdk, result.id, { + name: orderbook.OrderStatusName.ACTIVE, + }); + + return result.id; + })(); + // cancel listing & bid const { signableAction } = await orderBookSdk.prepareOrderCancellations([ listingId, bidId, + collectionBidId, ]); const signatures = await actionAll([signableAction], maker); const { result } = await orderBookSdk.cancelOrders( - [listingId, bidId], + [listingId, bidId, collectionBidId], maker.address, signatures[0] ); expect(result.successful_cancellations).toEqual( - expect.arrayContaining([listingId, bidId]) + expect.arrayContaining([listingId, bidId, collectionBidId]) ); await Promise.all([ @@ -472,6 +856,11 @@ describe("Orderbook", () => { cancellation_type: "OFF_CHAIN" as any, // Cancellation type enum is not exported pending: false, }), + waitForCollectionBidToBeOfStatus(orderBookSdk, collectionBidId, { + name: orderbook.OrderStatusName.CANCELLED, + cancellation_type: "OFF_CHAIN" as any, // Cancellation type enum is not exported + pending: false, + }), ]); }); }); diff --git a/tests/func-tests/zkevm/utils/orderbook/collection-bid.ts b/tests/func-tests/zkevm/utils/orderbook/collection-bid.ts new file mode 100644 index 0000000000..cb7f73af4d --- /dev/null +++ b/tests/func-tests/zkevm/utils/orderbook/collection-bid.ts @@ -0,0 +1,51 @@ +import { orderbook } from "@imtbl/sdk"; +import { reduceFraction } from "./math"; +import { statusIsEqual } from "./order"; +import { retry } from "./retry"; + +export async function waitForCollectionBidToBeOfStatus( + orderBookSdk: orderbook.Orderbook, + collectionBidId: string, + status: orderbook.Order["status"], + fillStatus?: { numerator: number; denominator: number } +) { + if (status.name === orderbook.OrderStatusName.FILLED) { + fillStatus = fillStatus ?? { numerator: 1, denominator: 1 }; + } + + await retry(async () => { + const { result } = await orderBookSdk.getCollectionBid(collectionBidId); + const equalStatus = statusIsEqual(result.status, status); + let equalFillStatus = true; + + const reducedLastCheckedFillStatus = reduceFraction( + Number(result.fillStatus.numerator), + Number(result.fillStatus.denominator) + ); + + if (fillStatus) { + const reducedFillStatus = reduceFraction( + fillStatus.numerator, + fillStatus.denominator + ); + + if ( + reducedFillStatus[0] !== reducedLastCheckedFillStatus[0] || + reducedFillStatus[1] !== reducedLastCheckedFillStatus[1] + ) { + equalFillStatus = false; + } + } + if (!equalStatus || !equalFillStatus) { + throw new Error( + `Collection bid ${collectionBidId} is of status [${JSON.stringify({ + status: result.status, + fillStatus: { + numerator: reducedLastCheckedFillStatus[0], + denominator: reducedLastCheckedFillStatus[1], + }, + })}] not expected status [${JSON.stringify({ status, fillStatus })}]` + ); + } + }); +} diff --git a/yarn.lock b/yarn.lock index 3437935c6d..6450f2d5b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34546,7 +34546,7 @@ __metadata: resolution: "seaport-core@https://github.com/immutable/seaport-core.git#commit=0633350ec34f21fcede657ff812f11cf7d19144e" dependencies: seaport-types: ^0.0.1 - checksum: 392bce86bbfc4f7c00b65575b238825f4c696bddf5af08be7aa496862e63879375387fd4400f6e900ffee08d65c1f75cf3adad9c6c41ddcf7a3b0389cd73c3c7 + checksum: d8adba0d54106c6fe9370f0775fadef2198e5eab440b36919d1f917705ce2f0a7028e4da021b6df049aa3ca35d7e673a28b78a731130f0ff9fdf7a8bd32e3b94 languageName: node linkType: hard @@ -34590,7 +34590,7 @@ __metadata: seaport-sol: ^1.5.0 seaport-types: ^0.0.1 solady: ^0.0.84 - checksum: f31a7443a50fa1c35ec03ea031743d1d10896653ae443fa15ab8e6f5b4a2ca43f6743d523ae4e1f14df867451e5b2b2130b0bfa58a1085b0bcae3fceb8dfdc9b + checksum: a77e141e4ab5d2c4bb190a38fbb6cda3011fdf5f350b250fbeb4d82ae81cf917a966a2dcb8d9e4fd1bed29e5510ede9b15941b0ac77aeb4272dab94c9f51e7ff languageName: node linkType: hard