From ac61e81c7f53eb4ef674b9adfe305f8be5484a2c Mon Sep 17 00:00:00 2001 From: Chris Terech <34779743+cterech@users.noreply.github.com> Date: Thu, 9 Mar 2023 17:25:05 -0500 Subject: [PATCH] collection offers (#868) * add collection offer posting --- .github/workflows/npm-publish.yml | 37 +++++++ package.json | 1 + src/__integration_tests__/README.md | 16 +++ src/__integration_tests__/postOrder.ts | 49 +++++++++ src/__tests__/constants.ts | 4 + src/api.ts | 62 ++++++++++++ src/constants.ts | 2 +- src/orders/types.ts | 40 +++++++- src/orders/utils.ts | 41 ++++++++ src/sdk.ts | 131 ++++++++++++++++++++----- src/types.ts | 2 +- 11 files changed, 359 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/npm-publish.yml create mode 100644 src/__integration_tests__/README.md create mode 100644 src/__integration_tests__/postOrder.ts diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 000000000..fae698978 --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,37 @@ +# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created +# For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages + +name: Node.js Package + +on: + release: + types: [created] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 16.11.0 + - run: npm ci + - run: npm test + - run: npm run build + - run: yarn build + + publish-npm: + needs: build + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 16.11.0 + registry-url: https://registry.npmjs.org/ + - run: npm ci + - run: npm run build + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{secrets.npm_token}} diff --git a/package.json b/package.json index 1d30c8c01..315b06203 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "coverage-report": "nyc report --reporter=text-lcov | coveralls", "docs-build": "typedoc --out docs src/index.ts", "eslint:check": "eslint . --max-warnings 0 --ext .js,.ts", + "integration_tests": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' TS_NODE_TRANSPILE_ONLY=true nyc mocha -r ts-node/register src/__integration_tests__/**/*.ts --timeout 15000", "lint:check": "concurrently \"yarn check-types\" \"yarn prettier:check\" \"yarn eslint:check\"", "prepare": "husky install && npm run build", "test": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' TS_NODE_TRANSPILE_ONLY=true nyc mocha -r ts-node/register src/__tests__/**/*.ts --timeout 15000", diff --git a/src/__integration_tests__/README.md b/src/__integration_tests__/README.md new file mode 100644 index 000000000..5912e5c07 --- /dev/null +++ b/src/__integration_tests__/README.md @@ -0,0 +1,16 @@ +# Integration Tests + +These tests were built to test the order posting functionality of the SDK. Signing and posting order requires a bit more setup than the other tests, so we detail that here. + +### Environment variables: + +- `API_KEY`: your OpenSea mainnet API key. +- `WALLET_ADDRESS`: the wallet address to send your offer from. +- `WALLET_PRIV_KEY`: the private key to your wallet. This is required to sign the order. +- `ALCHEMY_API_KEY`: your Alchemy API key. + +### How to run: + +``` +API_KEY="..." WALLET_ADDRESS="..." WALLET_PRIV_KEY="..." ALCHEMY_API_KEY="..." npm run integration_tests +``` diff --git a/src/__integration_tests__/postOrder.ts b/src/__integration_tests__/postOrder.ts new file mode 100644 index 000000000..db3488142 --- /dev/null +++ b/src/__integration_tests__/postOrder.ts @@ -0,0 +1,49 @@ +import { ethers } from "ethers"; +import { suite, test } from "mocha"; +import Web3 from "web3"; +import { + MAINNET_API_KEY, + WALLET_ADDRESS, + WALLET_PRIV_KEY, + ALCHEMY_API_KEY, + WETH_ADDRESS, +} from "../__tests__/constants"; +import { OpenSeaSDK } from "../index"; +import { Network } from "../types"; + +const webProvider = new Web3.providers.HttpProvider( + `https://eth-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}` +); + +const rpcProvider = new ethers.providers.JsonRpcProvider( + `https://eth-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}` +); + +const wallet = new ethers.Wallet( + WALLET_PRIV_KEY ? WALLET_PRIV_KEY : "", + rpcProvider +); + +const sdk = new OpenSeaSDK( + webProvider, + { + networkName: Network.Main, + apiKey: MAINNET_API_KEY, + }, + (line) => console.info(`MAINNET: ${line}`), + wallet +); + +suite("SDK: order posting", () => { + test("Post collection offer", async () => { + const collection = await sdk.api.getCollection("cool-cats-nft"); + const postOrderRequest = { + collectionSlug: collection.slug, + accountAddress: WALLET_ADDRESS ? WALLET_ADDRESS : "", + amount: "0.004", + quantity: 1, + paymentTokenAddress: WETH_ADDRESS, + }; + await sdk.createCollectionOffer(postOrderRequest); + }); +}); diff --git a/src/__tests__/constants.ts b/src/__tests__/constants.ts index c4e268acb..5f122607f 100644 --- a/src/__tests__/constants.ts +++ b/src/__tests__/constants.ts @@ -2,6 +2,9 @@ import { OpenSeaAPI } from "../api"; import { Network } from "../types"; export const MAINNET_API_KEY = process.env.API_KEY; +export const WALLET_ADDRESS = process.env.WALLET_ADDRESS; +export const WALLET_PRIV_KEY = process.env.WALLET_PRIV_KEY; +export const ALCHEMY_API_KEY = process.env.ALCHEMY_API_KEY; export const mainApi = new OpenSeaAPI( { @@ -24,3 +27,4 @@ export const BAYC_CONTRACT_ADDRESS = "0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d"; export const BAYC_TOKEN_ID = "1"; export const BAYC_TOKEN_IDS = ["1", "2", "3", "4", "5", "6", "7", "8", "9"]; +export const WETH_ADDRESS = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; diff --git a/src/api.ts b/src/api.ts index 78d4da0c7..a7d2a87af 100644 --- a/src/api.ts +++ b/src/api.ts @@ -9,13 +9,16 @@ import { ORDERBOOK_PATH, } from "./constants"; import { + BuildOfferResponse, FulfillmentDataResponse, + GetCollectionResponse, OrderAPIOptions, OrderSide, OrdersPostQueryResponse, OrdersQueryOptions, OrdersQueryResponse, OrderV2, + PostOfferResponse, ProtocolData, QueryCursors, } from "./orders/types"; @@ -26,6 +29,11 @@ import { getFulfillmentDataPath, getFulfillListingPayload, getFulfillOfferPayload, + getBuildOfferPath, + getBuildCollectionOfferPayload, + getCollectionPath, + getPostCollectionOfferPath, + getPostCollectionOfferPayload, } from "./orders/utils"; import { Network, @@ -34,6 +42,7 @@ import { OpenSeaAssetBundle, OpenSeaAssetBundleQuery, OpenSeaAssetQuery, + OpenSeaCollection, OpenSeaFungibleToken, OpenSeaFungibleTokenQuery, } from "./types"; @@ -42,6 +51,7 @@ import { assetFromJSON, delay, tokenFromJSON, + collectionFromJSON, } from "./utils/utils"; export class OpenSeaAPI { @@ -196,6 +206,49 @@ export class OpenSeaAPI { return deserializeOrder(response.order); } + /** + * Build an offer + */ + public async buildOffer( + offererAddress: string, + quantity: number, + collectionSlug: string + ): Promise { + const payload = getBuildCollectionOfferPayload( + offererAddress, + quantity, + collectionSlug + ); + const response = await this.post( + getBuildOfferPath(), + payload + ); + return response; + } + + /** + * Post collection offer + */ + public async postCollectionOffer( + order: ProtocolData, + slug: string, + retries = 0 + ): Promise { + const payload = getPostCollectionOfferPayload(slug, order); + console.log("Post Order Payload"); + console.log(JSON.stringify(payload, null, 4)); + try { + return await this.post( + getPostCollectionOfferPath(), + payload + ); + } catch (error) { + _throwOrContinue(error, retries); + await delay(1000); + return this.postCollectionOffer(order, slug, retries - 1); + } + } + /** * Create a whitelist entry for an asset to prevent others from buying. * Buyers will have to have verified at least one of the emails @@ -294,6 +347,15 @@ export class OpenSeaAPI { }; } + /** + * Fetch a collection through the API + */ + public async getCollection(slug: string): Promise { + const path = getCollectionPath(slug); + const response = await this.get(path); + return collectionFromJSON(response.collection); + } + /** * Fetch list of fungible tokens from the API matching parameters * @param query Query to use for getting orders. A subset of parameters on the `OpenSeaAssetJSON` type is supported diff --git a/src/constants.ts b/src/constants.ts index 69f033c5e..92f0b945e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -32,7 +32,7 @@ export const UNISWAP_FACTORY_ADDRESS_RINKEBY = "0xf5D915570BC477f9B8D6C0E980aA81757A3AaC36"; export const DEFAULT_WRAPPED_NFT_LIQUIDATION_UNISWAP_SLIPPAGE_IN_BASIS_POINTS = 1000; export const MIN_EXPIRATION_MINUTES = 15; -export const MAX_EXPIRATION_MONTHS = 3; +export const MAX_EXPIRATION_MONTHS = 1; export const ORDER_MATCHING_LATENCY_SECONDS = 60 * 60 * 24 * 7; const ORDERBOOK_VERSION = 1 as number; export const API_BASE_MAINNET = "https://api.opensea.io"; diff --git a/src/orders/types.ts b/src/orders/types.ts index 074ebf364..99cd4c73e 100644 --- a/src/orders/types.ts +++ b/src/orders/types.ts @@ -1,4 +1,7 @@ -import { OrderWithCounter } from "@opensea/seaport-js/lib/types"; +import { + ConsiderationItem, + OrderWithCounter, +} from "@opensea/seaport-js/lib/types"; import { OpenSeaAccount, OpenSeaAssetBundle } from "../types"; // Protocol data @@ -58,6 +61,41 @@ type Transaction = { input_data: object; }; +export type BuildOfferResponse = { + partialParameters: PartialParameters; +}; + +export type GetCollectionResponse = { + collection: object; +}; + +export type PostOfferResponse = { + order_hash: string; + chain: string; + criteria: Criteria; + protocol_data: ProtocolData; + protocol_address: string; +}; + +type Criteria = { + collection: CollectionCriteria; + contract?: ContractCriteria; +}; + +type CollectionCriteria = { + slug: string; +}; + +type ContractCriteria = { + address: string; +}; + +type PartialParameters = { + consideration: ConsiderationItem[]; + zone: string; + zoneHash: string; +}; + // API query types type OpenOrderOrderingOption = "created_date" | "eth_price"; type OrderByDirection = "asc" | "desc"; diff --git a/src/orders/utils.ts b/src/orders/utils.ts index c972bcc2f..3afb9f18d 100644 --- a/src/orders/utils.ts +++ b/src/orders/utils.ts @@ -6,6 +6,7 @@ import { OrderSide, OrderV2, SerializedOrderV2, + ProtocolData, } from "./types"; const NETWORK_TO_CHAIN = { @@ -24,6 +25,46 @@ export const getOrdersAPIPath = ( return `/v2/orders/${chain}/${protocol}/${sidePath}`; }; +export const getCollectionPath = (slug: string) => { + return `/api/v1/collection/${slug}`; +}; + +export const getBuildOfferPath = () => { + return `/v2/offers/build`; +}; + +export const getPostCollectionOfferPath = () => { + return `/v2/offers`; +}; + +export const getPostCollectionOfferPayload = ( + collectionSlug: string, + protocol_data: ProtocolData +) => { + return { + criteria: { + collection: { slug: collectionSlug }, + }, + protocol_data, + }; +}; + +export const getBuildCollectionOfferPayload = ( + offererAddress: string, + quantity: number, + collectionSlug: string +) => { + return { + offerer: offererAddress, + quantity, + criteria: { + collection: { + slug: collectionSlug, + }, + }, + }; +}; + export const getFulfillmentDataPath = (side: OrderSide) => { const sidePath = side === "ask" ? "listings" : "offers"; return `/v2/${sidePath}/fulfillment_data`; diff --git a/src/sdk.ts b/src/sdk.ts index d1067af42..7226ff0a7 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -11,7 +11,7 @@ import { import { BigNumber } from "bignumber.js"; import { Web3JsProvider } from "ethereum-types"; import { isValidAddress } from "ethereumjs-util"; -import { providers } from "ethers"; +import { ethers, providers } from "ethers"; import { EventEmitter, EventSubscription } from "fbemitter"; import * as _ from "lodash"; import Web3 from "web3"; @@ -67,7 +67,7 @@ import { getPrivateListingConsiderations, getPrivateListingFulfillments, } from "./orders/privateListings"; -import { OrderV2 } from "./orders/types"; +import { OrderV2, PostOfferResponse } from "./orders/types"; import { ERC1155Abi } from "./typechain/contracts/ERC1155Abi"; import { ERC721v3Abi } from "./typechain/contracts/ERC721v3Abi"; import { UniswapExchangeAbi } from "./typechain/contracts/UniswapExchangeAbi"; @@ -85,6 +85,7 @@ import { Network, OpenSeaAPIConfig, OpenSeaAsset, + OpenSeaCollection, OpenSeaFungibleToken, Order, OrderSide, @@ -172,12 +173,14 @@ export class OpenSeaSDK { * `const provider = new Web3.providers.HttpProvider('https://mainnet.infura.io')` * @param apiConfig configuration options, including `networkName` * @param logger logger, optional, a function that will be called with debugging + * @param wallet optional, if you'd like to use an ethers wallet for order posting * information */ constructor( provider: Web3["currentProvider"], apiConfig: OpenSeaAPIConfig = {}, - logger?: (arg: string) => void + logger?: (arg: string) => void, + wallet?: ethers.Wallet ) { // API config apiConfig.networkName = apiConfig.networkName || Network.Main; @@ -202,14 +205,17 @@ export class OpenSeaSDK { this.ethersProvider = new providers.Web3Provider( provider as providers.ExternalProvider ); - this.seaport = new Seaport(this.ethersProvider, { + + const providerOrSinger = wallet ? wallet : this.ethersProvider; + this.seaport = new Seaport(providerOrSinger, { conduitKeyToConduit: CONDUIT_KEYS_TO_CONDUIT, overrides: { defaultConduitKey: CROSS_CHAIN_DEFAULT_CONDUIT_KEY, }, seaportVersion: "1.1", }); - this.seaport_v1_4 = new Seaport(this.ethersProvider, { + + this.seaport_v1_4 = new Seaport(providerOrSinger, { conduitKeyToConduit: CONDUIT_KEYS_TO_CONDUIT, overrides: { defaultConduitKey: CROSS_CHAIN_DEFAULT_CONDUIT_KEY, @@ -689,12 +695,12 @@ export class OpenSeaSDK { }; private async getFees({ - openseaAsset: asset, + collection, paymentTokenAddress, startAmount, endAmount, }: { - openseaAsset: OpenSeaAsset; + collection: OpenSeaCollection; paymentTokenAddress: string; startAmount: BigNumber; endAmount?: BigNumber; @@ -704,12 +710,11 @@ export class OpenSeaSDK { collectionSellerFees: ConsiderationInputItem[]; }> { // Seller fee basis points - const openseaSellerFeeBasisPoints = feesToBasisPoints( - asset.collection.fees?.openseaFees - ); - const collectionSellerFeeBasisPoints = feesToBasisPoints( - asset.collection.fees?.sellerFees - ); + const osFees = collection.fees?.openseaFees; + const creatorFees = collection.fees?.sellerFees; + + const openseaSellerFeeBasisPoints = feesToBasisPoints(osFees); + const collectionSellerFeeBasisPoints = feesToBasisPoints(creatorFees); // Seller basis points const sellerBasisPoints = @@ -742,16 +747,12 @@ export class OpenSeaSDK { return { sellerFee: getConsiderationItem(sellerBasisPoints), openseaSellerFees: - openseaSellerFeeBasisPoints > 0 && asset.collection.fees - ? getConsiderationItemsFromFeeCategory( - asset.collection.fees.openseaFees - ) + openseaSellerFeeBasisPoints > 0 && collection.fees + ? getConsiderationItemsFromFeeCategory(osFees) : [], collectionSellerFees: - collectionSellerFeeBasisPoints > 0 && asset.collection.fees - ? getConsiderationItemsFromFeeCategory( - asset.collection.fees.sellerFees - ) + collectionSellerFeeBasisPoints > 0 && collection.fees + ? getConsiderationItemsFromFeeCategory(creatorFees) : [], }; } @@ -824,7 +825,7 @@ export class OpenSeaSDK { const { openseaSellerFees, collectionSellerFees: collectionSellerFees } = await this.getFees({ - openseaAsset, + collection: openseaAsset.collection, paymentTokenAddress, startAmount: basePrice, }); @@ -925,7 +926,7 @@ export class OpenSeaSDK { openseaSellerFees, collectionSellerFees: collectionSellerFees, } = await this.getFees({ - openseaAsset, + collection: openseaAsset.collection, paymentTokenAddress, startAmount: basePrice, endAmount: endPrice, @@ -967,6 +968,90 @@ export class OpenSeaSDK { }); } + /** + * Create a collection offer + */ + public async createCollectionOffer({ + collectionSlug, + accountAddress, + amount, + quantity, + domain, + salt, + expirationTime, + paymentTokenAddress, + }: { + collectionSlug: string; + accountAddress: string; + amount: string; + quantity: number; + domain?: string; + salt?: string; + expirationTime?: BigNumberInput; + paymentTokenAddress: string; + }): Promise { + const collection = await this.api.getCollection(collectionSlug); + const buildOfferResult = await this.api.buildOffer( + accountAddress, + quantity, + collectionSlug + ); + const item = buildOfferResult.partialParameters.consideration[0]; + const convertedConsiderationItem = { + itemType: item.itemType, + token: item.token, + identifier: item.identifierOrCriteria, + amount: item.startAmount, + }; + + const { basePrice } = await this._getPriceParameters( + OrderSide.Buy, + paymentTokenAddress, + makeBigNumber(expirationTime ?? getMaxOrderExpirationTimestamp()), + makeBigNumber(amount) + ); + const { openseaSellerFees, collectionSellerFees: collectionSellerFees } = + await this.getFees({ + collection, + paymentTokenAddress, + startAmount: basePrice, + endAmount: basePrice, + }); + + const considerationItems = [ + convertedConsiderationItem, + ...openseaSellerFees, + ...collectionSellerFees, + ]; + + const payload = { + offerer: accountAddress, + offer: [ + { + token: paymentTokenAddress, + amount: basePrice.toString(), + }, + ], + consideration: considerationItems, + endTime: + expirationTime?.toString() ?? + getMaxOrderExpirationTimestamp().toString(), + zone: DEFAULT_ZONE_BY_NETWORK[this._networkName], + domain, + salt, + restrictedByZone: false, + allowPartialFills: true, + }; + + const { executeAllActions } = await this.seaport_v1_4.createOrder( + payload, + accountAddress + ); + const order = await executeAllActions(); + + return this.api.postCollectionOffer(order, collectionSlug); + } + private async fulfillPrivateOrder({ order, accountAddress, diff --git a/src/types.ts b/src/types.ts index f00416ec9..91368a4bb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -343,7 +343,7 @@ export interface OpenSeaCollection extends OpenSeaFees { // Link to the collection's wiki, if available wikiLink?: string; // Map of collection fees holding OpenSea and seller fees - fees?: Fees | null; + fees: Fees; } export interface OpenSeaTraitStats {