Skip to content

Commit c06a382

Browse files
authored
add collection offer reads and token id decoding (#1110)
* add collection offer reads and token id decoding * remove dep * add tests and fix helper function * fix util function * fix integ test * fixes * fixes * fixes * fixes * fixes * fixes * fixes
1 parent 1e96c76 commit c06a382

9 files changed

Lines changed: 3948 additions & 2012 deletions

File tree

package-lock.json

Lines changed: 3745 additions & 2001 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "opensea-js",
3-
"version": "6.1.4",
3+
"version": "6.1.5",
44
"description": "JavaScript SDK for the OpenSea marketplace helps developers build new experiences using NFTs and our marketplace data!",
55
"license": "MIT",
66
"author": "OpenSea Developers",

src/api/api.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { ethers } from "ethers";
22
import {
33
BuildOfferResponse,
4-
PostOfferResponse,
4+
Offer,
55
GetCollectionResponse,
66
ListNFTsResponse,
77
GetNFTResponse,
8+
ListCollectionOffersResponse,
89
} from "./types";
910
import { API_BASE_MAINNET, API_BASE_TESTNET, API_PATH } from "../constants";
1011
import {
@@ -34,6 +35,7 @@ import {
3435
getListNFTsByContractPath,
3536
getNFTPath,
3637
getRefreshMetadataPath,
38+
getCollectionOffersPath,
3739
} from "../orders/utils";
3840
import {
3941
Chain,
@@ -222,19 +224,45 @@ export class OpenSeaAPI {
222224
}
223225

224226
/**
225-
* Post collection offer
227+
* Get collection offers for a given slug in the API
228+
*
229+
* @param slug The collection you would like to list offers for
230+
* @param retries Number of times to retry if the service is unavailable for any reason
231+
*
232+
* @returns {@link ListCollectionOffersResponse}
233+
*/
234+
public async getCollectionOffers(
235+
slug: string,
236+
retries = 0,
237+
): Promise<ListCollectionOffersResponse | null> {
238+
try {
239+
return await this.get<ListCollectionOffersResponse>(
240+
getCollectionOffersPath(slug),
241+
);
242+
} catch (error) {
243+
_throwOrContinue(error, retries);
244+
await delay(1000);
245+
return this.getCollectionOffers(slug, retries - 1);
246+
}
247+
}
248+
249+
/**
250+
* Post a collection offer to the API
251+
*
252+
* @param order The order to post
253+
* @param slug The collection you would like to post an offer for
254+
* @param retries Number of times to retry if the service is unavailable for any reason
255+
*
256+
* @returns {@link Offer}
226257
*/
227258
public async postCollectionOffer(
228259
order: ProtocolData,
229260
slug: string,
230261
retries = 0,
231-
): Promise<PostOfferResponse | null> {
262+
): Promise<Offer | null> {
232263
const payload = getPostCollectionOfferPayload(slug, order);
233264
try {
234-
return await this.post<PostOfferResponse>(
235-
getPostCollectionOfferPath(),
236-
payload,
237-
);
265+
return await this.post<Offer>(getPostCollectionOfferPath(), payload);
238266
} catch (error) {
239267
_throwOrContinue(error, retries);
240268
await delay(1000);

src/api/types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ type PartialParameters = {
1414
type Criteria = {
1515
collection: CollectionCriteria;
1616
contract?: ContractCriteria;
17+
encoded_token_ids?: string;
1718
};
1819

1920
type CollectionCriteria = {
@@ -28,14 +29,18 @@ export type GetCollectionResponse = {
2829
collection: object;
2930
};
3031

31-
export type PostOfferResponse = {
32+
export type Offer = {
3233
order_hash: string;
3334
chain: string;
3435
criteria: Criteria;
3536
protocol_data: ProtocolData;
3637
protocol_address: string;
3738
};
3839

40+
export type ListCollectionOffersResponse = {
41+
offers: Offer[];
42+
};
43+
3944
export type ListNFTsResponse = {
4045
nfts: NFT[];
4146
next: string;

src/orders/utils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ export const getPostCollectionOfferPath = () => {
3535
return `/v2/offers`;
3636
};
3737

38+
export const getCollectionOffersPath = (slug: string) => {
39+
return `/v2/offers/collection/${slug}`;
40+
};
41+
3842
export const getListNFTsByCollectionPath = (slug: string) => {
3943
return `/v2/collection/${slug}/nfts`;
4044
};

src/sdk.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
} from "ethers";
1818
import { parseEther } from "ethers/lib/utils";
1919
import { OpenSeaAPI } from "./api/api";
20-
import { PostOfferResponse, NFT } from "./api/types";
20+
import { Offer, NFT } from "./api/types";
2121
import { INVERSE_BASIS_POINT, DEFAULT_ZONE } from "./constants";
2222
import {
2323
constructPrivateListingCounterOrder,
@@ -551,7 +551,7 @@ export class OpenSeaSDK {
551551
salt?: BigNumberish;
552552
expirationTime?: number | string;
553553
paymentTokenAddress: string;
554-
}): Promise<PostOfferResponse | null> {
554+
}): Promise<Offer | null> {
555555
await this._checkAccountIsAvailable(accountAddress);
556556

557557
const collection = await this.api.getCollection(collectionSlug);

src/utils/utils.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,3 +420,71 @@ export const isValidProtocol = (protocolAddress: string): boolean => {
420420
);
421421
return validProtocolAddresses.includes(checkSumAddress);
422422
};
423+
424+
/**
425+
* Decodes an encoded string of token IDs into an array of individual token IDs using BigNumber for precise calculations.
426+
*
427+
* The encoded token IDs can be in the following formats:
428+
* 1. Single numbers: '123' => ['123']
429+
* 2. Comma-separated numbers: '1,2,3,4' => ['1', '2', '3', '4']
430+
* 3. Ranges of numbers: '5:8' => ['5', '6', '7', '8']
431+
* 4. Combinations of single numbers and ranges: '1,3:5,8' => ['1', '3', '4', '5', '8']
432+
* 5. Wildcard '*' (matches all token IDs): '*' => ['*']
433+
*
434+
* @param encodedTokenIds - The encoded string of token IDs to be decoded.
435+
* @returns An array of individual token IDs after decoding the input.
436+
*
437+
* @throws {Error} If the input is not correctly formatted or if BigNumber operations fail.
438+
*
439+
* @example
440+
* const encoded = '1,3:5,8';
441+
* const decoded = decodeTokenIds(encoded); // Output: ['1', '3', '4', '5', '8']
442+
*
443+
* @example
444+
* const encodedWildcard = '*';
445+
* const decodedWildcard = decodeTokenIds(encodedWildcard); // Output: ['*']
446+
*
447+
* @example
448+
* const emptyEncoded = '';
449+
* const decodedEmpty = decodeTokenIds(emptyEncoded); // Output: []
450+
*/
451+
export const decodeTokenIds = (encodedTokenIds: string): string[] => {
452+
if (encodedTokenIds === "*") {
453+
return ["*"];
454+
}
455+
456+
const validFormatRegex = /^(\d+(:\d+)?)(,\d+(:\d+)?)*$/;
457+
458+
if (!validFormatRegex.test(encodedTokenIds)) {
459+
throw new Error(
460+
"Invalid input format. Expected a valid comma-separated list of numbers and ranges.",
461+
);
462+
}
463+
464+
const ranges = encodedTokenIds.split(",");
465+
const tokenIds: string[] = [];
466+
467+
for (const range of ranges) {
468+
if (range.includes(":")) {
469+
const [startStr, endStr] = range.split(":");
470+
const start = BigNumber.from(startStr);
471+
const end = BigNumber.from(endStr);
472+
const diff = end.sub(start).add(1);
473+
474+
if (diff.lte(0)) {
475+
throw new Error(
476+
`Invalid range. End value: ${end} must be greater than or equal to the start value: ${start}.`,
477+
);
478+
}
479+
480+
for (let i = BigNumber.from(0); i.lt(diff); i = i.add(1)) {
481+
tokenIds.push(start.add(i).toString());
482+
}
483+
} else {
484+
const tokenId = BigNumber.from(range);
485+
tokenIds.push(tokenId.toString());
486+
}
487+
}
488+
489+
return tokenIds;
490+
};
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { assert } from "chai";
2+
import { suite, test } from "mocha";
3+
import { sdk } from "./setup";
4+
import { decodeTokenIds } from "../../src/utils/utils";
5+
6+
suite("SDK: getCollectionOffers", () => {
7+
test("Get Collection Offers", async () => {
8+
const slug = "cool-cats-nft";
9+
const response = await sdk.api.getCollectionOffers(slug);
10+
11+
assert(response, "Response should not be null");
12+
assert(response.offers, "Collection offers should not be null");
13+
assert(response.offers.length > 0, "Collection offers should not be empty");
14+
const offer = response.offers[0];
15+
assert(offer.order_hash, "Order hash should not be null");
16+
const tokens = offer.criteria.encoded_token_ids;
17+
assert(tokens, "Criteria should not be null");
18+
19+
const encodedTokenIds = offer.criteria.encoded_token_ids;
20+
assert(encodedTokenIds, "Encoded tokens should not be null");
21+
22+
const decodedTokenIds = decodeTokenIds(encodedTokenIds);
23+
assert(decodedTokenIds[0], "Decoded tokens should not be null");
24+
});
25+
});

test/utils/utils.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { expect } from "chai";
22
import { OrderV2 } from "src/orders/types";
3+
import { decodeTokenIds } from "../../src/utils/utils";
34

45
export const expectValidOrder = (order: OrderV2) => {
56
const requiredFields = [
@@ -28,3 +29,64 @@ export const expectValidOrder = (order: OrderV2) => {
2829
expect(field in order).to.be.true;
2930
}
3031
};
32+
33+
describe("decodeTokenIds", () => {
34+
it('should return ["*"] when given "*" as input', () => {
35+
expect(decodeTokenIds("*")).deep.equal(["*"]);
36+
});
37+
38+
it("should correctly decode a single number", () => {
39+
expect(decodeTokenIds("123")).deep.equal(["123"]);
40+
});
41+
42+
it("should correctly decode multiple comma-separated numbers", () => {
43+
expect(decodeTokenIds("1,2,3,4")).deep.equal(["1", "2", "3", "4"]);
44+
});
45+
46+
it("should correctly decode a single number", () => {
47+
expect(decodeTokenIds("10:10")).deep.equal(["10"]);
48+
});
49+
50+
it("should correctly decode a range of numbers", () => {
51+
expect(decodeTokenIds("5:8")).deep.equal(["5", "6", "7", "8"]);
52+
});
53+
54+
it("should correctly decode multiple ranges of numbers", () => {
55+
expect(decodeTokenIds("1:3,7:9")).deep.equal([
56+
"1",
57+
"2",
58+
"3",
59+
"7",
60+
"8",
61+
"9",
62+
]);
63+
});
64+
65+
it("should correctly decode a mix of single numbers and ranges", () => {
66+
expect(decodeTokenIds("1,3:5,8")).deep.equal(["1", "3", "4", "5", "8"]);
67+
});
68+
69+
it("should throw an error for invalid input format", () => {
70+
expect(() => decodeTokenIds("1:3:5,8")).throw(
71+
"Invalid input format. Expected a valid comma-separated list of numbers and ranges.",
72+
);
73+
expect(() => decodeTokenIds("1;3:5,8")).throw(
74+
"Invalid input format. Expected a valid comma-separated list of numbers and ranges.",
75+
);
76+
});
77+
78+
it("should throw an error for invalid range format", () => {
79+
expect(() => decodeTokenIds("5:2")).throws(
80+
"Invalid range. End value: 2 must be greater than or equal to the start value: 5.",
81+
);
82+
});
83+
84+
it("should handle very large input numbers", () => {
85+
const encoded = "10000000000000000000000000:10000000000000000000000002";
86+
expect(decodeTokenIds(encoded)).deep.equal([
87+
"10000000000000000000000000",
88+
"10000000000000000000000001",
89+
"10000000000000000000000002",
90+
]);
91+
});
92+
});

0 commit comments

Comments
 (0)