From ca3074ef6c02d059077b47fe9a6990399e0bff56 Mon Sep 17 00:00:00 2001 From: John Carlo San Pedro Date: Tue, 5 May 2026 16:55:01 +1200 Subject: [PATCH 1/2] feat: add utility functions for metadata bids --- .../orderbook/src/api-client/api-client.ts | 82 ++++++++++ packages/orderbook/src/openapi/mapper.ts | 72 +++++++++ packages/orderbook/src/openapi/sdk/index.ts | 3 + .../models/CreateMetadataBidRequestBody.ts | 46 ++++++ .../sdk/models/ListMetadataBidsResult.ts | 11 ++ .../openapi/sdk/models/MetadataBidResult.ts | 9 ++ .../orderbook/src/openapi/sdk/models/Order.ts | 5 + .../src/openapi/sdk/services/OrdersService.ts | 151 ++++++++++++++++++ packages/orderbook/src/orderbook.ts | 87 ++++++++++ .../src/seaport/map-to-seaport-order.ts | 1 + packages/orderbook/src/types.ts | 36 ++++- 11 files changed, 502 insertions(+), 1 deletion(-) create mode 100644 packages/orderbook/src/openapi/sdk/models/CreateMetadataBidRequestBody.ts create mode 100644 packages/orderbook/src/openapi/sdk/models/ListMetadataBidsResult.ts create mode 100644 packages/orderbook/src/openapi/sdk/models/MetadataBidResult.ts diff --git a/packages/orderbook/src/api-client/api-client.ts b/packages/orderbook/src/api-client/api-client.ts index e387e8924f..9dfbde489f 100644 --- a/packages/orderbook/src/api-client/api-client.ts +++ b/packages/orderbook/src/api-client/api-client.ts @@ -7,8 +7,10 @@ import { ListCollectionBidsResult, ListingResult, ListListingsResult, + ListMetadataBidsResult, ListTradeResult, ListTraitBidsResult, + MetadataBidResult, OrdersService, TradeResult, TraitBidResult, @@ -27,10 +29,12 @@ import { CreateBidParams, CreateCollectionBidParams, CreateListingParams, + CreateMetadataBidParams, CreateTraitBidParams, ListBidsParams, ListCollectionBidsParams, ListListingsParams, + ListMetadataBidsParams, ListTraitBidsParams, ListTradesParams, } from '../types'; @@ -127,6 +131,22 @@ export class ImmutableApiClient { }); } + async getMetadataBid(metadataBidId: string): Promise { + return this.orderbookService.getMetadataBid({ + chainName: this.chainName, + metadataBidId, + }); + } + + async listMetadataBids( + listOrderParams: ListMetadataBidsParams, + ): Promise { + return this.orderbookService.listMetadataBids({ + chainName: this.chainName, + ...listOrderParams, + }); + } + async listTrades( listTradesParams: ListTradesParams, ): Promise { @@ -315,6 +335,68 @@ export class ImmutableApiClient { }); } + async createMetadataBid({ + orderHash, + orderComponents, + orderSignature, + makerFees, + metadataId, + }: CreateMetadataBidParams): Promise { + if (orderComponents.offer.length !== 1) { + throw new Error('Only one item can be listed for a metadata bid'); + } + + if (orderComponents.consideration.length !== 1) { + throw new Error('Only one item can be used as currency for a metadata bid'); + } + + if (ItemType.ERC20 !== orderComponents.offer[0].itemType) { + throw new Error('Only ERC20 tokens can be used as the currency item in a metadata 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'); + } + + if (!metadataId) { + throw new Error('A metadata_id is required for a metadata bid'); + } + + return this.orderbookService.createMetadataBid({ + 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), + ).toISOString(), + metadata_id: metadataId, + }, + }); + } + async createTraitBid({ orderHash, orderComponents, diff --git a/packages/orderbook/src/openapi/mapper.ts b/packages/orderbook/src/openapi/mapper.ts index 1e20dd75e9..a3cb29ca07 100644 --- a/packages/orderbook/src/openapi/mapper.ts +++ b/packages/orderbook/src/openapi/mapper.ts @@ -8,6 +8,7 @@ import { ERC721Item, FeeType, Listing, + MetadataBid, NativeItem, Order, Page, @@ -315,6 +316,75 @@ export function mapTraitBidFromOpenApiOrder(order: OpenApiOrder): TraitBid { }; } +export function mapMetadataBidFromOpenApiOrder(order: OpenApiOrder): MetadataBid { + if (order.type !== OpenApiOrder.type.METADATA_BID) { + throw new Error('Order type must be METADATA_BID'); + } + + const sellItems: ERC20Item[] = order.sell.map((item) => { + if (item.type === 'ERC20') { + return { + type: 'ERC20', + contractAddress: item.contract_address, + amount: item.amount, + }; + } + + throw new Error('Metadata bid sell items must be ERC20'); + }); + + const buyItems: (ERC721CollectionItem | ERC1155CollectionItem)[] = order.buy.map((item) => { + if (item.type === 'ERC721_COLLECTION') { + return { + type: 'ERC721_COLLECTION', + contractAddress: item.contract_address, + amount: item.amount, + }; + } + + if (item.type === 'ERC1155_COLLECTION') { + return { + type: 'ERC1155_COLLECTION', + contractAddress: item.contract_address, + amount: item.amount, + }; + } + + throw new Error('Metadata bid buy items must either ERC721_COLLECTION or ERC1155_COLLECTION'); + }); + + return { + id: order.id, + type: order.type, + chain: order.chain, + accountAddress: order.account_address, + sell: sellItems, + buy: buyItems, + fees: order.fees.map((fee) => ({ + amount: fee.amount, + recipientAddress: fee.recipient_address, + type: fee.type as unknown as FeeType, + })), + status: order.status, + fillStatus: order.fill_status, + startAt: order.start_at, + endAt: order.end_at, + salt: order.salt, + signature: order.signature, + orderHash: order.order_hash, + protocolData: { + orderType: order.protocol_data.order_type, + counter: order.protocol_data.counter, + seaportAddress: order.protocol_data.seaport_address, + seaportVersion: order.protocol_data.seaport_version, + zoneAddress: order.protocol_data.zone_address, + }, + createdAt: order.created_at, + updatedAt: order.updated_at, + metadataId: order.metadata_id ?? '', + }; +} + export function mapOrderFromOpenApiOrder(order: OpenApiOrder): Order { switch (order.type) { case OpenApiOrder.type.LISTING: @@ -325,6 +395,8 @@ export function mapOrderFromOpenApiOrder(order: OpenApiOrder): Order { return mapCollectionBidFromOpenApiOrder(order); case OpenApiOrder.type.TRAIT_BID: return mapTraitBidFromOpenApiOrder(order); + case OpenApiOrder.type.METADATA_BID: + return mapMetadataBidFromOpenApiOrder(order); default: return exhaustiveSwitch(order.type); } diff --git a/packages/orderbook/src/openapi/sdk/index.ts b/packages/orderbook/src/openapi/sdk/index.ts index e3deba6ce9..3962740ea8 100644 --- a/packages/orderbook/src/openapi/sdk/index.ts +++ b/packages/orderbook/src/openapi/sdk/index.ts @@ -22,6 +22,7 @@ export type { CollectionBidResult } from './models/CollectionBidResult'; export type { CreateBidRequestBody } from './models/CreateBidRequestBody'; export type { CreateCollectionBidRequestBody } from './models/CreateCollectionBidRequestBody'; export type { CreateTraitBidRequestBody } from './models/CreateTraitBidRequestBody'; +export type { CreateMetadataBidRequestBody } from './models/CreateMetadataBidRequestBody'; export type { CreateListingRequestBody } from './models/CreateListingRequestBody'; export type { ERC1155CollectionItem } from './models/ERC1155CollectionItem'; export type { ERC1155Item } from './models/ERC1155Item'; @@ -41,6 +42,7 @@ export type { Item } from './models/Item'; export type { ListBidsResult } from './models/ListBidsResult'; export type { ListCollectionBidsResult } from './models/ListCollectionBidsResult'; export type { ListTraitBidsResult } from './models/ListTraitBidsResult'; +export type { ListMetadataBidsResult } from './models/ListMetadataBidsResult'; export type { ListingResult } from './models/ListingResult'; export type { ListListingsResult } from './models/ListListingsResult'; export type { ListTradeResult } from './models/ListTradeResult'; @@ -57,6 +59,7 @@ export type { Trade } from './models/Trade'; export type { TradeBlockchainMetadata } from './models/TradeBlockchainMetadata'; export type { TradeResult } from './models/TradeResult'; export type { TraitBidResult } from './models/TraitBidResult'; +export type { MetadataBidResult } from './models/MetadataBidResult'; export type { TraitFilter } from './models/TraitFilter'; export type { UnfulfillableOrder } from './models/UnfulfillableOrder'; diff --git a/packages/orderbook/src/openapi/sdk/models/CreateMetadataBidRequestBody.ts b/packages/orderbook/src/openapi/sdk/models/CreateMetadataBidRequestBody.ts new file mode 100644 index 0000000000..3624d5ecb8 --- /dev/null +++ b/packages/orderbook/src/openapi/sdk/models/CreateMetadataBidRequestBody.ts @@ -0,0 +1,46 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { AssetCollectionItem } from './AssetCollectionItem'; +import type { ERC20Item } from './ERC20Item'; +import type { Fee } from './Fee'; +import type { ProtocolData } from './ProtocolData'; + +export type CreateMetadataBidRequestBody = { + account_address: string; + order_hash: string; + /** + * Buy item for metadata bid should either be ERC721 or ERC1155 collection item + */ + buy: Array; + /** + * Buy fees should only include maker marketplace fees and should be no more than two entries as more entires will incur more gas. It is best practice to have this as few as possible. + */ + fees: Array; + /** + * Time after which the Order is considered expired + */ + end_at: string; + protocol_data: ProtocolData; + /** + * A random value added to the create Order request + */ + salt: string; + /** + * Sell item for metadata bid should be an ERC20 item + */ + sell: Array; + /** + * Digital signature generated by the user for the specific Order + */ + signature: string; + /** + * Time after which Order is considered active + */ + start_at: string; + /** + * The metadata_id (stack_id) that NFTs must have to fulfil this bid + */ + metadata_id: string; +}; diff --git a/packages/orderbook/src/openapi/sdk/models/ListMetadataBidsResult.ts b/packages/orderbook/src/openapi/sdk/models/ListMetadataBidsResult.ts new file mode 100644 index 0000000000..30741e7520 --- /dev/null +++ b/packages/orderbook/src/openapi/sdk/models/ListMetadataBidsResult.ts @@ -0,0 +1,11 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { Order } from './Order'; +import type { Page } from './Page'; + +export type ListMetadataBidsResult = { + page: Page; + result: Array; +}; diff --git a/packages/orderbook/src/openapi/sdk/models/MetadataBidResult.ts b/packages/orderbook/src/openapi/sdk/models/MetadataBidResult.ts new file mode 100644 index 0000000000..2e1802e1ee --- /dev/null +++ b/packages/orderbook/src/openapi/sdk/models/MetadataBidResult.ts @@ -0,0 +1,9 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { Order } from './Order'; + +export type MetadataBidResult = { + result: Order; +}; diff --git a/packages/orderbook/src/openapi/sdk/models/Order.ts b/packages/orderbook/src/openapi/sdk/models/Order.ts index f241203476..d736cc971f 100644 --- a/packages/orderbook/src/openapi/sdk/models/Order.ts +++ b/packages/orderbook/src/openapi/sdk/models/Order.ts @@ -56,6 +56,10 @@ export type Order = { * Trait criteria for trait bids when returned by the API */ trait_criteria?: Array; + /** + * Metadata ID (stack ID) for metadata bids when returned by the API + */ + metadata_id?: string; }; export namespace Order { @@ -68,6 +72,7 @@ export namespace Order { BID = 'BID', COLLECTION_BID = 'COLLECTION_BID', TRAIT_BID = 'TRAIT_BID', + METADATA_BID = 'METADATA_BID', } diff --git a/packages/orderbook/src/openapi/sdk/services/OrdersService.ts b/packages/orderbook/src/openapi/sdk/services/OrdersService.ts index b4d0865c18..c4029b7853 100644 --- a/packages/orderbook/src/openapi/sdk/services/OrdersService.ts +++ b/packages/orderbook/src/openapi/sdk/services/OrdersService.ts @@ -9,12 +9,14 @@ import type { CollectionBidResult } from '../models/CollectionBidResult'; import type { CreateBidRequestBody } from '../models/CreateBidRequestBody'; import type { CreateCollectionBidRequestBody } from '../models/CreateCollectionBidRequestBody'; import type { CreateTraitBidRequestBody } from '../models/CreateTraitBidRequestBody'; +import type { CreateMetadataBidRequestBody } from '../models/CreateMetadataBidRequestBody'; import type { CreateListingRequestBody } from '../models/CreateListingRequestBody'; import type { FulfillableOrder } from '../models/FulfillableOrder'; import type { FulfillmentDataRequest } from '../models/FulfillmentDataRequest'; import type { ListBidsResult } from '../models/ListBidsResult'; import type { ListCollectionBidsResult } from '../models/ListCollectionBidsResult'; import type { ListTraitBidsResult } from '../models/ListTraitBidsResult'; +import type { ListMetadataBidsResult } from '../models/ListMetadataBidsResult'; import type { ListingResult } from '../models/ListingResult'; import type { ListListingsResult } from '../models/ListListingsResult'; import type { ListTradeResult } from '../models/ListTradeResult'; @@ -23,6 +25,7 @@ import type { PageCursor } from '../models/PageCursor'; import type { PageSize } from '../models/PageSize'; import type { TradeResult } from '../models/TradeResult'; import type { TraitBidResult } from '../models/TraitBidResult'; +import type { MetadataBidResult } from '../models/MetadataBidResult'; import type { UnfulfillableOrder } from '../models/UnfulfillableOrder'; import type { CancelablePromise } from '../core/CancelablePromise'; @@ -537,6 +540,154 @@ export class OrdersService { }); } + /** + * List all metadata bids + * List all metadata bids + * @returns ListMetadataBidsResult OK response. + * @throws ApiError + */ + public listMetadataBids({ + chainName, + status, + buyItemContractAddress, + sellItemContractAddress, + accountAddress, + metadataId, + fromUpdatedAt, + pageSize, + sortBy, + sortDirection, + pageCursor, + }: { + chainName: ChainName, + /** + * Order status to filter by + */ + status?: OrderStatusName, + /** + * Buy item contract address to filter by + */ + buyItemContractAddress?: string, + /** + * Sell item contract address to filter by + */ + sellItemContractAddress?: string, + /** + * The account address of the user who created the bid + */ + accountAddress?: string, + /** + * The metadata_id to filter by + */ + metadataId?: string, + /** + * From updated at including given date + */ + fromUpdatedAt?: string, + /** + * Maximum number of orders to return per page + */ + pageSize?: PageSize, + /** + * Order field to sort by. `sell_item_amount` sorts by per token price, for example if 10eth is offered for 5 ERC1155 items, it's sorted as 2eth for `sell_item_amount`. + */ + sortBy?: 'created_at' | 'updated_at' | 'sell_item_amount', + /** + * Ascending or descending direction for sort + */ + sortDirection?: 'asc' | 'desc', + /** + * Page cursor to retrieve previous or next page. Use the value returned in the response. + */ + pageCursor?: PageCursor, + }): CancelablePromise { + return this.httpRequest.request({ + method: 'GET', + url: '/v1/chains/{chain_name}/orders/metadata-bids', + path: { + 'chain_name': chainName, + }, + query: { + 'status': status, + 'buy_item_contract_address': buyItemContractAddress, + 'sell_item_contract_address': sellItemContractAddress, + 'account_address': accountAddress, + 'metadata_id': metadataId, + 'from_updated_at': fromUpdatedAt, + 'page_size': pageSize, + 'sort_by': sortBy, + 'sort_direction': sortDirection, + 'page_cursor': pageCursor, + }, + errors: { + 400: `Bad Request (400)`, + 404: `The specified resource was not found (404)`, + 500: `Internal Server Error (500)`, + }, + }); + } + + /** + * Create a metadata bid + * Create a metadata bid + * @returns MetadataBidResult Created response. + * @throws ApiError + */ + public createMetadataBid({ + chainName, + requestBody, + }: { + chainName: ChainName, + requestBody: CreateMetadataBidRequestBody, + }): CancelablePromise { + return this.httpRequest.request({ + method: 'POST', + url: '/v1/chains/{chain_name}/orders/metadata-bids', + path: { + 'chain_name': chainName, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Bad Request (400)`, + 404: `The specified resource was not found (404)`, + 500: `Internal Server Error (500)`, + 501: `Not Implemented Error (501)`, + }, + }); + } + + /** + * Get a single metadata bid by ID + * Get a single metadata bid by ID + * @returns MetadataBidResult OK response. + * @throws ApiError + */ + public getMetadataBid({ + chainName, + metadataBidId, + }: { + chainName: ChainName, + /** + * Global Metadata Bid identifier + */ + metadataBidId: string, + }): CancelablePromise { + return this.httpRequest.request({ + method: 'GET', + url: '/v1/chains/{chain_name}/orders/metadata-bids/{metadata_bid_id}', + path: { + 'chain_name': chainName, + 'metadata_bid_id': metadataBidId, + }, + errors: { + 400: `Bad Request (400)`, + 404: `The specified resource was not found (404)`, + 500: `Internal Server Error (500)`, + }, + }); + } + /** * Get a single listing by ID * Get a single listing by ID diff --git a/packages/orderbook/src/orderbook.ts b/packages/orderbook/src/orderbook.ts index a6a7bdae1e..a3fcceb920 100644 --- a/packages/orderbook/src/orderbook.ts +++ b/packages/orderbook/src/orderbook.ts @@ -10,6 +10,7 @@ import { import { mapBidFromOpenApiOrder, mapCollectionBidFromOpenApiOrder, + mapMetadataBidFromOpenApiOrder, mapTraitBidFromOpenApiOrder, mapFromOpenApiPage, mapFromOpenApiTrade, @@ -30,6 +31,7 @@ import { CollectionBidResult, CreateBidParams, CreateCollectionBidParams, + CreateMetadataBidParams, CreateTraitBidParams, CreateListingParams, FeeValue, @@ -41,6 +43,8 @@ import { ListBidsResult, ListCollectionBidsParams, ListCollectionBidsResult, + ListMetadataBidsParams, + ListMetadataBidsResult, ListTraitBidsParams, ListTraitBidsResult, ListingResult, @@ -56,8 +60,11 @@ import { PrepareCancelOrdersResponse, PrepareCollectionBidParams, PrepareCollectionBidResponse, + PrepareMetadataBidParams, + PrepareMetadataBidResponse, PrepareTraitBidParams, PrepareTraitBidResponse, + MetadataBidResult, TraitBidResult, PrepareListingParams, PrepareListingResponse, @@ -189,6 +196,18 @@ export class Orderbook { }; } + /** + * Get a metadata bid by ID + * @param {string} metadataBidId - The metadata bid id to find. + * @return {MetadataBidResult} The returned metadata bid result. + */ + async getMetadataBid(metadataBidId: string): Promise { + const apiMetadataBid = await this.apiClient.getMetadataBid(metadataBidId); + return { + result: mapMetadataBidFromOpenApiOrder(apiMetadataBid.result), + }; + } + /** * Get a trade by ID * @param {string} tradeId - The tradeId to find. @@ -265,6 +284,22 @@ export class Orderbook { }; } + /** + * List metadata bids. This method is used to get a list of metadata bids filtered + * by conditions specified in the params object. + * @param {ListMetadataBidsParams} listOrderParams - Filtering, ordering and page parameters. + * @return {ListMetadataBidsResult} The paged metadata bids. + */ + async listMetadataBids( + listOrderParams: ListMetadataBidsParams, + ): Promise { + const apiMetadataBids = await this.apiClient.listMetadataBids(listOrderParams); + return { + page: mapFromOpenApiPage(apiMetadataBids.page), + result: apiMetadataBids.result.map(mapMetadataBidFromOpenApiOrder), + }; + } + /** * List trades. This method is used to get a list of trades filtered by conditions specified * in the params object @@ -643,6 +678,48 @@ export class Orderbook { }; } + /** + * Get required transactions and messages for signing prior to creating a metadata bid + * through the {@linkcode createMetadataBid} method. Uses the same Seaport criteria-based + * order shape as collection bids. + * @param {PrepareMetadataBidParams} params - Details about the metadata bid to be created. + * @return {PrepareMetadataBidResponse} Unsigned approval (if any), typed order message, and + * order components for {@linkcode createMetadataBid}. + */ + async prepareMetadataBid({ + makerAddress, + sell, + buy, + orderStart, + orderExpiry, + }: PrepareMetadataBidParams): Promise { + return this.seaport.prepareSeaportOrder( + makerAddress, + sell, + buy, + true, + orderStart || new Date(), + orderExpiry || Orderbook.defaultOrderExpiry(), + ); + } + + /** + * Create a metadata bid (collection criteria offer plus metadata ID submitted to the API). + * @param {CreateMetadataBidParams} createMetadataBidParams - Signed order and metadata ID. + * @return {MetadataBidResult} The created metadata bid. + */ + async createMetadataBid( + createMetadataBidParams: CreateMetadataBidParams, + ): Promise { + const apiMetadataBidResponse = await this.apiClient.createMetadataBid( + createMetadataBidParams, + ); + + return { + result: mapMetadataBidFromOpenApiOrder(apiMetadataBidResponse.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 @@ -934,12 +1011,22 @@ export class Orderbook { })), ); + const metadataBidResultsPromises = Promise.all( + orderIds.map((id) => this.apiClient.getMetadataBid(id).catch((e: ApiError) => { + if (e.status === 404) { + return undefined; + } + throw e; + })), + ); + const orders = [ await Promise.all([ listingResultsPromises, bidResultsPromises, collectionBidResultsPromises, traitBidResultsPromises, + metadataBidResultsPromises, ]), ].flat(2).filter((r) => r !== undefined).map((f) => f.result); diff --git a/packages/orderbook/src/seaport/map-to-seaport-order.ts b/packages/orderbook/src/seaport/map-to-seaport-order.ts index 89f794bb89..45ca7551b4 100644 --- a/packages/orderbook/src/seaport/map-to-seaport-order.ts +++ b/packages/orderbook/src/seaport/map-to-seaport-order.ts @@ -125,6 +125,7 @@ export function mapImmutableOrderToSeaportOrderComponents( case Order.type.BID: case Order.type.COLLECTION_BID: case Order.type.TRAIT_BID: + case Order.type.METADATA_BID: return offerItems[0]; default: return exhaustiveSwitch(ot); diff --git a/packages/orderbook/src/types.ts b/packages/orderbook/src/types.ts index 194272d6e2..8720ea8496 100644 --- a/packages/orderbook/src/types.ts +++ b/packages/orderbook/src/types.ts @@ -45,7 +45,7 @@ export interface ERC1155CollectionItem { /* Orders */ -export type Order = Listing | Bid | CollectionBid | TraitBid; +export type Order = Listing | Bid | CollectionBid | TraitBid | MetadataBid; export interface Listing extends OrderFields { type: 'LISTING'; @@ -83,6 +83,16 @@ export interface TraitBid extends OrderFields { traitCriteria: TraitFilter[]; } +export interface MetadataBid extends OrderFields { + type: 'METADATA_BID'; + sell: ERC20Item[]; + buy: (ERC721CollectionItem | ERC1155CollectionItem)[]; + /** + * The metadata ID (stack ID) that NFTs must match to fulfil this bid. + */ + metadataId: string; +} + interface OrderFields { id: string; chain: { @@ -371,6 +381,30 @@ export interface CreateTraitBidParams extends CreateCollectionBidParams { traitCriteria: TraitFilter[]; } +/* Metadata bid ops */ + +export type ListMetadataBidsParams = Omit< +Parameters[0], +'chainName' +>; + +export interface MetadataBidResult { + result: MetadataBid; +} + +export interface ListMetadataBidsResult { + page: Page; + result: MetadataBid[]; +} + +export type PrepareMetadataBidParams = PrepareCollectionBidParams; + +export type PrepareMetadataBidResponse = PrepareOrderResponse; + +export interface CreateMetadataBidParams extends CreateCollectionBidParams { + metadataId: string; +} + /* Fulfilment Ops */ export interface FulfillmentOrder { From d2d18a2a6e730c33f54a38aa99bf72f547188fcb Mon Sep 17 00:00:00 2001 From: John Carlo San Pedro Date: Wed, 6 May 2026 11:06:14 +1200 Subject: [PATCH 2/2] test: add e2e tests for metadata bids --- packages/orderbook/src/test/helpers/order.ts | 22 +- packages/orderbook/src/test/helpers/token.ts | 2 +- .../orderbook/src/test/metadata-bid.e2e.ts | 317 ++++++++++++++++++ 3 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 packages/orderbook/src/test/metadata-bid.e2e.ts diff --git a/packages/orderbook/src/test/helpers/order.ts b/packages/orderbook/src/test/helpers/order.ts index 8a205fc708..beeec973d5 100644 --- a/packages/orderbook/src/test/helpers/order.ts +++ b/packages/orderbook/src/test/helpers/order.ts @@ -1,5 +1,5 @@ import { Orderbook } from '../../orderbook'; -import { Order, OrderStatusName } from '../../types'; +import { MetadataBid, Order, OrderStatusName } from '../../types'; export async function waitForOrderToBeOfStatus( sdk: Orderbook, @@ -20,3 +20,23 @@ export async function waitForOrderToBeOfStatus( await new Promise((resolve) => setTimeout(resolve, 1000)); return waitForOrderToBeOfStatus(sdk, orderId, status, attemps + 1); } + +export async function waitForMetadataBidToBeOfStatus( + sdk: Orderbook, + metadataBidId: string, + status: OrderStatusName, + attempts = 0, +): Promise { + if (attempts > 50) { + throw new Error(`Metadata bid ${metadataBidId} never reached status ${status}`); + } + + const { result: bid } = await sdk.getMetadataBid(metadataBidId); + if (bid.status.name === status) { + return bid; + } + + // eslint-disable-next-line + await new Promise((resolve) => setTimeout(resolve, 1000)); + return waitForMetadataBidToBeOfStatus(sdk, metadataBidId, status, attempts + 1); +} diff --git a/packages/orderbook/src/test/helpers/token.ts b/packages/orderbook/src/test/helpers/token.ts index 49a9177c61..cbd7e98805 100644 --- a/packages/orderbook/src/test/helpers/token.ts +++ b/packages/orderbook/src/test/helpers/token.ts @@ -1,5 +1,5 @@ import { hexlify, randomBytes } from 'ethers'; export function getRandomTokenId(): string { - return BigInt(`0x${hexlify(randomBytes(4))}`).toString(10); + return BigInt(`${hexlify(randomBytes(4))}`).toString(10); } diff --git a/packages/orderbook/src/test/metadata-bid.e2e.ts b/packages/orderbook/src/test/metadata-bid.e2e.ts new file mode 100644 index 0000000000..910d85b1fd --- /dev/null +++ b/packages/orderbook/src/test/metadata-bid.e2e.ts @@ -0,0 +1,317 @@ +import { randomUUID, randomBytes } from 'crypto'; +import { execSync } from 'child_process'; +import { Contract } from 'ethers'; +import { Environment } from '@imtbl/config'; +import { OrderStatusName } from '../openapi/sdk'; +import { Orderbook } from '../orderbook'; +import { getLocalhostProvider } from './helpers/provider'; +import { getOffererWallet, getFulfillerWallet } from './helpers/signers'; +import { waitForMetadataBidToBeOfStatus } from './helpers/order'; +import { getConfigFromEnv, getRandomTokenId } from './helpers'; +import { actionAll } from './helpers/actions'; +import { GAS_OVERRIDES } from './helpers/gas'; + +const LOCAL_CHAIN_ID = 'eip155:31337'; +const LOCAL_CHAIN_NAME = 'imtbl-zkevm-local'; + +const ERC20_ABI = [ + 'function mint(address to, uint256 amount) external', + 'function approve(address spender, uint256 amount) external returns (bool)', + 'function balanceOf(address) external view returns (uint256)', +]; + +const ERC721_ABI = [ + 'function safeMint(address to, uint256 tokenId) external', + 'function setApprovalForAll(address operator, bool approved) external', + 'function ownerOf(uint256 tokenId) external view returns (address)', +]; + +/** + * Seeds the indexer-mr database so that the given NFT is associated with a metadata_id. + * This allows the fulfillment endpoint to validate the token against the metadata bid. + */ +function ensureIndexerNft( + contractAddress: string, + tokenId: string, +): string { + const port = process.env.INDEXER_MR_POSTGRES_PORT ?? '5434'; + const connStr = `postgres://postgres:postgres@localhost:${port}/indexer-mr`; + const metadataId = randomUUID(); + const metadataHash = randomBytes(16).toString('hex'); + const nftId = randomUUID(); + + const sql = ` + BEGIN; + + INSERT INTO chains (id, name, rpc_url, operator_allowlist_address, minter_address) + VALUES ('${LOCAL_CHAIN_ID}', '${LOCAL_CHAIN_NAME}', 'http://127.0.0.1:8545', + '0x0000000000000000000000000000000000000000', + '0x0000000000000000000000000000000000000001') + ON CONFLICT (id) DO NOTHING; + + INSERT INTO collections + (chain_id, contract_address, contract_type, indexed_at, updated_at, verification_status) + VALUES + ('${LOCAL_CHAIN_ID}', LOWER('${contractAddress}'), 'erc721'::collections_contract_type, + NOW(), NOW(), 'unverified'::asset_verification_status) + ON CONFLICT (chain_id, contract_address) DO NOTHING; + + INSERT INTO nft_metadata + (id, chain_id, contract_address, hash, name, image, attributes, created_at, updated_at) + VALUES + ('${metadataId}'::uuid, '${LOCAL_CHAIN_ID}', LOWER('${contractAddress}'), + '${metadataHash}', 'e2e metadata-bid nft', 'https://example.com/nft.png', + '[]'::jsonb, NOW(), NOW()); + + INSERT INTO nfts + (id, chain_id, contract_address, token_id, contract_type, indexed_at, updated_at, metadata_id) + VALUES + ('${nftId}'::uuid, '${LOCAL_CHAIN_ID}', LOWER('${contractAddress}'), + ${tokenId}::numeric, 'erc721'::collections_contract_type, + NOW(), NOW(), '${metadataId}'::uuid) + ON CONFLICT (chain_id, contract_address, token_id) DO UPDATE SET + metadata_id = EXCLUDED.metadata_id, + updated_at = EXCLUDED.updated_at; + + COMMIT; + `; + + execSync(`psql "${connStr}" -c "${sql.replace(/\n/g, ' ')}"`, { stdio: 'pipe' }); + return metadataId; +} + +describe('metadata bid e2e', () => { + const provider = getLocalhostProvider(); + const maker = getOffererWallet(provider); + const taker = getFulfillerWallet(provider); + + const localConfigOverrides = getConfigFromEnv(); + const sdk = new Orderbook({ + baseConfig: { + environment: Environment.SANDBOX, + }, + overrides: { + ...localConfigOverrides, + }, + }); + + let erc721Address: string; + let erc20Address: string; + + beforeAll(async () => { + erc721Address = process.env.ZKEVM_ORDERBOOK_ERC721!; + if (!erc721Address) { + throw new Error('ZKEVM_ORDERBOOK_ERC721 must be set'); + } + + erc20Address = process.env.ZKEVM_ORDERBOOK_ERC20!; + if (!erc20Address) { + throw new Error('ZKEVM_ORDERBOOK_ERC20 must be set'); + } + + const erc20 = new Contract(erc20Address, ERC20_ABI, maker); + + const mintTx = await erc20.mint(maker.address, BigInt('1000000000000000000'), GAS_OVERRIDES); + await mintTx.wait(); + }, 60_000); + + it('should prepare, create, and get a metadata bid', async () => { + const metadataId = randomUUID(); + + const prepareResult = await sdk.prepareMetadataBid({ + makerAddress: maker.address, + sell: { + contractAddress: erc20Address, + amount: '1000000', + type: 'ERC20', + }, + buy: { + contractAddress: erc721Address, + type: 'ERC721_COLLECTION', + amount: '1', + }, + }); + + const signatures = await actionAll(prepareResult.actions, maker); + + const { result: createdBid } = await sdk.createMetadataBid({ + orderComponents: prepareResult.orderComponents, + orderHash: prepareResult.orderHash, + orderSignature: signatures[0], + makerFees: [], + metadataId, + }); + + expect(createdBid.id).toBeDefined(); + expect(createdBid.type).toEqual('METADATA_BID'); + expect(createdBid.metadataId).toEqual(metadataId); + + const activeBid = await waitForMetadataBidToBeOfStatus( + sdk, + createdBid.id, + OrderStatusName.ACTIVE, + ); + + expect(activeBid.id).toEqual(createdBid.id); + expect(activeBid.metadataId).toEqual(metadataId); + expect(activeBid.status.name).toEqual(OrderStatusName.ACTIVE); + }, 60_000); + + it('should list metadata bids filtered by account address', async () => { + const metadataId = randomUUID(); + + const prepareResult = await sdk.prepareMetadataBid({ + makerAddress: maker.address, + sell: { + contractAddress: erc20Address, + amount: '500000', + type: 'ERC20', + }, + buy: { + contractAddress: erc721Address, + type: 'ERC721_COLLECTION', + amount: '1', + }, + }); + + const signatures = await actionAll(prepareResult.actions, maker); + + const { result: createdBid } = await sdk.createMetadataBid({ + orderComponents: prepareResult.orderComponents, + orderHash: prepareResult.orderHash, + orderSignature: signatures[0], + makerFees: [], + metadataId, + }); + + await waitForMetadataBidToBeOfStatus( + sdk, + createdBid.id, + OrderStatusName.ACTIVE, + ); + + const listResult = await sdk.listMetadataBids({ + accountAddress: maker.address, + status: OrderStatusName.ACTIVE, + }); + + expect(listResult.result.length).toBeGreaterThan(0); + + const found = listResult.result.find((bid) => bid.id === createdBid.id); + expect(found).toBeDefined(); + expect(found!.type).toEqual('METADATA_BID'); + expect(found!.metadataId).toEqual(metadataId); + }, 60_000); + + it('should list metadata bids filtered by buy item contract address', async () => { + const metadataId = randomUUID(); + + const prepareResult = await sdk.prepareMetadataBid({ + makerAddress: maker.address, + sell: { + contractAddress: erc20Address, + amount: '300000', + type: 'ERC20', + }, + buy: { + contractAddress: erc721Address, + type: 'ERC721_COLLECTION', + amount: '1', + }, + }); + + const signatures = await actionAll(prepareResult.actions, maker); + + const { result: createdBid } = await sdk.createMetadataBid({ + orderComponents: prepareResult.orderComponents, + orderHash: prepareResult.orderHash, + orderSignature: signatures[0], + makerFees: [], + metadataId, + }); + + await waitForMetadataBidToBeOfStatus( + sdk, + createdBid.id, + OrderStatusName.ACTIVE, + ); + + const listResult = await sdk.listMetadataBids({ + buyItemContractAddress: erc721Address, + status: OrderStatusName.ACTIVE, + }); + + expect(listResult.result.length).toBeGreaterThan(0); + + const found = listResult.result.find((bid) => bid.id === createdBid.id); + expect(found).toBeDefined(); + expect(found!.metadataId).toEqual(metadataId); + }, 60_000); + + it('should fulfill a metadata bid', async () => { + const tokenId = getRandomTokenId(); + + const erc721 = new Contract(erc721Address, ERC721_ABI, maker); + const mintTx = await erc721.safeMint(taker.address, tokenId, GAS_OVERRIDES); + await mintTx.wait(); + + const metadataId = ensureIndexerNft(erc721Address, tokenId); + + const takerErc721 = new Contract(erc721Address, ERC721_ABI, taker); + const seaportAddress = process.env.SEAPORT_CONTRACT_ADDRESS!; + const approvalTx = await takerErc721.setApprovalForAll(seaportAddress, true, GAS_OVERRIDES); + await approvalTx.wait(); + + const blockTime = await provider.getBlock('latest'); + + const prepareResult = await sdk.prepareMetadataBid({ + makerAddress: maker.address, + sell: { + contractAddress: erc20Address, + amount: '100000', + type: 'ERC20', + }, + buy: { + contractAddress: erc721Address, + type: 'ERC721_COLLECTION', + amount: '1', + }, + orderStart: new Date(blockTime!.timestamp - 1000), + }); + + const signatures = await actionAll(prepareResult.actions, maker); + + const { result: createdBid } = await sdk.createMetadataBid({ + orderComponents: prepareResult.orderComponents, + orderHash: prepareResult.orderHash, + orderSignature: signatures[0], + makerFees: [], + metadataId, + }); + + await waitForMetadataBidToBeOfStatus( + sdk, + createdBid.id, + OrderStatusName.ACTIVE, + ); + + const fulfillment = await sdk.fulfillOrder( + createdBid.id, + taker.address, + [], + undefined, + tokenId, + ); + + await actionAll(fulfillment.actions, taker); + + const filledBid = await waitForMetadataBidToBeOfStatus( + sdk, + createdBid.id, + OrderStatusName.FILLED, + ); + + expect(filledBid.id).toEqual(createdBid.id); + expect(filledBid.status.name).toEqual(OrderStatusName.FILLED); + }, 120_000); +});