From 5f6403fb7ed8a1d6d6ce68425b17b4a529731b64 Mon Sep 17 00:00:00 2001 From: Taylor Petrychyn Date: Sat, 6 Dec 2025 20:38:46 -0800 Subject: [PATCH 1/3] getOrderByHash handle os2 order response --- src/api/api.ts | 5 ++-- src/api/orders.ts | 13 +++++++--- src/api/types.ts | 7 ++++++ src/sdk/cancellation.ts | 54 +++++++++++++++++++++-------------------- 4 files changed, 47 insertions(+), 32 deletions(-) diff --git a/src/api/api.ts b/src/api/api.ts index e866ee489..069db95f4 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -34,6 +34,7 @@ import { GetBestListingResponse, GetOffersResponse, GetListingsResponse, + GetOrderByHashResponse, CollectionOffer, CollectionOrderByOption, CancelOrderResponse, @@ -126,14 +127,14 @@ export class OpenSeaAPI { * @param orderHash The hash of the order to fetch * @param protocolAddress The address of the seaport contract * @param chain The chain where the order is located. Defaults to the chain set in the constructor. - * @returns The {@link OrderV2} returned by the API + * @returns The {@link GetOrderByHashResponse} returned by the API (can be Offer or Listing) * @throws An error if the order is not found */ public async getOrderByHash( orderHash: string, protocolAddress: string, chain: Chain = this.chain, - ): Promise { + ): Promise { return this.ordersAPI.getOrderByHash(orderHash, protocolAddress, chain); } diff --git a/src/api/orders.ts b/src/api/orders.ts index 0711a0106..22c57c585 100644 --- a/src/api/orders.ts +++ b/src/api/orders.ts @@ -3,7 +3,11 @@ import { getOrderByHashPath, getCancelOrderPath, } from "./apiPaths"; -import { GetOrdersResponse, CancelOrderResponse } from "./types"; +import { + GetOrdersResponse, + CancelOrderResponse, + GetOrderByHashResponse, +} from "./types"; import { FulfillmentDataResponse, OrderAPIOptions, @@ -72,16 +76,17 @@ export class OrdersAPI { /** * Gets a single order by its order hash. + * Returns the raw API response which can be either an Offer or Listing. */ async getOrderByHash( orderHash: string, protocolAddress: string, chain: Chain = this.chain, - ): Promise { + ): Promise { const response = await this.fetcher.get<{ - order: OrdersQueryResponse["orders"][0]; + order: GetOrderByHashResponse; }>(getOrderByHashPath(chain, protocolAddress, orderHash)); - return deserializeOrder(response.order); + return response.order; } /** diff --git a/src/api/types.ts b/src/api/types.ts index 3f5c83f8f..ce489c192 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -243,6 +243,13 @@ export type GetBestOfferResponse = Offer | CollectionOffer; */ export type GetBestListingResponse = Listing; +/** + * Response from OpenSea API for fetching an order by hash. + * Can be either an Offer or a Listing. + * @category API Response Types + */ +export type GetOrderByHashResponse = Offer | Listing; + /** * Response from OpenSea API for offchain canceling an order. * @category API Response Types diff --git a/src/sdk/cancellation.ts b/src/sdk/cancellation.ts index f5ea9994a..66edcd45f 100644 --- a/src/sdk/cancellation.ts +++ b/src/sdk/cancellation.ts @@ -1,5 +1,6 @@ import { OrderComponents } from "@opensea/seaport-js/lib/types"; import { Overrides, Signer } from "ethers"; +import { GetOrderByHashResponse } from "../api/types"; import { OrderV2 } from "../orders/types"; import { DEFAULT_SEAPORT_CONTRACT_ADDRESS } from "../orders/utils"; import { Chain, EventType } from "../types"; @@ -55,37 +56,41 @@ export class CancellationManager { // Check account availability after parameter validation await this.context.requireAccountIsAvailable(accountAddress); - let orderToCancel: OrderV2; + let orderComponents: OrderComponents; + let effectiveProtocolAddress: string; if (order) { // Using OrderV2 object directly requireValidProtocol(order.protocolAddress); - orderToCancel = order; + effectiveProtocolAddress = order.protocolAddress; + orderComponents = order.protocolData.parameters; + + this.context.dispatch(EventType.CancelOrder, { + orderV2: order, + accountAddress, + }); } else if (orderHash) { // Fetch order from API using order hash requireValidProtocol(protocolAddress); - orderToCancel = await this.context.api.getOrderByHash( + const fetchedOrder = await this.context.api.getOrderByHash( orderHash, protocolAddress, this.context.chain, ); - requireValidProtocol(orderToCancel.protocolAddress); + requireValidProtocol(fetchedOrder.protocol_address); + effectiveProtocolAddress = fetchedOrder.protocol_address; + orderComponents = fetchedOrder.protocol_data.parameters; } else { // Should never reach here due to earlier validation throw new Error("Invalid input"); } - this.context.dispatch(EventType.CancelOrder, { - orderV2: orderToCancel, - accountAddress, - }); - // Transact and get the transaction hash const transactionHash = await this.cancelSeaportOrders({ - orders: [orderToCancel.protocolData.parameters], + orders: [orderComponents], accountAddress, domain, - protocolAddress: orderToCancel.protocolAddress, + protocolAddress: effectiveProtocolAddress, }); // Await transaction confirmation @@ -176,9 +181,17 @@ export class CancellationManager { return order as OrderComponents; } }); + + // Dispatch event for the first order if available (for backwards compatibility with cancelOrder) + if (firstOrderV2) { + this.context.dispatch(EventType.CancelOrder, { + orderV2: firstOrderV2, + accountAddress, + }); + } } else if (orderHashes) { // Fetch orders from the API using order hashes - const fetchedOrders: OrderV2[] = []; + const fetchedOrders: GetOrderByHashResponse[] = []; for (const orderHash of orderHashes) { const order = await this.context.api.getOrderByHash( orderHash, @@ -190,26 +203,15 @@ export class CancellationManager { // Extract OrderComponents from the fetched orders orderComponents = fetchedOrders.map((order) => { - requireValidProtocol(order.protocolAddress); - effectiveProtocolAddress = order.protocolAddress; - return order.protocolData.parameters; + requireValidProtocol(order.protocol_address); + effectiveProtocolAddress = order.protocol_address; + return order.protocol_data.parameters; }); - - // Save the first order for event dispatching - firstOrderV2 = fetchedOrders[0]; } else { // Should never reach here due to earlier validation throw new Error("Invalid input"); } - // Dispatch event for the first order if available (for backwards compatibility with cancelOrder) - if (firstOrderV2) { - this.context.dispatch(EventType.CancelOrder, { - orderV2: firstOrderV2, - accountAddress, - }); - } - // Transact and get the transaction hash const transactionHash = await this.cancelSeaportOrders({ orders: orderComponents, From 23d8bd1bef34f9a5a6daa63b04baab257c88717a Mon Sep 17 00:00:00 2001 From: Taylor Petrychyn Date: Sun, 7 Dec 2025 11:59:51 -0800 Subject: [PATCH 2/3] add tests --- test/api/getOrderByHash.spec.ts | 248 ++++++++++++++++++++++++ test/integration/getOrderByHash.spec.ts | 125 ++++++++++++ 2 files changed, 373 insertions(+) create mode 100644 test/api/getOrderByHash.spec.ts create mode 100644 test/integration/getOrderByHash.spec.ts diff --git a/test/api/getOrderByHash.spec.ts b/test/api/getOrderByHash.spec.ts new file mode 100644 index 000000000..ffc435e47 --- /dev/null +++ b/test/api/getOrderByHash.spec.ts @@ -0,0 +1,248 @@ +import { expect } from "chai"; +import { suite, test } from "mocha"; +import * as sinon from "sinon"; +import { OrdersAPI } from "../../src/api/orders"; +import { + GetOrderByHashResponse, + Offer, + Listing, + OrderStatus, +} from "../../src/api/types"; +import { OrderType, ProtocolData } from "../../src/orders/types"; +import { Chain } from "../../src/types"; +import { createMockFetcher } from "../fixtures/fetcher"; + +suite("API: OrdersAPI.getOrderByHash", () => { + let mockGet: sinon.SinonStub; + let ordersAPI: OrdersAPI; + + beforeEach(() => { + const { fetcher, mockGet: getMock } = createMockFetcher(); + mockGet = getMock; + ordersAPI = new OrdersAPI(fetcher, Chain.Mainnet); + }); + + afterEach(() => { + sinon.restore(); + }); + + test("returns an Offer type response", async () => { + const mockOffer: Offer = { + order_hash: + "0x143be64aaf5d170c61e56ceb37dff0f8494e2630a7eae3eb24c8edbef09af9d5", + chain: "ethereum", + protocol_data: { + parameters: { + offerer: "0xaf68f720d7e51b88a76ec35aab2b1694f8f0892a", + offer: [ + { + itemType: 1, + token: "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + identifierOrCriteria: "0", + startAmount: "5480000000000000000", + endAmount: "5480000000000000000", + }, + ], + consideration: [], + startTime: "1765058920", + endTime: "1765318120", + orderType: 2, + zone: "0x000056f7000000ece9003ca63978907a00ffd100", + zoneHash: + "0x0000000000000000000000000000000000000000000000000000000000000000", + salt: "0x3d958fe20000000000000000000000000000000000000000aed879ed15914a7b", + conduitKey: + "0x0000007b02230091a7ed01230072f7006a004d60a8d4e71d599b8104250f0000", + totalOriginalConsiderationItems: 2, + counter: 0, + }, + signature: null, + } as unknown as ProtocolData, + protocol_address: "0x0000000000000068f116a894984e2db1123eb395", + price: { + currency: "WETH", + decimals: 18, + value: "5480000000000000000", + }, + criteria: { + collection: { slug: "boredapeyachtclub" }, + contract: { address: "0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d" }, + }, + status: OrderStatus.ACTIVE, + }; + + mockGet.resolves({ order: mockOffer }); + + const result = await ordersAPI.getOrderByHash( + "0x143be64aaf5d170c61e56ceb37dff0f8494e2630a7eae3eb24c8edbef09af9d5", + "0x0000000000000068f116a894984e2db1123eb395", + ); + + expect(mockGet.calledOnce).to.be.true; + expect(mockGet.firstCall.args[0]).to.equal( + "/api/v2/orders/chain/ethereum/protocol/0x0000000000000068f116a894984e2db1123eb395/0x143be64aaf5d170c61e56ceb37dff0f8494e2630a7eae3eb24c8edbef09af9d5", + ); + + // Verify it returns the raw API response (Offer type) + expect(result.order_hash).to.equal( + "0x143be64aaf5d170c61e56ceb37dff0f8494e2630a7eae3eb24c8edbef09af9d5", + ); + expect(result.protocol_address).to.equal( + "0x0000000000000068f116a894984e2db1123eb395", + ); + expect(result.protocol_data.parameters.offerer).to.equal( + "0xaf68f720d7e51b88a76ec35aab2b1694f8f0892a", + ); + expect((result as Offer).criteria?.collection.slug).to.equal( + "boredapeyachtclub", + ); + }); + + test("returns a Listing type response", async () => { + const mockListing: Listing = { + order_hash: + "0xabc123def456789012345678901234567890123456789012345678901234abcd", + chain: "ethereum", + protocol_data: { + parameters: { + offerer: "0x1234567890123456789012345678901234567890", + offer: [ + { + itemType: 2, + token: "0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d", + identifierOrCriteria: "1234", + startAmount: "1", + endAmount: "1", + }, + ], + consideration: [], + startTime: "1765058920", + endTime: "1765318120", + orderType: 0, + zone: "0x000056f7000000ece9003ca63978907a00ffd100", + zoneHash: + "0x0000000000000000000000000000000000000000000000000000000000000000", + salt: "0x1234", + conduitKey: + "0x0000007b02230091a7ed01230072f7006a004d60a8d4e71d599b8104250f0000", + totalOriginalConsiderationItems: 1, + counter: 0, + }, + signature: "0xsignature", + } as unknown as ProtocolData, + protocol_address: "0x0000000000000068f116a894984e2db1123eb395", + type: OrderType.BASIC, + price: { + current: { + currency: "ETH", + decimals: 18, + value: "1000000000000000000", + }, + }, + remaining_quantity: 1, + status: OrderStatus.ACTIVE, + }; + + mockGet.resolves({ order: mockListing }); + + const result = await ordersAPI.getOrderByHash( + "0xabc123def456789012345678901234567890123456789012345678901234abcd", + "0x0000000000000068f116a894984e2db1123eb395", + ); + + // Verify it returns the raw API response (Listing type) + expect(result.order_hash).to.equal( + "0xabc123def456789012345678901234567890123456789012345678901234abcd", + ); + expect(result.protocol_address).to.equal( + "0x0000000000000068f116a894984e2db1123eb395", + ); + expect((result as Listing).remaining_quantity).to.equal(1); + expect((result as Listing).type).to.equal(OrderType.BASIC); + }); + + test("response can be used for order cancellation", async () => { + const mockOffer: Offer = { + order_hash: "0x123", + chain: "ethereum", + protocol_data: { + parameters: { + offerer: "0xofferer", + offer: [], + consideration: [], + startTime: "0", + endTime: "0", + orderType: 0, + zone: "0x0", + zoneHash: "0x0", + salt: "0", + conduitKey: "0x0", + totalOriginalConsiderationItems: 0, + counter: 0, + }, + signature: null, + } as unknown as ProtocolData, + protocol_address: "0xprotocol", + price: { + currency: "ETH", + decimals: 18, + value: "1000000000000000000", + }, + status: OrderStatus.ACTIVE, + }; + + mockGet.resolves({ order: mockOffer }); + + const result: GetOrderByHashResponse = await ordersAPI.getOrderByHash( + "0x123", + "0xprotocol", + ); + + // Verify the response has the fields needed for cancellation + expect(result.protocol_address).to.equal("0xprotocol"); + expect(result.protocol_data).to.exist; + expect(result.protocol_data.parameters).to.exist; + expect(result.protocol_data.parameters.offerer).to.equal("0xofferer"); + }); + + test("passes chain parameter correctly", async () => { + // Create a new API instance for this test with Polygon chain + const { fetcher, mockGet: polygonMockGet } = createMockFetcher(); + const polygonOrdersAPI = new OrdersAPI(fetcher, Chain.Polygon); + + const mockOffer: Offer = { + order_hash: "0x123", + chain: "polygon", + protocol_data: { + parameters: { + offerer: "0x1", + offer: [], + consideration: [], + startTime: "0", + endTime: "0", + orderType: 0, + zone: "0x0", + zoneHash: "0x0", + salt: "0", + conduitKey: "0x0", + totalOriginalConsiderationItems: 0, + counter: 0, + }, + signature: null, + } as unknown as ProtocolData, + protocol_address: "0xprotocol", + price: { + currency: "ETH", + decimals: 18, + value: "1000000000000000000", + }, + status: OrderStatus.ACTIVE, + }; + + polygonMockGet.resolves({ order: mockOffer }); + + await polygonOrdersAPI.getOrderByHash("0x123", "0xprotocol"); + + expect(polygonMockGet.firstCall.args[0]).to.include("/chain/polygon/"); + }); +}); diff --git a/test/integration/getOrderByHash.spec.ts b/test/integration/getOrderByHash.spec.ts new file mode 100644 index 000000000..a1594e412 --- /dev/null +++ b/test/integration/getOrderByHash.spec.ts @@ -0,0 +1,125 @@ +import { assert } from "chai"; +import { JsonRpcProvider } from "ethers"; +import { suite, test } from "mocha"; +import { OpenSeaSDK } from "../../src/sdk"; +import { Chain } from "../../src/types"; +import { OPENSEA_API_KEY } from "../utils/env"; + +// Create SDK without wallet - only needs API key for read-only operations +const provider = new JsonRpcProvider("https://cloudflare-eth.com"); +const sdk = new OpenSeaSDK(provider, { + chain: Chain.Mainnet, + apiKey: OPENSEA_API_KEY, +}); + +suite("SDK: getOrderByHash", () => { + test("Get Order By Hash - Offer", async () => { + const slug = "boredapeyachtclub"; + + // First get an offer to get its hash and protocol address + const offersResponse = await sdk.api.getAllOffers(slug); + assert(offersResponse.offers.length > 0, "Should have at least one offer"); + + const offer = offersResponse.offers[0]; + const orderHash = offer.order_hash; + const protocolAddress = offer.protocol_address; + + // Now fetch the same order by hash + const response = await sdk.api.getOrderByHash(orderHash, protocolAddress); + + assert(response, "Response should not be null"); + assert.equal( + response.order_hash, + orderHash, + "Order hash should match the requested hash", + ); + assert.equal( + response.protocol_address, + protocolAddress, + "Protocol address should match", + ); + assert(response.protocol_data, "Protocol data should not be null"); + assert( + response.protocol_data.parameters, + "Protocol data parameters should not be null", + ); + assert( + response.protocol_data.parameters.offerer, + "Offerer should not be null", + ); + }); + + test("Get Order By Hash - Listing", async () => { + const slug = "boredapeyachtclub"; + + // First get a listing to get its hash and protocol address + const listingsResponse = await sdk.api.getAllListings(slug); + assert( + listingsResponse.listings.length > 0, + "Should have at least one listing", + ); + + const listing = listingsResponse.listings[0]; + const orderHash = listing.order_hash; + const protocolAddress = listing.protocol_address; + + // Now fetch the same order by hash + const response = await sdk.api.getOrderByHash(orderHash, protocolAddress); + + assert(response, "Response should not be null"); + assert.equal( + response.order_hash, + orderHash, + "Order hash should match the requested hash", + ); + assert.equal( + response.protocol_address, + protocolAddress, + "Protocol address should match", + ); + assert(response.protocol_data, "Protocol data should not be null"); + assert( + response.protocol_data.parameters, + "Protocol data parameters should not be null", + ); + }); + + test("Get Order By Hash returns data usable for cancellation", async () => { + const slug = "boredapeyachtclub"; + + // Get an offer + const offersResponse = await sdk.api.getAllOffers(slug); + assert(offersResponse.offers.length > 0, "Should have at least one offer"); + + const offer = offersResponse.offers[0]; + + // Fetch by hash + const response = await sdk.api.getOrderByHash( + offer.order_hash, + offer.protocol_address, + ); + + // Verify the response has fields needed for cancellation + assert( + response.protocol_address, + "protocol_address is required for cancel", + ); + assert(response.protocol_data, "protocol_data is required for cancel"); + assert( + response.protocol_data.parameters, + "protocol_data.parameters is required for cancel", + ); + assert( + response.protocol_data.parameters.offerer, + "offerer is required for cancel", + ); + assert( + Array.isArray(response.protocol_data.parameters.offer), + "offer array is required for cancel", + ); + assert( + Array.isArray(response.protocol_data.parameters.consideration), + "consideration array is required for cancel", + ); + }); +}); From 66ef830b7fbdd5107e0419b9a54ec2a489b7714c Mon Sep 17 00:00:00 2001 From: Taylor Petrychyn Date: Sun, 7 Dec 2025 19:11:26 -0800 Subject: [PATCH 3/3] fix --- src/sdk/cancellation.ts | 39 ++++++++++++++++++++++++--------------- src/types.ts | 5 +++++ 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/src/sdk/cancellation.ts b/src/sdk/cancellation.ts index 66edcd45f..b307b37de 100644 --- a/src/sdk/cancellation.ts +++ b/src/sdk/cancellation.ts @@ -1,6 +1,6 @@ import { OrderComponents } from "@opensea/seaport-js/lib/types"; import { Overrides, Signer } from "ethers"; -import { GetOrderByHashResponse } from "../api/types"; +import { Offer, Listing } from "../api/types"; import { OrderV2 } from "../orders/types"; import { DEFAULT_SEAPORT_CONTRACT_ADDRESS } from "../orders/utils"; import { Chain, EventType } from "../types"; @@ -64,7 +64,6 @@ export class CancellationManager { requireValidProtocol(order.protocolAddress); effectiveProtocolAddress = order.protocolAddress; orderComponents = order.protocolData.parameters; - this.context.dispatch(EventType.CancelOrder, { orderV2: order, accountAddress, @@ -80,6 +79,10 @@ export class CancellationManager { requireValidProtocol(fetchedOrder.protocol_address); effectiveProtocolAddress = fetchedOrder.protocol_address; orderComponents = fetchedOrder.protocol_data.parameters; + this.context.dispatch(EventType.CancelOrder, { + order: fetchedOrder, + accountAddress, + }); } else { // Should never reach here due to earlier validation throw new Error("Invalid input"); @@ -161,17 +164,16 @@ export class CancellationManager { let orderComponents: OrderComponents[]; let effectiveProtocolAddress = protocolAddress; - let firstOrderV2: OrderV2 | undefined; if (orders) { // Extract OrderComponents from either OrderV2 objects or use OrderComponents directly + let firstOrderV2: OrderV2 | undefined; orderComponents = orders.map((order) => { if ("protocolData" in order) { // It's an OrderV2 object const orderV2 = order as OrderV2; requireValidProtocol(orderV2.protocolAddress); effectiveProtocolAddress = orderV2.protocolAddress; - // Save the first OrderV2 for event dispatching if (!firstOrderV2) { firstOrderV2 = orderV2; } @@ -181,8 +183,7 @@ export class CancellationManager { return order as OrderComponents; } }); - - // Dispatch event for the first order if available (for backwards compatibility with cancelOrder) + // Dispatch event for the first OrderV2 if available if (firstOrderV2) { this.context.dispatch(EventType.CancelOrder, { orderV2: firstOrderV2, @@ -191,22 +192,30 @@ export class CancellationManager { } } else if (orderHashes) { // Fetch orders from the API using order hashes - const fetchedOrders: GetOrderByHashResponse[] = []; - for (const orderHash of orderHashes) { - const order = await this.context.api.getOrderByHash( - orderHash, + const fetchedOrders: (Offer | Listing)[] = []; + for (const hash of orderHashes) { + const fetched = await this.context.api.getOrderByHash( + hash, protocolAddress, this.context.chain, ); - fetchedOrders.push(order); + fetchedOrders.push(fetched); } // Extract OrderComponents from the fetched orders - orderComponents = fetchedOrders.map((order) => { - requireValidProtocol(order.protocol_address); - effectiveProtocolAddress = order.protocol_address; - return order.protocol_data.parameters; + orderComponents = fetchedOrders.map((fetched) => { + requireValidProtocol(fetched.protocol_address); + effectiveProtocolAddress = fetched.protocol_address; + return fetched.protocol_data.parameters; }); + + // Dispatch event for the first fetched order + if (fetchedOrders.length > 0) { + this.context.dispatch(EventType.CancelOrder, { + order: fetchedOrders[0], + accountAddress, + }); + } } else { // Should never reach here due to earlier validation throw new Error("Invalid input"); diff --git a/src/types.ts b/src/types.ts index 6e2b895ea..a4875b783 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import { BigNumberish } from "ethers"; +import type { Offer, Listing } from "./api/types"; import type { OrderV2 } from "./orders/types"; /** @@ -83,6 +84,10 @@ export interface EventData { * The {@link OrderV2} object. */ orderV2?: OrderV2; + /** + * The order as returned by the API ({@link Offer} or {@link Listing}). + */ + order?: Offer | Listing; /** * Array of assets for bulk transfer and batch approval operations. */