Skip to content

Commit

Permalink
add collection offer reads and token id decoding (#1110)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
cterech authored Jul 26, 2023
1 parent 1e96c76 commit c06a382
Show file tree
Hide file tree
Showing 9 changed files with 3,948 additions and 2,012 deletions.
5,746 changes: 3,745 additions & 2,001 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "opensea-js",
"version": "6.1.4",
"version": "6.1.5",
"description": "JavaScript SDK for the OpenSea marketplace helps developers build new experiences using NFTs and our marketplace data!",
"license": "MIT",
"author": "OpenSea Developers",
Expand Down
42 changes: 35 additions & 7 deletions src/api/api.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { ethers } from "ethers";
import {
BuildOfferResponse,
PostOfferResponse,
Offer,
GetCollectionResponse,
ListNFTsResponse,
GetNFTResponse,
ListCollectionOffersResponse,
} from "./types";
import { API_BASE_MAINNET, API_BASE_TESTNET, API_PATH } from "../constants";
import {
Expand Down Expand Up @@ -34,6 +35,7 @@ import {
getListNFTsByContractPath,
getNFTPath,
getRefreshMetadataPath,
getCollectionOffersPath,
} from "../orders/utils";
import {
Chain,
Expand Down Expand Up @@ -222,19 +224,45 @@ export class OpenSeaAPI {
}

/**
* Post collection offer
* Get collection offers for a given slug in the API
*
* @param slug The collection you would like to list offers for
* @param retries Number of times to retry if the service is unavailable for any reason
*
* @returns {@link ListCollectionOffersResponse}
*/
public async getCollectionOffers(
slug: string,
retries = 0,
): Promise<ListCollectionOffersResponse | null> {
try {
return await this.get<ListCollectionOffersResponse>(
getCollectionOffersPath(slug),
);
} catch (error) {
_throwOrContinue(error, retries);
await delay(1000);
return this.getCollectionOffers(slug, retries - 1);
}
}

/**
* Post a collection offer to the API
*
* @param order The order to post
* @param slug The collection you would like to post an offer for
* @param retries Number of times to retry if the service is unavailable for any reason
*
* @returns {@link Offer}
*/
public async postCollectionOffer(
order: ProtocolData,
slug: string,
retries = 0,
): Promise<PostOfferResponse | null> {
): Promise<Offer | null> {
const payload = getPostCollectionOfferPayload(slug, order);
try {
return await this.post<PostOfferResponse>(
getPostCollectionOfferPath(),
payload,
);
return await this.post<Offer>(getPostCollectionOfferPath(), payload);
} catch (error) {
_throwOrContinue(error, retries);
await delay(1000);
Expand Down
7 changes: 6 additions & 1 deletion src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type PartialParameters = {
type Criteria = {
collection: CollectionCriteria;
contract?: ContractCriteria;
encoded_token_ids?: string;
};

type CollectionCriteria = {
Expand All @@ -28,14 +29,18 @@ export type GetCollectionResponse = {
collection: object;
};

export type PostOfferResponse = {
export type Offer = {
order_hash: string;
chain: string;
criteria: Criteria;
protocol_data: ProtocolData;
protocol_address: string;
};

export type ListCollectionOffersResponse = {
offers: Offer[];
};

export type ListNFTsResponse = {
nfts: NFT[];
next: string;
Expand Down
4 changes: 4 additions & 0 deletions src/orders/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ export const getPostCollectionOfferPath = () => {
return `/v2/offers`;
};

export const getCollectionOffersPath = (slug: string) => {
return `/v2/offers/collection/${slug}`;
};

export const getListNFTsByCollectionPath = (slug: string) => {
return `/v2/collection/${slug}/nfts`;
};
Expand Down
4 changes: 2 additions & 2 deletions src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
} from "ethers";
import { parseEther } from "ethers/lib/utils";
import { OpenSeaAPI } from "./api/api";
import { PostOfferResponse, NFT } from "./api/types";
import { Offer, NFT } from "./api/types";
import { INVERSE_BASIS_POINT, DEFAULT_ZONE } from "./constants";
import {
constructPrivateListingCounterOrder,
Expand Down Expand Up @@ -551,7 +551,7 @@ export class OpenSeaSDK {
salt?: BigNumberish;
expirationTime?: number | string;
paymentTokenAddress: string;
}): Promise<PostOfferResponse | null> {
}): Promise<Offer | null> {
await this._checkAccountIsAvailable(accountAddress);

const collection = await this.api.getCollection(collectionSlug);
Expand Down
68 changes: 68 additions & 0 deletions src/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,3 +420,71 @@ export const isValidProtocol = (protocolAddress: string): boolean => {
);
return validProtocolAddresses.includes(checkSumAddress);
};

/**
* Decodes an encoded string of token IDs into an array of individual token IDs using BigNumber for precise calculations.
*
* The encoded token IDs can be in the following formats:
* 1. Single numbers: '123' => ['123']
* 2. Comma-separated numbers: '1,2,3,4' => ['1', '2', '3', '4']
* 3. Ranges of numbers: '5:8' => ['5', '6', '7', '8']
* 4. Combinations of single numbers and ranges: '1,3:5,8' => ['1', '3', '4', '5', '8']
* 5. Wildcard '*' (matches all token IDs): '*' => ['*']
*
* @param encodedTokenIds - The encoded string of token IDs to be decoded.
* @returns An array of individual token IDs after decoding the input.
*
* @throws {Error} If the input is not correctly formatted or if BigNumber operations fail.
*
* @example
* const encoded = '1,3:5,8';
* const decoded = decodeTokenIds(encoded); // Output: ['1', '3', '4', '5', '8']
*
* @example
* const encodedWildcard = '*';
* const decodedWildcard = decodeTokenIds(encodedWildcard); // Output: ['*']
*
* @example
* const emptyEncoded = '';
* const decodedEmpty = decodeTokenIds(emptyEncoded); // Output: []
*/
export const decodeTokenIds = (encodedTokenIds: string): string[] => {
if (encodedTokenIds === "*") {
return ["*"];
}

const validFormatRegex = /^(\d+(:\d+)?)(,\d+(:\d+)?)*$/;

if (!validFormatRegex.test(encodedTokenIds)) {
throw new Error(
"Invalid input format. Expected a valid comma-separated list of numbers and ranges.",
);
}

const ranges = encodedTokenIds.split(",");
const tokenIds: string[] = [];

for (const range of ranges) {
if (range.includes(":")) {
const [startStr, endStr] = range.split(":");
const start = BigNumber.from(startStr);
const end = BigNumber.from(endStr);
const diff = end.sub(start).add(1);

if (diff.lte(0)) {
throw new Error(
`Invalid range. End value: ${end} must be greater than or equal to the start value: ${start}.`,
);
}

for (let i = BigNumber.from(0); i.lt(diff); i = i.add(1)) {
tokenIds.push(start.add(i).toString());
}
} else {
const tokenId = BigNumber.from(range);
tokenIds.push(tokenId.toString());
}
}

return tokenIds;
};
25 changes: 25 additions & 0 deletions test/integration/getCollectionOffers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { assert } from "chai";
import { suite, test } from "mocha";
import { sdk } from "./setup";
import { decodeTokenIds } from "../../src/utils/utils";

suite("SDK: getCollectionOffers", () => {
test("Get Collection Offers", async () => {
const slug = "cool-cats-nft";
const response = await sdk.api.getCollectionOffers(slug);

assert(response, "Response should not be null");
assert(response.offers, "Collection offers should not be null");
assert(response.offers.length > 0, "Collection offers should not be empty");
const offer = response.offers[0];
assert(offer.order_hash, "Order hash should not be null");
const tokens = offer.criteria.encoded_token_ids;
assert(tokens, "Criteria should not be null");

const encodedTokenIds = offer.criteria.encoded_token_ids;
assert(encodedTokenIds, "Encoded tokens should not be null");

const decodedTokenIds = decodeTokenIds(encodedTokenIds);
assert(decodedTokenIds[0], "Decoded tokens should not be null");
});
});
62 changes: 62 additions & 0 deletions test/utils/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect } from "chai";
import { OrderV2 } from "src/orders/types";
import { decodeTokenIds } from "../../src/utils/utils";

export const expectValidOrder = (order: OrderV2) => {
const requiredFields = [
Expand Down Expand Up @@ -28,3 +29,64 @@ export const expectValidOrder = (order: OrderV2) => {
expect(field in order).to.be.true;
}
};

describe("decodeTokenIds", () => {
it('should return ["*"] when given "*" as input', () => {
expect(decodeTokenIds("*")).deep.equal(["*"]);
});

it("should correctly decode a single number", () => {
expect(decodeTokenIds("123")).deep.equal(["123"]);
});

it("should correctly decode multiple comma-separated numbers", () => {
expect(decodeTokenIds("1,2,3,4")).deep.equal(["1", "2", "3", "4"]);
});

it("should correctly decode a single number", () => {
expect(decodeTokenIds("10:10")).deep.equal(["10"]);
});

it("should correctly decode a range of numbers", () => {
expect(decodeTokenIds("5:8")).deep.equal(["5", "6", "7", "8"]);
});

it("should correctly decode multiple ranges of numbers", () => {
expect(decodeTokenIds("1:3,7:9")).deep.equal([
"1",
"2",
"3",
"7",
"8",
"9",
]);
});

it("should correctly decode a mix of single numbers and ranges", () => {
expect(decodeTokenIds("1,3:5,8")).deep.equal(["1", "3", "4", "5", "8"]);
});

it("should throw an error for invalid input format", () => {
expect(() => decodeTokenIds("1:3:5,8")).throw(
"Invalid input format. Expected a valid comma-separated list of numbers and ranges.",
);
expect(() => decodeTokenIds("1;3:5,8")).throw(
"Invalid input format. Expected a valid comma-separated list of numbers and ranges.",
);
});

it("should throw an error for invalid range format", () => {
expect(() => decodeTokenIds("5:2")).throws(
"Invalid range. End value: 2 must be greater than or equal to the start value: 5.",
);
});

it("should handle very large input numbers", () => {
const encoded = "10000000000000000000000000:10000000000000000000000002";
expect(decodeTokenIds(encoded)).deep.equal([
"10000000000000000000000000",
"10000000000000000000000001",
"10000000000000000000000002",
]);
});
});

0 comments on commit c06a382

Please sign in to comment.