From 4b8636e0daec784dc8a95fe69038926d15cb6356 Mon Sep 17 00:00:00 2001 From: Ryan Ghods Date: Fri, 28 Nov 2025 11:51:24 -0800 Subject: [PATCH] Fix: Support zero-payment private listings Fixes SDK crash when fulfilling zero-payment private listings (e.g., rewards claims). Problem: - constructPrivateListingCounterOrder crashes when paymentItems is empty - fulfillPrivateOrder crashes accessing counterOrder.parameters.offer[0] Solution (mirrors backend behavior in os2-core Seaport.kt): - Allow empty payment items in constructPrivateListingCounterOrder - Return counter order with offer: [] for zero-payment listings - Add computePrivateListingValue() to calculate ETH value from original order - Use computed value instead of counter order's offer[0].startAmount This enables fulfillment of zero-payment private listings used for rewards claims via REWARDS_PRIVATE_LISTING_CLAIM_WALLET. Closes #1832 --- package.json | 2 +- src/orders/privateListings.ts | 66 ++++-- src/sdk/fulfillment.ts | 11 +- test/orders/privateListings.spec.ts | 300 ++++++++++++++++++++++++++++ 4 files changed, 359 insertions(+), 20 deletions(-) create mode 100644 test/orders/privateListings.spec.ts diff --git a/package.json b/package.json index c4adb94e9..ff98605ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opensea-js", - "version": "8.0.8", + "version": "8.0.9", "description": "TypeScript SDK for the OpenSea marketplace helps developers build new experiences using NFTs and our marketplace data", "license": "MIT", "author": "OpenSea Developers", diff --git a/src/orders/privateListings.ts b/src/orders/privateListings.ts index 5ce9b3cf2..2afbc593c 100644 --- a/src/orders/privateListings.ts +++ b/src/orders/privateListings.ts @@ -1,3 +1,4 @@ +import { ItemType } from "@opensea/seaport-js/lib/constants"; import { ConsiderationInputItem, CreateInputItem, @@ -7,6 +8,28 @@ import { } from "@opensea/seaport-js/lib/types"; import { isCurrencyItem } from "@opensea/seaport-js/lib/utils/item"; import { generateRandomSalt } from "@opensea/seaport-js/lib/utils/order"; +import { ZeroAddress } from "ethers"; + +/** + * Compute the native currency (ETH) value required to fulfill a private listing. + * Sums all native currency consideration items not going to the taker. + * @param order The private listing order + * @param takerAddress The address of the private listing recipient + * @returns The total native currency value as a bigint + */ +export const computePrivateListingValue = ( + order: OrderWithCounter, + takerAddress: string, +): bigint => { + return order.parameters.consideration + .filter( + (item) => + item.recipient.toLowerCase() !== takerAddress.toLowerCase() && + item.token.toLowerCase() === ZeroAddress.toLowerCase() && + item.itemType === ItemType.NATIVE, + ) + .reduce((sum, item) => sum + BigInt(item.startAmount), 0n); +}; export const getPrivateListingConsiderations = ( offer: CreateInputItem[], @@ -28,15 +51,18 @@ export const constructPrivateListingCounterOrder = ( item.recipient.toLowerCase() !== privateSaleRecipient.toLowerCase(), ); - if (!paymentItems.every((item) => isCurrencyItem(item))) { - throw new Error( - "The consideration for the private listing did not contain only currency items", - ); - } - if ( - !paymentItems.every((item) => item.itemType === paymentItems[0].itemType) - ) { - throw new Error("Not all currency items were the same for private order"); + // Only validate payment items if there are any (zero-payment private listings are valid) + if (paymentItems.length > 0) { + if (!paymentItems.every((item) => isCurrencyItem(item))) { + throw new Error( + "The consideration for the private listing did not contain only currency items", + ); + } + if ( + !paymentItems.every((item) => item.itemType === paymentItems[0].itemType) + ) { + throw new Error("Not all currency items were the same for private order"); + } } const { aggregatedStartAmount, aggregatedEndAmount } = paymentItems.reduce( @@ -54,15 +80,19 @@ export const constructPrivateListingCounterOrder = ( parameters: { ...order.parameters, offerer: privateSaleRecipient, - offer: [ - { - itemType: paymentItems[0].itemType, - token: paymentItems[0].token, - identifierOrCriteria: paymentItems[0].identifierOrCriteria, - startAmount: aggregatedStartAmount.toString(), - endAmount: aggregatedEndAmount.toString(), - }, - ], + // Empty offer for zero-payment private listings, single aggregated item otherwise + offer: + paymentItems.length > 0 + ? [ + { + itemType: paymentItems[0].itemType, + token: paymentItems[0].token, + identifierOrCriteria: paymentItems[0].identifierOrCriteria, + startAmount: aggregatedStartAmount.toString(), + endAmount: aggregatedEndAmount.toString(), + }, + ] + : [], // The consideration here is empty as the original private listing order supplies // the taker address to receive the desired items. consideration: [], diff --git a/src/sdk/fulfillment.ts b/src/sdk/fulfillment.ts index 4c2332d1c..0fbe8183a 100644 --- a/src/sdk/fulfillment.ts +++ b/src/sdk/fulfillment.ts @@ -5,6 +5,7 @@ import { SDKContext } from "./context"; import { OrdersManager } from "./orders"; import { Listing, Offer, Order } from "../api/types"; import { + computePrivateListingValue, constructPrivateListingCounterOrder, getPrivateListingFulfillments, } from "../orders/privateListings"; @@ -60,6 +61,14 @@ export class FulfillmentManager { order.taker.address, ); const fulfillments = getPrivateListingFulfillments(order.protocolData); + + // Compute ETH value from original order's consideration items + // This handles both standard private listings and zero-payment listings (e.g., rewards) + const value = computePrivateListingValue( + order.protocolData, + order.taker.address, + ); + const seaport = getSeaportInstance( order.protocolAddress, this.context.seaport, @@ -70,7 +79,7 @@ export class FulfillmentManager { fulfillments, overrides: { ...overrides, - value: counterOrder.parameters.offer[0].startAmount, + value, }, accountAddress, domain, diff --git a/test/orders/privateListings.spec.ts b/test/orders/privateListings.spec.ts new file mode 100644 index 000000000..950312290 --- /dev/null +++ b/test/orders/privateListings.spec.ts @@ -0,0 +1,300 @@ +import { ItemType } from "@opensea/seaport-js/lib/constants"; +import { OrderWithCounter } from "@opensea/seaport-js/lib/types"; +import { expect } from "chai"; +import { ZeroAddress } from "ethers"; +import { suite, test } from "mocha"; +import { + computePrivateListingValue, + constructPrivateListingCounterOrder, +} from "../../src/orders/privateListings"; + +const SELLER_ADDRESS = "0x1111111111111111111111111111111111111111"; +const TAKER_ADDRESS = "0x2222222222222222222222222222222222222222"; +const FEE_RECIPIENT = "0x3333333333333333333333333333333333333333"; +const NFT_CONTRACT = "0x4444444444444444444444444444444444444444"; +const WETH_ADDRESS = "0x5555555555555555555555555555555555555555"; + +const createMockOrder = ( + consideration: OrderWithCounter["parameters"]["consideration"], +): OrderWithCounter => ({ + parameters: { + offerer: SELLER_ADDRESS, + zone: ZeroAddress, + offer: [ + { + itemType: ItemType.ERC721, + token: NFT_CONTRACT, + identifierOrCriteria: "1", + startAmount: "1", + endAmount: "1", + }, + ], + consideration, + orderType: 0, + startTime: "0", + endTime: "0", + zoneHash: + "0x0000000000000000000000000000000000000000000000000000000000000000", + salt: "0", + conduitKey: + "0x0000000000000000000000000000000000000000000000000000000000000000", + totalOriginalConsiderationItems: consideration.length, + counter: "0", + }, + signature: "0x", +}); + +suite("Orders: privateListings", () => { + suite("computePrivateListingValue", () => { + test("should return 0 for zero-payment private listings", () => { + const order = createMockOrder([ + // Only NFT going to taker, no payment items + { + itemType: ItemType.ERC721, + token: NFT_CONTRACT, + identifierOrCriteria: "1", + startAmount: "1", + endAmount: "1", + recipient: TAKER_ADDRESS, + }, + ]); + + const value = computePrivateListingValue(order, TAKER_ADDRESS); + expect(value).to.equal(0n); + }); + + test("should sum native currency items not going to taker", () => { + const order = createMockOrder([ + // NFT to taker + { + itemType: ItemType.ERC721, + token: NFT_CONTRACT, + identifierOrCriteria: "1", + startAmount: "1", + endAmount: "1", + recipient: TAKER_ADDRESS, + }, + // Payment to seller (1 ETH) + { + itemType: ItemType.NATIVE, + token: ZeroAddress, + identifierOrCriteria: "0", + startAmount: "1000000000000000000", + endAmount: "1000000000000000000", + recipient: SELLER_ADDRESS, + }, + // Fee to fee recipient (0.1 ETH) + { + itemType: ItemType.NATIVE, + token: ZeroAddress, + identifierOrCriteria: "0", + startAmount: "100000000000000000", + endAmount: "100000000000000000", + recipient: FEE_RECIPIENT, + }, + ]); + + const value = computePrivateListingValue(order, TAKER_ADDRESS); + // 1 ETH + 0.1 ETH = 1.1 ETH + expect(value).to.equal(1100000000000000000n); + }); + + test("should ignore ERC20 currency items", () => { + const order = createMockOrder([ + // NFT to taker + { + itemType: ItemType.ERC721, + token: NFT_CONTRACT, + identifierOrCriteria: "1", + startAmount: "1", + endAmount: "1", + recipient: TAKER_ADDRESS, + }, + // WETH payment to seller (should be ignored) + { + itemType: ItemType.ERC20, + token: WETH_ADDRESS, + identifierOrCriteria: "0", + startAmount: "1000000000000000000", + endAmount: "1000000000000000000", + recipient: SELLER_ADDRESS, + }, + ]); + + const value = computePrivateListingValue(order, TAKER_ADDRESS); + expect(value).to.equal(0n); + }); + + test("should handle mixed native and ERC20 payments", () => { + const order = createMockOrder([ + // NFT to taker + { + itemType: ItemType.ERC721, + token: NFT_CONTRACT, + identifierOrCriteria: "1", + startAmount: "1", + endAmount: "1", + recipient: TAKER_ADDRESS, + }, + // Native payment to seller (0.5 ETH) + { + itemType: ItemType.NATIVE, + token: ZeroAddress, + identifierOrCriteria: "0", + startAmount: "500000000000000000", + endAmount: "500000000000000000", + recipient: SELLER_ADDRESS, + }, + // WETH payment (should be ignored) + { + itemType: ItemType.ERC20, + token: WETH_ADDRESS, + identifierOrCriteria: "0", + startAmount: "500000000000000000", + endAmount: "500000000000000000", + recipient: SELLER_ADDRESS, + }, + ]); + + const value = computePrivateListingValue(order, TAKER_ADDRESS); + // Only native ETH counts + expect(value).to.equal(500000000000000000n); + }); + }); + + suite("constructPrivateListingCounterOrder", () => { + test("should return empty offer for zero-payment private listings", () => { + const order = createMockOrder([ + // Only NFT going to taker + { + itemType: ItemType.ERC721, + token: NFT_CONTRACT, + identifierOrCriteria: "1", + startAmount: "1", + endAmount: "1", + recipient: TAKER_ADDRESS, + }, + ]); + + const counterOrder = constructPrivateListingCounterOrder( + order, + TAKER_ADDRESS, + ); + + expect(counterOrder.parameters.offer).to.deep.equal([]); + expect(counterOrder.parameters.consideration).to.deep.equal([]); + expect(counterOrder.parameters.offerer).to.equal(TAKER_ADDRESS); + }); + + test("should aggregate payment items into single offer", () => { + const order = createMockOrder([ + // NFT to taker + { + itemType: ItemType.ERC721, + token: NFT_CONTRACT, + identifierOrCriteria: "1", + startAmount: "1", + endAmount: "1", + recipient: TAKER_ADDRESS, + }, + // Payment to seller + { + itemType: ItemType.NATIVE, + token: ZeroAddress, + identifierOrCriteria: "0", + startAmount: "1000000000000000000", + endAmount: "1000000000000000000", + recipient: SELLER_ADDRESS, + }, + // Fee to fee recipient + { + itemType: ItemType.NATIVE, + token: ZeroAddress, + identifierOrCriteria: "0", + startAmount: "100000000000000000", + endAmount: "100000000000000000", + recipient: FEE_RECIPIENT, + }, + ]); + + const counterOrder = constructPrivateListingCounterOrder( + order, + TAKER_ADDRESS, + ); + + expect(counterOrder.parameters.offer).to.have.length(1); + expect(counterOrder.parameters.offer[0].itemType).to.equal( + ItemType.NATIVE, + ); + expect(counterOrder.parameters.offer[0].startAmount).to.equal( + "1100000000000000000", + ); + expect(counterOrder.parameters.offer[0].endAmount).to.equal( + "1100000000000000000", + ); + }); + + test("should throw if payment items contain non-currency items", () => { + const order = createMockOrder([ + // NFT to taker + { + itemType: ItemType.ERC721, + token: NFT_CONTRACT, + identifierOrCriteria: "1", + startAmount: "1", + endAmount: "1", + recipient: TAKER_ADDRESS, + }, + // Another NFT to seller (invalid) + { + itemType: ItemType.ERC721, + token: NFT_CONTRACT, + identifierOrCriteria: "2", + startAmount: "1", + endAmount: "1", + recipient: SELLER_ADDRESS, + }, + ]); + + expect(() => + constructPrivateListingCounterOrder(order, TAKER_ADDRESS), + ).to.throw("did not contain only currency items"); + }); + + test("should throw if payment items have mixed currency types", () => { + const order = createMockOrder([ + // NFT to taker + { + itemType: ItemType.ERC721, + token: NFT_CONTRACT, + identifierOrCriteria: "1", + startAmount: "1", + endAmount: "1", + recipient: TAKER_ADDRESS, + }, + // ETH to seller + { + itemType: ItemType.NATIVE, + token: ZeroAddress, + identifierOrCriteria: "0", + startAmount: "500000000000000000", + endAmount: "500000000000000000", + recipient: SELLER_ADDRESS, + }, + // WETH to fee recipient (different currency type) + { + itemType: ItemType.ERC20, + token: WETH_ADDRESS, + identifierOrCriteria: "0", + startAmount: "100000000000000000", + endAmount: "100000000000000000", + recipient: FEE_RECIPIENT, + }, + ]); + + expect(() => + constructPrivateListingCounterOrder(order, TAKER_ADDRESS), + ).to.throw("Not all currency items were the same"); + }); + }); +});