From c493fe18bfd245584c472b4b74ef3db67ef76a08 Mon Sep 17 00:00:00 2001 From: BCLeFevre Date: Fri, 7 Nov 2025 09:06:07 -0600 Subject: [PATCH] Add multi-trait offers --- .gitignore | 2 + package-lock.json | 12 ---- src/api/api.ts | 6 ++ src/api/offers.ts | 20 ++++++ src/api/types.ts | 4 +- src/orders/utils.ts | 18 ++++- src/sdk.ts | 4 ++ src/sdk/orders.ts | 5 ++ test/api/offers.spec.ts | 126 +++++++++++++++++++++++++++++++++ test/orders/utils.spec.ts | 118 ++++++++++++++++++++++++++++++ test/sdk/ordersManager.spec.ts | 22 ++++++ 11 files changed, 322 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 26c11db35..bddad78d2 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,5 @@ yarn.lock # Local Claude settings .claude/settings.local.json + +examples/ diff --git a/package-lock.json b/package-lock.json index 8f71aca6d..3d0952acc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -101,7 +101,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.3.tgz", "integrity": "sha512-5FcvN1JHw2sHJChotgx8Ek0lyuh4kCKelgMTTqhYJJtloNvUfpAFMeNQUtdlIaktwrSV9LtCdqwk48wL2wBacQ==", "dev": true, - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.2", @@ -1141,7 +1140,6 @@ "integrity": "sha512-puiYfGeg5Ydop8eusb/Hy1k7QmOU6X3nvsqCgzrB2K4qMavK//21+PzNE8qeECgNOIoertJPUC1SpegHDI515A==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.17.0", "@typescript-eslint/types": "7.17.0", @@ -1547,7 +1545,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1956,7 +1953,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001587", "electron-to-chromium": "^1.4.668", @@ -2982,7 +2978,6 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3038,7 +3033,6 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -3134,7 +3128,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6307,7 +6300,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -7496,7 +7488,6 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7709,7 +7700,6 @@ "resolved": "https://registry.npmjs.org/typechain/-/typechain-8.3.2.tgz", "integrity": "sha512-x/sQYr5w9K7yv3es7jo4KTX05CLxOf7TRWwoHlrjRh8H82G64g+k7VuWPJlgMo6qrjfCulOdfBjiaDtmhFYD/Q==", "dev": true, - "peer": true, "dependencies": { "@types/prettier": "^2.1.1", "debug": "^4.3.1", @@ -7910,7 +7900,6 @@ "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.25.13.tgz", "integrity": "sha512-pQqiwiJ+Z4pigfOnnysObszLiU3mVLWAExSPf+Mu06G/qsc3wzbuM56SZQvONhHLncLUhYzOVkjFFpFfL5AzhQ==", "dev": true, - "peer": true, "dependencies": { "lunr": "^2.3.9", "marked": "^4.3.0", @@ -7942,7 +7931,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/api/api.ts b/src/api/api.ts index d292158c9..cc6c4a42a 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -326,6 +326,7 @@ export class OpenSeaAPI { * @param offerProtectionEnabled Build the offer on OpenSea's signed zone to provide offer protections from receiving an item which is disabled from trading. * @param traitType If defined, the trait name to create the collection offer for. * @param traitValue If defined, the trait value to create the collection offer for. + * @param traits If defined, an array of traits to create the multi-trait collection offer for. * @returns The {@link BuildOfferResponse} returned by the API. */ public async buildOffer( @@ -335,6 +336,7 @@ export class OpenSeaAPI { offerProtectionEnabled = true, traitType?: string, traitValue?: string, + traits?: Array<{ type: string; value: string }>, ): Promise { return this.offersAPI.buildOffer( offererAddress, @@ -343,6 +345,7 @@ export class OpenSeaAPI { offerProtectionEnabled, traitType, traitValue, + traits, ); } @@ -363,6 +366,7 @@ export class OpenSeaAPI { * @param slug The slug (identifier) of the collection to post the offer for. * @param traitType If defined, the trait name to create the collection offer for. * @param traitValue If defined, the trait value to create the collection offer for. + * @param traits If defined, an array of traits to create the multi-trait collection offer for. * @returns The {@link Offer} returned to the API. */ public async postCollectionOffer( @@ -370,12 +374,14 @@ export class OpenSeaAPI { slug: string, traitType?: string, traitValue?: string, + traits?: Array<{ type: string; value: string }>, ): Promise { return this.offersAPI.postCollectionOffer( order, slug, traitType, traitValue, + traits, ); } diff --git a/src/api/offers.ts b/src/api/offers.ts index 656c07e91..9342bc15f 100644 --- a/src/api/offers.ts +++ b/src/api/offers.ts @@ -99,7 +99,14 @@ export class OffersAPI { offerProtectionEnabled = true, traitType?: string, traitValue?: string, + traits?: Array<{ type: string; value: string }>, ): Promise { + // Validate trait parameters + if (traits && traits.length > 0 && (traitType || traitValue)) { + throw new Error( + "Cannot use both 'traits' array and individual 'traitType'/'traitValue' parameters. Please use only one approach.", + ); + } if (traitType || traitValue) { if (!traitType || !traitValue) { throw new Error( @@ -107,6 +114,16 @@ export class OffersAPI { ); } } + if (traits && traits.length > 0) { + // Validate each trait in the array has both type and value + for (const trait of traits) { + if (!trait.type || !trait.value) { + throw new Error( + "Each trait must have both 'type' and 'value' properties.", + ); + } + } + } const payload = getBuildCollectionOfferPayload( offererAddress, quantity, @@ -114,6 +131,7 @@ export class OffersAPI { offerProtectionEnabled, traitType, traitValue, + traits, ); const response = await this.fetcher.post( getBuildOfferPath(), @@ -141,12 +159,14 @@ export class OffersAPI { slug: string, traitType?: string, traitValue?: string, + traits?: Array<{ type: string; value: string }>, ): Promise { const payload = getPostCollectionOfferPayload( slug, order, traitType, traitValue, + traits, ); return await this.fetcher.post( getPostCollectionOfferPath(), diff --git a/src/api/types.ts b/src/api/types.ts index 1fdeb50a8..5731e2c8c 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -33,8 +33,10 @@ type Criteria = { contract: ContractCriteria; /** Represents a list of token ids which can be used to fulfill the criteria offer. */ encoded_token_ids?: string; - /** The trait for the criteria */ + /** The trait for the criteria (single trait) */ trait?: TraitCriteria; + /** Multiple traits for the criteria (multi-trait offers) */ + traits?: TraitCriteria[]; }; /** diff --git a/src/orders/utils.ts b/src/orders/utils.ts index 609cb406c..4aacff300 100644 --- a/src/orders/utils.ts +++ b/src/orders/utils.ts @@ -16,6 +16,7 @@ export const getPostCollectionOfferPayload = ( protocol_data: ProtocolData, traitType?: string, traitValue?: string, + traits?: Array<{ type: string; value: string }>, ) => { const payload = { criteria: { @@ -24,7 +25,13 @@ export const getPostCollectionOfferPayload = ( protocol_data, protocol_address: DEFAULT_SEAPORT_CONTRACT_ADDRESS, }; - if (traitType && traitValue) { + + // Prioritize traits array if provided + if (traits && traits.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (payload.criteria as any).traits = traits; + } else if (traitType && traitValue) { + // Fallback to single trait for backward compatibility // eslint-disable-next-line @typescript-eslint/no-explicit-any (payload.criteria as any).trait = { type: traitType, @@ -41,6 +48,7 @@ export const getBuildCollectionOfferPayload = ( offerProtectionEnabled: boolean, traitType?: string, traitValue?: string, + traits?: Array<{ type: string; value: string }>, ) => { const payload = { offerer: offererAddress, @@ -53,7 +61,13 @@ export const getBuildCollectionOfferPayload = ( protocol_address: DEFAULT_SEAPORT_CONTRACT_ADDRESS, offer_protection_enabled: offerProtectionEnabled, }; - if (traitType && traitValue) { + + // Prioritize traits array if provided + if (traits && traits.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (payload.criteria as any).traits = traits; + } else if (traitType && traitValue) { + // Fallback to single trait for backward compatibility // eslint-disable-next-line @typescript-eslint/no-explicit-any (payload.criteria as any).trait = { type: traitType, diff --git a/src/sdk.ts b/src/sdk.ts index 70319289d..ce3303235 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -434,6 +434,7 @@ export class OpenSeaSDK { * @param options.offerProtectionEnabled Build the offer on OpenSea's signed zone to provide offer protections from receiving an item which is disabled from trading. * @param options.traitType If defined, the trait name to create the collection offer for. * @param options.traitValue If defined, the trait value to create the collection offer for. + * @param options.traits If defined, an array of traits to create the multi-trait collection offer for. * @returns The {@link CollectionOffer} that was created. */ public async createCollectionOffer({ @@ -448,6 +449,7 @@ export class OpenSeaSDK { offerProtectionEnabled = true, traitType, traitValue, + traits, }: { collectionSlug: string; accountAddress: string; @@ -460,6 +462,7 @@ export class OpenSeaSDK { offerProtectionEnabled?: boolean; traitType?: string; traitValue?: string; + traits?: Array<{ type: string; value: string }>; }): Promise { return this._ordersManager.createCollectionOffer({ collectionSlug, @@ -473,6 +476,7 @@ export class OpenSeaSDK { offerProtectionEnabled, traitType, traitValue, + traits, }); } diff --git a/src/sdk/orders.ts b/src/sdk/orders.ts index 5a83985e1..165a48c81 100644 --- a/src/sdk/orders.ts +++ b/src/sdk/orders.ts @@ -1052,6 +1052,7 @@ export class OrdersManager { * @param options.offerProtectionEnabled Build the offer on OpenSea's signed zone to provide offer protections from receiving an item which is disabled from trading. * @param options.traitType If defined, the trait name to create the collection offer for. * @param options.traitValue If defined, the trait value to create the collection offer for. + * @param options.traits If defined, an array of traits to create the multi-trait collection offer for. * @returns The {@link CollectionOffer} that was created. */ async createCollectionOffer({ @@ -1066,6 +1067,7 @@ export class OrdersManager { offerProtectionEnabled = true, traitType, traitValue, + traits, }: { collectionSlug: string; accountAddress: string; @@ -1078,6 +1080,7 @@ export class OrdersManager { offerProtectionEnabled?: boolean; traitType?: string; traitValue?: string; + traits?: Array<{ type: string; value: string }>; }): Promise { await this.context.requireAccountIsAvailable(accountAddress); @@ -1089,6 +1092,7 @@ export class OrdersManager { offerProtectionEnabled, traitType, traitValue, + traits, ); const item = buildOfferResult.partialParameters.consideration[0]; const convertedConsiderationItem = { @@ -1143,6 +1147,7 @@ export class OrdersManager { collectionSlug, traitType, traitValue, + traits, ); } } diff --git a/test/api/offers.spec.ts b/test/api/offers.spec.ts index ba5c11c9f..23a1058e3 100644 --- a/test/api/offers.spec.ts +++ b/test/api/offers.spec.ts @@ -503,6 +503,104 @@ suite("API: OffersAPI", () => { } }); + test("builds collection offer with multiple traits", async () => { + const mockResponse: BuildOfferResponse = { + partialParameters: + {} as unknown as BuildOfferResponse["partialParameters"], + }; + + mockPost.resolves(mockResponse); + + const traits = [ + { type: "Background", value: "Blue" }, + { type: "Hat", value: "Beanie" }, + ]; + + await offersAPI.buildOffer( + "0xofferer123", + 2, + "test-collection", + true, + undefined, + undefined, + traits, + ); + + expect(mockPost.calledOnce).to.be.true; + }); + + test("throws error when both traits array and traitType/traitValue are provided", async () => { + const traits = [ + { type: "Background", value: "Blue" }, + { type: "Hat", value: "Beanie" }, + ]; + + try { + await offersAPI.buildOffer( + "0xofferer123", + 5, + "test-collection", + true, + "Fur", + "Golden", + traits, + ); + expect.fail("Expected error to be thrown"); + } catch (error) { + expect((error as Error).message).to.include( + "Cannot use both 'traits' array and individual 'traitType'/'traitValue' parameters", + ); + } + }); + + test("throws error when trait in array is missing type", async () => { + const traits = [ + { type: "Background", value: "Blue" }, + { type: "", value: "Beanie" }, + ]; + + try { + await offersAPI.buildOffer( + "0xofferer123", + 5, + "test-collection", + true, + undefined, + undefined, + traits, + ); + expect.fail("Expected error to be thrown"); + } catch (error) { + expect((error as Error).message).to.include( + "Each trait must have both 'type' and 'value' properties", + ); + } + }); + + test("throws error when trait in array is missing value", async () => { + const traits = [ + { type: "Background", value: "Blue" }, + { type: "Hat", value: "" }, + ]; + + try { + await offersAPI.buildOffer( + "0xofferer123", + 5, + "test-collection", + true, + undefined, + undefined, + traits, + ); + expect.fail("Expected error to be thrown"); + } catch (error) { + expect((error as Error).message).to.include( + "Each trait must have both 'type' and 'value' properties", + ); + } + }); + test("throws error on API failure", async () => { mockPost.rejects(new Error("Build failed")); @@ -620,6 +718,34 @@ suite("API: OffersAPI", () => { expect(mockPost.calledOnce).to.be.true; }); + test("posts collection offer with multiple traits", async () => { + const mockOrder: ProtocolData = { + parameters: {} as unknown as OrderComponents, + signature: "0xsig789", + }; + + const mockResponse: CollectionOffer = { + protocol_data: mockOrder, + } as unknown as CollectionOffer; + + mockPost.resolves(mockResponse); + + const traits = [ + { type: "Background", value: "Blue" }, + { type: "Hat", value: "Beanie" }, + ]; + + await offersAPI.postCollectionOffer( + mockOrder, + "test-collection", + undefined, + undefined, + traits, + ); + + expect(mockPost.calledOnce).to.be.true; + }); + test("returns null when appropriate", async () => { const mockOrder: ProtocolData = { parameters: {} as unknown as OrderComponents, diff --git a/test/orders/utils.spec.ts b/test/orders/utils.spec.ts index c11dcb7d8..f8a564607 100644 --- a/test/orders/utils.spec.ts +++ b/test/orders/utils.spec.ts @@ -171,6 +171,65 @@ suite("Orders: utils", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((result.criteria as any).trait).to.be.undefined; }); + + test("should add traits array when provided", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const protocolData = { parameters: {} } as any as ProtocolData; + const traits = [ + { type: "Background", value: "Blue" }, + { type: "Hat", value: "Beanie" }, + ]; + const result = getPostCollectionOfferPayload( + "boredapeyachtclub", + protocolData, + undefined, + undefined, + traits, + ); + + expect(result.criteria).to.have.property("traits"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((result.criteria as any).traits).to.deep.equal(traits); + }); + + test("should prioritize traits array over individual traitType/traitValue", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const protocolData = { parameters: {} } as any as ProtocolData; + const traits = [ + { type: "Background", value: "Blue" }, + { type: "Hat", value: "Beanie" }, + ]; + const result = getPostCollectionOfferPayload( + "boredapeyachtclub", + protocolData, + "Fur", + "Brown", + traits, + ); + + expect(result.criteria).to.have.property("traits"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((result.criteria as any).traits).to.deep.equal(traits); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((result.criteria as any).trait).to.be.undefined; + }); + + test("should not add traits when empty array is provided", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const protocolData = { parameters: {} } as any as ProtocolData; + const result = getPostCollectionOfferPayload( + "boredapeyachtclub", + protocolData, + undefined, + undefined, + [], + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((result.criteria as any).traits).to.be.undefined; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((result.criteria as any).trait).to.be.undefined; + }); }); suite("getBuildCollectionOfferPayload", () => { @@ -213,6 +272,65 @@ suite("Orders: utils", () => { }); expect(result.offer_protection_enabled).to.be.false; }); + + test("should add traits array when provided", () => { + const traits = [ + { type: "Background", value: "Blue" }, + { type: "Hat", value: "Beanie" }, + ]; + const result = getBuildCollectionOfferPayload( + "0xOfferer", + 2, + "boredapeyachtclub", + true, + undefined, + undefined, + traits, + ); + + expect(result.criteria).to.have.property("traits"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((result.criteria as any).traits).to.deep.equal(traits); + }); + + test("should prioritize traits array over individual traitType/traitValue", () => { + const traits = [ + { type: "Background", value: "Blue" }, + { type: "Fur", value: "Golden" }, + ]; + const result = getBuildCollectionOfferPayload( + "0xOfferer", + 1, + "boredapeyachtclub", + false, + "Hat", + "Crown", + traits, + ); + + expect(result.criteria).to.have.property("traits"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((result.criteria as any).traits).to.deep.equal(traits); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((result.criteria as any).trait).to.be.undefined; + }); + + test("should not add traits when empty array is provided", () => { + const result = getBuildCollectionOfferPayload( + "0xOfferer", + 5, + "boredapeyachtclub", + true, + undefined, + undefined, + [], + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((result.criteria as any).traits).to.be.undefined; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((result.criteria as any).trait).to.be.undefined; + }); }); suite("getFulfillListingPayload", () => { diff --git a/test/sdk/ordersManager.spec.ts b/test/sdk/ordersManager.spec.ts index cd2df65d8..c74474fbb 100644 --- a/test/sdk/ordersManager.spec.ts +++ b/test/sdk/ordersManager.spec.ts @@ -414,6 +414,28 @@ suite("SDK: OrdersManager", () => { expect(postOfferArgs[3]).to.equal("Blue"); }); + test("creates collection offer with multiple traits", async () => { + const traits = [ + { type: "Background", value: "Blue" }, + { type: "Hat", value: "Beanie" }, + ]; + + await ordersManager.createCollectionOffer({ + collectionSlug: "test-collection", + accountAddress: "0xBuyer", + amount: "1000000000000000000", + quantity: 1, + paymentTokenAddress: "0xWETH", + traits, + }); + + const buildOfferArgs = mockAPI.buildOffer.firstCall.args; + expect(buildOfferArgs[6]).to.deep.equal(traits); + + const postOfferArgs = mockAPI.postCollectionOffer.firstCall.args; + expect(postOfferArgs[4]).to.deep.equal(traits); + }); + test("creates collection offer with expiration time", async () => { const expirationTime = getUnixTimestampInSeconds(TimeInSeconds.DAY);