diff --git a/examples/react/src/components/Connected.tsx b/examples/react/src/components/Connected.tsx index 3885a8d75..746f19d2b 100644 --- a/examples/react/src/components/Connected.tsx +++ b/examples/react/src/components/Connected.tsx @@ -21,7 +21,7 @@ import { useOpenWalletModal } from '@0xsequence/wallet-widget' import { CardButton, Header, WalletListItem } from 'example-shared-components' import { AnimatePresence } from 'motion/react' import React, { type ComponentProps, useEffect } from 'react' -import { encodeFunctionData, formatUnits, parseAbi, toHex } from 'viem' +import { encodeFunctionData, formatUnits, parseAbi, toHex, zeroAddress } from 'viem' import { useAccount, useChainId, usePublicClient, useSendTransaction, useWalletClient, useWriteContract } from 'wagmi' import { sponsoredContractAddresses } from '../config' @@ -357,11 +357,28 @@ export const Connected = () => { // const contractId = '674eb55a3d739107bbd18ecb' // // ERC-20 contract - const currencyAddress = '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359' - const salesContractAddress = '0xe65b75eb7c58ffc0bf0e671d64d0e1c6cd0d3e5b' + // const currencyAddress = '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359' + // const salesContractAddress = '0xe65b75eb7c58ffc0bf0e671d64d0e1c6cd0d3e5b' + // const collectionAddress = '0xdeb398f41ccd290ee5114df7e498cf04fac916cb' + // const price = '20000' + // const contractId = '674eb5613d739107bbd18ed2' + + // const chainId = 137 + + // Forte payment testnet testing opensea + // const currencyAddress = zeroAddress + // const salesContractAddress = '0x1130e2e03f682f05f298fd702787d9bd0bf94316' + // const collectionAddress = '0xb496d64e1fe4f3465fb83f3fd8cb50d8e227101b' + // const price = '600000000000000' + // const contractId = '' + // const chainId = 11155111 + + // Forte payment testnet testing magiceden + const currencyAddress = zeroAddress + const salesContractAddress = '0x0000000000000068F116a894984e2DB1123eB395' const collectionAddress = '0xdeb398f41ccd290ee5114df7e498cf04fac916cb' - const price = '20000' - const contractId = '674eb5613d739107bbd18ed2' + const price = '100000000000000' + const contractId = '' const chainId = 137 @@ -387,6 +404,8 @@ export const Connected = () => { ] }) + const creditCardProvider = checkoutProvider || 'transak' + openSelectPaymentModal({ collectibles, chain: chainId, @@ -395,14 +414,30 @@ export const Connected = () => { recipientAddress: address, currencyAddress, collectionAddress, - creditCardProviders: [checkoutProvider || 'transak'], + creditCardProviders: [creditCardProvider], onRampProvider: onRampProvider ? (onRampProvider as TransactionOnRampProvider) : TransactionOnRampProvider.transak, transakConfig: { contractId, apiKey: '5911d9ec-46b5-48fa-a755-d59a715ff0cf' }, + // forteConfig: { + // protocol: 'mint' + // }, + // Config for seaport testnet + // forteConfig: { + // protocol: 'seaport', + // orderHash: '0xa29984c1892bb28bc35170a0e7e4db64ceacfbd20dc5576bd67f1aae9dd678a3', + // // listings with amount > 1 are bugged + // // orderHash: '0x832b698e52508849fe533fdef53d6d9674be4f43eb1a2eb3415e46041f087af9', + // seaportProtocolAddress: '0x0000000000000068F116a894984e2DB1123eB395', + // sellerAddress: '0x184D4F89ad34bb0491563787ca28118273402986' + // }, + forteConfig: { + protocol: 'magiceden', + sellerAddress: '0xCb88b6315507e9d8c35D81AFB7F190aB6c3227C9' + }, copyrightText: 'ⓒ2024 Sequence', - onSuccess: (txnHash: string) => { + onSuccess: (txnHash?: string) => { console.log('success!', txnHash) }, onError: (error: Error) => { diff --git a/examples/react/src/components/CustomCheckout/index.tsx b/examples/react/src/components/CustomCheckout/index.tsx index 5eba11baf..2a702ee05 100644 --- a/examples/react/src/components/CustomCheckout/index.tsx +++ b/examples/react/src/components/CustomCheckout/index.tsx @@ -59,7 +59,7 @@ export const CustomCheckout = () => { contractId, apiKey: '5911d9ec-46b5-48fa-a755-d59a715ff0cf' }, - onSuccess: (txnHash: string) => { + onSuccess: (txnHash?: string) => { console.log('success!', txnHash) }, onError: (error: Error) => { diff --git a/examples/react/src/config.ts b/examples/react/src/config.ts index fe53c3a8f..0d6b1f9d5 100644 --- a/examples/react/src/config.ts +++ b/examples/react/src/config.ts @@ -167,7 +167,9 @@ export const checkoutConfig: SequenceCheckoutConfig = { sardineCheckoutUrl: 'https://sardine-checkout-sandbox.sequence.info', sardineOnRampUrl: 'https://crypto.sandbox.sardine.ai/', transakApiUrl: 'https://global-stg.transak.com', - transakApiKey: 'c20f2a0e-fe6a-4133-8fa7-77e9f84edf98' + transakApiKey: 'c20f2a0e-fe6a-4133-8fa7-77e9f84edf98', + fortePaymentUrl: 'https://staging-api.pti-dev.cloud', + forteWidgetUrl: 'https://dev-forte-payments-cdn.pti-dev.cloud/forte-payments-widget.js' } : undefined } diff --git a/packages/checkout/README.md b/packages/checkout/README.md index 1d9775a98..3728c4021 100644 --- a/packages/checkout/README.md +++ b/packages/checkout/README.md @@ -102,7 +102,7 @@ const MyComponent = () => { collectionAddress, creditCardProviders: ['sardine'], copyrightText: 'ⓒ2024 Sequence', - onSuccess: (txnHash: string) => { + onSuccess: (txnHash?: string) => { console.log('success!', txnHash) }, onError: (error: Error) => { @@ -156,7 +156,7 @@ const MyComponent = () => { quantity: "1", }, ], - onSuccess: (txnHash: string) => { + onSuccess: (txnHash?: string) => { console.log("success!", txnHash); }, onError: (error: Error) => { @@ -278,7 +278,7 @@ const CustomCheckoutUI = () => { contractId, apiKey: '5911d9ec-46b5-48fa-a755-d59a715ff0cf' }, - onSuccess: (txnHash: string) => { + onSuccess: (txnHash?: string) => { console.log('success!', txnHash) }, onError: (error: Error) => { diff --git a/packages/checkout/src/api/data.ts b/packages/checkout/src/api/data.ts index e80eba91d..5caefea27 100644 --- a/packages/checkout/src/api/data.ts +++ b/packages/checkout/src/api/data.ts @@ -1,8 +1,16 @@ import { SequenceAPIClient } from '@0xsequence/api' import { TokenMetadata } from '@0xsequence/metadata' -import { ChainId, networks } from '@0xsequence/network' +import { ChainId, networks, findSupportedNetwork } from '@0xsequence/network' +import { zeroAddress } from 'viem' -import { CreditCardCheckout } from '../contexts' +import { + CreditCardCheckout, + ForteProtocolType, + ForteConfig, + ForteMintConfig, + ForteSeaportConfig, + ForteMagicedenConfig +} from '../contexts' export interface FetchSardineClientTokenReturn { token: string @@ -233,3 +241,233 @@ export const fetchSardineOnRampLink = async ({ return url.href } + +export interface FetchForteAccessTokenReturn { + accessToken: string + expiresIn: number + tokenType: string +} + +export const fetchForteAccessToken = async (forteApiUrl: string): Promise => { + const clientId = '5tpnj5869vs3jpgtpif2ci8v08' + const clientSecret = 'jpkbg3e2ho9rbd0959qe5l6ke238d4bca2nptstfga2i9hant5e' + + const url = `${forteApiUrl}/auth/v1/oauth2/token` + + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + client_id: clientId, + client_secret: clientSecret + }) + }) + + const { data } = await res.json() + + return { + accessToken: data.access_token, + expiresIn: data.expires_in, + tokenType: data.token_type + } +} + +export interface CreateFortePaymentIntentArgs { + accessToken: string + tokenType: string + nftQuantity: string + recipientAddress: string + chainId: string + signature?: string + nftAddress: string + currencyAddress: string + targetContractAddress: string + nftName: string + imageUrl: string + tokenId: string + currencyQuantity: string + protocolConfig: ForteConfig +} + +const forteCurrencyMap: { [chainId: string]: { [currencyAddress: string]: string } } = { + '1': { + [zeroAddress]: 'ETH', + ['0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'.toLowerCase()]: 'USDC_ETH' + }, + '137': { + [zeroAddress]: 'POL', + ['0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359'.toLowerCase()]: 'USDC_POLYGON' + }, + '8453': { + [zeroAddress]: 'BASE_ETH' + } +} + +const getForteCurrency = (chainId: string, currencyAddress: string) => { + return forteCurrencyMap[chainId]?.[currencyAddress.toLowerCase()] || 'ETH' +} + +export const createFortePaymentIntent = async (forteApiUrl: string, args: CreateFortePaymentIntentArgs): Promise => { + const { + accessToken, + tokenType, + recipientAddress, + chainId, + signature, + targetContractAddress, + nftName, + nftAddress, + nftQuantity, + imageUrl, + tokenId, + protocolConfig, + currencyAddress, + currencyQuantity + } = args + + const network = findSupportedNetwork(chainId) + + if (!network) { + throw new Error('Invalid chainId') + } + + const url = `${forteApiUrl}/payments/v2/intent` + const forteBlockchainName = network.name.toLowerCase().replace('-', '_') + const idempotencyKey = `${recipientAddress}-${tokenId}-${targetContractAddress}-${nftName}-${new Date().getTime()}` + + let body: { [key: string]: any } = { + blockchain: forteBlockchainName, + idempotency_key: idempotencyKey, + buyer: { + id: recipientAddress, + wallet: { + address: recipientAddress, + blockchain: forteBlockchainName + } + } + } + + if (protocolConfig.protocol == 'mint') { + body = { + ...body, + transaction_type: 'BUY_NFT_MINT', + currency: 'USD', + items: [ + { + name: nftName, + quantity: nftQuantity, + price: { + amount: nftQuantity, + image_url: imageUrl, + title: nftName, + mint_data: { + nonce: `${targetContractAddress}-${Date.now()}`, + signature: signature, + token_ids: [tokenId], + protocol_address: targetContractAddress, + protocol: 'protocol-mint' + } + } + } + ] + } + } else { + let listingData: { [key: string]: any } = {} + + if (protocolConfig.protocol == 'seaport') { + listingData = { + protocol: protocolConfig.protocol, + order_hash: protocolConfig.orderHash, + protocol_address: protocolConfig.seaportProtocolAddress + } + } else if (protocolConfig.protocol == 'magiceden') { + listingData = { + protocol: protocolConfig.protocol, + auction_house: targetContractAddress, + token_address: nftAddress + } + } + + body = { + ...body, + transaction_type: 'BUY_NFT', + currency: getForteCurrency(chainId, currencyAddress), + items: [ + { + amount: currencyQuantity, + id: '1', + image_url: imageUrl, + listing_data: listingData, + nft_data: { + contract_address: nftAddress, + token_id: tokenId + }, + title: nftName + } + ], + seller: { + wallet: { + address: protocolConfig.sellerAddress || '', + blockchain: forteBlockchainName + } + } + } + } + + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `${tokenType} ${accessToken}` + }, + body: JSON.stringify(body) + }) + + if (!res.ok) { + throw new Error(`Failed to fetch widget data, with status: ${res.status}`) + } + + const data = await res.json() + + return data.data +} + +export interface FetchFortePaymentStatusArgs { + accessToken: string + tokenType: string + paymentIntentId: string +} + +export type FortePaymentStatus = 'Expired' | 'Created' | 'Declined' | 'Approved' + +export interface FetchFortePaymentStatusReturn { + status: FortePaymentStatus +} + +export const fetchFortePaymentStatus = async ( + forteApiUrl: string, + args: FetchFortePaymentStatusArgs +): Promise => { + const { accessToken, tokenType, paymentIntentId } = args + + const url = `${forteApiUrl}/payments/v1/payments/statuses` + + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `${tokenType} ${accessToken}` + }, + body: JSON.stringify({ + payment_intent_ids: [paymentIntentId] + }) + }) + + const { data } = await res.json() + + return { + status: (data[0]?.status as FortePaymentStatus) || '' + } +} diff --git a/packages/checkout/src/components/SequenceCheckoutProvider/ForteController.tsx b/packages/checkout/src/components/SequenceCheckoutProvider/ForteController.tsx new file mode 100644 index 000000000..db728fd36 --- /dev/null +++ b/packages/checkout/src/components/SequenceCheckoutProvider/ForteController.tsx @@ -0,0 +1,113 @@ +import { useState, useEffect } from 'react' + +import { fetchFortePaymentStatus } from '../../api' +import { FortePaymentControllerProvider, FortePaymentData } from '../../contexts' +import { useEnvironmentContext } from '../../contexts/Environment' + +const POLLING_TIME = 10 * 1000 + +export const ForteController = ({ children }: { children: React.ReactNode }) => { + const [fortePaymentData, setFortePaymentData] = useState() + const { fortePaymentUrl, forteWidgetUrl } = useEnvironmentContext() + + const initializeWidget = (fortePaymentData: FortePaymentData) => { + setFortePaymentData(fortePaymentData) + } + + const resetWidget = () => { + setFortePaymentData(undefined) + document.getElementById('forte-widget-script')?.remove() + document.getElementById('forte-payments-widget-container')?.remove() + } + + useEffect(() => { + let interval: NodeJS.Timeout | undefined + let widgetClosedListener: () => void + + if (fortePaymentData) { + interval = setInterval(() => { + checkFortePaymentStatus() + }, POLLING_TIME) + + widgetClosedListener = () => { + fortePaymentData.creditCardCheckout?.onClose?.() + resetWidget() + } + + window.addEventListener('FortePaymentsWidgetClosed', widgetClosedListener) + } + + return () => { + clearInterval(interval) + window.removeEventListener('FortePaymentsWidgetClosed', widgetClosedListener) + } + }, [fortePaymentData]) + + useEffect(() => { + if (!fortePaymentData) { + return + } + if (document.getElementById('forte-widget-script')) { + return + } + + const container = document.createElement('div') + container.id = 'forte-payments-widget-container' + document.body.appendChild(container) + + const script = document.createElement('script') + script.id = 'forte-widget-script' + script.type = 'module' + script.async = true + script.src = forteWidgetUrl + + const widgetData = fortePaymentData.widgetData + script.onload = () => { + // @ts-ignore-next-line + if (window?.initFortePaymentsWidget && widgetData) { + // @ts-ignore-next-line + window.initFortePaymentsWidget({ + containerId: 'forte-payments-widget-container', + data: widgetData + }) + } + } + + document.body.appendChild(script) + }, [fortePaymentData]) + + const checkFortePaymentStatus = async () => { + if (!fortePaymentData) { + return + } + + const { status } = await fetchFortePaymentStatus(fortePaymentUrl, { + accessToken: fortePaymentData.accessToken, + tokenType: fortePaymentData.tokenType, + paymentIntentId: fortePaymentData.paymentIntentId + }) + + if (status === 'Approved') { + fortePaymentData.creditCardCheckout?.onSuccess?.() + } + + if (status === 'Declined' || status === 'Expired') { + fortePaymentData.creditCardCheckout?.onError?.( + new Error('A problem occurred while processing your payment'), + fortePaymentData.creditCardCheckout + ) + } + } + + return ( + + {children} + + ) +} diff --git a/packages/checkout/src/components/SequenceCheckoutProvider/SequenceCheckoutProvider.tsx b/packages/checkout/src/components/SequenceCheckoutProvider/SequenceCheckoutProvider.tsx index ccfffe08b..e0bed50e1 100644 --- a/packages/checkout/src/components/SequenceCheckoutProvider/SequenceCheckoutProvider.tsx +++ b/packages/checkout/src/components/SequenceCheckoutProvider/SequenceCheckoutProvider.tsx @@ -37,6 +37,8 @@ import { } from '../../views' import { NavigationHeader } from '../NavigationHeader' +import { ForteController } from './ForteController' + export interface SequenceCheckoutConfig { env?: Partial } @@ -204,7 +206,9 @@ export const SequenceCheckoutProvider = ({ children, config }: SequenceCheckoutP sardineCheckoutUrl: config?.env?.sardineCheckoutUrl ?? 'https://sardine-checkout.sequence.info', sardineOnRampUrl: config?.env?.sardineOnRampUrl ?? 'https://crypto.sardine.ai/', transakApiUrl: config?.env?.transakApiUrl ?? 'https://global.transak.com', - transakApiKey: config?.env?.transakApiKey ?? '5911d9ec-46b5-48fa-a755-d59a715ff0cf' + transakApiKey: config?.env?.transakApiKey ?? '5911d9ec-46b5-48fa-a755-d59a715ff0cf', + fortePaymentUrl: config?.env?.fortePaymentUrl ?? 'https://api.payments.forte.io', + forteWidgetUrl: config?.env?.forteWidgetUrl ?? 'https://client.payments.forte.io/forte-payments-widget.js' }} > - - - {openCheckoutModal && ( - setOpenCheckoutModal(false)} - > -
- {getCheckoutHeader()} - {getCheckoutContent()} -
-
- )} - {openAddFundsModal && ( - -
- {getAddFundsHeader()} - {getAddFundsContent()} -
-
- )} - {openPaymentSelectionModal && ( - setOpenPaymentSelectionModal(false)} - > -
- -
-
- )} - {openTransferFundsModal && ( - -
- - -
-
- )} - {openTransactionStatusModal && ( - -
- -
-
- )} - {isOpenSwapModal && ( - -
- - -
-
- )} -
-
- {children} + + + + {openCheckoutModal && ( + setOpenCheckoutModal(false)} + > +
+ {getCheckoutHeader()} + {getCheckoutContent()} +
+
+ )} + {openAddFundsModal && ( + +
+ {getAddFundsHeader()} + {getAddFundsContent()} +
+
+ )} + {openPaymentSelectionModal && ( + setOpenPaymentSelectionModal(false)} + > +
+ +
+
+ )} + {openTransferFundsModal && ( + +
+ + +
+
+ )} + {openTransactionStatusModal && ( + +
+ +
+
+ )} + {isOpenSwapModal && ( + +
+ + +
+
+ )} +
+
+ {children} +
diff --git a/packages/checkout/src/contexts/CheckoutModal.ts b/packages/checkout/src/contexts/CheckoutModal.ts index 4528d1c31..e64450b97 100644 --- a/packages/checkout/src/contexts/CheckoutModal.ts +++ b/packages/checkout/src/contexts/CheckoutModal.ts @@ -23,6 +23,26 @@ export interface TransakConfig { callDataOverride?: string } +export type ForteProtocolType = 'seaport' | 'magiceden' | 'mint' + +export interface ForteMintConfig { + protocol: 'mint' +} + +export interface ForteSeaportConfig { + protocol: 'seaport' + orderHash: string + seaportProtocolAddress: string + sellerAddress: string +} + +export interface ForteMagicedenConfig { + protocol: 'magiceden' + sellerAddress: string +} + +export type ForteConfig = ForteMintConfig | ForteSeaportConfig | ForteMagicedenConfig + export interface CreditCardCheckout { chainId: number contractAddress: string @@ -36,9 +56,10 @@ export interface CreditCardCheckout { nftQuantity: string nftDecimals?: string calldata: string - provider?: 'sardine' | 'transak' + provider?: 'sardine' | 'transak' | 'forte' transakConfig?: TransakConfig - onSuccess?: (transactionHash: string, settings: CreditCardCheckout) => void + forteConfig?: ForteConfig + onSuccess?: (transactionHash?: string, settings?: CreditCardCheckout) => void onError?: (error: Error, settings: CreditCardCheckout) => void onClose?: () => void approvedSpenderAddress?: string diff --git a/packages/checkout/src/contexts/Environment.ts b/packages/checkout/src/contexts/Environment.ts index 491a53e49..9c5efac1a 100644 --- a/packages/checkout/src/contexts/Environment.ts +++ b/packages/checkout/src/contexts/Environment.ts @@ -8,6 +8,8 @@ export interface EnvironmentOverrides { transakApiKey: string sardineCheckoutUrl: string sardineOnRampUrl: string + fortePaymentUrl: string + forteWidgetUrl: string } const [useEnvironmentContext, EnvironmentContextProvider] = createGenericContext() diff --git a/packages/checkout/src/contexts/FortePayment.ts b/packages/checkout/src/contexts/FortePayment.ts new file mode 100644 index 000000000..55636e9da --- /dev/null +++ b/packages/checkout/src/contexts/FortePayment.ts @@ -0,0 +1,22 @@ +'use client' + +import { CreditCardCheckout } from './CheckoutModal' +import { createGenericContext } from './genericContext' + +export interface FortePaymentData { + paymentIntentId: string + widgetData: any + accessToken: string + tokenType: string + creditCardCheckout: CreditCardCheckout +} + +export interface FortePaymentController { + data?: FortePaymentData + initializeWidget: (fortePaymentData: FortePaymentData) => void + resetWidget: () => void +} + +const [useFortePaymentController, FortePaymentControllerProvider] = createGenericContext() + +export { useFortePaymentController, FortePaymentControllerProvider } diff --git a/packages/checkout/src/contexts/SelectPaymentModal.ts b/packages/checkout/src/contexts/SelectPaymentModal.ts index fc12a6481..1d3013d33 100644 --- a/packages/checkout/src/contexts/SelectPaymentModal.ts +++ b/packages/checkout/src/contexts/SelectPaymentModal.ts @@ -1,11 +1,11 @@ import { TransactionOnRampProvider } from '@0xsequence/marketplace' import { Hex } from 'viem' -import type { TransakConfig } from '../contexts/CheckoutModal' +import type { TransakConfig, ForteConfig } from '../contexts/CheckoutModal' import { createGenericContext } from './genericContext' -export type CreditCardProviders = 'sardine' | 'transak' +export type CreditCardProviders = 'sardine' | 'transak' | 'forte' export interface Collectible { tokenId: string @@ -33,7 +33,7 @@ export interface SelectPaymentSettings { recipientAddress: string | Hex approvedSpenderAddress?: string transactionConfirmations?: number - onSuccess?: (txHash: string) => void + onSuccess?: (txHash?: string) => void onError?: (error: Error) => void onClose?: () => void onRampProvider?: TransactionOnRampProvider @@ -44,6 +44,7 @@ export interface SelectPaymentSettings { copyrightText?: string transakConfig?: TransakConfig sardineConfig?: SardineConfig + forteConfig?: ForteConfig customProviderCallback?: (onSuccess: (txHash: string) => void, onError: (error: Error) => void, onClose: () => void) => void supplementaryAnalyticsInfo?: SupplementaryAnalyticsInfo skipNativeBalanceCheck?: boolean diff --git a/packages/checkout/src/contexts/index.ts b/packages/checkout/src/contexts/index.ts index 3ef22fbd4..0aea184c3 100644 --- a/packages/checkout/src/contexts/index.ts +++ b/packages/checkout/src/contexts/index.ts @@ -6,3 +6,4 @@ export * from './TransferFundsModal' export * from './TransactionStatusModal' export * from './SwapModal' export * from './Environment' +export * from './FortePayment' diff --git a/packages/checkout/src/hooks/index.ts b/packages/checkout/src/hooks/index.ts index 717d41208..6d5aa65cf 100644 --- a/packages/checkout/src/hooks/index.ts +++ b/packages/checkout/src/hooks/index.ts @@ -12,3 +12,5 @@ export * from './useCheckoutOptionsSalesContract' export * from './useERC1155SaleContractCheckout' export * from './useSkipOnCloseCallback' export * from './useSardineOnRampLink' +export * from './useForteAccessToken' +export * from './useFortePaymentIntent' diff --git a/packages/checkout/src/hooks/useForteAccessToken.ts b/packages/checkout/src/hooks/useForteAccessToken.ts new file mode 100644 index 000000000..50bb16f58 --- /dev/null +++ b/packages/checkout/src/hooks/useForteAccessToken.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@tanstack/react-query' + +import { fetchForteAccessToken } from '../api/data' +import { useEnvironmentContext } from '../contexts/Environment' + +export const useForteAccessToken = () => { + const { fortePaymentUrl } = useEnvironmentContext() + + return useQuery({ + queryKey: ['useForteAccessToken'], + queryFn: async () => { + const res = await fetchForteAccessToken(fortePaymentUrl) + + return res + }, + retry: false, + staleTime: 60 * 1000, + refetchOnWindowFocus: false + }) +} diff --git a/packages/checkout/src/hooks/useFortePaymentIntent.ts b/packages/checkout/src/hooks/useFortePaymentIntent.ts new file mode 100644 index 000000000..0e20516b4 --- /dev/null +++ b/packages/checkout/src/hooks/useFortePaymentIntent.ts @@ -0,0 +1,24 @@ +import { useQuery } from '@tanstack/react-query' + +import { createFortePaymentIntent, CreateFortePaymentIntentArgs } from '../api/data' +import { useEnvironmentContext } from '../contexts/Environment' +interface UseFortePaymentIntentOptions { + disabled?: boolean +} + +export const useFortePaymentIntent = (args: CreateFortePaymentIntentArgs, options?: UseFortePaymentIntentOptions) => { + const { fortePaymentUrl } = useEnvironmentContext() + + return useQuery({ + queryKey: ['useFortePaymentIntent', args], + queryFn: async () => { + const res = await createFortePaymentIntent(fortePaymentUrl, args) + + return res + }, + retry: false, + staleTime: 60 * 1000, + refetchOnMount: 'always', + enabled: !options?.disabled + }) +} diff --git a/packages/checkout/src/index.ts b/packages/checkout/src/index.ts index c3b9f7bdf..b615b3105 100644 --- a/packages/checkout/src/index.ts +++ b/packages/checkout/src/index.ts @@ -11,7 +11,7 @@ export { useSwapModal } from './hooks/useSwapModal' export { useERC1155SaleContractCheckout, useERC1155SaleContractPaymentModal } from './hooks/useERC1155SaleContractCheckout' export { useCheckoutUI } from './hooks/useCheckoutUI' -export { type CheckoutSettings } from './contexts/CheckoutModal' +export { type CheckoutSettings, type ForteProtocolType } from './contexts/CheckoutModal' export { type AddFundsSettings } from './contexts/AddFundsModal' export { type SelectPaymentSettings } from './contexts/SelectPaymentModal' export { type SwapModalSettings } from './contexts/SwapModal' diff --git a/packages/checkout/src/views/PaymentSelection/PayWithCreditCard/index.tsx b/packages/checkout/src/views/PaymentSelection/PayWithCreditCard/index.tsx index 6dde9cc2c..582075f25 100644 --- a/packages/checkout/src/views/PaymentSelection/PayWithCreditCard/index.tsx +++ b/packages/checkout/src/views/PaymentSelection/PayWithCreditCard/index.tsx @@ -14,7 +14,7 @@ interface PayWithCreditCardProps { skipOnCloseCallback: () => void } -type BasePaymentProviderOptions = 'sardine' | 'transak' +type BasePaymentProviderOptions = 'sardine' | 'transak' | 'forte' type CustomPaymentProviderOptions = 'custom' type PaymentProviderOptions = BasePaymentProviderOptions | CustomPaymentProviderOptions @@ -32,8 +32,9 @@ export const PayWithCreditCard = ({ settings, disableButtons, skipOnCloseCallbac onError = () => {}, onClose = () => {}, creditCardProviders = [], + supplementaryAnalyticsInfo = {}, transakConfig, - supplementaryAnalyticsInfo = {} + forteConfig } = settings const { address: userAddress } = useAccount() @@ -64,6 +65,7 @@ export const PayWithCreditCard = ({ settings, disableButtons, skipOnCloseCallbac return case 'sardine': case 'transak': + case 'forte': onPurchase() return default: @@ -87,7 +89,7 @@ export const PayWithCreditCard = ({ settings, disableButtons, skipOnCloseCallbac const checkoutSettings: CheckoutSettings = { creditCardCheckout: { - onSuccess: (txHash: string) => { + onSuccess: (txHash?: string) => { clearCachedBalances() onSuccess(txHash) }, @@ -106,9 +108,10 @@ export const PayWithCreditCard = ({ settings, disableButtons, skipOnCloseCallbac nftDecimals: collectible.decimals === undefined ? undefined : String(collectible.decimals), provider: selectedPaymentProvider as BasePaymentProviderOptions, calldata: txData, - transakConfig, approvedSpenderAddress: sardineConfig?.approvedSpenderAddress || targetContractAddress, - supplementaryAnalyticsInfo + supplementaryAnalyticsInfo, + transakConfig, + forteConfig } } @@ -134,6 +137,7 @@ export const PayWithCreditCard = ({ settings, disableButtons, skipOnCloseCallbac switch (creditCardProvider) { case 'sardine': case 'transak': + case 'forte': case 'custom': return ( { const { skipOnCloseCallback } = useSkipOnCloseCallback(onClose) switch (provider) { + case 'forte': + return case 'transak': return case 'sardine': @@ -431,3 +436,113 @@ export const PendingCreditCardTransactionSardine = ({ skipOnCloseCallback }: Pen ) } + +export const PendingCreditCardTransactionForte = ({ skipOnCloseCallback }: PendingCreditTransactionProps) => { + const { initializeWidget } = useFortePaymentController() + const { data: accessTokenData, isLoading: isLoadingAccessToken, isError: isErrorAccessToken } = useForteAccessToken() + const { data: signatureData, signMessage } = useSignMessage() + const { address } = useAccount() + const nav = useNavigation() + const { + params: { creditCardCheckout } + } = nav.navigation as TransactionPendingNavigation + const publicClient = usePublicClient({ chainId: creditCardCheckout.chainId }) + const isMessageSigned = signatureData !== undefined + const isSignatureRequired = creditCardCheckout.forteConfig?.protocol === 'mint' + const { closeCheckout } = useCheckoutModal() + + const { + data: tokenMetadatas, + isLoading: isLoadingTokenMetadata, + isError: isErrorTokenMetadata + } = useGetTokenMetadata({ + chainID: String(creditCardCheckout.chainId), + contractAddress: creditCardCheckout.nftAddress, + tokenIDs: [creditCardCheckout.nftId] + }) + + const tokenMetadata = tokenMetadatas ? tokenMetadatas[0] : undefined + + const nftQuantity = formatUnits(BigInt(creditCardCheckout.nftQuantity), Number(creditCardCheckout.nftDecimals || 0)) + const currencyQuantity = formatUnits( + BigInt(creditCardCheckout.currencyQuantity), + Number(creditCardCheckout.currencyDecimals || 18) + ) + + const { data: paymentIntentData, isError: isErrorPaymentIntent } = useFortePaymentIntent( + { + accessToken: accessTokenData?.accessToken || '', + tokenType: accessTokenData?.tokenType || '', + nftQuantity, + recipientAddress: creditCardCheckout.recipientAddress, + chainId: creditCardCheckout.chainId.toString(), + signature: signatureData || '', + nftAddress: creditCardCheckout.nftAddress, + currencyAddress: creditCardCheckout.currencyAddress, + targetContractAddress: creditCardCheckout.contractAddress, + nftName: tokenMetadata?.name || '', + imageUrl: tokenMetadata?.image || '', + tokenId: creditCardCheckout.nftId, + protocolConfig: creditCardCheckout.forteConfig || { protocol: 'mint' }, + currencyQuantity + }, + { + disabled: (!isMessageSigned && isSignatureRequired) || isLoadingTokenMetadata || isLoadingAccessToken + } + ) + + useEffect(() => { + if (!paymentIntentData) { + return + } + + initializeWidget({ + paymentIntentId: paymentIntentData.payment_intent_id, + widgetData: paymentIntentData, + accessToken: accessTokenData?.accessToken || '', + tokenType: accessTokenData?.tokenType || '', + creditCardCheckout + }) + skipOnCloseCallback() + closeCheckout() + }, [paymentIntentData]) + + const isError = isErrorTokenMetadata || isErrorAccessToken || isErrorPaymentIntent + + const onClickSignMessage = async () => { + if (!publicClient || !address) { + console.error('No public client or address') + return + } + + try { + await signMessage({ message: creditCardCheckout.calldata }) + } catch (e) { + console.error('An error occurred while signing the message') + } + } + + if (!isMessageSigned && isSignatureRequired) { + return ( +
+ +
+ ) + } + + if (isError) { + return ( +
+ An error has occurred +
+ ) + } + + return ( +
+ +
+ ) +} diff --git a/packages/connect/src/styles.ts b/packages/connect/src/styles.ts index 7b2414fe9..d7e717f0a 100644 --- a/packages/connect/src/styles.ts +++ b/packages/connect/src/styles.ts @@ -307,6 +307,9 @@ export const styles = String.raw` .my-4 { margin-block: calc(var(--spacing) * 4); } + .mt-0 { + margin-top: calc(var(--spacing) * 0); + } .mt-0\.5 { margin-top: calc(var(--spacing) * 0.5); } @@ -382,6 +385,9 @@ export const styles = String.raw` .inline-flex { display: inline-flex; } + .table { + display: table; + } .aspect-square { aspect-ratio: 1 / 1; } @@ -472,6 +478,9 @@ export const styles = String.raw` .min-h-full { min-height: 100%; } + .w-1 { + width: calc(var(--spacing) * 1); + } .w-1\/2 { width: calc(1/2 * 100%); } @@ -565,12 +574,21 @@ export const styles = String.raw` .min-w-full { min-width: 100%; } + .flex-shrink { + flex-shrink: 1; + } .shrink-0 { flex-shrink: 0; } + .flex-grow { + flex-grow: 1; + } .grow { flex-grow: 1; } + .border-collapse { + border-collapse: collapse; + } .origin-top { transform-origin: top; } @@ -939,6 +957,9 @@ export const styles = String.raw` .pt-0 { padding-top: calc(var(--spacing) * 0); } + .pt-1 { + padding-top: calc(var(--spacing) * 1); + } .pt-1\.5 { padding-top: calc(var(--spacing) * 1.5); } @@ -1221,6 +1242,9 @@ export const styles = String.raw` .ring-border-normal { --tw-ring-color: var(--seq-color-border-normal); } + .ring-white { + --tw-ring-color: var(--color-white); + } .ring-white\/10 { --tw-ring-color: color-mix(in oklab, var(--color-white) 10%, transparent); } @@ -1256,6 +1280,10 @@ export const styles = String.raw` -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } + .backdrop-filter { + -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + } .transition { transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter; transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));