opensea-js
Version:
TypeScript SDK for the OpenSea marketplace helps developers build new experiences using NFTs and our marketplace data
1,154 lines (1,063 loc) • 35.9 kB
text/typescript
import {
ConsiderationInputItem,
CreateInputItem,
OrderComponents,
} from "@opensea/seaport-js/lib/types";
import { BigNumberish, ZeroAddress } from "ethers";
import { CollectionOffer, NFT } from "../api/types";
import { INVERSE_BASIS_POINT } from "../constants";
import { SDKContext } from "./context";
import { OrderV2, ProtocolData } from "../orders/types";
import {
Fee,
OpenSeaCollection,
OrderSide,
TokenStandard,
AssetWithTokenId,
} from "../types";
import { oneMonthFromNowInSeconds } from "../utils/dateHelper";
import { pluralize } from "../utils/stringHelper";
import {
getAssetItemType,
getAddressAfterRemappingSharedStorefrontAddressToLazyMintAdapterAddress,
basisPointsForFee,
totalBasisPointsForFees,
getFeeRecipient,
getOfferPaymentToken,
getListingPaymentToken,
getSignedZone,
} from "../utils/utils";
/**
* Result type for bulk operations that may partially succeed.
* Contains successfully submitted orders and any failures with error information.
*/
export interface BulkOrderResult {
/** Successfully submitted orders */
successful: OrderV2[];
/** Failed order submissions with error information */
failed: Array<{
/** Index of the failed order in the original input array */
index: number;
/** The signed order that failed to submit (undefined if order creation failed before signing) */
order?: ProtocolData;
/** The error that occurred during submission */
error: Error;
}>;
}
/**
* Manager for order building and creation operations.
* Handles listing creation, offer creation, and collection offers.
*/
export class OrdersManager {
constructor(
private context: SDKContext,
private getPriceParametersCallback: (
orderSide: OrderSide,
tokenAddress: string,
amount: BigNumberish,
) => Promise<{ basePrice: bigint }>,
) {}
private getAmountWithBasisPointsApplied(
amount: bigint,
basisPoints: bigint,
): string {
return ((amount * basisPoints) / INVERSE_BASIS_POINT).toString();
}
private isNotMarketplaceFee(fee: Fee): boolean {
return (
fee.recipient.toLowerCase() !==
getFeeRecipient(this.context.chain).toLowerCase()
);
}
private getNFTItems(
nfts: NFT[],
quantities: bigint[] = [],
): CreateInputItem[] {
return nfts.map((nft, index) => ({
itemType: getAssetItemType(
nft.token_standard.toUpperCase() as TokenStandard,
),
token:
getAddressAfterRemappingSharedStorefrontAddressToLazyMintAdapterAddress(
nft.contract,
),
identifier: nft.identifier ?? undefined,
amount: quantities[index].toString() ?? "1",
}));
}
private async getFees({
collection,
seller,
paymentTokenAddress,
amount,
includeOptionalCreatorFees = false,
isPrivateListing = false,
}: {
collection: OpenSeaCollection;
seller?: string;
paymentTokenAddress: string;
amount: bigint;
includeOptionalCreatorFees?: boolean;
isPrivateListing?: boolean;
}): Promise<ConsiderationInputItem[]> {
let collectionFees = includeOptionalCreatorFees
? collection.fees
: collection.fees.filter((fee) => fee.required);
if (isPrivateListing) {
collectionFees = collectionFees.filter((fee) =>
this.isNotMarketplaceFee(fee),
);
}
const collectionFeesBasisPoints = totalBasisPointsForFees(collectionFees);
const sellerBasisPoints = INVERSE_BASIS_POINT - collectionFeesBasisPoints;
const getConsiderationItem = (basisPoints: bigint, recipient?: string) => {
return {
token: paymentTokenAddress,
amount: this.getAmountWithBasisPointsApplied(amount, basisPoints),
recipient,
};
};
const considerationItems: ConsiderationInputItem[] = [];
if (seller) {
considerationItems.push(getConsiderationItem(sellerBasisPoints, seller));
}
if (collectionFeesBasisPoints > 0) {
for (const fee of collectionFees) {
considerationItems.push(
getConsiderationItem(basisPointsForFee(fee), fee.recipient),
);
}
}
return considerationItems;
}
/**
* Build listing order without submitting to API
* @param options Listing parameters
* @returns OrderWithCounter ready for API submission or onchain validation
*/
private async _buildListingOrder({
asset,
accountAddress,
amount,
quantity = 1,
domain,
salt,
listingTime,
expirationTime,
paymentTokenAddress = getListingPaymentToken(this.context.chain),
buyerAddress,
includeOptionalCreatorFees = false,
zone = ZeroAddress,
}: {
asset: AssetWithTokenId;
accountAddress: string;
amount: BigNumberish;
quantity?: BigNumberish;
domain?: string;
salt?: BigNumberish;
listingTime?: number;
expirationTime?: number;
paymentTokenAddress?: string;
buyerAddress?: string;
includeOptionalCreatorFees?: boolean;
zone?: string;
}) {
await this.context.requireAccountIsAvailable(accountAddress);
const { nft } = await this.context.api.getNFT(
asset.tokenAddress,
asset.tokenId,
);
const offerAssetItems = this.getNFTItems([nft], [BigInt(quantity ?? 1)]);
const { basePrice } = await this.getPriceParametersCallback(
OrderSide.LISTING,
paymentTokenAddress,
amount,
);
const collection = await this.context.api.getCollection(nft.collection);
const considerationFeeItems = await this.getFees({
collection,
seller: accountAddress,
paymentTokenAddress,
amount: basePrice,
includeOptionalCreatorFees,
isPrivateListing: !!buyerAddress,
});
if (buyerAddress) {
const { getPrivateListingConsiderations } = await import(
"../orders/privateListings"
);
considerationFeeItems.push(
...getPrivateListingConsiderations(offerAssetItems, buyerAddress),
);
}
if (collection.requiredZone) {
zone = collection.requiredZone;
}
const { executeAllActions } = await this.context.seaport.createOrder(
{
offer: offerAssetItems,
consideration: considerationFeeItems,
startTime: listingTime?.toString(),
endTime:
expirationTime?.toString() ?? oneMonthFromNowInSeconds().toString(),
zone,
domain,
salt: BigInt(salt ?? 0).toString(),
restrictedByZone: zone !== ZeroAddress,
allowPartialFills: true,
},
accountAddress,
);
return executeAllActions();
}
/**
* Build listing order components without submitting to API
* @param options Listing parameters
* @returns OrderComponents ready for onchain validation
*/
async buildListingOrderComponents({
asset,
accountAddress,
amount,
quantity = 1,
domain,
salt,
listingTime,
expirationTime,
paymentTokenAddress = getListingPaymentToken(this.context.chain),
buyerAddress,
includeOptionalCreatorFees = false,
zone = ZeroAddress,
}: {
asset: AssetWithTokenId;
accountAddress: string;
amount: BigNumberish;
quantity?: BigNumberish;
domain?: string;
salt?: BigNumberish;
listingTime?: number;
expirationTime?: number;
paymentTokenAddress?: string;
buyerAddress?: string;
includeOptionalCreatorFees?: boolean;
zone?: string;
}): Promise<OrderComponents> {
const order = await this._buildListingOrder({
asset,
accountAddress,
amount,
quantity,
domain,
salt,
listingTime,
expirationTime,
paymentTokenAddress,
buyerAddress,
includeOptionalCreatorFees,
zone,
});
return order.parameters;
}
/**
* Build offer order without submitting to API
* @param options Offer parameters
* @returns OrderWithCounter ready for API submission or onchain validation
*/
private async _buildOfferOrder({
asset,
accountAddress,
amount,
quantity = 1,
domain,
salt,
expirationTime,
paymentTokenAddress = getOfferPaymentToken(this.context.chain),
zone = getSignedZone(this.context.chain),
}: {
asset: AssetWithTokenId;
accountAddress: string;
amount: BigNumberish;
quantity?: BigNumberish;
domain?: string;
salt?: BigNumberish;
expirationTime?: BigNumberish;
paymentTokenAddress?: string;
zone?: string;
}) {
await this.context.requireAccountIsAvailable(accountAddress);
const { nft } = await this.context.api.getNFT(
asset.tokenAddress,
asset.tokenId,
);
const considerationAssetItems = this.getNFTItems(
[nft],
[BigInt(quantity ?? 1)],
);
const { basePrice } = await this.getPriceParametersCallback(
OrderSide.OFFER,
paymentTokenAddress,
amount,
);
const collection = await this.context.api.getCollection(nft.collection);
const considerationFeeItems = await this.getFees({
collection,
paymentTokenAddress,
amount: basePrice,
});
if (collection.requiredZone) {
zone = collection.requiredZone;
}
const { executeAllActions } = await this.context.seaport.createOrder(
{
offer: [
{
token: paymentTokenAddress,
amount: basePrice.toString(),
},
],
consideration: [...considerationAssetItems, ...considerationFeeItems],
endTime:
expirationTime !== undefined
? BigInt(expirationTime).toString()
: oneMonthFromNowInSeconds().toString(),
zone,
domain,
salt: BigInt(salt ?? 0).toString(),
restrictedByZone: zone !== ZeroAddress,
allowPartialFills: true,
},
accountAddress,
);
return executeAllActions();
}
/**
* Build offer order components without submitting to API
* @param options Offer parameters
* @returns OrderComponents ready for onchain validation
*/
async buildOfferOrderComponents({
asset,
accountAddress,
amount,
quantity = 1,
domain,
salt,
expirationTime,
paymentTokenAddress = getOfferPaymentToken(this.context.chain),
zone = getSignedZone(this.context.chain),
}: {
asset: AssetWithTokenId;
accountAddress: string;
amount: BigNumberish;
quantity?: BigNumberish;
domain?: string;
salt?: BigNumberish;
expirationTime?: BigNumberish;
paymentTokenAddress?: string;
zone?: string;
}): Promise<OrderComponents> {
const order = await this._buildOfferOrder({
asset,
accountAddress,
amount,
quantity,
domain,
salt,
expirationTime,
paymentTokenAddress,
zone,
});
return order.parameters;
}
/**
* Create and submit an offer on an asset.
* @param options
* @param options.asset The asset to trade. tokenAddress and tokenId must be defined.
* @param options.accountAddress Address of the wallet making the offer.
* @param options.amount Amount in decimal format (e.g., "1.5" for 1.5 ETH, not wei). Automatically converted to base units.
* @param options.quantity Number of assets to bid for. Defaults to 1.
* @param options.domain Optional domain for on-chain attribution. Hashed and included in salt.
* @param options.salt Arbitrary salt. Auto-generated if not provided.
* @param options.expirationTime Expiration time for the order, in UTC seconds
* @param options.paymentTokenAddress ERC20 address for the payment token in the order. If unspecified, defaults to WETH
* @param options.zone Zone for order protection. Defaults to chain's signed zone.
*
* @returns The {@link OrderV2} that was created.
*
* @throws Error if the asset does not contain a token id.
* @throws Error if the accountAddress is not available through wallet or provider.
* @throws Error if the amount is not greater than 0.
* @throws Error if paymentTokenAddress is not WETH on anything other than Ethereum mainnet.
*/
async createOffer({
asset,
accountAddress,
amount,
quantity = 1,
domain,
salt,
expirationTime,
paymentTokenAddress = getOfferPaymentToken(this.context.chain),
zone = getSignedZone(this.context.chain),
}: {
asset: AssetWithTokenId;
accountAddress: string;
amount: BigNumberish;
quantity?: BigNumberish;
domain?: string;
salt?: BigNumberish;
expirationTime?: BigNumberish;
paymentTokenAddress?: string;
zone?: string;
}): Promise<OrderV2> {
const order = await this._buildOfferOrder({
asset,
accountAddress,
amount,
quantity,
domain,
salt,
expirationTime,
paymentTokenAddress,
zone,
});
return this.context.api.postOrder(order, {
protocol: "seaport",
protocolAddress: this.context.seaport.contract.target as string,
side: OrderSide.OFFER,
});
}
/**
* Create and submit a listing for an asset.
* @param options
* @param options.asset The asset to trade. tokenAddress and tokenId must be defined.
* @param options.accountAddress Address of the wallet making the listing
* @param options.amount Amount in decimal format (e.g., "1.5" for 1.5 ETH, not wei). Automatically converted to base units.
* @param options.quantity Number of assets to list. Defaults to 1.
* @param options.domain Optional domain for on-chain attribution. Hashed and included in salt. This can be used for on-chain order attribution to assist with analytics.
* @param options.salt Arbitrary salt. Auto-generated if not provided.
* @param options.listingTime Optional time when the order will become fulfillable, in UTC seconds. Undefined means it will start now.
* @param options.expirationTime Expiration time for the order, in UTC seconds.
* @param options.paymentTokenAddress ERC20 address for the payment token in the order. If unspecified, defaults to ETH
* @param options.buyerAddress Optional address that's allowed to purchase this item. If specified, no other address will be able to take the order, unless its value is the null address.
* @param options.includeOptionalCreatorFees If true, optional creator fees will be included in the listing. Default: false.
* @param options.zone Zone for order protection. Defaults to no zone.
* @returns The {@link OrderV2} that was created.
*
* @throws Error if the asset does not contain a token id.
* @throws Error if the accountAddress is not available through wallet or provider.
* @throws Error if the amount is not greater than 0.
* @throws Error if paymentTokenAddress is not WETH on anything other than Ethereum mainnet.
*/
async createListing({
asset,
accountAddress,
amount,
quantity = 1,
domain,
salt,
listingTime,
expirationTime,
paymentTokenAddress = getListingPaymentToken(this.context.chain),
buyerAddress,
includeOptionalCreatorFees = false,
zone = ZeroAddress,
}: {
asset: AssetWithTokenId;
accountAddress: string;
amount: BigNumberish;
quantity?: BigNumberish;
domain?: string;
salt?: BigNumberish;
listingTime?: number;
expirationTime?: number;
paymentTokenAddress?: string;
buyerAddress?: string;
includeOptionalCreatorFees?: boolean;
zone?: string;
}): Promise<OrderV2> {
const order = await this._buildListingOrder({
asset,
accountAddress,
amount,
quantity,
domain,
salt,
listingTime,
expirationTime,
paymentTokenAddress,
buyerAddress,
includeOptionalCreatorFees,
zone,
});
return this.context.api.postOrder(order, {
protocol: "seaport",
protocolAddress: this.context.seaport.contract.target as string,
side: OrderSide.LISTING,
});
}
/**
* Create and submit multiple listings using Seaport's bulk order creation.
* This method uses a single signature for all listings and submits them individually to the OpenSea API with rate limit handling.
* All listings must be from the same account address.
*
* Note: If only one listing is provided, this method will use a normal order signature instead of a bulk signature,
* as bulk signatures are more expensive to decode on-chain due to the merkle proof verification.
*
* @param options
* @param options.listings Array of listing parameters. Each listing requires asset, amount, and optionally other listing parameters.
* @param options.accountAddress Address of the wallet making the listings
* @param options.continueOnError If true, continue submitting remaining listings even if some fail. Default: false (throw on first error).
* @param options.onProgress Optional callback for progress updates. Called after each listing is submitted (successfully or not).
* @returns {@link BulkOrderResult} containing successful orders and any failures.
*
* @throws Error if listings array is empty
* @throws Error if the accountAddress is not available through wallet or provider.
* @throws Error if any asset does not contain a token id.
* @throws Error if continueOnError is false and any submission fails.
*/
async createBulkListings({
listings,
accountAddress,
continueOnError = false,
onProgress,
}: {
listings: Array<{
asset: AssetWithTokenId;
amount: BigNumberish;
quantity?: BigNumberish;
domain?: string;
salt?: BigNumberish;
listingTime?: number;
expirationTime?: number;
paymentTokenAddress?: string;
buyerAddress?: string;
includeOptionalCreatorFees?: boolean;
zone?: string;
}>;
accountAddress: string;
continueOnError?: boolean;
onProgress?: (completed: number, total: number) => void;
}): Promise<BulkOrderResult> {
if (listings.length === 0) {
throw new Error("Listings array cannot be empty");
}
// If only one listing, use normal signature to avoid bulk signature overhead
if (listings.length === 1) {
try {
const order = await this.createListing({
...listings[0],
accountAddress,
});
return {
successful: [order],
failed: [],
};
} catch (error) {
if (continueOnError) {
return {
successful: [],
failed: [
{
index: 0,
order: {} as ProtocolData, // Order wasn't created
error: error as Error,
},
],
};
}
throw error;
}
}
await this.context.requireAccountIsAvailable(accountAddress);
// Build metadata array for each listing
const listingMetadata: Array<{
nft: NFT;
collection: OpenSeaCollection;
paymentTokenAddress: string;
zone: string;
domain?: string;
salt?: BigNumberish;
listingTime?: number;
expirationTime?: number;
}> = [];
// Build all order inputs
for (const listing of listings) {
const {
asset,
amount,
quantity = 1,
domain,
salt,
listingTime,
expirationTime,
paymentTokenAddress = getListingPaymentToken(this.context.chain),
buyerAddress,
includeOptionalCreatorFees = false,
zone = ZeroAddress,
} = listing;
// Fetch NFT and collection data
const { nft } = await this.context.api.getNFT(
asset.tokenAddress,
asset.tokenId,
);
const collection = await this.context.api.getCollection(nft.collection);
const offerAssetItems = this.getNFTItems([nft], [BigInt(quantity ?? 1)]);
const { basePrice } = await this.getPriceParametersCallback(
OrderSide.LISTING,
paymentTokenAddress,
amount,
);
const considerationFeeItems = await this.getFees({
collection,
seller: accountAddress,
paymentTokenAddress,
amount: basePrice,
includeOptionalCreatorFees,
isPrivateListing: !!buyerAddress,
});
if (buyerAddress) {
const { getPrivateListingConsiderations } = await import(
"../orders/privateListings"
);
considerationFeeItems.push(
...getPrivateListingConsiderations(offerAssetItems, buyerAddress),
);
}
let finalZone = zone;
if (collection.requiredZone) {
finalZone = collection.requiredZone;
}
listingMetadata.push({
nft,
collection,
paymentTokenAddress,
zone: finalZone,
domain,
salt,
listingTime,
expirationTime,
});
}
// Create the bulk orders using seaport's createBulkOrders method
const createOrderInputsForSeaport = listings.map((listing, index) => {
const {
amount,
quantity = 1,
listingTime,
expirationTime,
buyerAddress,
includeOptionalCreatorFees = false,
} = listing;
const metadata = listingMetadata[index];
const offerAssetItems = this.getNFTItems(
[metadata.nft],
[BigInt(quantity ?? 1)],
);
return this.getPriceParametersCallback(
OrderSide.LISTING,
metadata.paymentTokenAddress,
amount,
).then(async ({ basePrice }) => {
const considerationFeeItems = await this.getFees({
collection: metadata.collection,
seller: accountAddress,
paymentTokenAddress: metadata.paymentTokenAddress,
amount: basePrice,
includeOptionalCreatorFees,
isPrivateListing: !!buyerAddress,
});
if (buyerAddress) {
const { getPrivateListingConsiderations } = await import(
"../orders/privateListings"
);
considerationFeeItems.push(
...getPrivateListingConsiderations(offerAssetItems, buyerAddress),
);
}
return {
offer: offerAssetItems,
consideration: considerationFeeItems,
startTime: listingTime?.toString(),
endTime:
expirationTime?.toString() ?? oneMonthFromNowInSeconds().toString(),
zone: metadata.zone,
domain: metadata.domain,
salt: metadata.salt
? BigInt(metadata.salt ?? 0).toString()
: undefined,
restrictedByZone: metadata.zone !== ZeroAddress,
allowPartialFills: true,
};
});
});
const resolvedInputs = await Promise.all(createOrderInputsForSeaport);
const { executeAllActions } = await this.context.seaport.createBulkOrders(
resolvedInputs,
accountAddress,
);
const orders = await executeAllActions();
// Submit each order individually to the OpenSea API
// Rate limiting is handled automatically by the API client
this.context.logger(
`Starting submission of ${orders.length} bulk-signed ${pluralize(orders.length, "listing")} to OpenSea API...`,
);
const submittedOrders: OrderV2[] = [];
const failedOrders: BulkOrderResult["failed"] = [];
for (let i = 0; i < orders.length; i++) {
this.context.logger(`Submitting listing ${i + 1}/${orders.length}...`);
try {
const submittedOrder = await this.context.api.postOrder(orders[i], {
protocol: "seaport",
protocolAddress: this.context.seaport.contract.target as string,
side: OrderSide.LISTING,
});
submittedOrders.push(submittedOrder);
this.context.logger(`Completed listing ${i + 1}/${orders.length}`);
} catch (error) {
const errorMessage = (error as Error).message;
this.context.logger(
`Failed listing ${i + 1}/${orders.length}: ${errorMessage}`,
);
failedOrders.push({
index: i,
order: orders[i],
error: error as Error,
});
// If not continuing on error, throw immediately
if (!continueOnError) {
throw error;
}
}
// Call progress callback after each listing (successful or failed)
onProgress?.(i + 1, orders.length);
}
if (submittedOrders.length > 0) {
this.context.logger(
`Successfully submitted ${submittedOrders.length}/${orders.length} ${pluralize(submittedOrders.length, "listing")}`,
);
}
if (failedOrders.length > 0) {
this.context.logger(
`Failed to submit ${failedOrders.length}/${orders.length} ${pluralize(failedOrders.length, "listing")}`,
);
}
return {
successful: submittedOrders,
failed: failedOrders,
};
}
/**
* Create and submit multiple offers using Seaport's bulk order creation.
* This method uses a single signature for all offers and submits them individually to the OpenSea API with rate limit handling.
* All offers must be from the same account address.
*
* Note: If only one offer is provided, this method will use a normal order signature instead of a bulk signature,
* as bulk signatures are more expensive to decode on-chain due to the merkle proof verification.
*
* @param options
* @param options.offers Array of offer parameters. Each offer requires asset, amount, and optionally other offer parameters.
* @param options.accountAddress Address of the wallet making the offers
* @param options.continueOnError If true, continue submitting remaining offers even if some fail. Default: false (throw on first error).
* @param options.onProgress Optional callback for progress updates. Called after each offer is submitted (successfully or not).
* @returns {@link BulkOrderResult} containing successful orders and any failures.
*
* @throws Error if offers array is empty
* @throws Error if the accountAddress is not available through wallet or provider.
* @throws Error if any asset does not contain a token id.
* @throws Error if continueOnError is false and any submission fails.
*/
async createBulkOffers({
offers,
accountAddress,
continueOnError = false,
onProgress,
}: {
offers: Array<{
asset: AssetWithTokenId;
amount: BigNumberish;
quantity?: BigNumberish;
domain?: string;
salt?: BigNumberish;
expirationTime?: BigNumberish;
paymentTokenAddress?: string;
zone?: string;
}>;
accountAddress: string;
continueOnError?: boolean;
onProgress?: (completed: number, total: number) => void;
}): Promise<BulkOrderResult> {
if (offers.length === 0) {
throw new Error("Offers array cannot be empty");
}
// If only one offer, use normal signature to avoid bulk signature overhead
if (offers.length === 1) {
try {
const order = await this.createOffer({
...offers[0],
accountAddress,
});
return {
successful: [order],
failed: [],
};
} catch (error) {
if (continueOnError) {
return {
successful: [],
failed: [
{
index: 0,
order: {} as ProtocolData, // Order wasn't created
error: error as Error,
},
],
};
}
throw error;
}
}
await this.context.requireAccountIsAvailable(accountAddress);
// Build metadata array for each offer
const offerMetadata: Array<{
nft: NFT;
collection: OpenSeaCollection;
paymentTokenAddress: string;
zone: string;
domain?: string;
salt?: BigNumberish;
expirationTime?: BigNumberish;
}> = [];
// Build all order inputs
for (const offer of offers) {
const {
asset,
domain,
salt,
expirationTime,
paymentTokenAddress = getOfferPaymentToken(this.context.chain),
zone = getSignedZone(this.context.chain),
} = offer;
// Fetch NFT and collection data
const { nft } = await this.context.api.getNFT(
asset.tokenAddress,
asset.tokenId,
);
const collection = await this.context.api.getCollection(nft.collection);
let finalZone = zone;
if (collection.requiredZone) {
finalZone = collection.requiredZone;
}
offerMetadata.push({
nft,
collection,
paymentTokenAddress,
zone: finalZone,
domain,
salt,
expirationTime,
});
}
// Create the bulk orders using seaport's createBulkOrders method
const createOrderInputsForSeaport = offers.map((offer, index) => {
const { amount, quantity = 1 } = offer;
const metadata = offerMetadata[index];
const considerationAssetItems = this.getNFTItems(
[metadata.nft],
[BigInt(quantity ?? 1)],
);
return this.getPriceParametersCallback(
OrderSide.OFFER,
metadata.paymentTokenAddress,
amount,
).then(async ({ basePrice }) => {
const considerationFeeItems = await this.getFees({
collection: metadata.collection,
paymentTokenAddress: metadata.paymentTokenAddress,
amount: basePrice,
});
return {
offer: [
{
token: metadata.paymentTokenAddress,
amount: basePrice.toString(),
},
],
consideration: [...considerationAssetItems, ...considerationFeeItems],
endTime:
metadata.expirationTime !== undefined
? BigInt(metadata.expirationTime).toString()
: oneMonthFromNowInSeconds().toString(),
zone: metadata.zone,
domain: metadata.domain,
salt: metadata.salt
? BigInt(metadata.salt ?? 0).toString()
: undefined,
restrictedByZone: metadata.zone !== ZeroAddress,
allowPartialFills: true,
};
});
});
const resolvedInputs = await Promise.all(createOrderInputsForSeaport);
const { executeAllActions } = await this.context.seaport.createBulkOrders(
resolvedInputs,
accountAddress,
);
const orders = await executeAllActions();
// Submit each order individually to the OpenSea API
// Rate limiting is handled automatically by the API client
this.context.logger(
`Starting submission of ${orders.length} bulk-signed ${pluralize(orders.length, "offer")} to OpenSea API...`,
);
const submittedOrders: OrderV2[] = [];
const failedOrders: BulkOrderResult["failed"] = [];
for (let i = 0; i < orders.length; i++) {
this.context.logger(`Submitting offer ${i + 1}/${orders.length}...`);
try {
const submittedOrder = await this.context.api.postOrder(orders[i], {
protocol: "seaport",
protocolAddress: this.context.seaport.contract.target as string,
side: OrderSide.OFFER,
});
submittedOrders.push(submittedOrder);
this.context.logger(`Completed offer ${i + 1}/${orders.length}`);
} catch (error) {
const errorMessage = (error as Error).message;
this.context.logger(
`Failed offer ${i + 1}/${orders.length}: ${errorMessage}`,
);
failedOrders.push({
index: i,
order: orders[i],
error: error as Error,
});
// If not continuing on error, throw immediately
if (!continueOnError) {
throw error;
}
}
// Call progress callback after each offer (successful or failed)
onProgress?.(i + 1, orders.length);
}
if (submittedOrders.length > 0) {
this.context.logger(
`Successfully submitted ${submittedOrders.length}/${orders.length} ${pluralize(submittedOrders.length, "offer")}`,
);
}
if (failedOrders.length > 0) {
this.context.logger(
`Failed to submit ${failedOrders.length}/${orders.length} ${pluralize(failedOrders.length, "offer")}`,
);
}
return {
successful: submittedOrders,
failed: failedOrders,
};
}
/**
* Create and submit a collection offer.
* @param options
* @param options.collectionSlug Identifier for the collection.
* @param options.accountAddress Address of the wallet making the offer.
* @param options.amount Amount in decimal format (e.g., "1.5" for 1.5 ETH, not wei). Automatically converted to base units.
* @param options.quantity Number of assets to bid for.
* @param options.domain Optional domain for on-chain attribution. Hashed and included in salt. This can be used for on-chain order attribution to assist with analytics.
* @param options.salt Arbitrary salt. Auto-generated if not provided.
* @param options.expirationTime Expiration time for the order, in UTC seconds.
* @param options.paymentTokenAddress ERC20 address for the payment token in the order. If unspecified, defaults to WETH.
* @param options.offerProtectionEnabled Use signed zone for protection against disabled items. Default: true.
* @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({
collectionSlug,
accountAddress,
amount,
quantity,
domain,
salt,
expirationTime,
paymentTokenAddress = getOfferPaymentToken(this.context.chain),
offerProtectionEnabled = true,
traitType,
traitValue,
traits,
}: {
collectionSlug: string;
accountAddress: string;
amount: BigNumberish;
quantity: number;
domain?: string;
salt?: BigNumberish;
expirationTime?: number | string;
paymentTokenAddress: string;
offerProtectionEnabled?: boolean;
traitType?: string;
traitValue?: string;
traits?: Array<{ type: string; value: string }>;
}): Promise<CollectionOffer | null> {
await this.context.requireAccountIsAvailable(accountAddress);
const collection = await this.context.api.getCollection(collectionSlug);
const buildOfferResult = await this.context.api.buildOffer(
accountAddress,
quantity,
collectionSlug,
offerProtectionEnabled,
traitType,
traitValue,
traits,
);
const item = buildOfferResult.partialParameters.consideration[0];
const convertedConsiderationItem = {
itemType: item.itemType,
token: item.token,
identifier: item.identifierOrCriteria,
amount: item.startAmount,
};
const { basePrice } = await this.getPriceParametersCallback(
OrderSide.LISTING,
paymentTokenAddress,
amount,
);
const considerationFeeItems = await this.getFees({
collection,
paymentTokenAddress,
amount: basePrice,
});
const considerationItems = [
convertedConsiderationItem,
...considerationFeeItems,
];
const payload = {
offerer: accountAddress,
offer: [
{
token: paymentTokenAddress,
amount: basePrice.toString(),
},
],
consideration: considerationItems,
endTime:
expirationTime?.toString() ?? oneMonthFromNowInSeconds().toString(),
zone: buildOfferResult.partialParameters.zone,
domain,
salt: BigInt(salt ?? 0).toString(),
restrictedByZone: true,
allowPartialFills: true,
};
const { executeAllActions } = await this.context.seaport.createOrder(
payload,
accountAddress,
);
const order = await executeAllActions();
return this.context.api.postCollectionOffer(
order,
collectionSlug,
traitType,
traitValue,
traits,
);
}
}