UNPKG

opensea-js

Version:

TypeScript SDK for the OpenSea marketplace helps developers build new experiences using NFTs and our marketplace data

596 lines (550 loc) 18.8 kB
import { BigNumberish, Contract, Signer, Overrides, ContractTransactionResponse, ethers, } from "ethers"; import { TRANSFER_HELPER_ADDRESS, MULTICALL3_ADDRESS } from "../constants"; import { ERC1155__factory, ERC20__factory, ERC721__factory, } from "../typechain/contracts"; import { EventType, TokenStandard, AssetWithTokenStandard } from "../types"; import { SDKContext } from "./context"; import { getDefaultConduit } from "../utils/utils"; /** * Asset transfer and approval operations */ export class AssetsManager { constructor(private context: SDKContext) {} /** * Get an account's balance of any Asset. This asset can be an ERC20, ERC1155, or ERC721. * @param options * @param options.accountAddress Account address to check * @param options.asset The Asset to check balance for. tokenStandard must be set. * @returns The balance of the asset for the account. * * @throws Error if the token standard does not support balanceOf. */ async getBalance({ accountAddress, asset, }: { accountAddress: string; asset: AssetWithTokenStandard; }): Promise<bigint> { switch (asset.tokenStandard) { case TokenStandard.ERC20: { const contract = ERC20__factory.connect( asset.tokenAddress, this.context.provider, ); return await contract.balanceOf.staticCall(accountAddress); } case TokenStandard.ERC1155: { if (asset.tokenId === undefined || asset.tokenId === null) { throw new Error("Missing ERC1155 tokenId for getBalance"); } const contract = ERC1155__factory.connect( asset.tokenAddress, this.context.provider, ); return await contract.balanceOf.staticCall( accountAddress, asset.tokenId, ); } case TokenStandard.ERC721: { if (asset.tokenId === undefined || asset.tokenId === null) { throw new Error("Missing ERC721 tokenId for getBalance"); } const contract = ERC721__factory.connect( asset.tokenAddress, this.context.provider, ); try { const owner = await contract.ownerOf.staticCall(asset.tokenId); return BigInt(owner.toLowerCase() == accountAddress.toLowerCase()); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { this.context.logger( `Failed to get ownerOf ERC721: ${error.message ?? error}`, ); return 0n; } } default: throw new Error("Unsupported token standard for getBalance"); } } /** * Transfer an asset. This asset can be an ERC20, ERC1155, or ERC721. * @param options * @param options.asset The Asset to transfer. tokenStandard must be set. * @param options.amount Amount of asset to transfer. Not used for ERC721. * @param options.fromAddress The address to transfer from * @param options.toAddress The address to transfer to * @param options.overrides Transaction overrides, ignored if not set. */ async transfer({ asset, amount, fromAddress, toAddress, overrides, }: { asset: AssetWithTokenStandard; amount?: BigNumberish; fromAddress: string; toAddress: string; overrides?: Overrides; }): Promise<void> { overrides = { ...overrides, from: fromAddress }; let transaction: Promise<ContractTransactionResponse>; switch (asset.tokenStandard) { case TokenStandard.ERC20: { if (!amount) { throw new Error("Missing ERC20 amount for transfer"); } const contract = ERC20__factory.connect( asset.tokenAddress, this.context.signerOrProvider, ); transaction = contract.transfer(toAddress, amount, overrides); break; } case TokenStandard.ERC1155: { if (asset.tokenId === undefined || asset.tokenId === null) { throw new Error("Missing ERC1155 tokenId for transfer"); } if (!amount) { throw new Error("Missing ERC1155 amount for transfer"); } const contract = ERC1155__factory.connect( asset.tokenAddress, this.context.signerOrProvider, ); transaction = contract.safeTransferFrom( fromAddress, toAddress, asset.tokenId, amount, "0x", overrides, ); break; } case TokenStandard.ERC721: { if (asset.tokenId === undefined || asset.tokenId === null) { throw new Error("Missing ERC721 tokenId for transfer"); } const contract = ERC721__factory.connect( asset.tokenAddress, this.context.signerOrProvider, ); transaction = contract.transferFrom( fromAddress, toAddress, asset.tokenId, overrides, ); break; } default: throw new Error("Unsupported token standard for transfer"); } try { const transactionResponse = await transaction; await this.context.confirmTransaction( transactionResponse.hash, EventType.Transfer, "Transferring asset", ); } catch (error) { console.error(error); this.context.dispatch(EventType.TransactionDenied, { error, accountAddress: fromAddress, }); } } /** * Bulk transfer multiple assets using OpenSea's TransferHelper contract. * This method is more gas-efficient than calling transfer() multiple times. * Note: All assets must be approved for transfer to the OpenSea conduit before calling this method. * @param options * @param options.assets Array of assets to transfer. Each asset must have tokenStandard set. * @param options.fromAddress The address to transfer from * @param options.overrides Transaction overrides, ignored if not set. * @returns Transaction hash of the bulk transfer * * @throws Error if any asset is missing required fields (tokenId for NFTs, amount for ERC20/ERC1155). * @throws Error if any asset is not approved for transfer to the OpenSea conduit. * @throws Error if the fromAddress is not available through wallet or provider. */ async bulkTransfer({ assets, fromAddress, overrides, }: { assets: Array<{ asset: AssetWithTokenStandard; toAddress: string; amount?: BigNumberish; }>; fromAddress: string; overrides?: Overrides; }): Promise<string> { // Validate basic parameters before making any blockchain calls if (assets.length === 0) { throw new Error("At least one asset must be provided"); } // Validate asset data and build transfer items array for TransferHelper // This validation happens before any blockchain calls to ensure proper error messages const transferItems: Array<{ itemType: number; token: string; identifier: string; amount: string; recipient: string; }> = []; for (const { asset, toAddress, amount } of assets) { let itemType: number; let identifier: string; let transferAmount: string; switch (asset.tokenStandard) { case TokenStandard.ERC20: itemType = 1; // ERC20 identifier = "0"; if (!amount) { throw new Error("Missing ERC20 amount for bulk transfer"); } transferAmount = amount.toString(); break; case TokenStandard.ERC721: itemType = 2; // ERC721 if (asset.tokenId === undefined || asset.tokenId === null) { throw new Error("Missing ERC721 tokenId for bulk transfer"); } identifier = asset.tokenId.toString(); transferAmount = "1"; break; case TokenStandard.ERC1155: itemType = 3; // ERC1155 if (asset.tokenId === undefined || asset.tokenId === null) { throw new Error("Missing ERC1155 tokenId for bulk transfer"); } if (!amount) { throw new Error("Missing ERC1155 amount for bulk transfer"); } identifier = asset.tokenId.toString(); transferAmount = amount.toString(); break; default: throw new Error( `Unsupported token standard for bulk transfer: ${asset.tokenStandard}`, ); } transferItems.push({ itemType, token: asset.tokenAddress, identifier, amount: transferAmount, recipient: toAddress, }); } // Check account availability after parameter validation await this.context.requireAccountIsAvailable(fromAddress); // Get the chain-specific default conduit const defaultConduit = getDefaultConduit(this.context.chain); // Check approvals for all assets before attempting transfer const unapprovedAssets: string[] = []; for (const { asset, amount } of assets) { const isApproved = await this.checkAssetApproval( asset, fromAddress, defaultConduit.address, amount, ); if (!isApproved) { const assetIdentifier = asset.tokenId !== undefined ? `${asset.tokenAddress}:${asset.tokenId}` : asset.tokenAddress; unapprovedAssets.push(assetIdentifier); } } if (unapprovedAssets.length > 0) { throw new Error( `The following asset(s) are not approved for transfer to the OpenSea conduit:\n${unapprovedAssets.join("\n")}\n\n` + `Please approve these assets before transferring. You can use the batchApproveAssets() method to approve multiple assets efficiently in a single transaction.`, ); } // Create TransferHelper contract instance const transferHelper = this.getTransferHelperContract(); this.context.dispatch(EventType.Transfer, { accountAddress: fromAddress, assets, }); try { // Use chain-specific conduit key for bulk transfers const transaction = await transferHelper.bulkTransfer( transferItems, defaultConduit.key, { ...overrides, from: fromAddress }, ); await this.context.confirmTransaction( transaction.hash, EventType.Transfer, `Bulk transferring ${assets.length} asset(s)`, ); return transaction.hash; } catch (error) { console.error(error); this.context.dispatch(EventType.TransactionDenied, { error, accountAddress: fromAddress, }); throw error; } } /** * Batch approve multiple assets for transfer to the OpenSea conduit. * This method checks which assets need approval and batches them efficiently: * - 0 approvals needed: Returns early * - 1 approval needed: Sends single transaction * - 2+ approvals needed: Uses Multicall3 to batch all approvals in one transaction * * @param options * @param options.assets Array of assets to approve for transfer * @param options.fromAddress The address that owns the assets * @param options.overrides Transaction overrides, ignored if not set. * @returns Transaction hash of the approval transaction, or undefined if no approvals needed * * @throws Error if the fromAddress is not available through wallet or provider. */ async batchApproveAssets({ assets, fromAddress, overrides, }: { assets: Array<{ asset: AssetWithTokenStandard; amount?: BigNumberish; }>; fromAddress: string; overrides?: Overrides; }): Promise<string | undefined> { // Validate basic parameters before making any blockchain calls if (assets.length === 0) { return undefined; } // Validate ERC20 assets have amounts before making any blockchain calls for (const { asset, amount } of assets) { if (asset.tokenStandard === TokenStandard.ERC20 && !amount) { throw new Error( `Amount required for ERC20 approval: ${asset.tokenAddress}`, ); } } // Check account availability after parameter validation await this.context.requireAccountIsAvailable(fromAddress); // Get the chain-specific default conduit const defaultConduit = getDefaultConduit(this.context.chain); // Check which assets need approval and build approval calldata const approvalsNeeded: Array<{ target: string; callData: string }> = []; const processedContracts = new Set<string>(); for (const { asset, amount } of assets) { const isApproved = await this.checkAssetApproval( asset, fromAddress, defaultConduit.address, amount, ); if (!isApproved) { // For ERC721/ERC1155, only approve once per contract if ( asset.tokenStandard === TokenStandard.ERC721 || asset.tokenStandard === TokenStandard.ERC1155 ) { if (processedContracts.has(asset.tokenAddress.toLowerCase())) { continue; } processedContracts.add(asset.tokenAddress.toLowerCase()); // setApprovalForAll(operator, true) const iface = new ethers.Interface([ "function setApprovalForAll(address operator, bool approved)", ]); const callData = iface.encodeFunctionData("setApprovalForAll", [ defaultConduit.address, true, ]); approvalsNeeded.push({ target: asset.tokenAddress, callData, }); } else if (asset.tokenStandard === TokenStandard.ERC20) { // approve(spender, amount) - use max uint256 for unlimited const iface = new ethers.Interface([ "function approve(address spender, uint256 amount) returns (bool)", ]); const callData = iface.encodeFunctionData("approve", [ defaultConduit.address, ethers.MaxUint256, // Approve max for convenience ]); approvalsNeeded.push({ target: asset.tokenAddress, callData, }); } } } // No approvals needed if (approvalsNeeded.length === 0) { return undefined; } // Single approval: send directly if (approvalsNeeded.length === 1) { const { target, callData } = approvalsNeeded[0]; const signer = this.context.signerOrProvider as Signer; const tx = await signer.sendTransaction({ to: target, data: callData, ...overrides, from: fromAddress, }); await this.context.confirmTransaction( tx.hash, EventType.ApproveAllAssets, "Approving asset for transfer", ); return tx.hash; } // Multiple approvals: use Multicall3 const multicall3 = this.getMulticall3Contract(); const calls = approvalsNeeded.map(({ target, callData }) => ({ target, allowFailure: false, callData, })); try { const transaction = await multicall3.aggregate3(calls, { ...overrides, from: fromAddress, }); await this.context.confirmTransaction( transaction.hash, EventType.ApproveAllAssets, `Batch approving ${approvalsNeeded.length} asset(s) for transfer`, ); return transaction.hash; } catch (error) { console.error(error); this.context.dispatch(EventType.TransactionDenied, { error, accountAddress: fromAddress, }); throw error; } } /** * Check if an asset is approved for transfer to a specific operator (conduit). * @param asset The asset to check approval for * @param owner The owner address * @param operator The operator address (conduit) * @param amount Optional amount for ERC20 tokens * @returns True if approved, false otherwise */ private async checkAssetApproval( asset: AssetWithTokenStandard, owner: string, operator: string, amount?: BigNumberish, ): Promise<boolean> { try { switch (asset.tokenStandard) { case TokenStandard.ERC20: { const contract = ERC20__factory.connect( asset.tokenAddress, this.context.provider, ); const allowance = await contract.allowance.staticCall( owner, operator, ); // Check if allowance is sufficient if (!amount) { return false; } return allowance >= BigInt(amount.toString()); } case TokenStandard.ERC721: { const contract = ERC721__factory.connect( asset.tokenAddress, this.context.provider, ); // Check isApprovedForAll first const isApprovedForAll = await contract.isApprovedForAll.staticCall( owner, operator, ); if (isApprovedForAll) { return true; } // Check individual token approval if (asset.tokenId !== undefined && asset.tokenId !== null) { const approved = await contract.getApproved.staticCall( asset.tokenId, ); return approved.toLowerCase() === operator.toLowerCase(); } return false; } case TokenStandard.ERC1155: { const contract = ERC1155__factory.connect( asset.tokenAddress, this.context.provider, ); return await contract.isApprovedForAll.staticCall(owner, operator); } default: return false; } } catch (error) { // If there's an error checking approval (e.g., contract doesn't exist), return false this.context.logger( `Error checking approval for ${asset.tokenAddress}: ${error}`, ); return false; } } /** * Get a TransferHelper contract instance. * @returns Contract instance for TransferHelper */ private getTransferHelperContract(): Contract { return new Contract( TRANSFER_HELPER_ADDRESS, [ "function bulkTransfer(tuple(uint8 itemType, address token, uint256 identifier, uint256 amount, address recipient)[] items, bytes32 conduitKey) external returns (bytes4)", ], this.context.signerOrProvider, ); } /** * Get a Multicall3 contract instance. * @returns Contract instance for Multicall3 */ private getMulticall3Contract(): Contract { return new Contract( MULTICALL3_ADDRESS, [ "function aggregate3(tuple(address target, bool allowFailure, bytes callData)[] calls) payable returns (tuple(bool success, bytes returnData)[] returnData)", ], this.context.signerOrProvider, ); } }