From bf556b161da43a7858551114a857d7de8487544d Mon Sep 17 00:00:00 2001 From: Mikhala <122326421+imx-mikhala@users.noreply.github.com> Date: Tue, 22 Aug 2023 10:00:41 +0800 Subject: [PATCH] WT-1568 ERC721 Approval Check NO-CHANGELOG (#719) --- .../sdk-sample-app/src/components/Buy.tsx | 30 +- .../src/components/SmartCheckoutForm.tsx | 413 ++++++++++++ .../src/pages/SmartCheckout.tsx | 22 +- .../checkout/sdk/src/errors/checkoutError.ts | 1 + packages/checkout/sdk/src/index.ts | 7 +- .../erc20.test.ts} | 97 ++- .../{allowance.ts => allowance/erc20.ts} | 32 +- .../smartCheckout/allowance/erc721.test.ts | 591 ++++++++++++++++++ .../sdk/src/smartCheckout/allowance/erc721.ts | 233 +++++++ .../sdk/src/smartCheckout/allowance/index.ts | 2 + .../sdk/src/smartCheckout/allowance/types.ts | 21 + .../src/smartCheckout/itemAggregator.test.ts | 253 +++++++- .../sdk/src/smartCheckout/itemAggregator.ts | 25 +- .../sdk/src/smartCheckout/smartCheckout.ts | 17 +- packages/checkout/sdk/src/types/constants.ts | 72 +++ .../checkout/sdk/src/types/smartCheckout.ts | 6 +- 16 files changed, 1711 insertions(+), 111 deletions(-) create mode 100644 packages/checkout/sdk-sample-app/src/components/SmartCheckoutForm.tsx rename packages/checkout/sdk/src/smartCheckout/{allowance.test.ts => allowance/erc20.test.ts} (74%) rename packages/checkout/sdk/src/smartCheckout/{allowance.ts => allowance/erc20.ts} (87%) create mode 100644 packages/checkout/sdk/src/smartCheckout/allowance/erc721.test.ts create mode 100644 packages/checkout/sdk/src/smartCheckout/allowance/erc721.ts create mode 100644 packages/checkout/sdk/src/smartCheckout/allowance/index.ts create mode 100644 packages/checkout/sdk/src/smartCheckout/allowance/types.ts diff --git a/packages/checkout/sdk-sample-app/src/components/Buy.tsx b/packages/checkout/sdk-sample-app/src/components/Buy.tsx index 2df226569e..e8fcd2c73d 100644 --- a/packages/checkout/sdk-sample-app/src/components/Buy.tsx +++ b/packages/checkout/sdk-sample-app/src/components/Buy.tsx @@ -3,19 +3,24 @@ import { Web3Provider } from '@ethersproject/providers'; import LoadingButton from './LoadingButton'; import { useEffect, useState } from 'react'; import { SuccessMessage, ErrorMessage } from './messages'; +import { Box, FormControl, TextInput } from '@biom3/react'; interface BuyProps { checkout: Checkout; provider: Web3Provider | undefined; } -export default function Buy(props: BuyProps) { - const { checkout, provider } = props; - +export default function Buy({ checkout, provider }: BuyProps) { + const [orderId, setOrderId] = useState(''); + const [orderIdError, setOrderIdError] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); async function buyClick() { + if (!orderId) { + setOrderIdError('Please enter an order ID'); + return; + } if (!checkout) { console.error('missing checkout, please connect first'); return; @@ -29,7 +34,7 @@ export default function Buy(props: BuyProps) { try { await checkout.buy({ provider, - orderId: '0189d7cc-5bf6-94b2-29ab-af73aa8ab24d', + orderId, }); setLoading(false); } catch (err: any) { @@ -42,13 +47,26 @@ export default function Buy(props: BuyProps) { } } + const updateOrderId = (event: any) => { + setOrderId(event.target.value); + setOrderIdError(''); + } + useEffect(() => { setError(null); setLoading(false); }, [checkout]); return ( -
+ + + Order ID + + {orderIdError && ( + {orderIdError} + )} + +
Buy @@ -58,6 +76,6 @@ export default function Buy(props: BuyProps) { {error.message}. Check console logs for more details. )} -
+ ); } diff --git a/packages/checkout/sdk-sample-app/src/components/SmartCheckoutForm.tsx b/packages/checkout/sdk-sample-app/src/components/SmartCheckoutForm.tsx new file mode 100644 index 0000000000..3c890f5d80 --- /dev/null +++ b/packages/checkout/sdk-sample-app/src/components/SmartCheckoutForm.tsx @@ -0,0 +1,413 @@ +import { + Checkout, + FulfilmentTransaction, + GasAmount, + GasTokenType, + ItemRequirement, + ItemType, + TransactionOrGasType, +} from '@imtbl/checkout-sdk'; +import { + Action, ActionType, TransactionPurpose, constants, Orderbook, +} from '@imtbl/orderbook'; +import { Web3Provider } from '@ethersproject/providers'; +import { useEffect, useState } from 'react'; +import { BigNumber } from 'ethers'; +import { Body, Box, Button, FormControl, Heading, Select, TextInput, Option, OptionKey } from '@biom3/react'; +import LoadingButton from './LoadingButton'; +import { ErrorMessage, SuccessMessage } from './messages'; + +interface SmartCheckoutProps { + checkout: Checkout; + provider: Web3Provider | undefined; +} + +export const SmartCheckoutForm = ({ checkout, provider }: SmartCheckoutProps) => { + const [itemRequirements, setItemRequirements] = useState([]); + const [itemRequirementsError, setItemRequirementsError] = useState(''); + const [transactionOrGasAmount, setTransactionOrGasAmount] = useState( + { + type: TransactionOrGasType.GAS, + gasToken: { + type: GasTokenType.NATIVE, + limit: BigNumber.from(400000), + } + } + ); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [loading, setLoading] = useState(false); + + const [seaportContractAddress, setSeaportContractAddress] = useState(''); + + const [disableAmount, setDisableAmount] = useState(true); + const [disableId, setDisabledId] = useState(true); + const [disableContractAddress, setDisabledContractAddress] = useState(true); + const [disableSpenderAddress, setDisabledSpenderAddress] = useState(true); + + const [itemType, setItemType] = useState(undefined); + const [amount, setAmount] = useState(''); + const [amountError, setAmountError] = useState(''); + const [id, setId] = useState(''); + const [idError, setIdError] = useState(''); + const [contractAddress, setContractAddress] = useState(''); + const [contractAddressError, setContractAddressError] = useState(''); + const [spenderAddress, setSpenderAddress] = useState(''); + const [spenderAddressError, setSpenderAddressError] = useState(''); + + useEffect(() => { + if (!checkout) return; + + const orderbook = new Orderbook({ + baseConfig: { + environment: checkout.config.environment, + }, + }); + + const { seaportContractAddress } = orderbook.config(); + setSeaportContractAddress(seaportContractAddress); + }, [checkout]) + + async function smartCheckout() { + setSuccess(false); + + if (itemRequirements.length === 0) { + setItemRequirementsError('Add item requirements using the form above'); + return; + } + setItemRequirementsError(''); + + if (!checkout) { + setError('missing checkout, please connect first') + return; + } + + if (!provider) { + setError('missing provider, please connect first') + return; + } + + setItemRequirementsError(''); + setError(''); + setLoading(true); + + try { + checkout.smartCheckout( + { + provider, + itemRequirements, + transactionOrGasAmount, + } + ); + setLoading(false); + setSuccess(true); + } catch (err: any) { + setError(err); + setLoading(false); + console.log(err.message); + console.log(err.type); + console.log(err.data); + console.log(err.stack); + } + } + + const updateItemRequirements = (itemRequirement: ItemRequirement) => { + setItemRequirements([...itemRequirements, itemRequirement]); + } + + const addNativeRequirement = () => { + if (!amount) { + setAmountError('Amount is required for native token'); + return; + } + const bn = BigNumber.from(amount); + updateItemRequirements({ + type: ItemType.NATIVE, + amount: bn, + }); + } + + const addERC20Requirement = () => { + if (!amount) { + setAmountError('Amount is required for ERC20 token'); + } + if (!contractAddress) { + setContractAddressError('Contract address is required for ERC20 token'); + } + if (!spenderAddress) { + setSpenderAddressError('Spender address is required for ERC20 token'); + } + if (!amount || !contractAddress || !spenderAddress) { + return; + } + const bn = BigNumber.from(amount); + updateItemRequirements({ + type: ItemType.ERC20, + amount: bn, + contractAddress, + spenderAddress, + }); + } + + const addERC721Requirement = () => { + if (!id) { + setIdError('ID is required for ERC721 token'); + } + if (!contractAddress) { + setContractAddressError('Contract address is required for ERC721 token'); + } + if (!spenderAddress) { + setSpenderAddressError('Spender address is required for ERC721 token'); + } + if (!id || !contractAddress || !spenderAddress) { + return; + } + updateItemRequirements({ + type: ItemType.ERC721, + id, + contractAddress, + spenderAddress, + }); + } + + const addItemRequirement = () => { + switch (itemType) { + case ItemType.NATIVE: + addNativeRequirement(); + break; + case ItemType.ERC20: + addERC20Requirement(); + break; + case ItemType.ERC721: + addERC721Requirement(); + break; + } + } + + const clearItemRequirements = () => { + setItemRequirements([]); + } + const getItemRequirementRow = (item: ItemRequirement, index: number) => { + switch (item.type) { + case ItemType.NATIVE: + return ( + + {item.type} + {item.amount.toString()} + + + + + ); + case ItemType.ERC20: + return ( + + {item.type} + {item.amount.toString()} + + {item.contractAddress} + {item.spenderAddress} + + ); + case ItemType.ERC721: + return ( + + {item.type} + + {item.id} + {item.contractAddress} + {item.spenderAddress} + + ) + } + } + + const selectItemType = (value: OptionKey) => { + setAmountError(''); + setIdError(''); + setContractAddressError(''); + setSpenderAddressError(''); + + switch (value) { + case 'native': + setItemType(ItemType.NATIVE); + setDisableAmount(false); + setDisabledId(true); + setDisabledContractAddress(true); + setDisabledSpenderAddress(true); + setId(''); + setContractAddress(''); + setSpenderAddress(''); + break; + case 'erc20': + setItemType(ItemType.ERC20); + setDisableAmount(false); + setDisabledId(true); + setDisabledContractAddress(false); + setDisabledSpenderAddress(false); + setId(''); + break; + case 'erc721': + setItemType(ItemType.ERC721); + setDisableAmount(true); + setDisabledId(false); + setDisabledContractAddress(false); + setDisabledSpenderAddress(false); + setAmount(''); + break; + } + } + + const updateAmount = (event: any) => { + const value = event.target.value; + setAmount(value.split('.')[0]); + setAmountError(''); + } + + const updateSpenderAddress = (event: any) => { + setSpenderAddress(event.target.value); + setSpenderAddressError(''); + } + + const itemRequirementsTable = () => { + return ( + + + + + + + + + + + + {itemRequirements.map((item, index) => getItemRequirementRow(item, index))} + + + + + + + + + +
TypeAmountIDContract AddressSpender Address
+ + + + + {amountError && ( + {amountError} + )} + + + + { + setId(event.target.value); + setIdError(''); + }} + /> + {idError && ( + {idError} + )} + + + + { + setContractAddress(event.target.value); + setContractAddressError(''); + }} + /> + {contractAddressError && ( + {contractAddressError} + )} + + + + + { + setSpenderAddress(seaportContractAddress); + setSpenderAddressError(''); + }} + > + Seaport + + + {spenderAddressError && ( + {spenderAddressError} + )} + + + +
+ ) + } + + return( + + + Add item requirements below and click Run Smart Checkout to run the smart checkout function with the item requirements. + + + {itemRequirementsTable()} + + + + Run Smart Checkout + + {itemRequirementsError && ( + + {itemRequirementsError} + + )} + {error && ( + + {error.message}. Check console logs for more details. + + )} + {success && Checkout success - view console for info.} + + ) +} diff --git a/packages/checkout/sdk-sample-app/src/pages/SmartCheckout.tsx b/packages/checkout/sdk-sample-app/src/pages/SmartCheckout.tsx index 569843e16c..8d33db1de5 100644 --- a/packages/checkout/sdk-sample-app/src/pages/SmartCheckout.tsx +++ b/packages/checkout/sdk-sample-app/src/pages/SmartCheckout.tsx @@ -7,6 +7,7 @@ import { Checkout } from '@imtbl/checkout-sdk'; import { useState, useMemo } from 'react'; import { Web3Provider } from '@ethersproject/providers'; import Buy from '../components/Buy'; +import { SmartCheckoutForm } from '../components/SmartCheckoutForm'; export default function SmartCheckout() { const [environment, setEnvironment] = useState(Environment.SANDBOX); @@ -14,14 +15,6 @@ export default function SmartCheckout() { return new Checkout({ baseConfig: { environment: environment } }); }, [environment]); const [provider, setProvider] = useState(); - - function toggleEnvironment() { - if (environment === Environment.PRODUCTION) { - setEnvironment(Environment.SANDBOX); - } else { - setEnvironment(Environment.PRODUCTION); - } - } return (
@@ -103,6 +96,19 @@ export default function SmartCheckout() { + + + Smart Checkout + + +
); } \ No newline at end of file diff --git a/packages/checkout/sdk/src/errors/checkoutError.ts b/packages/checkout/sdk/src/errors/checkoutError.ts index c18f42f3ec..f8aa4b4fc1 100644 --- a/packages/checkout/sdk/src/errors/checkoutError.ts +++ b/packages/checkout/sdk/src/errors/checkoutError.ts @@ -22,6 +22,7 @@ export enum CheckoutErrorType { GET_ORDER_LISTING_ERROR = 'GET_ORDER_LISTING_ERROR', SWITCH_NETWORK_UNSUPPORTED = 'SWITCH_NETWORK_UNSUPPORTED', GET_ERC20_ALLOWANCE_ERROR = 'GET_ERC20_ALLOWANCE_ERROR', + GET_ERC721_ALLOWANCE_ERROR = 'GET_ERC721_ALLOWANCE_ERROR', } /** diff --git a/packages/checkout/sdk/src/index.ts b/packages/checkout/sdk/src/index.ts index 1b9ff32529..fc2e72a86c 100644 --- a/packages/checkout/sdk/src/index.ts +++ b/packages/checkout/sdk/src/index.ts @@ -2,12 +2,15 @@ export { Checkout } from './Checkout'; export { ChainId, ChainName, - WalletProviderName, + CHECKOUT_API_BASE_URL, GasEstimateType, + GasTokenType, + ItemType, NetworkFilterTypes, TokenFilterTypes, + TransactionOrGasType, WalletFilterTypes, - CHECKOUT_API_BASE_URL, + WalletProviderName, } from './types'; export type { AllowedNetworkConfig, diff --git a/packages/checkout/sdk/src/smartCheckout/allowance.test.ts b/packages/checkout/sdk/src/smartCheckout/allowance/erc20.test.ts similarity index 74% rename from packages/checkout/sdk/src/smartCheckout/allowance.test.ts rename to packages/checkout/sdk/src/smartCheckout/allowance/erc20.test.ts index d2d4d0a029..cd4b7c3e08 100644 --- a/packages/checkout/sdk/src/smartCheckout/allowance.test.ts +++ b/packages/checkout/sdk/src/smartCheckout/allowance/erc20.test.ts @@ -1,8 +1,8 @@ import { BigNumber, Contract } from 'ethers'; import { Web3Provider } from '@ethersproject/providers'; -import { getERC20Allowance, getERC20ApprovalTransaction, hasERC20Allowances } from './allowance'; -import { CheckoutErrorType } from '../errors'; -import { ItemRequirement, ItemType } from '../types'; +import { getERC20Allowance, getERC20ApprovalTransaction, hasERC20Allowances } from './erc20'; +import { CheckoutErrorType } from '../../errors'; +import { ItemRequirement, ItemType } from '../../types'; jest.mock('ethers', () => ({ ...jest.requireActual('ethers'), @@ -11,7 +11,7 @@ jest.mock('ethers', () => ({ })); describe('allowance', () => { - let mockProvider: Web3Provider; + const mockProvider = {} as unknown as Web3Provider; describe('getERC20Allowance', () => { it('should get the allowance from the contract', async () => { @@ -20,8 +20,6 @@ describe('allowance', () => { allowance: allowanceMock, }); - mockProvider = {} as unknown as Web3Provider; - const allowance = await getERC20Allowance( mockProvider, '0xADDRESS', @@ -38,7 +36,9 @@ describe('allowance', () => { allowance: allowanceMock, }); - mockProvider = {} as unknown as Web3Provider; + let message = ''; + let type = ''; + let data = {}; try { await getERC20Allowance( @@ -48,38 +48,17 @@ describe('allowance', () => { '0xSEAPORT', ); } catch (err: any) { - expect(err.message).toEqual('Failed to get the allowance for ERC20'); - expect(err.type).toEqual(CheckoutErrorType.GET_ERC20_ALLOWANCE_ERROR); - expect(err.data).toEqual({ - contractAddress: 'OxERC20', - }); - expect(allowanceMock).toBeCalledWith('0xADDRESS', '0xSEAPORT'); + message = err.message; + type = err.type; + data = err.data; } - }); - it('should throw checkout error when provider call errors', async () => { - const allowanceMock = jest.fn().mockResolvedValue({}); - (Contract as unknown as jest.Mock).mockReturnValue({ - allowance: allowanceMock, + expect(message).toEqual('Failed to get the allowance for ERC20'); + expect(type).toEqual(CheckoutErrorType.GET_ERC20_ALLOWANCE_ERROR); + expect(data).toEqual({ + contractAddress: 'OxERC20', }); - - try { - mockProvider = {} as unknown as Web3Provider; - - await getERC20Allowance( - mockProvider, - '0xADDRESS', - 'OxERC20', - '0xSEAPORT', - ); - } catch (err: any) { - expect(err.message).toEqual('Failed to get the allowance for ERC20'); - expect(err.type).toEqual(CheckoutErrorType.GET_ERC20_ALLOWANCE_ERROR); - expect(err.data).toEqual({ - contractAddress: 'OxERC20', - }); - expect(allowanceMock).toBeCalledTimes(0); - } + expect(allowanceMock).toBeCalledWith('0xADDRESS', '0xSEAPORT'); }); }); @@ -92,8 +71,6 @@ describe('allowance', () => { }, }); - mockProvider = {} as unknown as Web3Provider; - const approvalTransaction = await getERC20ApprovalTransaction( mockProvider, '0xADDRESS', @@ -113,25 +90,30 @@ describe('allowance', () => { }, }); - mockProvider = {} as unknown as Web3Provider; + let message = ''; + let type = ''; + let data = {}; try { - const approvalTransaction = await getERC20ApprovalTransaction( + await getERC20ApprovalTransaction( mockProvider, '0xADDRESS', 'OxERC20', '0xSEAPORT', BigNumber.from(1), ); - expect(approvalTransaction).toBeUndefined(); } catch (err: any) { - expect(err.message).toEqual('Failed to get the approval transaction for ERC20'); - expect(err.type).toEqual(CheckoutErrorType.GET_ERC20_ALLOWANCE_ERROR); - expect(err.data).toEqual({ - contractAddress: 'OxERC20', - }); - expect(approveMock).toBeCalledWith('0xSEAPORT', BigNumber.from(1)); + message = err.message; + type = err.type; + data = err.data; } + + expect(message).toEqual('Failed to get the approval transaction for ERC20'); + expect(type).toEqual(CheckoutErrorType.GET_ERC20_ALLOWANCE_ERROR); + expect(data).toEqual({ + contractAddress: 'OxERC20', + }); + expect(approveMock).toBeCalledWith('0xSEAPORT', BigNumber.from(1)); }); }); @@ -146,8 +128,6 @@ describe('allowance', () => { }, }); - mockProvider = {} as unknown as Web3Provider; - const itemRequirements: ItemRequirement[] = [ { type: ItemType.NATIVE, @@ -165,6 +145,7 @@ describe('allowance', () => { expect(allowances.sufficient).toBeFalsy(); expect(allowances.allowances).toEqual([ { + type: ItemType.ERC20, sufficient: false, delta: BigNumber.from(1), itemRequirement: itemRequirements[1], @@ -183,8 +164,6 @@ describe('allowance', () => { }, }); - mockProvider = {} as unknown as Web3Provider; - const itemRequirements: ItemRequirement[] = [ { type: ItemType.NATIVE, @@ -218,8 +197,6 @@ describe('allowance', () => { }, }); - mockProvider = {} as unknown as Web3Provider; - const itemRequirements: ItemRequirement[] = [ { type: ItemType.NATIVE, @@ -237,6 +214,12 @@ describe('allowance', () => { amount: BigNumber.from(1), spenderAddress: '0xSEAPORT', }, + { + type: ItemType.ERC20, + contractAddress: '0xERC20c', + amount: BigNumber.from(2), + spenderAddress: '0xSEAPORT', + }, ]; const allowances = await hasERC20Allowances(mockProvider, '0xADDRESS', itemRequirements); @@ -247,11 +230,19 @@ describe('allowance', () => { itemRequirement: itemRequirements[2], }, { + type: ItemType.ERC20, sufficient: false, delta: BigNumber.from(1), itemRequirement: itemRequirements[1], approvalTransaction: { from: '0xADDRESS' }, }, + { + type: ItemType.ERC20, + sufficient: false, + delta: BigNumber.from(1), + itemRequirement: itemRequirements[3], + approvalTransaction: { from: '0xADDRESS' }, + }, ])); }); }); diff --git a/packages/checkout/sdk/src/smartCheckout/allowance.ts b/packages/checkout/sdk/src/smartCheckout/allowance/erc20.ts similarity index 87% rename from packages/checkout/sdk/src/smartCheckout/allowance.ts rename to packages/checkout/sdk/src/smartCheckout/allowance/erc20.ts index 07c1f56010..1cf49a6cea 100644 --- a/packages/checkout/sdk/src/smartCheckout/allowance.ts +++ b/packages/checkout/sdk/src/smartCheckout/allowance/erc20.ts @@ -1,8 +1,10 @@ import { TransactionRequest, Web3Provider } from '@ethersproject/providers'; import { BigNumber, Contract } from 'ethers'; -import { ERC20ABI, ItemRequirement, ItemType } from '../types'; -import { CheckoutError, CheckoutErrorType } from '../errors'; +import { CheckoutError, CheckoutErrorType } from '../../errors'; +import { ERC20ABI, ItemRequirement, ItemType } from '../../types'; +import { SufficientAllowance } from './types'; +// Gets the amount an address has allowed to be spent by the spender for the ERC20. export const getERC20Allowance = async ( provider: Web3Provider, ownerAddress: string, @@ -25,6 +27,8 @@ export const getERC20Allowance = async ( } }; +// Returns the approval transaction for the ERC20 that the owner can sign +// to approve the spender spending the provided amount of ERC20. export const getERC20ApprovalTransaction = async ( provider: Web3Provider, ownerAddress: string, @@ -50,17 +54,6 @@ export const getERC20ApprovalTransaction = async ( } }; -type SufficientAllowance = { - sufficient: true, - itemRequirement: ItemRequirement, -} -| { - sufficient: false, - delta: BigNumber, - itemRequirement: ItemRequirement, - approvalTransaction: TransactionRequest | undefined, -}; - export const hasERC20Allowances = async ( provider: Web3Provider, ownerAddress: string, @@ -81,9 +74,10 @@ export const hasERC20Allowances = async ( for (const itemRequirement of itemRequirements) { if (itemRequirement.type !== ItemType.ERC20) continue; const { contractAddress, spenderAddress } = itemRequirement; - erc20s.set(`${contractAddress}${spenderAddress}`, itemRequirement); + const key = `${contractAddress}${spenderAddress}`; + erc20s.set(key, itemRequirement); allowancePromises.set( - `${contractAddress}${spenderAddress}`, + key, getERC20Allowance(provider, ownerAddress, contractAddress, spenderAddress), ); } @@ -108,11 +102,13 @@ export const hasERC20Allowances = async ( sufficient = false; // Set sufficient false on the root of the return object when an ERC20 is insufficient const { contractAddress, spenderAddress } = itemRequirement; + const key = `${contractAddress}${spenderAddress}`; const delta = itemRequirement.amount.sub(allowances[index]); // Create maps for both the insufficient ERC20 data and the transaction promises using the same key so the results can be merged insufficientERC20s.set( - `${contractAddress}${spenderAddress}`, + key, { + type: ItemType.ERC20, sufficient: false, delta, itemRequirement, @@ -120,7 +116,7 @@ export const hasERC20Allowances = async ( }, ); transactionPromises.set( - `${contractAddress}${spenderAddress}`, + key, getERC20ApprovalTransaction( provider, ownerAddress, @@ -133,7 +129,7 @@ export const hasERC20Allowances = async ( // Resolves the approval transactions and merges them with the insufficient ERC20 data const transactions = await Promise.all(transactionPromises.values()); - const transactionPromiseIds = Array.from(allowancePromises.keys()); + const transactionPromiseIds = Array.from(transactionPromises.keys()); transactions.forEach((transaction, index) => { const insufficientERC20 = insufficientERC20s.get(transactionPromiseIds[index]); if (!insufficientERC20) return; diff --git a/packages/checkout/sdk/src/smartCheckout/allowance/erc721.test.ts b/packages/checkout/sdk/src/smartCheckout/allowance/erc721.test.ts new file mode 100644 index 0000000000..912b500226 --- /dev/null +++ b/packages/checkout/sdk/src/smartCheckout/allowance/erc721.test.ts @@ -0,0 +1,591 @@ +import { BigNumber, Contract } from 'ethers'; +import { Web3Provider } from '@ethersproject/providers'; +import { + convertIdToNumber, + getApproveTransaction, + getERC721ApprovedAddress, + getERC721ApprovedForAll, + hasERC721Allowances, +} from './erc721'; +import { CheckoutErrorType } from '../../errors'; +import { ItemRequirement, ItemType } from '../../types'; + +jest.mock('ethers', () => ({ + ...jest.requireActual('ethers'), + // eslint-disable-next-line @typescript-eslint/naming-convention + Contract: jest.fn(), +})); + +describe('erc721', () => { + const mockProvider = {} as unknown as Web3Provider; + + describe('getERC20Allowance', () => { + it('should get the allowance from the contract', async () => { + const getApprovedMock = jest.fn().mockResolvedValue('0xSEAPORT'); + (Contract as unknown as jest.Mock).mockReturnValue({ + getApproved: getApprovedMock, + }); + + const address = await getERC721ApprovedAddress( + mockProvider, + '0xERC721', + 0, + ); + expect(address).toEqual('0xSEAPORT'); + expect(getApprovedMock).toBeCalledWith(0); + }); + + it('should throw checkout error when getApproved call errors', async () => { + const getApprovedMock = jest.fn().mockRejectedValue({}); + (Contract as unknown as jest.Mock).mockReturnValue({ + getApproved: getApprovedMock, + }); + + let message = ''; + let type = ''; + let data = {}; + + try { + await getERC721ApprovedAddress( + mockProvider, + '0xERC721', + 0, + ); + } catch (err: any) { + message = err.message; + type = err.type; + data = err.data; + } + + expect(message).toEqual('Failed to get approved address for ERC721'); + expect(type).toEqual(CheckoutErrorType.GET_ERC721_ALLOWANCE_ERROR); + expect(data).toEqual({ + contractAddress: '0xERC721', + }); + expect(getApprovedMock).toBeCalledWith(0); + }); + }); + + describe('getApproveTransaction', () => { + it('should get the approval transaction from the contract with the from added', async () => { + const approveMock = jest.fn().mockResolvedValue({ data: '0xDATA' }); + (Contract as unknown as jest.Mock).mockReturnValue({ + populateTransaction: { + approve: approveMock, + }, + }); + + const approvalTransaction = await getApproveTransaction( + mockProvider, + '0xADDRESS', + '0xERC721', + '0xSEAPORT', + 0, + ); + expect(approvalTransaction).toEqual({ from: '0xADDRESS', data: '0xDATA' }); + expect(approveMock).toBeCalledWith('0xSEAPORT', 0); + }); + + it('should error is call to approve fails', async () => { + const approveMock = jest.fn().mockRejectedValue({ from: '0xADDRESS' }); + (Contract as unknown as jest.Mock).mockReturnValue({ + populateTransaction: { + approve: approveMock, + }, + }); + + let message = ''; + let type = ''; + let data = {}; + + try { + await getApproveTransaction( + mockProvider, + '0xADDRESS', + '0xERC721', + '0xSEAPORT', + 0, + ); + } catch (err: any) { + message = err.message; + type = err.type; + data = err.data; + } + + expect(message).toEqual('Failed to get the approval transaction for ERC721'); + expect(type).toEqual(CheckoutErrorType.GET_ERC721_ALLOWANCE_ERROR); + expect(data).toEqual({ + contractAddress: '0xERC721', + }); + expect(approveMock).toBeCalledWith('0xSEAPORT', 0); + }); + }); + + describe('getERC721ApprovedForAll', () => { + it('should get the approved for all from the contract', async () => { + const isApprovedForAllMock = jest.fn().mockResolvedValue(true); + (Contract as unknown as jest.Mock).mockReturnValue({ + isApprovedForAll: isApprovedForAllMock, + }); + + const approvedForAll = await getERC721ApprovedForAll( + mockProvider, + '0xADDRESS', + '0xERC721', + '0xSEAPORT', + ); + expect(approvedForAll).toBeTruthy(); + expect(isApprovedForAllMock).toBeCalledWith('0xADDRESS', '0xSEAPORT'); + }); + + it('should error if call to isApprovedForAll fails', async () => { + const isApprovedForAllMock = jest.fn().mockRejectedValue({}); + (Contract as unknown as jest.Mock).mockReturnValue({ + isApprovedForAll: isApprovedForAllMock, + }); + + let message = ''; + let type = ''; + let data = {}; + + try { + await getERC721ApprovedForAll( + mockProvider, + '0xADDRESS', + '0xERC721', + '0xSEAPORT', + ); + } catch (err: any) { + message = err.message; + type = err.type; + data = err.data; + } + + expect(message).toEqual('Failed to check approval for all ERC721s of collection'); + expect(type).toEqual(CheckoutErrorType.GET_ERC721_ALLOWANCE_ERROR); + expect(data).toEqual({ + contractAddress: '0xERC721', + }); + expect(isApprovedForAllMock).toBeCalledWith('0xADDRESS', '0xSEAPORT'); + }); + }); + + describe('hasERC721Allowances', () => { + it( + 'should return allowances with sufficient false and approval transaction if allowance not sufficient', + async () => { + const isApprovedForAllMock = jest.fn().mockResolvedValue(false); + const getApprovedMock = jest.fn().mockResolvedValue('0x00000000'); + const approveMock = jest.fn().mockResolvedValue({ data: '0xDATA', to: '0x00000' }); + (Contract as unknown as jest.Mock).mockReturnValue({ + getApproved: getApprovedMock, + isApprovedForAll: isApprovedForAllMock, + populateTransaction: { + approve: approveMock, + }, + }); + + const itemRequirements: ItemRequirement[] = [ + { + type: ItemType.NATIVE, + amount: BigNumber.from(1), + }, + { + type: ItemType.ERC721, + contractAddress: '0xERC721', + id: '0', + spenderAddress: '0xSEAPORT', + }, + ]; + + const allowances = await hasERC721Allowances(mockProvider, '0xADDRESS', itemRequirements); + expect(allowances.sufficient).toBeFalsy(); + expect(allowances.allowances).toEqual([ + { + type: ItemType.ERC721, + sufficient: false, + itemRequirement: itemRequirements[1], + approvalTransaction: { from: '0xADDRESS', data: '0xDATA', to: '0x00000' }, + }, + ]); + }, + ); + + it('should return allowances with sufficient true if allowance sufficient', async () => { + const isApprovedForAllMock = jest.fn().mockResolvedValue(false); + const getApprovedMock = jest.fn().mockResolvedValue('0xSEAPORT'); + (Contract as unknown as jest.Mock).mockReturnValue({ + isApprovedForAll: isApprovedForAllMock, + getApproved: getApprovedMock, + }); + + const itemRequirements: ItemRequirement[] = [ + { + type: ItemType.NATIVE, + amount: BigNumber.from(1), + }, + { + type: ItemType.ERC721, + contractAddress: '0xERC721', + id: '0', + spenderAddress: '0xSEAPORT', + }, + ]; + + const allowances = await hasERC721Allowances(mockProvider, '0xADDRESS', itemRequirements); + expect(allowances.sufficient).toBeTruthy(); + expect(allowances.allowances).toEqual([ + { + sufficient: true, + itemRequirement: itemRequirements[1], + }, + ]); + }); + + it('should handle multiple ERC721 item requirements', async () => { + const getApprovedMock = jest.fn().mockResolvedValue('0x00000000'); + const isApprovedForAllMock = jest.fn().mockResolvedValue(false); + const approveMock = jest.fn().mockResolvedValue({ data: '0xDATA', to: '0x00000' }); + (Contract as unknown as jest.Mock).mockReturnValue({ + getApproved: getApprovedMock, + isApprovedForAll: isApprovedForAllMock, + populateTransaction: { + approve: approveMock, + }, + }); + + const itemRequirements: ItemRequirement[] = [ + { + type: ItemType.NATIVE, + amount: BigNumber.from(1), + }, + { + type: ItemType.ERC721, + contractAddress: '0xERC721', + id: '0', + spenderAddress: '0xSEAPORT', + }, + { + type: ItemType.ERC721, + contractAddress: '0xERC721', + id: '1', + spenderAddress: '0xSEAPORT', + }, + { + type: ItemType.ERC721, + contractAddress: '0xERC721', + id: '2', + spenderAddress: '0x00000000', + }, + ]; + + const allowances = await hasERC721Allowances(mockProvider, '0xADDRESS', itemRequirements); + expect(allowances.sufficient).toBeFalsy(); + expect(allowances.allowances).toEqual(expect.arrayContaining([ + { + type: ItemType.ERC721, + sufficient: false, + itemRequirement: itemRequirements[1], + approvalTransaction: { from: '0xADDRESS', data: '0xDATA', to: '0x00000' }, + }, + { + type: ItemType.ERC721, + sufficient: false, + itemRequirement: itemRequirements[2], + approvalTransaction: { from: '0xADDRESS', data: '0xDATA', to: '0x00000' }, + }, + { + sufficient: true, + itemRequirement: itemRequirements[3], + }, + ])); + }); + + it('should error if an item requirement has an invalid id', async () => { + const getApprovedMock = jest.fn().mockResolvedValue('0x00000000'); + const approveMock = jest.fn().mockResolvedValue({ data: '0xDATA', to: '0x00000' }); + const isApprovedForAllMock = jest.fn().mockResolvedValue(false); + (Contract as unknown as jest.Mock).mockReturnValue({ + getApproved: getApprovedMock, + isApprovedForAll: isApprovedForAllMock, + populateTransaction: { + approve: approveMock, + }, + }); + + const itemRequirements: ItemRequirement[] = [ + { + type: ItemType.ERC721, + contractAddress: '0xERC721', + id: '1', + spenderAddress: '0xSEAPORT', + }, + { + type: ItemType.ERC721, + contractAddress: '0xERC721', + id: 'invalid', + spenderAddress: '0xSEAPORT', + }, + ]; + + let message = ''; + let type = ''; + let data = {}; + + try { + await hasERC721Allowances(mockProvider, '0xADDRESS', itemRequirements); + } catch (err: any) { + message = err.message; + type = err.type; + data = err.data; + } + + expect(message).toEqual('Invalid ERC721 ID'); + expect(type).toEqual(CheckoutErrorType.GET_ERC721_ALLOWANCE_ERROR); + expect(data).toEqual({ + id: 'invalid', + contractAddress: '0xERC721', + }); + }); + + it('should return sufficient true if approved for all', async () => { + const isApprovedForAllMock = jest.fn().mockResolvedValue(true); + const getApprovedMock = jest.fn().mockResolvedValue('0x00000000'); + const approveMock = jest.fn().mockResolvedValue({ data: '0xDATA', to: '0x00000' }); + (Contract as unknown as jest.Mock).mockReturnValue({ + getApproved: getApprovedMock, + isApprovedForAll: isApprovedForAllMock, + populateTransaction: { + approve: approveMock, + }, + }); + + const itemRequirements: ItemRequirement[] = [ + { + type: ItemType.ERC721, + contractAddress: '0xERC721', + id: '0', + spenderAddress: '0xSEAPORT', + }, + { + type: ItemType.ERC721, + contractAddress: '0xERC721', + id: '1', + spenderAddress: '0xSEAPORT', + }, + { + type: ItemType.ERC721, + contractAddress: '0xERC721', + id: '2', + spenderAddress: '0xSEAPORT', + }, + ]; + + const allowances = await hasERC721Allowances(mockProvider, '0xADDRESS', itemRequirements); + expect(allowances.sufficient).toBeTruthy(); + expect(allowances.allowances).toEqual([ + { + sufficient: true, + itemRequirement: itemRequirements[0], + }, + { + sufficient: true, + itemRequirement: itemRequirements[1], + }, + { + sufficient: true, + itemRequirement: itemRequirements[2], + }, + ]); + + expect(isApprovedForAllMock).toBeCalledWith('0xADDRESS', '0xSEAPORT'); + expect(getApprovedMock).toBeCalledTimes(0); + expect(approveMock).toBeCalledTimes(0); + }); + + it( + 'should return sufficient false for non-approved items and true for contract addresses that are approved for all', + async () => { + const isApprovedForAllMock = jest.fn() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + const getApprovedMock = jest.fn().mockResolvedValue('0xSEAPORT'); + const approveMock = jest.fn().mockResolvedValue({ data: '0xDATA', to: '0xOTHER' }); + (Contract as unknown as jest.Mock).mockReturnValue({ + getApproved: getApprovedMock, + isApprovedForAll: isApprovedForAllMock, + populateTransaction: { + approve: approveMock, + }, + }); + + const itemRequirements: ItemRequirement[] = [ + { + type: ItemType.ERC721, + contractAddress: '0xERC721', + id: '0', + spenderAddress: '0xSEAPORT', + }, + { + type: ItemType.ERC721, + contractAddress: '0xERC721', + id: '1', + spenderAddress: '0xSEAPORT', + }, + { + type: ItemType.ERC721, + contractAddress: '0xERC721', + id: '2', + spenderAddress: '0xOTHER', + }, + ]; + + const allowances = await hasERC721Allowances(mockProvider, '0xADDRESS', itemRequirements); + expect(allowances.sufficient).toBeFalsy(); + expect(allowances.allowances).toEqual([ + { + sufficient: true, + itemRequirement: itemRequirements[0], + }, + { + sufficient: true, + itemRequirement: itemRequirements[1], + }, + { + type: ItemType.ERC721, + sufficient: false, + itemRequirement: itemRequirements[2], + approvalTransaction: { from: '0xADDRESS', data: '0xDATA', to: '0xOTHER' }, + }, + ]); + + expect(isApprovedForAllMock).toBeCalledTimes(2); + expect(getApprovedMock).toBeCalledTimes(1); + expect(approveMock).toBeCalledTimes(1); + }, + ); + }); + + describe('convertIdToNumber', () => { + it('should converts a valid string ID to a number', () => { + const id = '123'; + const result = convertIdToNumber(id, '0xERC721'); + expect(result).toBe(123); + }); + + it('should throws an error for invalid string ID', () => { + const id = 'invalid'; + + let message = ''; + let type = ''; + let data = {}; + + try { + convertIdToNumber(id, '0xERC721'); + } catch (err: any) { + message = err.message; + type = err.type; + data = err.data; + } + + expect(message).toEqual('Invalid ERC721 ID'); + expect(type).toEqual(CheckoutErrorType.GET_ERC721_ALLOWANCE_ERROR); + expect(data).toEqual({ + id: 'invalid', + contractAddress: '0xERC721', + }); + }); + + it('should throws an error for empty string ID', () => { + const id = ''; + + let message = ''; + let type = ''; + let data = {}; + + try { + convertIdToNumber(id, '0xERC721'); + } catch (err: any) { + message = err.message; + type = err.type; + data = err.data; + } + + expect(message).toEqual('Invalid ERC721 ID'); + expect(type).toEqual(CheckoutErrorType.GET_ERC721_ALLOWANCE_ERROR); + expect(data).toEqual({ + id: '', + contractAddress: '0xERC721', + }); + }); + + it('should throws an error for whitespace string ID', () => { + const id = ' '; + + let message = ''; + let type = ''; + let data = {}; + + try { + convertIdToNumber(id, '0xERC721'); + } catch (err: any) { + message = err.message; + type = err.type; + data = err.data; + } + + expect(message).toEqual('Invalid ERC721 ID'); + expect(type).toEqual(CheckoutErrorType.GET_ERC721_ALLOWANCE_ERROR); + expect(data).toEqual({ + id: ' ', + contractAddress: '0xERC721', + }); + }); + + it('should throws an error for null ID', () => { + const id = null as any; + + let message = ''; + let type = ''; + let data = {}; + + try { + convertIdToNumber(id, '0xERC721'); + } catch (err: any) { + message = err.message; + type = err.type; + data = err.data; + } + + expect(message).toEqual('Invalid ERC721 ID'); + expect(type).toEqual(CheckoutErrorType.GET_ERC721_ALLOWANCE_ERROR); + expect(data).toEqual({ + id: null, + contractAddress: '0xERC721', + }); + }); + + it('should throws an error for undefined ID', () => { + const id = undefined as any; + + let message = ''; + let type = ''; + let data = {}; + + try { + convertIdToNumber(id, '0xERC721'); + } catch (err: any) { + message = err.message; + type = err.type; + data = err.data; + } + + expect(message).toEqual('Invalid ERC721 ID'); + expect(type).toEqual(CheckoutErrorType.GET_ERC721_ALLOWANCE_ERROR); + expect(data).toEqual({ + id: undefined, + contractAddress: '0xERC721', + }); + }); + }); +}); diff --git a/packages/checkout/sdk/src/smartCheckout/allowance/erc721.ts b/packages/checkout/sdk/src/smartCheckout/allowance/erc721.ts new file mode 100644 index 0000000000..4bbed46d66 --- /dev/null +++ b/packages/checkout/sdk/src/smartCheckout/allowance/erc721.ts @@ -0,0 +1,233 @@ +import { TransactionRequest, Web3Provider } from '@ethersproject/providers'; +import { Contract } from 'ethers'; +import { CheckoutError, CheckoutErrorType } from '../../errors'; +import { ERC721ABI, ItemRequirement, ItemType } from '../../types'; +import { SufficientAllowance } from './types'; + +// Returns true if the spender address is approved for all ERC721s of this collection +export const getERC721ApprovedForAll = async ( + provider: Web3Provider, + ownerAddress: string, + contractAddress: string, + spenderAddress: string, +): Promise => { + try { + const contract = new Contract( + contractAddress, + JSON.stringify(ERC721ABI), + provider, + ); + return await contract.isApprovedForAll(ownerAddress, spenderAddress); + } catch (err: any) { + throw new CheckoutError( + 'Failed to check approval for all ERC721s of collection', + CheckoutErrorType.GET_ERC721_ALLOWANCE_ERROR, + { contractAddress }, + ); + } +}; + +// Returns a populated transaction to approve the ERC721 for the spender. +export const getApproveTransaction = async ( + provider: Web3Provider, + ownerAddress: string, + contractAddress: string, + spenderAddress: string, + id: number, +): Promise => { + try { + const contract = new Contract( + contractAddress, + JSON.stringify(ERC721ABI), + provider, + ); + const transaction = await contract.populateTransaction.approve(spenderAddress, id); + if (transaction) transaction.from = ownerAddress; + return transaction; + } catch (err: any) { + throw new CheckoutError( + 'Failed to get the approval transaction for ERC721', + CheckoutErrorType.GET_ERC721_ALLOWANCE_ERROR, + { contractAddress }, + ); + } +}; + +// Returns the address that is approved for the ERC721. +// This is sufficient when the spender is the approved address +export const getERC721ApprovedAddress = async ( + provider: Web3Provider, + contractAddress: string, + id: number, +): Promise => { + try { + const contract = new Contract( + contractAddress, + JSON.stringify(ERC721ABI), + provider, + ); + return await contract.getApproved(id); + } catch (err: any) { + throw new CheckoutError( + 'Failed to get approved address for ERC721', + CheckoutErrorType.GET_ERC721_ALLOWANCE_ERROR, + { contractAddress }, + ); + } +}; + +export const convertIdToNumber = (id: string, contractAddress: string): number => { + const parsedId = parseInt(id, 10); + + if (Number.isNaN(parsedId)) { + throw new CheckoutError( + 'Invalid ERC721 ID', + CheckoutErrorType.GET_ERC721_ALLOWANCE_ERROR, + { id, contractAddress }, + ); + } + + return parsedId; +}; + +export const getApprovedCollections = async ( + provider: Web3Provider, + itemRequirements: ItemRequirement[], + owner: string, +): Promise> => { + const approvedCollections = new Map(); + const approvedForAllPromises = new Map>(); + + for (const itemRequirement of itemRequirements) { + if (itemRequirement.type !== ItemType.ERC721) continue; + const { contractAddress, spenderAddress } = itemRequirement; + const key = `${contractAddress}-${spenderAddress}`; + approvedCollections.set(key, false); + if (approvedForAllPromises.has(key)) continue; + approvedForAllPromises.set(key, getERC721ApprovedForAll( + provider, + owner, + contractAddress, + spenderAddress, + )); + } + + const approvals = await Promise.all(approvedForAllPromises.values()); + const keys = Array.from(approvedForAllPromises.keys()); + approvals.forEach((approval, index) => { + approvedCollections.set(keys[index], approval); + }); + + return approvedCollections; +}; + +export const hasERC721Allowances = async ( + provider: Web3Provider, + ownerAddress: string, + itemRequirements: ItemRequirement[], +): Promise<{ + sufficient: boolean, + allowances: SufficientAllowance[] +}> => { + let sufficient = true; + const sufficientAllowances: SufficientAllowance[] = []; + + // Setup maps to be able to link data back to the associated promises + const erc721s = new Map(); + const approvedAddressPromises = new Map>(); + const insufficientERC721s = new Map(); + const transactionPromises = new Map>(); + + // Check if there are any collections with approvals for all ERC721s for a given spender + const approvedCollections = await getApprovedCollections( + provider, + itemRequirements, + ownerAddress, + ); + + // Populate maps for both the ERC721 data and promises to get the approved addresses using the same key + // so the promise and data can be linked together when the promise is resolved + for (const itemRequirement of itemRequirements) { + if (itemRequirement.type !== ItemType.ERC721) continue; + + const { contractAddress, id, spenderAddress } = itemRequirement; + + // If the collection is approved for all then just set the item requirements and sufficient true + const approvedForAllKey = `${contractAddress}-${spenderAddress}`; + const approvedForAll = approvedCollections.get(approvedForAllKey); + if (approvedForAll) { + sufficientAllowances.push({ + sufficient: true, + itemRequirement, + }); + continue; + } + + // If collection not approved for all then check if the given ERC721 is approved for the spender + const key = `${contractAddress}-${id}`; + const convertedId = convertIdToNumber(id, contractAddress); + erc721s.set(key, itemRequirement); + approvedAddressPromises.set( + key, + getERC721ApprovedAddress(provider, contractAddress, convertedId), + ); + } + + const approvedAddresses = await Promise.all(approvedAddressPromises.values()); + const approvedAddressPromiseIds = Array.from(approvedAddressPromises.keys()); + + // Iterate through the approved address promises and get the ERC721 data from the ERC721 map + // If the approved address returned for that ERC721 is for the spender then just set the item requirements and sufficient true + // If the approved address does not match the spender then return the approval transaction + for (let index = 0; index < approvedAddresses.length; index++) { + const itemRequirement = erc721s.get(approvedAddressPromiseIds[index]); + if (!itemRequirement || itemRequirement.type !== ItemType.ERC721) continue; + + if (approvedAddresses[index] === itemRequirement.spenderAddress) { + sufficientAllowances.push({ + sufficient: true, + itemRequirement, + }); + continue; + } + + sufficient = false; // Set sufficient false on the root of the return object when an ERC721 is insufficient + + const { contractAddress, id, spenderAddress } = itemRequirement; + const key = `${contractAddress}-${id}`; + const convertedId = convertIdToNumber(id, contractAddress); + // Create maps for both the insufficient ERC721 data and the transaction promises using the same key so the results can be merged + insufficientERC721s.set( + key, + { + type: ItemType.ERC721, + sufficient: false, + itemRequirement, + approvalTransaction: undefined, + }, + ); + transactionPromises.set( + key, + getApproveTransaction( + provider, + ownerAddress, + contractAddress, + spenderAddress, + convertedId, + ), + ); + } + + // Resolves the approval transactions and merges them with the insufficient ERC721 data + const transactions = await Promise.all(transactionPromises.values()); + const transactionPromiseIds = Array.from(transactionPromises.keys()); + transactions.forEach((transaction, index) => { + const insufficientERC721 = insufficientERC721s.get(transactionPromiseIds[index]); + if (!insufficientERC721) return; + if (insufficientERC721.sufficient) return; + insufficientERC721.approvalTransaction = transaction; + }); + + // Merge the allowance arrays to get both the sufficient allowances and the insufficient ERC721 allowances + return { sufficient, allowances: sufficientAllowances.concat(Array.from(insufficientERC721s.values())) }; +}; diff --git a/packages/checkout/sdk/src/smartCheckout/allowance/index.ts b/packages/checkout/sdk/src/smartCheckout/allowance/index.ts new file mode 100644 index 0000000000..09241e3968 --- /dev/null +++ b/packages/checkout/sdk/src/smartCheckout/allowance/index.ts @@ -0,0 +1,2 @@ +export * from './erc20'; +export * from './erc721'; diff --git a/packages/checkout/sdk/src/smartCheckout/allowance/types.ts b/packages/checkout/sdk/src/smartCheckout/allowance/types.ts new file mode 100644 index 0000000000..f6840516d9 --- /dev/null +++ b/packages/checkout/sdk/src/smartCheckout/allowance/types.ts @@ -0,0 +1,21 @@ +import { TransactionRequest } from '@ethersproject/providers'; +import { BigNumber } from 'ethers'; +import { ItemRequirement, ItemType } from '../../types'; + +export type SufficientAllowance = { + sufficient: true, + itemRequirement: ItemRequirement, +} +| { + type: ItemType.ERC20, + sufficient: false, + delta: BigNumber, + itemRequirement: ItemRequirement, + approvalTransaction: TransactionRequest | undefined, +} +| { + type: ItemType.ERC721, + sufficient: false, + itemRequirement: ItemRequirement, + approvalTransaction: TransactionRequest | undefined, +}; diff --git a/packages/checkout/sdk/src/smartCheckout/itemAggregator.test.ts b/packages/checkout/sdk/src/smartCheckout/itemAggregator.test.ts index a05ec4d5df..7847d5938f 100644 --- a/packages/checkout/sdk/src/smartCheckout/itemAggregator.test.ts +++ b/packages/checkout/sdk/src/smartCheckout/itemAggregator.test.ts @@ -1,6 +1,6 @@ import { BigNumber } from 'ethers'; import { ItemRequirement, ItemType } from '../types'; -import { erc20ItemAggregator, itemAggregator } from './itemAggregator'; +import { erc20ItemAggregator, erc721ItemAggregator, itemAggregator } from './itemAggregator'; describe('itemAggregator', () => { describe('itemAggregator', () => { @@ -26,6 +26,18 @@ describe('itemAggregator', () => { contractAddress: '0xERC20', spenderAddress: '0xSEAPORT', }, + { + type: ItemType.ERC721, + id: '1', + contractAddress: '0xERC20', + spenderAddress: '0xSEAPORT', + }, + { + type: ItemType.ERC721, + id: '1', + contractAddress: '0xERC20', + spenderAddress: '0xSEAPORT', + }, ]; const aggregatedItems = itemAggregator(itemRequirements); @@ -44,6 +56,12 @@ describe('itemAggregator', () => { contractAddress: '0xERC20', spenderAddress: '0xSEAPORT', }, + { + type: ItemType.ERC721, + id: '1', + contractAddress: '0xERC20', + spenderAddress: '0xSEAPORT', + }, ]); }); }); @@ -92,16 +110,18 @@ describe('itemAggregator', () => { ]); }); - it('should return same inputs', () => { + it('should not aggregate erc20s', () => { const itemRequirements: ItemRequirement[] = [ { - type: ItemType.NATIVE, + type: ItemType.ERC20, amount: BigNumber.from(1), + contractAddress: '0xERC20_1', + spenderAddress: '0xSEAPORT', }, { type: ItemType.ERC20, amount: BigNumber.from(1), - contractAddress: '0xERC20', + contractAddress: '0xERC20_2', spenderAddress: '0xSEAPORT', }, ]; @@ -109,13 +129,15 @@ describe('itemAggregator', () => { const aggregatedItems = erc20ItemAggregator(itemRequirements); expect(aggregatedItems).toEqual([ { - type: ItemType.NATIVE, + type: ItemType.ERC20, amount: BigNumber.from(1), + contractAddress: '0xERC20_1', + spenderAddress: '0xSEAPORT', }, { type: ItemType.ERC20, amount: BigNumber.from(1), - contractAddress: '0xERC20', + contractAddress: '0xERC20_2', spenderAddress: '0xSEAPORT', }, ]); @@ -173,7 +195,7 @@ describe('itemAggregator', () => { ); }); - it('should not filter unknown item types', () => { + it('should not aggregate unknown item types', () => { const itemRequirements: ItemRequirement[] = [ { type: ItemType.NATIVE, @@ -196,13 +218,13 @@ describe('itemAggregator', () => { spenderAddress: '0xSEAPORT', }, { - type: 'ERC721' as ItemType, - contractAddress: '0xERC721', + type: 'ERC1559' as ItemType, + contractAddress: '0xERC1559', spenderAddress: '0xSEAPORT', } as ItemRequirement, { - type: 'ERC721' as ItemType, - contractAddress: '0xERC721', + type: 'ERC1559' as ItemType, + contractAddress: '0xERC1559', spenderAddress: '0xSEAPORT', } as ItemRequirement, ]; @@ -225,14 +247,217 @@ describe('itemAggregator', () => { spenderAddress: '0xSEAPORT', }, { - type: 'ERC721' as ItemType, - contractAddress: '0xERC721', + type: 'ERC1559' as ItemType, + contractAddress: '0xERC1559', spenderAddress: '0xSEAPORT', } as ItemRequirement, { - type: 'ERC721' as ItemType, + type: 'ERC1559' as ItemType, + contractAddress: '0xERC1559', + spenderAddress: '0xSEAPORT', + } as ItemRequirement, + ]), + ); + }); + }); + + describe('erc721ItemAggregator', () => { + it('should return aggregated erc721 items', () => { + const itemRequirements: ItemRequirement[] = [ + { + type: ItemType.NATIVE, + amount: BigNumber.from(1), + }, + { + type: ItemType.NATIVE, + amount: BigNumber.from(1), + }, + { + type: ItemType.ERC721, + id: '1', + contractAddress: '0xERC721', + spenderAddress: '0xSEAPORT', + }, + { + type: ItemType.ERC721, + id: '1', + contractAddress: '0xERC721', + spenderAddress: '0xSEAPORT', + }, + ]; + + const aggregatedItems = erc721ItemAggregator(itemRequirements); + expect(aggregatedItems).toEqual([ + { + type: ItemType.NATIVE, + amount: BigNumber.from(1), + }, + { + type: ItemType.NATIVE, + amount: BigNumber.from(1), + }, + { + type: ItemType.ERC721, + id: '1', + contractAddress: '0xERC721', + spenderAddress: '0xSEAPORT', + }, + ]); + }); + + it('should not aggregate erc721s', () => { + const itemRequirements: ItemRequirement[] = [ + { + type: ItemType.NATIVE, + amount: BigNumber.from(1), + }, + { + type: ItemType.ERC721, + id: '1', + contractAddress: '0xERC721', + spenderAddress: '0xSEAPORT', + }, + { + type: ItemType.ERC721, + id: '2', + contractAddress: '0xERC721', + spenderAddress: '0xSEAPORT', + }, + ]; + + const aggregatedItems = erc721ItemAggregator(itemRequirements); + expect(aggregatedItems).toEqual([ + { + type: ItemType.NATIVE, + amount: BigNumber.from(1), + }, + { + type: ItemType.ERC721, + id: '1', + contractAddress: '0xERC721', + spenderAddress: '0xSEAPORT', + }, + { + type: ItemType.ERC721, + id: '2', + contractAddress: '0xERC721', + spenderAddress: '0xSEAPORT', + }, + ]); + }); + + it('should return empty array', () => { + const itemRequirements: ItemRequirement[] = []; + + const aggregatedItems = erc721ItemAggregator(itemRequirements); + expect(aggregatedItems).toEqual([]); + }); + + it('should return aggregated items when not ordered', () => { + const itemRequirements: ItemRequirement[] = [ + { + type: ItemType.NATIVE, + amount: BigNumber.from(1), + }, + { + type: ItemType.ERC721, + id: '1', + contractAddress: '0xERC721', + spenderAddress: '0xSEAPORT', + }, + { + type: ItemType.NATIVE, + amount: BigNumber.from(1), + }, + { + type: ItemType.ERC721, + id: '1', + contractAddress: '0xERC721', + spenderAddress: '0xSEAPORT', + }, + ]; + + const aggregatedItems = erc20ItemAggregator(itemRequirements); + expect(aggregatedItems).toEqual( + expect.arrayContaining([ + { + type: ItemType.NATIVE, + amount: BigNumber.from(1), + }, + { + type: ItemType.NATIVE, + amount: BigNumber.from(1), + }, + { + type: ItemType.ERC721, + id: '1', contractAddress: '0xERC721', spenderAddress: '0xSEAPORT', + }, + ]), + ); + }); + + it('should not aggregate unknown item types', () => { + const itemRequirements: ItemRequirement[] = [ + { + type: ItemType.NATIVE, + amount: BigNumber.from(1), + }, + { + type: ItemType.NATIVE, + amount: BigNumber.from(1), + }, + { + type: ItemType.ERC721, + id: '1', + contractAddress: '0xERC721', + spenderAddress: '0xSEAPORT', + }, + { + type: ItemType.ERC721, + id: '1', + contractAddress: '0xERC721', + spenderAddress: '0xSEAPORT', + }, + { + type: 'ERC1559' as ItemType, + contractAddress: '0xERC1559', + spenderAddress: '0xSEAPORT', + } as ItemRequirement, + { + type: 'ERC1559' as ItemType, + contractAddress: '0xERC1559', + spenderAddress: '0xSEAPORT', + } as ItemRequirement, + ]; + + const aggregatedItems = erc721ItemAggregator(itemRequirements); + expect(aggregatedItems).toEqual( + expect.arrayContaining([ + { + type: ItemType.NATIVE, + amount: BigNumber.from(1), + }, + { + type: ItemType.NATIVE, + amount: BigNumber.from(1), + }, + { + type: ItemType.ERC721, + id: '1', + contractAddress: '0xERC721', + spenderAddress: '0xSEAPORT', + }, + { + type: 'ERC1559' as ItemType, + contractAddress: '0xERC1559', + spenderAddress: '0xSEAPORT', + } as ItemRequirement, + { + type: 'ERC1559' as ItemType, + contractAddress: '0xERC1559', + spenderAddress: '0xSEAPORT', } as ItemRequirement, ]), ); diff --git a/packages/checkout/sdk/src/smartCheckout/itemAggregator.ts b/packages/checkout/sdk/src/smartCheckout/itemAggregator.ts index e42878eeb2..566b4ac1ce 100644 --- a/packages/checkout/sdk/src/smartCheckout/itemAggregator.ts +++ b/packages/checkout/sdk/src/smartCheckout/itemAggregator.ts @@ -28,6 +28,29 @@ export const erc20ItemAggregator = ( return aggregatedItemRequirements.concat(Array.from(aggregatedMap.values())); }; +export const erc721ItemAggregator = ( + itemRequirements: ItemRequirement[], +): ItemRequirement[] => { + const aggregatedMap = new Map(); + const aggregatedItemRequirements: ItemRequirement[] = []; + + itemRequirements.forEach((itemRequirement) => { + const { type } = itemRequirement; + + if (type !== ItemType.ERC721) { + aggregatedItemRequirements.push(itemRequirement); + return; + } + + const { contractAddress, spenderAddress, id } = itemRequirement; + const key = `${contractAddress}${spenderAddress}${id}`; + const aggregateItem = aggregatedMap.get(key); + if (!aggregateItem) aggregatedMap.set(key, { ...itemRequirement }); + }); + + return aggregatedItemRequirements.concat(Array.from(aggregatedMap.values())); +}; + export const itemAggregator = ( itemRequirements: ItemRequirement[], -): ItemRequirement[] => erc20ItemAggregator(itemRequirements); +): ItemRequirement[] => erc721ItemAggregator(erc20ItemAggregator(itemRequirements)); diff --git a/packages/checkout/sdk/src/smartCheckout/smartCheckout.ts b/packages/checkout/sdk/src/smartCheckout/smartCheckout.ts index 73d4b80158..b3f4a5debd 100644 --- a/packages/checkout/sdk/src/smartCheckout/smartCheckout.ts +++ b/packages/checkout/sdk/src/smartCheckout/smartCheckout.ts @@ -6,23 +6,28 @@ import { ItemRequirement, SmartCheckoutResult, TransactionRequirementType, } from '../types/smartCheckout'; import { itemAggregator } from './itemAggregator'; -import { hasERC20Allowances } from './allowance'; +import { + hasERC20Allowances, + hasERC721Allowances, +} from './allowance'; export const smartCheckout = async ( provider: Web3Provider, itemRequirements: ItemRequirement[], transactionOrGasAmount: FulfilmentTransaction | GasAmount, ): Promise => { - const ownerAddress = await provider.getSigner().getAddress(); - // eslint-disable-next-line no-console console.log(provider, itemRequirements, transactionOrGasAmount); + + const ownerAddress = await provider.getSigner().getAddress(); const aggregatedItems = itemAggregator(itemRequirements); - // eslint-disable-next-line no-console - console.log(aggregatedItems); const erc20Allowances = await hasERC20Allowances(provider, ownerAddress, aggregatedItems); + const erc721Allowances = await hasERC721Allowances(provider, ownerAddress, aggregatedItems); + + // eslint-disable-next-line no-console + console.log('ERC20 Allowances', erc20Allowances); // eslint-disable-next-line no-console - console.log(erc20Allowances); + console.log('ERC721 Allowances', erc721Allowances); return { sufficient: true, diff --git a/packages/checkout/sdk/src/types/constants.ts b/packages/checkout/sdk/src/types/constants.ts index 6867ac4b73..6bb10a9f20 100644 --- a/packages/checkout/sdk/src/types/constants.ts +++ b/packages/checkout/sdk/src/types/constants.ts @@ -232,3 +232,75 @@ export const ERC20ABI = [ type: 'function', }, ]; + +export const ERC721ABI = [ + { + constant: false, + inputs: [ + { + internalType: 'address', + name: 'to', + type: 'address', + }, + { + internalType: 'uint256', + name: 'tokenId', + type: 'uint256', + }, + ], + name: 'approve', + outputs: [ + + ], + payable: false, + stateMutability: 'nonpayable', + type: 'function', + }, + { + constant: true, + inputs: [ + { + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + internalType: 'address', + name: 'operator', + type: 'address', + }, + ], + name: 'isApprovedForAll', + outputs: [ + { + internalType: 'bool', + name: '', + type: 'bool', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: true, + inputs: [ + { + internalType: 'uint256', + name: 'tokenId', + type: 'uint256', + }, + ], + name: 'getApproved', + outputs: [ + { + internalType: 'address', + name: '', + type: 'address', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, +]; diff --git a/packages/checkout/sdk/src/types/smartCheckout.ts b/packages/checkout/sdk/src/types/smartCheckout.ts index e7681a6d35..d6422f63b7 100644 --- a/packages/checkout/sdk/src/types/smartCheckout.ts +++ b/packages/checkout/sdk/src/types/smartCheckout.ts @@ -38,7 +38,7 @@ export enum ItemType { * @property {ItemType} type - The type indicate this is a native item. * @property {BigNumber} amount - The amount of the item. */ -type NativeItem = { +export type NativeItem = { type: ItemType.NATIVE; amount: BigNumber; }; @@ -50,7 +50,7 @@ type NativeItem = { * @property {BigNumber} amount - The amount of the item. * @property {string} spenderAddress - The contract address of the approver. */ -type ERC20Item = { +export type ERC20Item = { type: ItemType.ERC20; contractAddress: string; amount: BigNumber; @@ -64,7 +64,7 @@ type ERC20Item = { * @property {string} id - The ID of this ERC721 in the collection. * @property {string} spenderAddress - The contract address of the approver. */ -type ERC721Item = { +export type ERC721Item = { type: ItemType.ERC721; contractAddress: string; id: string;