diff --git a/packages/demo-wallet-client/src/App.scss b/packages/demo-wallet-client/src/App.scss index ae594a5b..606d37fb 100644 --- a/packages/demo-wallet-client/src/App.scss +++ b/packages/demo-wallet-client/src/App.scss @@ -488,3 +488,28 @@ text-align: center; } } + +// SEP-6 +.Sep6Selection { + padding-left: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + + &__label { + margin-top: 0.5rem; + } +} + +.AnchorQuote { + display: flex; + flex-direction: column; + gap: 1rem; + + &__item { + &__value { + word-break: break-all; + line-height: 1.4; + } + } +} diff --git a/packages/demo-wallet-client/src/components/Assets.tsx b/packages/demo-wallet-client/src/components/Assets.tsx index a581ba66..e5418f2b 100644 --- a/packages/demo-wallet-client/src/components/Assets.tsx +++ b/packages/demo-wallet-client/src/components/Assets.tsx @@ -69,8 +69,8 @@ export const Assets = ({ allAssets, assetOverrides, claimAsset, - sep6DepositAsset, - sep6WithdrawAsset, + sep6Deposit, + sep6Withdraw, sep24DepositAsset, sep24WithdrawAsset, sep31Send, @@ -83,8 +83,8 @@ export const Assets = ({ "allAssets", "assetOverrides", "claimAsset", - "sep6DepositAsset", - "sep6WithdrawAsset", + "sep6Deposit", + "sep6Withdraw", "sep24DepositAsset", "sep24WithdrawAsset", "sep31Send", @@ -287,50 +287,50 @@ export const Assets = ({ } }, [untrustedAssets.status, dispatch]); - // SEP-6 Deposit asset + // SEP-6 Deposit useEffect(() => { if ( - sep6DepositAsset.status === ActionStatus.SUCCESS && - sep6DepositAsset.data.trustedAssetAdded + sep6Deposit.status === ActionStatus.SUCCESS && + sep6Deposit.data.trustedAssetAdded ) { - handleRemoveUntrustedAsset(sep6DepositAsset.data.trustedAssetAdded); + handleRemoveUntrustedAsset(sep6Deposit.data.trustedAssetAdded); } - if (sep6DepositAsset.data.currentStatus === TransactionStatus.COMPLETED) { + if (sep6Deposit.data.currentStatus === TransactionStatus.COMPLETED) { handleRefreshAccount(); handleFetchClaimableBalances(); } setActiveAssetStatusAndToastMessage({ - status: sep6DepositAsset.status, + status: sep6Deposit.status, message: "SEP-6 deposit in progress", }); }, [ - sep6DepositAsset.status, - sep6DepositAsset.data.currentStatus, - sep6DepositAsset.data.trustedAssetAdded, + sep6Deposit.status, + sep6Deposit.data.currentStatus, + sep6Deposit.data.trustedAssetAdded, handleRefreshAccount, handleFetchClaimableBalances, handleRemoveUntrustedAsset, setActiveAssetStatusAndToastMessage, ]); - // SEP-6 Withdraw asset + // SEP-6 Withdraw useEffect(() => { if ( - sep6WithdrawAsset.status === ActionStatus.SUCCESS && - sep6WithdrawAsset.data.currentStatus === TransactionStatus.COMPLETED + sep6Withdraw.status === ActionStatus.SUCCESS && + sep6Withdraw.data.currentStatus === TransactionStatus.COMPLETED ) { handleRefreshAccount(); } setActiveAssetStatusAndToastMessage({ - status: sep6WithdrawAsset.status, + status: sep6Withdraw.status, message: "SEP-6 withdrawal in progress", }); }, [ - sep6WithdrawAsset.status, - sep6WithdrawAsset.data.currentStatus, + sep6Withdraw.status, + sep6Withdraw.data.currentStatus, handleRefreshAccount, setActiveAssetStatusAndToastMessage, ]); diff --git a/packages/demo-wallet-client/src/components/Balance.tsx b/packages/demo-wallet-client/src/components/Balance.tsx index b3416041..8872fa0f 100644 --- a/packages/demo-wallet-client/src/components/Balance.tsx +++ b/packages/demo-wallet-client/src/components/Balance.tsx @@ -2,8 +2,8 @@ import { useDispatch } from "react-redux"; import { TextLink } from "@stellar/design-system"; import { BalanceRow } from "components/BalanceRow"; -import { initiateDepositAction as initiateSep6SendAction } from "ducks/sep6DepositAsset"; -import { initiateWithdrawAction as initiateSep6WithdrawAction } from "ducks/sep6WithdrawAsset"; +import { initiateDepositAction as initiateSep6SendAction } from "ducks/sep6Deposit"; +import { initiateWithdrawAction as initiateSep6WithdrawAction } from "ducks/sep6Withdraw"; import { initiateSep8SendAction } from "ducks/sep8Send"; import { depositAssetAction } from "ducks/sep24DepositAsset"; import { initiateSendAction } from "ducks/sep31Send"; diff --git a/packages/demo-wallet-client/src/components/Sep6/Sep6Deposit.tsx b/packages/demo-wallet-client/src/components/Sep6Deposit.tsx similarity index 55% rename from packages/demo-wallet-client/src/components/Sep6/Sep6Deposit.tsx rename to packages/demo-wallet-client/src/components/Sep6Deposit.tsx index e146eb92..049737f7 100644 --- a/packages/demo-wallet-client/src/components/Sep6/Sep6Deposit.tsx +++ b/packages/demo-wallet-client/src/components/Sep6Deposit.tsx @@ -1,40 +1,47 @@ -import React, { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useDispatch } from "react-redux"; import { Button, - Select, - TextLink, - Modal, + DetailsTooltip, Heading3, Input, - DetailsTooltip, + Modal, + RadioButton, + Select, + TextLink, } from "@stellar/design-system"; import { CSS_MODAL_PARENT_ID } from "demo-wallet-shared/build/constants/settings"; import { KycField, KycFieldInput } from "components/KycFieldInput"; -import { AnchorQuotesModal } from "components/AnchorQuotesModal"; +import { Sep6PriceModal } from "components/Sep6PriceModal"; +import { Sep6QuoteModal } from "components/Sep6QuoteModal"; + +import { useRedux } from "hooks/useRedux"; +import { AppDispatch } from "config/store"; import { resetActiveAssetAction } from "ducks/activeAsset"; import { + getDepositQuoteAction, + initSep6DepositFlowAction, + initSep6DepositFlowWithQuoteAction, resetSep6DepositAction, - submitSep6DepositFields, - sep6DepositAction, - submitSep6CustomerInfoFields, - setStatusAction, - submitSep6DepositWithQuotesFields, -} from "ducks/sep6DepositAsset"; -import { - fetchSep38QuotesSep6InfoAction, - resetSep38QuotesAction, -} from "ducks/sep38Quotes"; -import { useRedux } from "hooks/useRedux"; -import { AppDispatch } from "config/store"; + sep6DepositPriceAction, + submitSep6CustomerInfoFieldsAction, + submitSep6DepositAction, +} from "ducks/sep6Deposit"; import { ActionStatus } from "types/types"; export const Sep6Deposit = () => { - const { sep6DepositAsset } = useRedux("sep6DepositAsset"); + const { sep6Deposit } = useRedux("sep6Deposit"); const { - data: { depositResponse }, - } = sep6DepositAsset; + data: { + depositAssets, + infoFields, + buyAsset, + minAmount, + maxAmount, + depositResponse, + }, + } = sep6Deposit; interface FormData { amount?: string; @@ -59,17 +66,24 @@ export const Sep6Deposit = () => { }; const [formData, setFormData] = useState(formInitialState); + const [selectedDepositAsset, setSelectedDepositAsset] = useState(""); + const [selectedDepositAssetCountryCode, setSelectedDepositAssetCountryCode] = + useState(""); + const [selectedDepositAssetSellMethod, setSelectedDepositAssetSellMethod] = + useState(""); const [isInfoModalVisible, setIsInfoModalVisible] = useState(true); const dispatch: AppDispatch = useDispatch(); + const supportsQuotes = depositAssets && depositAssets.length > 0; + const depositTypeChoices = useMemo( - () => sep6DepositAsset.data.infoFields?.type?.choices || [], - [sep6DepositAsset], + () => infoFields.type.choices || [], + [infoFields.type.choices], ); useEffect(() => { - if (sep6DepositAsset.status === ActionStatus.NEEDS_INPUT) { + if (sep6Deposit.status === ActionStatus.NEEDS_INPUT) { setFormData({ amount: "", depositType: { @@ -79,19 +93,99 @@ export const Sep6Deposit = () => { customerFields: {}, }); } - }, [sep6DepositAsset.status, depositTypeChoices, dispatch]); + }, [sep6Deposit.status, depositTypeChoices, dispatch]); const resetLocalState = () => { setFormData(formInitialState); + setSelectedDepositAsset(""); + setSelectedDepositAssetCountryCode(""); + setSelectedDepositAssetSellMethod(""); }; const handleClose = () => { dispatch(resetSep6DepositAction()); dispatch(resetActiveAssetAction()); - dispatch(resetSep38QuotesAction()); resetLocalState(); }; + const handleSubmit = ( + event: React.MouseEvent, + ) => { + event.preventDefault(); + + dispatch(submitSep6DepositAction()); + }; + + const handleSubmitWithQuote = ( + event: React.MouseEvent, + ) => { + event.preventDefault(); + + const { id, sell_asset, sell_amount, buy_asset } = + sep6Deposit.data.quote || {}; + + if (!(id && buy_asset && sell_asset && sell_amount)) { + return; + } + + dispatch( + initSep6DepositFlowWithQuoteAction({ + ...formData, + amount: sell_amount, + quoteId: id, + destinationAssetCode: buy_asset.split(":")[1], + sourceAsset: sell_asset, + }), + ); + }; + + const handleGetPrice = ( + event: React.MouseEvent, + ) => { + event.preventDefault(); + + if (buyAsset) { + dispatch( + sep6DepositPriceAction({ + sellAsset: selectedDepositAsset, + buyAsset: buyAsset, + sellAmount: formData.amount || "0", + sellDeliveryMethod: selectedDepositAssetSellMethod, + countryCode: selectedDepositAssetCountryCode, + }), + ); + } + }; + + const handleGetQuote = ( + event: React.MouseEvent, + ) => { + event.preventDefault(); + + if (buyAsset) { + dispatch( + getDepositQuoteAction({ + sellAsset: selectedDepositAsset, + buyAsset: buyAsset, + sellAmount: formData.amount || "0", + sellDeliveryMethod: selectedDepositAssetSellMethod, + countryCode: selectedDepositAssetCountryCode, + }), + ); + } + }; + + const handleAmountChange = (event: React.ChangeEvent) => { + const { id, value } = event.target; + + const updatedState = { + ...formData, + [id]: value.toString(), + }; + + setFormData(updatedState); + }; + const handleDepositTypeChange = ( event: React.ChangeEvent, ) => { @@ -145,78 +239,14 @@ export const Sep6Deposit = () => { setFormData(updatedState); }; - const handleAmountChange = (event: React.ChangeEvent) => { - const { id, value } = event.target; - - const updatedState = { - ...formData, - [id]: value.toString(), - }; - - setFormData(updatedState); - }; - - const handleSubmit = ( - event: React.MouseEvent, - ) => { - event.preventDefault(); - dispatch(submitSep6DepositFields({ ...formData })); - }; - - const handleShowQuotesModal = ( - event: React.MouseEvent, - ) => { - event.preventDefault(); - - if (!formData.amount) { - return; - } - - const { assetCode, assetIssuer } = sep6DepositAsset.data; - - dispatch( - fetchSep38QuotesSep6InfoAction({ - anchorQuoteServerUrl: sep6DepositAsset.data?.anchorQuoteServer, - buyAsset: `stellar:${assetCode}:${assetIssuer}`, - amount: formData.amount, - }), - ); - dispatch(setStatusAction(ActionStatus.ANCHOR_QUOTES)); - }; - - const handleSubmitWithQuotes = ( - event: React.MouseEvent, - quoteId?: string, - buyAsset?: string, - sellAsset?: string, - ) => { - event.preventDefault(); - - if (!(quoteId && buyAsset && sellAsset && formData.amount)) { - return; - } - - dispatch( - submitSep6DepositWithQuotesFields({ - ...formData, - amount: formData.amount, - quoteId, - destinationAssetCode: buyAsset.split(":")[1], - sourceAsset: sellAsset, - }), - ); - }; - const handleSubmitCustomerInfo = ( event: React.MouseEvent, ) => { event.preventDefault(); - dispatch(submitSep6CustomerInfoFields(formData.customerFields)); + dispatch(submitSep6CustomerInfoFieldsAction(formData.customerFields)); }; const renderMinMaxAmount = () => { - const { minAmount, maxAmount } = sep6DepositAsset.data; - if (minAmount === 0 && maxAmount === 0) { return null; } @@ -224,99 +254,102 @@ export const Sep6Deposit = () => { return `Min: ${minAmount} | Max: ${maxAmount}`; }; - if (sep6DepositAsset.status === ActionStatus.ANCHOR_QUOTES) { - return ( - - ); - } + const renderDepositAssets = () => { + if (!supportsQuotes) { + return null; + } - if (sep6DepositAsset.status === ActionStatus.NEEDS_KYC) { return ( - - SEP-6 Customer Info - - {Object.keys(sep6DepositAsset.data.customerFields).length ? ( - - - These are the fields the receiving anchor requires. The - sending client obtains them from the /customer endpoint.{" "} - - Learn more - - - } - isInline - tooltipPosition={DetailsTooltip.tooltipPosition.BOTTOM} - > - <>SEP-12 Required Info - - - ) : null} -
- {Object.entries(sep6DepositAsset.data.customerFields || {}).map( - ([id, input]) => ( - - ), - )} -
-
- - - - -
+ <> + Deposit Asset and Method +
+ {depositAssets.map((a) => ( +
+ { + setSelectedDepositAsset(a.asset); + setSelectedDepositAssetCountryCode(""); + setSelectedDepositAssetSellMethod(""); + }} + checked={a.asset === selectedDepositAsset} + /> + +
+ {a.sell_delivery_methods?.map((d, index) => ( + { + setSelectedDepositAssetSellMethod(d.name); + }} + checked={ + a.asset === selectedDepositAsset && + d.name === selectedDepositAssetSellMethod + } + /> + ))} +
+ + {a.country_codes?.length && a.country_codes.length > 1 ? ( +
+
Country code
+ {a.country_codes?.map((c) => ( + { + setSelectedDepositAssetCountryCode(c); + }} + checked={c === selectedDepositAssetCountryCode} + /> + ))} +
+ ) : null} +
+ ))} +
+ ); - } + }; - if (sep6DepositAsset.status === ActionStatus.NEEDS_INPUT) { - if ( - sep6DepositAsset.data.requiredCustomerInfoUpdates && - sep6DepositAsset.data.requiredCustomerInfoUpdates.length - ) { + const renderSubmitButton = () => { + if (supportsQuotes) { return ( - - SEP-6 Update Customer Info - -
- {sep6DepositAsset.data.requiredCustomerInfoUpdates.map( - (input) => ( - - ), - )} -
-
- - - - -
+ ); } + return ( + + ); + }; + + // Initial deposit modal + if (sep6Deposit.status === ActionStatus.NEEDS_INPUT) { return ( - SEP-6 Deposit Info + Updated SEP-6 Deposit Info
{
- {Object.entries(sep6DepositAsset.data.infoFields || {}).map( - ([id, input]) => - id === "type" ? ( -
- -
- ) : ( - + id === "type" ? ( +
+ +
+ ) : ( + + ), )}
+ + {renderDepositAssets()} - - + {renderSubmitButton()} @@ -414,7 +440,32 @@ export const Sep6Deposit = () => { ); } - if (sep6DepositAsset.status === ActionStatus.CAN_PROCEED) { + // Show price + if (sep6Deposit.status === ActionStatus.PRICE && sep6Deposit.data.price) { + return ( + + ); + } + + // Quote modal + if ( + sep6Deposit.status === ActionStatus.ANCHOR_QUOTES && + sep6Deposit.data.quote + ) { + return ( + + ); + } + + if (sep6Deposit.status === ActionStatus.CAN_PROCEED) { return ( SEP-6 Deposit Details @@ -428,7 +479,7 @@ export const Sep6Deposit = () => { - + @@ -437,48 +488,68 @@ export const Sep6Deposit = () => { ); } - if (sep6DepositAsset.status === ActionStatus.KYC_DONE) { + if (sep6Deposit.status === ActionStatus.NEEDS_KYC) { return ( - setIsInfoModalVisible(false)} - parentId={CSS_MODAL_PARENT_ID} - > - SEP-6 Deposit - + + SEP-6 Customer Info -

Submit the deposit.

+ {Object.keys(sep6Deposit.data.customerFields).length ? ( + + + These are the fields the receiving anchor requires. The + sending client obtains them from the /customer endpoint.{" "} + + Learn more + + + } + isInline + tooltipPosition={DetailsTooltip.tooltipPosition.BOTTOM} + > + <>SEP-12 Required Info + + + ) : null} +
+ {Object.entries(sep6Deposit.data.customerFields || {}).map( + ([id, input]) => ( + + ), + )} +
- - + +
); } - if (sep6DepositAsset.data.instructions) { + if (sep6Deposit.status === ActionStatus.KYC_DONE) { return ( setIsInfoModalVisible(false)} parentId={CSS_MODAL_PARENT_ID} > - SEP-6 Deposit Instructions + SEP-6 Deposit -

Transfer your offchain funds to the following destination:

-
- {Object.entries(sep6DepositAsset.data.instructions).map( - ([key, instr]) => ( -
- - {instr.value} -
- ), - )} -
+

Submit the deposit.

+ + + +
); } diff --git a/packages/demo-wallet-client/src/components/Sep6PriceModal.tsx b/packages/demo-wallet-client/src/components/Sep6PriceModal.tsx new file mode 100644 index 00000000..75f8a132 --- /dev/null +++ b/packages/demo-wallet-client/src/components/Sep6PriceModal.tsx @@ -0,0 +1,69 @@ +import { Button, Modal } from "@stellar/design-system"; +import { CSS_MODAL_PARENT_ID } from "demo-wallet-shared/build/constants/settings"; +import { AnchorPriceItem } from "demo-wallet-shared/build/types/types"; + +type Sep6PriceModalProps = { + priceItem: AnchorPriceItem; + onClose: () => void; + onProceed: (event: React.MouseEvent) => void; +}; + +export const Sep6PriceModal = ({ + priceItem, + onClose, + onProceed, +}: Sep6PriceModalProps) => { + const { price, sell_amount, buy_amount, total_price, fee } = priceItem; + + const priceItems = [ + { + label: "Price", + value: price, + }, + { + label: "Sell amount", + value: sell_amount, + }, + { + label: "Buy amount", + value: buy_amount, + }, + { + label: "Total price", + value: total_price, + }, + { + label: "Fee", + value: fee.total, + }, + ]; + + return ( + + Price Estimate + + +

+ These prices are indicative. The actual price will be calculated at + conversion time once the Anchor receives the funds. +

+ +
+ {priceItems.map((item) => ( +
+ +
{item.value}
+
+ ))} +
+
+ + + + + +
+ ); +}; diff --git a/packages/demo-wallet-client/src/components/Sep6QuoteModal.tsx b/packages/demo-wallet-client/src/components/Sep6QuoteModal.tsx new file mode 100644 index 00000000..f54a75f2 --- /dev/null +++ b/packages/demo-wallet-client/src/components/Sep6QuoteModal.tsx @@ -0,0 +1,94 @@ +import { Button, Modal } from "@stellar/design-system"; +import { CSS_MODAL_PARENT_ID } from "demo-wallet-shared/build/constants/settings"; +import { AnchorQuote } from "types/types"; + +type Sep6QuoteModalProps = { + quote: AnchorQuote; + onClose: () => void; + onSubmit: (event: React.MouseEvent) => void; +}; + +export const Sep6QuoteModal = ({ + quote, + onClose, + onSubmit, +}: Sep6QuoteModalProps) => { + const { + id, + price, + expires_at, + sell_asset, + sell_amount, + buy_asset, + buy_amount, + total_price, + fee, + } = quote; + + const quoteItems = [ + { + label: "ID", + value: id, + }, + { + label: "Price", + value: price, + }, + { + label: "Expires at", + value: expires_at, + }, + { + label: "Sell asset", + value: sell_asset, + }, + { + label: "Sell amount", + value: sell_amount, + }, + { + label: "Buy asset", + value: buy_asset, + }, + { + label: "Buy amount", + value: buy_amount, + }, + { + label: "Total price", + value: total_price, + }, + { + label: "Fee", + value: fee.total, + }, + ]; + + return ( + + SEP-6 Deposit Quote + +

+ These prices are indicative. The actual price will be calculated at + conversion time once the Anchor receives the funds. +

+ +
+ {quoteItems.map((item) => ( +
+ +
{item.value}
+
+ ))} +
+
+ + + + + +
+ ); +}; diff --git a/packages/demo-wallet-client/src/components/Sep6/Sep6Withdraw.tsx b/packages/demo-wallet-client/src/components/Sep6Withdraw.tsx similarity index 58% rename from packages/demo-wallet-client/src/components/Sep6/Sep6Withdraw.tsx rename to packages/demo-wallet-client/src/components/Sep6Withdraw.tsx index 61d85bc0..b56d3616 100644 --- a/packages/demo-wallet-client/src/components/Sep6/Sep6Withdraw.tsx +++ b/packages/demo-wallet-client/src/components/Sep6Withdraw.tsx @@ -1,70 +1,91 @@ -import React, { useEffect, useMemo, useState } from "react"; -import { useDispatch } from "react-redux"; +import { useEffect, useMemo, useState } from "react"; import { Button, + DetailsTooltip, + Heading3, Input, + Modal, + RadioButton, Select, TextLink, - Modal, - Heading3, - DetailsTooltip, } from "@stellar/design-system"; +import { useDispatch } from "react-redux"; + +import { CSS_MODAL_PARENT_ID } from "demo-wallet-shared/build/constants/settings"; +import { shortenStellarKey } from "demo-wallet-shared/build/helpers/shortenStellarKey"; import { ErrorMessage } from "components/ErrorMessage"; import { KycField, KycFieldInput } from "components/KycFieldInput"; -import { AnchorQuotesModal } from "components/AnchorQuotesModal"; +import { Sep6PriceModal } from "components/Sep6PriceModal"; +import { Sep6QuoteModal } from "components/Sep6QuoteModal"; -import { resetActiveAssetAction } from "ducks/activeAsset"; +import { useRedux } from "hooks/useRedux"; +import { AppDispatch } from "config/store"; import { + getWithdrawQuoteAction, + initSep6WithdrawFlow, + initSep6WithdrawFlowWithQuoteAction, resetSep6WithdrawAction, - submitSep6WithdrawFields, - sep6WithdrawAction, - submitSep6WithdrawCustomerInfoFields, - setStatusAction, - submitSep6WithdrawWithQuotesFields, -} from "ducks/sep6WithdrawAsset"; -import { - fetchSep38QuotesSep6InfoAction, - resetSep38QuotesAction, -} from "ducks/sep38Quotes"; + sep6WithdrawPriceAction, + submitSep6WithdrawAction, + submitSep6WithdrawCustomerInfoFieldsAction, +} from "ducks/sep6Withdraw"; +import { resetActiveAssetAction } from "ducks/activeAsset"; -import { useRedux } from "hooks/useRedux"; -import { CSS_MODAL_PARENT_ID } from "demo-wallet-shared/build/constants/settings"; -import { shortenStellarKey } from "demo-wallet-shared/build/helpers/shortenStellarKey"; -import { AppDispatch } from "config/store"; -import { ActionStatus, AnyObject } from "types/types"; +import { ActionStatus } from "types/types"; export const Sep6Withdraw = () => { - const { sep6WithdrawAsset } = useRedux("sep6WithdrawAsset"); + const { sep6Withdraw } = useRedux("sep6Withdraw"); const { - data: { assetCode, transactionResponse, withdrawResponse }, - } = sep6WithdrawAsset; + data: { + withdrawResponse, + withdrawAssets, + minAmount, + maxAmount, + sellAsset, + transactionResponse, + }, + } = sep6Withdraw; interface FormData { + amount?: string; withdrawType: { type: string; }; - customerFields: AnyObject; - infoFields: AnyObject; + infoFields: { + [key: string]: string; + }; + customerFields: { + [key: string]: string; + }; } const formInitialState: FormData = { + amount: "", withdrawType: { type: "", }, - customerFields: {}, infoFields: {}, + customerFields: {}, }; const [formData, setFormData] = useState(formInitialState); - const [withdrawAmount, setWithdrawAmount] = useState(""); + const [selectedWithdrawAsset, setSelectedWithdrawAsset] = useState(""); + const [selectedWithdrawBuyMethod, setSelectedWithdrawBuyMethod] = + useState(""); + const [ + selectedWithdrawAssetCountryCode, + setSelectedWithdrawAssetCountryCode, + ] = useState(""); const [isInfoModalVisible, setIsInfoModalVisible] = useState(true); const dispatch: AppDispatch = useDispatch(); + const supportsQuotes = withdrawAssets && withdrawAssets.length > 0; + const withdrawTypes = useMemo( - () => sep6WithdrawAsset.data.withdrawTypes?.types || { fields: {} }, - [sep6WithdrawAsset], + () => sep6Withdraw.data.withdrawTypes?.types || { fields: {} }, + [sep6Withdraw], ); const [activeWithdrawType, setActiveWithdrawType] = useState( @@ -77,27 +98,31 @@ export const Sep6Withdraw = () => { ); useEffect(() => { - if (sep6WithdrawAsset.status === ActionStatus.NEEDS_INPUT) { + if (sep6Withdraw.status === ActionStatus.NEEDS_INPUT) { const initialWithdrawType = withdrawTypesArr[0][0]; setFormData({ + amount: "", withdrawType: { type: initialWithdrawType, }, - customerFields: {}, infoFields: {}, + customerFields: {}, }); setActiveWithdrawType(initialWithdrawType); } - }, [sep6WithdrawAsset.status, withdrawTypesArr, dispatch]); + }, [sep6Withdraw.status, withdrawTypesArr, dispatch]); const resetLocalState = () => { setFormData(formInitialState); + setSelectedWithdrawAsset(""); + setSelectedWithdrawAssetCountryCode(""); + setSelectedWithdrawBuyMethod(""); + setIsInfoModalVisible(true); }; const handleClose = () => { dispatch(resetSep6WithdrawAction()); dispatch(resetActiveAssetAction()); - dispatch(resetSep38QuotesAction()); resetLocalState(); }; @@ -130,6 +155,19 @@ export const Sep6Withdraw = () => { setFormData(updatedState); }; + const handleAmountFieldChange = ( + event: React.ChangeEvent, + ) => { + const { id, value } = event.target; + + const updatedState = { + ...formData, + [id]: value.toString(), + }; + + setFormData(updatedState); + }; + const handleCustomerFieldChange = ( event: React.ChangeEvent, ) => { @@ -151,158 +189,200 @@ export const Sep6Withdraw = () => { setFormData(updatedState); }; - const handleFieldsSubmit = ( + const handleGetPrice = ( event: React.MouseEvent, ) => { event.preventDefault(); - dispatch(submitSep6WithdrawFields({ ...formData })); - }; - - const handleAmountFieldChange = ( - event: React.ChangeEvent, - ) => { - const { value } = event.target; - setWithdrawAmount(value); + if (sellAsset) { + dispatch( + sep6WithdrawPriceAction({ + sellAsset: sellAsset, + buyAsset: selectedWithdrawAsset, + sellAmount: formData.amount || "0", + buyDeliveryMethod: selectedWithdrawBuyMethod, + countryCode: selectedWithdrawAssetCountryCode, + }), + ); + } }; - const handlePollTransaction = ( + const handleGetQuote = ( event: React.MouseEvent, ) => { event.preventDefault(); - dispatch(sep6WithdrawAction(withdrawAmount)); + + if (sellAsset) { + dispatch( + getWithdrawQuoteAction({ + sellAsset: sellAsset, + buyAsset: selectedWithdrawAsset, + sellAmount: formData.amount || "0", + buyDeliveryMethod: selectedWithdrawBuyMethod, + countryCode: selectedWithdrawAssetCountryCode, + }), + ); + } }; - const handleShowQuotesModal = ( + const handleSubmitWithQuote = ( event: React.MouseEvent, ) => { event.preventDefault(); - if (!withdrawAmount) { + const { id, sell_asset, sell_amount, buy_asset } = + sep6Withdraw.data.quote || {}; + + if (!(id && buy_asset && sell_asset && sell_amount)) { return; } - const { assetCode, assetIssuer } = sep6WithdrawAsset.data; - dispatch( - fetchSep38QuotesSep6InfoAction({ - anchorQuoteServerUrl: sep6WithdrawAsset.data?.anchorQuoteServer, - sellAsset: `stellar:${assetCode}:${assetIssuer}`, - amount: withdrawAmount, + initSep6WithdrawFlowWithQuoteAction({ + ...formData, + amount: sell_amount, + quoteId: id, + destinationAsset: buy_asset, + sourceAssetCode: sell_asset.split(":")[1], }), ); - dispatch(setStatusAction(ActionStatus.ANCHOR_QUOTES)); }; - const handleSubmitWithQuotes = ( + const handleSubmit = ( event: React.MouseEvent, - quoteId?: string, - buyAsset?: string, - sellAsset?: string, ) => { event.preventDefault(); - if (!(quoteId && buyAsset && sellAsset && withdrawAmount)) { - return; - } - - dispatch( - submitSep6WithdrawWithQuotesFields({ - ...formData, - amount: withdrawAmount, - quoteId, - destinationAsset: buyAsset, - sourceAssetCode: sellAsset.split(":")[1], - }), - ); + dispatch(submitSep6WithdrawAction(formData.amount || "0")); }; const handleSubmitCustomerInfo = ( event: React.MouseEvent, ) => { event.preventDefault(); - dispatch(submitSep6WithdrawCustomerInfoFields(formData.customerFields)); + dispatch( + submitSep6WithdrawCustomerInfoFieldsAction(formData.customerFields), + ); + }; + + const renderMinMaxAmount = () => { + if (minAmount === 0 && maxAmount === 0) { + return null; + } + + return `Min: ${minAmount} | Max: ${maxAmount}`; }; - if (sep6WithdrawAsset.status === ActionStatus.ANCHOR_QUOTES) { + const renderWithdrawAssets = () => { + if (!supportsQuotes) { + return null; + } + return ( - + <> + Withdraw Asset and Method +
+ {withdrawAssets.map((a) => ( +
+ { + setSelectedWithdrawAsset(a.asset); + setSelectedWithdrawAssetCountryCode(""); + setSelectedWithdrawBuyMethod(""); + }} + checked={a.asset === selectedWithdrawAsset} + /> + +
+ {a.buy_delivery_methods?.map((d, index) => ( + { + setSelectedWithdrawBuyMethod(d.name); + }} + checked={ + a.asset === selectedWithdrawAsset && + d.name === selectedWithdrawBuyMethod + } + /> + ))} +
+ + {a.country_codes?.length && a.country_codes.length > 1 ? ( +
+
Country code
+ {a.country_codes?.map((c) => ( + { + setSelectedWithdrawAssetCountryCode(c); + }} + checked={c === selectedWithdrawAssetCountryCode} + /> + ))} +
+ ) : null} +
+ ))} +
+ ); - } + }; - if (sep6WithdrawAsset.status === ActionStatus.NEEDS_KYC) { - return ( - - SEP-6 Customer Info - - {Object.keys(sep6WithdrawAsset.data.fields).length ? ( - - - These are the fields the receiving anchor requires. The - sending client obtains them from the /customer endpoint.{" "} - - Learn more - - - } - isInline - tooltipPosition={DetailsTooltip.tooltipPosition.BOTTOM} - > - <>SEP-12 Required Info - - - ) : null} + const renderSubmitButton = () => { + if (supportsQuotes) { + return ( + + ); + } -
- {Object.entries(sep6WithdrawAsset.data.fields || {}).map( - ([field, fieldInfo]) => ( - - ), - )} -
-
- - - - -
+ return ( + ); - } + }; - if (sep6WithdrawAsset.status === ActionStatus.NEEDS_INPUT) { + // Initial withdraw modal + if (sep6Withdraw.status === ActionStatus.NEEDS_INPUT) { if ( - sep6WithdrawAsset.data.requiredCustomerInfoUpdates && - sep6WithdrawAsset.data.requiredCustomerInfoUpdates.length + sep6Withdraw.data.requiredCustomerInfoUpdates && + sep6Withdraw.data.requiredCustomerInfoUpdates.length ) { return ( SEP-6 Update Customer Info
- {sep6WithdrawAsset.data.requiredCustomerInfoUpdates.map( - (input) => ( - - ), - )} + {sep6Withdraw.data.requiredCustomerInfoUpdates.map((input) => ( + + ))}
@@ -322,10 +402,11 @@ export const Sep6Withdraw = () => { <> {withdrawResponse.min_amount || withdrawResponse.max_amount ? (
@@ -387,27 +468,50 @@ export const Sep6Withdraw = () => { /> ))}
- + + {renderWithdrawAssets()} + +
- -
); } - if (sep6WithdrawAsset.status === ActionStatus.CAN_PROCEED) { + // Show price + if (sep6Withdraw.status === ActionStatus.PRICE && sep6Withdraw.data.price) { + return ( + + ); + } + + // Quote modal + if ( + sep6Withdraw.status === ActionStatus.ANCHOR_QUOTES && + sep6Withdraw.data.quote + ) { + return ( + + ); + } + + if (sep6Withdraw.status === ActionStatus.CAN_PROCEED) { const isRequiredCustomerInfo = Boolean( - sep6WithdrawAsset.data.requiredCustomerInfoUpdates, + sep6Withdraw.data.requiredCustomerInfoUpdates, ); return ( @@ -455,13 +559,63 @@ export const Sep6Withdraw = () => { - + + + +
+ ); + } + + if (sep6Withdraw.status === ActionStatus.NEEDS_KYC) { + return ( + + SEP-6 Customer Info + + {Object.keys(sep6Withdraw.data.fields).length ? ( + + + These are the fields the receiving anchor requires. The + sending client obtains them from the /customer endpoint.{" "} + + Learn more + + + } + isInline + tooltipPosition={DetailsTooltip.tooltipPosition.BOTTOM} + > + <>SEP-12 Required Info + + + ) : null} + +
+ {Object.entries(sep6Withdraw.data.fields || {}).map( + ([field, fieldInfo]) => ( + + ), + )} +
+
+ + +
); } - if (sep6WithdrawAsset.status === ActionStatus.KYC_DONE) { + if (sep6Withdraw.status === ActionStatus.KYC_DONE) { return ( { - + ); } - if (sep6WithdrawAsset.status === ActionStatus.SUCCESS) { + if (sep6Withdraw.status === ActionStatus.SUCCESS) { return ( { )} {transactionResponse.amount_in && (
- Amount Withdrawn: {transactionResponse.amount_in} + Amount Withdrawn: {transactionResponse.amount_in}{" "} + {transactionResponse.amount_in_asset.split(":")[1]}

{transactionResponse.amount_fee && ( <>Fee: {transactionResponse.amount_fee} @@ -517,7 +672,7 @@ export const Sep6Withdraw = () => { {transactionResponse.amount_out && ( Total Amount Out: {transactionResponse.amount_out}{" "} - {assetCode} + {transactionResponse.amount_out_asset.split(":")[1]} )}

diff --git a/packages/demo-wallet-client/src/components/UntrustedBalance.tsx b/packages/demo-wallet-client/src/components/UntrustedBalance.tsx index bea9975b..9358949c 100644 --- a/packages/demo-wallet-client/src/components/UntrustedBalance.tsx +++ b/packages/demo-wallet-client/src/components/UntrustedBalance.tsx @@ -5,7 +5,7 @@ import { TextLink, DetailsTooltip } from "@stellar/design-system"; import { BalanceRow } from "components/BalanceRow"; import { resetActiveAssetAction } from "ducks/activeAsset"; -import { initiateDepositAction as initiateSep6SendAction } from "ducks/sep6DepositAsset"; +import { initiateDepositAction as initiateSep6SendAction } from "ducks/sep6Deposit"; import { depositAssetAction } from "ducks/sep24DepositAsset"; import { trustAssetAction } from "ducks/trustAsset"; import { diff --git a/packages/demo-wallet-client/src/config/store.ts b/packages/demo-wallet-client/src/config/store.ts index 2556bc9c..5a2e33e7 100644 --- a/packages/demo-wallet-client/src/config/store.ts +++ b/packages/demo-wallet-client/src/config/store.ts @@ -13,8 +13,8 @@ import { reducer as allAssets } from "ducks/allAssets"; import { reducer as assetOverrides } from "ducks/assetOverrides"; import { reducer as claimAsset } from "ducks/claimAsset"; import { reducer as claimableBalances } from "ducks/claimableBalances"; -import { reducer as sep6DepositAsset } from "ducks/sep6DepositAsset"; -import { reducer as sep6WithdrawAsset } from "ducks/sep6WithdrawAsset"; +import { reducer as sep6Deposit } from "ducks/sep6Deposit"; +import { reducer as sep6Withdraw } from "ducks/sep6Withdraw"; import { reducer as sep8Send } from "ducks/sep8Send"; import { reducer as sep24DepositAsset } from "ducks/sep24DepositAsset"; import { reducer as sep24WithdrawAsset } from "ducks/sep24WithdrawAsset"; @@ -52,8 +52,8 @@ const reducers = combineReducers({ extra, logs, sendPayment, - sep6DepositAsset, - sep6WithdrawAsset, + sep6Deposit, + sep6Withdraw, sep8Send, sep24DepositAsset, sep24WithdrawAsset, diff --git a/packages/demo-wallet-client/src/ducks/sep6DepositAsset.ts b/packages/demo-wallet-client/src/ducks/sep6Deposit.ts similarity index 69% rename from packages/demo-wallet-client/src/ducks/sep6DepositAsset.ts rename to packages/demo-wallet-client/src/ducks/sep6Deposit.ts index e6c47009..2d628a68 100644 --- a/packages/demo-wallet-client/src/ducks/sep6DepositAsset.ts +++ b/packages/demo-wallet-client/src/ducks/sep6Deposit.ts @@ -1,41 +1,52 @@ import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; -import { RootState, walletBackendEndpoint, clientDomain } from "config/store"; -import { accountSelector } from "ducks/account"; -import { settingsSelector } from "ducks/settings"; + import { getErrorMessage } from "demo-wallet-shared/build/helpers/getErrorMessage"; import { getNetworkConfig } from "demo-wallet-shared/build/helpers/getNetworkConfig"; -import { normalizeHomeDomainUrl } from "demo-wallet-shared/build/helpers/normalizeHomeDomainUrl"; import { log } from "demo-wallet-shared/build/helpers/log"; +import { normalizeHomeDomainUrl } from "demo-wallet-shared/build/helpers/normalizeHomeDomainUrl"; import { checkDepositWithdrawInfo } from "demo-wallet-shared/build/methods/checkDepositWithdrawInfo"; +import { checkTomlForFields } from "demo-wallet-shared/build/methods/checkTomlForFields"; +import { + sep10AuthSend, + sep10AuthSign, + sep10AuthStart, +} from "demo-wallet-shared/build/methods/sep10Auth"; +import { + getInfo, + getPrice, + postQuote, +} from "demo-wallet-shared/build/methods/sep38Quotes"; import { pollDepositUntilComplete, programmaticDepositExchangeFlow, programmaticDepositFlow, } from "demo-wallet-shared/build/methods/sep6"; -import { - sep10AuthStart, - sep10AuthSign, - sep10AuthSend, -} from "demo-wallet-shared/build/methods/sep10Auth"; +import { trustAsset } from "demo-wallet-shared/build/methods/trustAsset"; import { collectSep12Fields, putSep12FieldsRequest, } from "demo-wallet-shared/build/methods/sep12"; -import { checkTomlForFields } from "demo-wallet-shared/build/methods/checkTomlForFields"; -import { trustAsset } from "demo-wallet-shared/build/methods/trustAsset"; + +import { clientDomain, RootState, walletBackendEndpoint } from "config/store"; +import { sanitizeObject } from "helpers/sanitizeObject"; +import { accountSelector } from "ducks/account"; +import { settingsSelector } from "ducks/settings"; + import { - Asset, ActionStatus, + AnchorActionType, + AnchorQuote, + AnyObject, + Asset, + RejectMessage, + Sep12CustomerStatus, Sep6DepositAssetInitialState, Sep6DepositResponse, - RejectMessage, + SepInstructions, TomlFields, - AnchorActionType, - AnyObject, TransactionStatus, - SepInstructions, - Sep12CustomerStatus, } from "types/types"; +import { AnchorPriceItem } from "demo-wallet-shared/build/types/types"; type InitiateDepositActionPayload = Sep6DepositAssetInitialState["data"] & { status: ActionStatus; @@ -46,7 +57,7 @@ export const initiateDepositAction = createAsyncThunk< Asset, { rejectValue: RejectMessage; state: RootState } >( - "sep6DepositAsset/initiateDepositAction", + "sep6Deposit/initiateDepositAction", async (asset, { rejectWithValue, getState }) => { const { assetCode, assetIssuer, homeDomain } = asset; const { data, secretKey } = accountSelector(getState()); @@ -82,10 +93,15 @@ export const initiateDepositAction = createAsyncThunk< assetCode, }); + const buyAsset = `stellar:${assetCode}:${assetIssuer}`; + let anchorQuoteServer; + let depositAssets; + + const supportsQuotes = Boolean(infoData["deposit-exchange"]); // Check SEP-38 quote server key in toml, if supported - if (infoData["deposit-exchange"]) { + if (supportsQuotes) { const tomlSep38Response = await checkTomlForFields({ sepName: "SEP-38 Anchor RFQ", assetIssuer, @@ -97,7 +113,38 @@ export const initiateDepositAction = createAsyncThunk< anchorQuoteServer = tomlSep38Response.ANCHOR_QUOTE_SERVER; } - const assetInfoData = infoData[AnchorActionType.DEPOSIT][assetCode]; + if (anchorQuoteServer) { + log.instruction({ title: "Anchor supports SEP-38 quotes" }); + + const quotesResult = await getInfo({ + context: "sep6", + anchorQuoteServerUrl: anchorQuoteServer, + }); + + depositAssets = quotesResult.assets.filter( + (a) => a.asset !== buyAsset && a.buy_delivery_methods, + ); + + log.instruction({ + title: "Supported SEP-38 assets for deposit", + body: depositAssets, + }); + } + + // Get either deposit or deposit-exchange asset data + const assetInfoData = + infoData[ + supportsQuotes && depositAssets && depositAssets?.length > 0 + ? AnchorActionType.DEPOSIT_EXCHANGE + : AnchorActionType.DEPOSIT + ]?.[assetCode]; + + // This is unlikely + if (!assetInfoData) { + throw new Error( + `Something went wrong, deposit asset ${assetCode} is not configured.`, + ); + } const { authentication_required: isAuthenticationRequired, @@ -117,6 +164,8 @@ export const initiateDepositAction = createAsyncThunk< token: "", transferServerUrl: tomlResponse.TRANSFER_SERVER, anchorQuoteServer, + buyAsset, + depositAssets, } as InitiateDepositActionPayload; if (isAuthenticationRequired) { @@ -183,46 +232,47 @@ export const initiateDepositAction = createAsyncThunk< }, ); -// Submit transaction to start polling for the status -export const submitSep6DepositFields = createAsyncThunk< +// Get price +export const sep6DepositPriceAction = createAsyncThunk< { status: ActionStatus; - depositResponse: Sep6DepositResponse; + price: AnchorPriceItem; }, { - amount?: string; - depositType: AnyObject; - infoFields: AnyObject; + sellAsset: string; + buyAsset: string; + sellAmount: string; + sellDeliveryMethod: string; + countryCode?: string; }, { rejectValue: RejectMessage; state: RootState } >( - "sep6DepositAsset/submitSep6DepositFields", + "sep6Deposit/sep6DepositPriceAction", async ( - { amount, depositType, infoFields }, + { sellAsset, buyAsset, sellAmount, sellDeliveryMethod, countryCode }, { rejectWithValue, getState }, ) => { try { - const { data } = accountSelector(getState()); - const publicKey = data?.id || ""; - const { claimableBalanceSupported } = settingsSelector(getState()); const { data: sep6Data } = sep6DepositSelector(getState()); - const { assetCode, transferServerUrl, token } = sep6Data; + const { anchorQuoteServer, token } = sep6Data; - const depositResponse = (await programmaticDepositFlow({ - amount, - assetCode, - publicKey, - transferServerUrl, + const price = await getPrice({ + anchorQuoteServerUrl: anchorQuoteServer, token, - type: depositType.type, - depositFields: infoFields, - claimableBalanceSupported, - })) as Sep6DepositResponse; + options: { + context: "sep6", + sell_asset: sellAsset, + buy_asset: buyAsset, + sell_amount: sellAmount, + sell_delivery_method: sellDeliveryMethod, + ...(countryCode ? { country_code: countryCode } : {}), + }, + }); return { - status: ActionStatus.CAN_PROCEED, - depositResponse, + status: ActionStatus.PRICE, + price, }; } catch (e) { const errorMessage = getErrorMessage(e); @@ -238,8 +288,60 @@ export const submitSep6DepositFields = createAsyncThunk< }, ); +type DepositQuoteProps = { + sellAsset: string; + buyAsset: string; + sellAmount: string; + sellDeliveryMethod: string; + countryCode?: string; +}; + +export const getDepositQuoteAction = createAsyncThunk< + AnchorQuote, + DepositQuoteProps, + { rejectValue: RejectMessage; state: RootState } +>( + "sep6Deposit/getDepositQuoteAction", + async ( + { sellAsset, buyAsset, sellAmount, sellDeliveryMethod, countryCode }, + { rejectWithValue, getState }, + ) => { + const { data } = sep6DepositSelector(getState()); + + log.instruction({ title: "Getting SEP-38 quote" }); + + try { + const quote = await postQuote( + sanitizeObject({ + anchorQuoteServerUrl: data.anchorQuoteServer || "", + token: data.token, + sell_asset: sellAsset, + buy_asset: buyAsset, + sell_amount: sellAmount, + sell_delivery_method: sellDeliveryMethod, + country_code: countryCode, + context: "sep6", + }), + ); + + return quote; + } catch (error) { + const errorMessage = getErrorMessage(error); + + log.error({ + title: "SEP-38 quote failed", + body: errorMessage, + }); + + return rejectWithValue({ + errorString: errorMessage, + }); + } + }, +); + // Submit transaction with SEP-38 quotes to start polling for the status -export const submitSep6DepositWithQuotesFields = createAsyncThunk< +export const initSep6DepositFlowWithQuoteAction = createAsyncThunk< { status: ActionStatus; depositResponse: Sep6DepositResponse; @@ -254,7 +356,7 @@ export const submitSep6DepositWithQuotesFields = createAsyncThunk< }, { rejectValue: RejectMessage; state: RootState } >( - "sep6DepositAsset/submitSep6DepositWithQuotesFields", + "sep6Deposit/initSep6DepositFlowWithQuoteAction", async ( { amount, @@ -305,50 +407,46 @@ export const submitSep6DepositWithQuotesFields = createAsyncThunk< }, ); -export const submitSep6CustomerInfoFields = createAsyncThunk< - { status: ActionStatus; customerFields?: AnyObject }, - AnyObject, +// Submit transaction to start polling for the status +export const initSep6DepositFlowAction = createAsyncThunk< + { + status: ActionStatus; + depositResponse: Sep6DepositResponse; + }, + { + amount?: string; + depositType: AnyObject; + infoFields: AnyObject; + }, { rejectValue: RejectMessage; state: RootState } >( - "sep6DepositAsset/submitSep6CustomerInfoFields", - async (customerFields, { rejectWithValue, getState }) => { + "sep6Deposit/initSep6DepositFlowAction", + async ( + { amount, depositType, infoFields }, + { rejectWithValue, getState }, + ) => { try { - const { data: account, secretKey } = accountSelector(getState()); + const { data } = accountSelector(getState()); + const publicKey = data?.id || ""; + const { claimableBalanceSupported } = settingsSelector(getState()); const { data: sep6Data } = sep6DepositSelector(getState()); - const { kycServer, token } = sep6Data; - - if (Object.keys(customerFields).length) { - await putSep12FieldsRequest({ - fields: customerFields, - kycServer, - secretKey, - token, - transactionId: sep6Data.depositResponse?.id, - }); - } - - // Get SEP-12 fields - log.instruction({ - title: "Making GET `/customer` request for user", - }); + const { assetCode, transferServerUrl, token } = sep6Data; - const sep12Response = await collectSep12Fields({ - publicKey: account?.id!, + const depositResponse = (await programmaticDepositFlow({ + amount, + assetCode, + publicKey, + transferServerUrl, token, - kycServer, - transactionId: sep6Data.depositResponse?.id, - }); - - if (sep12Response.status !== Sep12CustomerStatus.ACCEPTED) { - return { - status: ActionStatus.NEEDS_KYC, - customerFields: sep12Response.fieldsToCollect, - }; - } + type: depositType.type, + depositFields: infoFields, + claimableBalanceSupported, + })) as Sep6DepositResponse; return { - status: ActionStatus.KYC_DONE, + status: ActionStatus.CAN_PROCEED, + depositResponse, }; } catch (e) { const errorMessage = getErrorMessage(e); @@ -364,7 +462,7 @@ export const submitSep6CustomerInfoFields = createAsyncThunk< }, ); -export const sep6DepositAction = createAsyncThunk< +export const submitSep6DepositAction = createAsyncThunk< { currentStatus: string; status: ActionStatus; @@ -375,7 +473,7 @@ export const sep6DepositAction = createAsyncThunk< undefined, { rejectValue: RejectMessage; state: RootState } >( - "sep6DepositAsset/sep6DepositAction", + "sep6Deposit/submitSep6DepositAction", async (_, { rejectWithValue, getState, dispatch }) => { try { const { secretKey, data } = accountSelector(getState()); @@ -466,6 +564,65 @@ export const sep6DepositAction = createAsyncThunk< }, ); +export const submitSep6CustomerInfoFieldsAction = createAsyncThunk< + { status: ActionStatus; customerFields?: AnyObject }, + AnyObject, + { rejectValue: RejectMessage; state: RootState } +>( + "sep6Deposit/submitSep6CustomerInfoFieldsAction", + async (customerFields, { rejectWithValue, getState }) => { + try { + const { data: account, secretKey } = accountSelector(getState()); + const { data: sep6Data } = sep6DepositSelector(getState()); + + const { kycServer, token } = sep6Data; + + if (Object.keys(customerFields).length) { + await putSep12FieldsRequest({ + fields: customerFields, + kycServer, + secretKey, + token, + transactionId: sep6Data.depositResponse?.id, + }); + } + + // Get SEP-12 fields + log.instruction({ + title: "Making GET `/customer` request for user", + }); + + const sep12Response = await collectSep12Fields({ + publicKey: account?.id!, + token, + kycServer, + transactionId: sep6Data.depositResponse?.id, + }); + + if (sep12Response.status !== Sep12CustomerStatus.ACCEPTED) { + return { + status: ActionStatus.NEEDS_KYC, + customerFields: sep12Response.fieldsToCollect, + }; + } + + return { + status: ActionStatus.KYC_DONE, + }; + } catch (e) { + const errorMessage = getErrorMessage(e); + + log.error({ + title: errorMessage, + }); + + return rejectWithValue({ + errorString: errorMessage, + }); + } + }, +); + const initialState: Sep6DepositAssetInitialState = { data: { assetCode: "", @@ -492,8 +649,8 @@ const initialState: Sep6DepositAssetInitialState = { errorString: undefined, }; -const sep6DepositAssetSlice = createSlice({ - name: "sep6DepositAsset", +const sep6DepositSlice = createSlice({ + name: "sep6Deposit", initialState, reducers: { resetSep6DepositAction: () => initialState, @@ -518,55 +675,68 @@ const sep6DepositAssetSlice = createSlice({ state.status = ActionStatus.ERROR; }); - builder.addCase(submitSep6DepositFields.pending, (state) => { + builder.addCase(sep6DepositPriceAction.pending, (state) => { state.errorString = undefined; state.status = ActionStatus.PENDING; }); - builder.addCase(submitSep6DepositFields.fulfilled, (state, action) => { + builder.addCase(sep6DepositPriceAction.fulfilled, (state, action) => { + state.data.price = action.payload.price; state.status = action.payload.status; - state.data.depositResponse = action.payload.depositResponse; }); - builder.addCase(submitSep6DepositFields.rejected, (state, action) => { + builder.addCase(sep6DepositPriceAction.rejected, (state, action) => { state.errorString = action.payload?.errorString; state.status = ActionStatus.ERROR; }); - builder.addCase(submitSep6DepositWithQuotesFields.pending, (state) => { + builder.addCase(getDepositQuoteAction.pending, (state) => { + state.errorString = undefined; + state.status = ActionStatus.PENDING; + }); + builder.addCase(getDepositQuoteAction.fulfilled, (state, action) => { + state.data.quote = action.payload; + state.status = ActionStatus.ANCHOR_QUOTES; + }); + builder.addCase(getDepositQuoteAction.rejected, (state, action) => { + state.errorString = action.payload?.errorString; + state.status = ActionStatus.ERROR; + }); + + builder.addCase(initSep6DepositFlowWithQuoteAction.pending, (state) => { state.errorString = undefined; state.status = ActionStatus.PENDING; }); builder.addCase( - submitSep6DepositWithQuotesFields.fulfilled, + initSep6DepositFlowWithQuoteAction.fulfilled, (state, action) => { state.status = action.payload.status; state.data.depositResponse = action.payload.depositResponse; }, ); builder.addCase( - submitSep6DepositWithQuotesFields.rejected, + initSep6DepositFlowWithQuoteAction.rejected, (state, action) => { state.errorString = action.payload?.errorString; state.status = ActionStatus.ERROR; }, ); - builder.addCase(submitSep6CustomerInfoFields.pending, (state) => { + builder.addCase(initSep6DepositFlowAction.pending, (state) => { state.errorString = undefined; state.status = ActionStatus.PENDING; }); - builder.addCase(submitSep6CustomerInfoFields.fulfilled, (state, action) => { + builder.addCase(initSep6DepositFlowAction.fulfilled, (state, action) => { state.status = action.payload.status; - state.data.customerFields = { ...action.payload.customerFields }; + state.data.depositResponse = action.payload.depositResponse; }); - builder.addCase(submitSep6CustomerInfoFields.rejected, (state, action) => { + builder.addCase(initSep6DepositFlowAction.rejected, (state, action) => { state.errorString = action.payload?.errorString; state.status = ActionStatus.ERROR; }); - builder.addCase(sep6DepositAction.pending, (state) => { + builder.addCase(submitSep6DepositAction.pending, (state) => { state.status = ActionStatus.PENDING; }); - builder.addCase(sep6DepositAction.fulfilled, (state, action) => { + builder.addCase(submitSep6DepositAction.fulfilled, (state, action) => { state.status = action.payload.status; state.data.currentStatus = action.payload.currentStatus; state.data.trustedAssetAdded = action.payload.trustedAssetAdded; @@ -585,18 +755,37 @@ const sep6DepositAssetSlice = createSlice({ state.data.customerFields = customerFields; } }); - builder.addCase(sep6DepositAction.rejected, (state, action) => { + builder.addCase(submitSep6DepositAction.rejected, (state, action) => { state.errorString = action.payload?.errorString; state.status = ActionStatus.ERROR; }); + + builder.addCase(submitSep6CustomerInfoFieldsAction.pending, (state) => { + state.errorString = undefined; + state.status = ActionStatus.PENDING; + }); + builder.addCase( + submitSep6CustomerInfoFieldsAction.fulfilled, + (state, action) => { + state.status = action.payload.status; + state.data.customerFields = { ...action.payload.customerFields }; + }, + ); + builder.addCase( + submitSep6CustomerInfoFieldsAction.rejected, + (state, action) => { + state.errorString = action.payload?.errorString; + state.status = ActionStatus.ERROR; + }, + ); }, }); -export const sep6DepositSelector = (state: RootState) => state.sep6DepositAsset; +export const sep6DepositSelector = (state: RootState) => state.sep6Deposit; -export const { reducer } = sep6DepositAssetSlice; +export const { reducer } = sep6DepositSlice; export const { resetSep6DepositAction, updateInstructionsAction, setStatusAction, -} = sep6DepositAssetSlice.actions; +} = sep6DepositSlice.actions; diff --git a/packages/demo-wallet-client/src/ducks/sep6WithdrawAsset.ts b/packages/demo-wallet-client/src/ducks/sep6Withdraw.ts similarity index 67% rename from packages/demo-wallet-client/src/ducks/sep6WithdrawAsset.ts rename to packages/demo-wallet-client/src/ducks/sep6Withdraw.ts index 39511d16..04488706 100644 --- a/packages/demo-wallet-client/src/ducks/sep6WithdrawAsset.ts +++ b/packages/demo-wallet-client/src/ducks/sep6Withdraw.ts @@ -1,38 +1,51 @@ import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; -import { RootState, walletBackendEndpoint, clientDomain } from "config/store"; -import { accountSelector } from "ducks/account"; -import { settingsSelector } from "ducks/settings"; -import { getErrorMessage } from "demo-wallet-shared/build/helpers/getErrorMessage"; -import { getNetworkConfig } from "demo-wallet-shared/build/helpers/getNetworkConfig"; -import { normalizeHomeDomainUrl } from "demo-wallet-shared/build/helpers/normalizeHomeDomainUrl"; -import { log } from "demo-wallet-shared/build/helpers/log"; + +import { checkTomlForFields } from "demo-wallet-shared/build/methods/checkTomlForFields"; import { checkDepositWithdrawInfo } from "demo-wallet-shared/build/methods/checkDepositWithdrawInfo"; +import { + sep10AuthSend, + sep10AuthSign, + sep10AuthStart, +} from "demo-wallet-shared/build/methods/sep10Auth"; +import { + getInfo, + getPrice, + postQuote, +} from "demo-wallet-shared/build/methods/sep38Quotes"; import { pollWithdrawUntilComplete, programmaticWithdrawExchangeFlow, programmaticWithdrawFlow, } from "demo-wallet-shared/build/methods/sep6"; -import { - sep10AuthStart, - sep10AuthSign, - sep10AuthSend, -} from "demo-wallet-shared/build/methods/sep10Auth"; import { collectSep12Fields, putSep12FieldsRequest, } from "demo-wallet-shared/build/methods/sep12"; -import { checkTomlForFields } from "demo-wallet-shared/build/methods/checkTomlForFields"; + +import { getNetworkConfig } from "demo-wallet-shared/build/helpers/getNetworkConfig"; +import { log } from "demo-wallet-shared/build/helpers/log"; +import { normalizeHomeDomainUrl } from "demo-wallet-shared/build/helpers/normalizeHomeDomainUrl"; +import { getErrorMessage } from "demo-wallet-shared/build/helpers/getErrorMessage"; +import { AnchorPriceItem } from "demo-wallet-shared/build/types/types"; + +import { clientDomain, RootState, walletBackendEndpoint } from "config/store"; +import { sanitizeObject } from "helpers/sanitizeObject"; + +import { accountSelector } from "ducks/account"; +import { settingsSelector } from "ducks/settings"; + import { - Asset, ActionStatus, + AnchorActionType, + AnchorQuote, + AnyObject, + Asset, + RejectMessage, + Sep12CustomerStatus, Sep6WithdrawAssetInitialState, Sep6WithdrawResponse, - RejectMessage, TomlFields, - AnchorActionType, - AnyObject, TransactionStatus, - Sep12CustomerStatus, } from "types/types"; type InitiateWithdrawActionPayload = Sep6WithdrawAssetInitialState["data"] & { @@ -44,7 +57,7 @@ export const initiateWithdrawAction = createAsyncThunk< Asset, { rejectValue: RejectMessage; state: RootState } >( - "sep6WithdrawAsset/initiateWithdrawAction", + "sep6Withdraw/initiateWithdrawAction", async (asset, { rejectWithValue, getState }) => { const { assetCode, assetIssuer, homeDomain } = asset; const { data, secretKey } = accountSelector(getState()); @@ -80,10 +93,15 @@ export const initiateWithdrawAction = createAsyncThunk< assetCode, }); + const sellAsset = `stellar:${assetCode}:${assetIssuer}`; + let anchorQuoteServer; + let withdrawAssets; + + const supportsQuotes = Boolean(infoData["withdraw-exchange"]); // Check SEP-38 quote server key in toml, if supported - if (infoData["withdraw-exchange"]) { + if (supportsQuotes) { const tomlSep38Response = await checkTomlForFields({ sepName: "SEP-38 Anchor RFQ", assetIssuer, @@ -95,10 +113,44 @@ export const initiateWithdrawAction = createAsyncThunk< anchorQuoteServer = tomlSep38Response.ANCHOR_QUOTE_SERVER; } - const assetInfoData = infoData[AnchorActionType.WITHDRAWAL][assetCode]; + if (anchorQuoteServer) { + log.instruction({ title: "Anchor supports SEP-38 quotes" }); - const { authentication_required: isAuthenticationRequired } = - assetInfoData; + const quotesResult = await getInfo({ + context: "sep6", + anchorQuoteServerUrl: anchorQuoteServer, + }); + + withdrawAssets = quotesResult.assets.filter( + (a) => a.asset !== sellAsset && a.sell_delivery_methods, + ); + + log.instruction({ + title: "Supported SEP-38 assets for withdrawal", + body: withdrawAssets, + }); + } + + // Get either deposit or deposit-exchange asset data + const assetInfoData = + infoData[ + supportsQuotes && withdrawAssets && withdrawAssets?.length > 0 + ? AnchorActionType.WITHDRAW_EXCHANGE + : AnchorActionType.WITHDRAWAL + ]?.[assetCode]; + + // This is unlikely + if (!assetInfoData) { + throw new Error( + `Something went wrong, withdraw asset ${assetCode} is not configured.`, + ); + } + + const { + authentication_required: isAuthenticationRequired, + min_amount: minAmount, + max_amount: maxAmount, + } = assetInfoData; let payload = { assetCode, @@ -106,10 +158,14 @@ export const initiateWithdrawAction = createAsyncThunk< withdrawTypes: { types: { ...assetInfoData.types } }, fields: {}, kycServer: "", + minAmount, + maxAmount, status: ActionStatus.NEEDS_INPUT, token: "", transferServerUrl: tomlResponse.TRANSFER_SERVER, anchorQuoteServer, + sellAsset, + withdrawAssets, } as InitiateWithdrawActionPayload; if (isAuthenticationRequired) { @@ -176,41 +232,47 @@ export const initiateWithdrawAction = createAsyncThunk< }, ); -// Submit transaction to start polling for the status -export const submitSep6WithdrawFields = createAsyncThunk< +// Get price +export const sep6WithdrawPriceAction = createAsyncThunk< { status: ActionStatus; - withdrawResponse: Sep6WithdrawResponse; + price: AnchorPriceItem; }, { - withdrawType: AnyObject; - infoFields: AnyObject; + sellAsset: string; + buyAsset: string; + sellAmount: string; + buyDeliveryMethod: string; + countryCode?: string; }, { rejectValue: RejectMessage; state: RootState } >( - "sep6WithdrawAsset/submitSep6WithdrawFields", - async ({ withdrawType, infoFields }, { rejectWithValue, getState }) => { + "sep6Withdraw/sep6WithdrawPriceAction", + async ( + { sellAsset, buyAsset, sellAmount, buyDeliveryMethod, countryCode }, + { rejectWithValue, getState }, + ) => { try { - const { data } = accountSelector(getState()); - const { claimableBalanceSupported } = settingsSelector(getState()); - const publicKey = data?.id || ""; + const { data: sep6Data } = sep6WithdrawSelector(getState()); - const { data: sep6Data } = sepWithdrawSelector(getState()); - const { assetCode, transferServerUrl, token } = sep6Data; + const { anchorQuoteServer, token } = sep6Data; - const withdrawResponse = (await programmaticWithdrawFlow({ - assetCode, - publicKey, - transferServerUrl, + const price = await getPrice({ + anchorQuoteServerUrl: anchorQuoteServer, token, - type: withdrawType.type, - withdrawFields: infoFields, - claimableBalanceSupported, - })) as Sep6WithdrawResponse; + options: { + context: "sep6", + sell_asset: sellAsset, + buy_asset: buyAsset, + sell_amount: sellAmount, + buy_delivery_method: buyDeliveryMethod, + ...(countryCode ? { country_code: countryCode } : {}), + }, + }); return { - status: ActionStatus.CAN_PROCEED, - withdrawResponse, + status: ActionStatus.PRICE, + price, }; } catch (e) { const errorMessage = getErrorMessage(e); @@ -226,8 +288,61 @@ export const submitSep6WithdrawFields = createAsyncThunk< }, ); +// Get quote +type WithdrawQuoteProps = { + sellAsset: string; + buyAsset: string; + sellAmount: string; + buyDeliveryMethod: string; + countryCode?: string; +}; + +export const getWithdrawQuoteAction = createAsyncThunk< + AnchorQuote, + WithdrawQuoteProps, + { rejectValue: RejectMessage; state: RootState } +>( + "sep6Withdraw/getWithdrawQuoteAction", + async ( + { sellAsset, buyAsset, sellAmount, buyDeliveryMethod, countryCode }, + { rejectWithValue, getState }, + ) => { + const { data } = sep6WithdrawSelector(getState()); + + log.instruction({ title: "Getting SEP-38 quote" }); + + try { + const quote = await postQuote( + sanitizeObject({ + anchorQuoteServerUrl: data.anchorQuoteServer || "", + token: data.token, + sell_asset: sellAsset, + buy_asset: buyAsset, + sell_amount: sellAmount, + buy_delivery_method: buyDeliveryMethod, + country_code: countryCode, + context: "sep6", + }), + ); + + return quote; + } catch (error) { + const errorMessage = getErrorMessage(error); + + log.error({ + title: "SEP-38 quote failed", + body: errorMessage, + }); + + return rejectWithValue({ + errorString: errorMessage, + }); + } + }, +); + // Submit transaction with SEP-38 quotes to start polling for the status -export const submitSep6WithdrawWithQuotesFields = createAsyncThunk< +export const initSep6WithdrawFlowWithQuoteAction = createAsyncThunk< { status: ActionStatus; withdrawResponse: Sep6WithdrawResponse; @@ -242,7 +357,7 @@ export const submitSep6WithdrawWithQuotesFields = createAsyncThunk< }, { rejectValue: RejectMessage; state: RootState } >( - "sep6WithdrawAsset/submitSep6WithdrawWithQuotesFields", + "sep6Withdraw/initSep6WithdrawFlowWithQuoteAction", async ( { amount, @@ -259,7 +374,7 @@ export const submitSep6WithdrawWithQuotesFields = createAsyncThunk< const { claimableBalanceSupported } = settingsSelector(getState()); const publicKey = data?.id || ""; - const { data: sep6Data } = sepWithdrawSelector(getState()); + const { data: sep6Data } = sep6WithdrawSelector(getState()); const { transferServerUrl, token } = sep6Data; const withdrawResponse = (await programmaticWithdrawExchangeFlow({ @@ -293,7 +408,57 @@ export const submitSep6WithdrawWithQuotesFields = createAsyncThunk< }, ); -export const sep6WithdrawAction = createAsyncThunk< +// Submit transaction to start polling for the status +export const initSep6WithdrawFlow = createAsyncThunk< + { + status: ActionStatus; + withdrawResponse: Sep6WithdrawResponse; + }, + { + withdrawType: AnyObject; + infoFields: AnyObject; + }, + { rejectValue: RejectMessage; state: RootState } +>( + "sep6Withdraw/initSep6WithdrawFlow", + async ({ withdrawType, infoFields }, { rejectWithValue, getState }) => { + try { + const { data } = accountSelector(getState()); + const { claimableBalanceSupported } = settingsSelector(getState()); + const publicKey = data?.id || ""; + + const { data: sep6Data } = sep6WithdrawSelector(getState()); + const { assetCode, transferServerUrl, token } = sep6Data; + + const withdrawResponse = (await programmaticWithdrawFlow({ + assetCode, + publicKey, + transferServerUrl, + token, + type: withdrawType.type, + withdrawFields: infoFields, + claimableBalanceSupported, + })) as Sep6WithdrawResponse; + + return { + status: ActionStatus.CAN_PROCEED, + withdrawResponse, + }; + } catch (e) { + const errorMessage = getErrorMessage(e); + + log.error({ + title: errorMessage, + }); + + return rejectWithValue({ + errorString: errorMessage, + }); + } + }, +); + +export const submitSep6WithdrawAction = createAsyncThunk< { currentStatus: TransactionStatus; transactionResponse: AnyObject; @@ -304,12 +469,12 @@ export const sep6WithdrawAction = createAsyncThunk< string, { rejectValue: RejectMessage; state: RootState } >( - "sep6WithdrawAsset/sep6WithdrawAction", + "sep6Withdraw/submitSep6WithdrawAction", async (amount, { rejectWithValue, getState }) => { try { const { secretKey, data } = accountSelector(getState()); const networkConfig = getNetworkConfig(); - const { data: sep6Data } = sepWithdrawSelector(getState()); + const { data: sep6Data } = sep6WithdrawSelector(getState()); const { assetCode, @@ -378,16 +543,16 @@ export const sep6WithdrawAction = createAsyncThunk< }, ); -export const submitSep6WithdrawCustomerInfoFields = createAsyncThunk< +export const submitSep6WithdrawCustomerInfoFieldsAction = createAsyncThunk< { status: ActionStatus; customerFields?: AnyObject }, AnyObject, { rejectValue: RejectMessage; state: RootState } >( - "sep6WithdrawAsset/submitSep6WithdrawCustomerInfoFields", + "sep6Withdraw/submitSep6WithdrawCustomerInfoFieldsAction", async (customerFields, { rejectWithValue, getState }) => { try { const { data: account, secretKey } = accountSelector(getState()); - const { data: sep6Data } = sepWithdrawSelector(getState()); + const { data: sep6Data } = sep6WithdrawSelector(getState()); const { kycServer, token } = sep6Data; if (Object.keys(customerFields).length) { @@ -445,6 +610,8 @@ const initialState: Sep6WithdrawAssetInitialState = { types: {}, }, fields: {}, + minAmount: 0, + maxAmount: 0, kycServer: "", transferServerUrl: "", trustedAssetAdded: "", @@ -458,8 +625,8 @@ const initialState: Sep6WithdrawAssetInitialState = { errorString: undefined, }; -const sep6WithdrawAssetSlice = createSlice({ - name: "sep6WithdrawAsset", +const sep6WithdrawSlice = createSlice({ + name: "sep6Withdraw", initialState, reducers: { resetSep6WithdrawAction: () => initialState, @@ -481,62 +648,69 @@ const sep6WithdrawAssetSlice = createSlice({ state.status = ActionStatus.ERROR; }); - builder.addCase(submitSep6WithdrawFields.pending, (state) => { + builder.addCase(sep6WithdrawPriceAction.pending, (state) => { state.errorString = undefined; state.status = ActionStatus.PENDING; }); - builder.addCase(submitSep6WithdrawFields.fulfilled, (state, action) => { + builder.addCase(sep6WithdrawPriceAction.fulfilled, (state, action) => { + state.data.price = action.payload.price; state.status = action.payload.status; - state.data.withdrawResponse = action.payload.withdrawResponse; }); - builder.addCase(submitSep6WithdrawFields.rejected, (state, action) => { + builder.addCase(sep6WithdrawPriceAction.rejected, (state, action) => { + state.errorString = action.payload?.errorString; + state.status = ActionStatus.ERROR; + }); + + builder.addCase(getWithdrawQuoteAction.pending, (state) => { + state.errorString = undefined; + state.status = ActionStatus.PENDING; + }); + builder.addCase(getWithdrawQuoteAction.fulfilled, (state, action) => { + state.data.quote = action.payload; + state.status = ActionStatus.ANCHOR_QUOTES; + }); + builder.addCase(getWithdrawQuoteAction.rejected, (state, action) => { state.errorString = action.payload?.errorString; state.status = ActionStatus.ERROR; }); - builder.addCase(submitSep6WithdrawWithQuotesFields.pending, (state) => { + builder.addCase(initSep6WithdrawFlowWithQuoteAction.pending, (state) => { state.errorString = undefined; state.status = ActionStatus.PENDING; }); builder.addCase( - submitSep6WithdrawWithQuotesFields.fulfilled, + initSep6WithdrawFlowWithQuoteAction.fulfilled, (state, action) => { state.status = action.payload.status; state.data.withdrawResponse = action.payload.withdrawResponse; }, ); builder.addCase( - submitSep6WithdrawWithQuotesFields.rejected, + initSep6WithdrawFlowWithQuoteAction.rejected, (state, action) => { state.errorString = action.payload?.errorString; state.status = ActionStatus.ERROR; }, ); - builder.addCase(submitSep6WithdrawCustomerInfoFields.pending, (state) => { + builder.addCase(initSep6WithdrawFlow.pending, (state) => { state.errorString = undefined; state.status = ActionStatus.PENDING; }); - builder.addCase( - submitSep6WithdrawCustomerInfoFields.fulfilled, - (state, action) => { - state.status = action.payload.status; - state.data.fields = { ...action.payload.customerFields }; - }, - ); - builder.addCase( - submitSep6WithdrawCustomerInfoFields.rejected, - (state, action) => { - state.errorString = action.payload?.errorString; - state.status = ActionStatus.ERROR; - }, - ); + builder.addCase(initSep6WithdrawFlow.fulfilled, (state, action) => { + state.status = action.payload.status; + state.data.withdrawResponse = action.payload.withdrawResponse; + }); + builder.addCase(initSep6WithdrawFlow.rejected, (state, action) => { + state.errorString = action.payload?.errorString; + state.status = ActionStatus.ERROR; + }); - builder.addCase(sep6WithdrawAction.pending, (state) => { + builder.addCase(submitSep6WithdrawAction.pending, (state) => { state.errorString = undefined; state.status = ActionStatus.PENDING; }); - builder.addCase(sep6WithdrawAction.fulfilled, (state, action) => { + builder.addCase(submitSep6WithdrawAction.fulfilled, (state, action) => { state.status = action.payload.status; state.data.currentStatus = action.payload.currentStatus; state.data.transactionResponse = action.payload.transactionResponse; @@ -555,16 +729,37 @@ const sep6WithdrawAssetSlice = createSlice({ state.data.fields = customerFields; } }); - builder.addCase(sep6WithdrawAction.rejected, (state, action) => { + builder.addCase(submitSep6WithdrawAction.rejected, (state, action) => { state.errorString = action.payload?.errorString; state.status = ActionStatus.NEEDS_INPUT; }); + + builder.addCase( + submitSep6WithdrawCustomerInfoFieldsAction.pending, + (state) => { + state.errorString = undefined; + state.status = ActionStatus.PENDING; + }, + ); + builder.addCase( + submitSep6WithdrawCustomerInfoFieldsAction.fulfilled, + (state, action) => { + state.status = action.payload.status; + state.data.fields = { ...action.payload.customerFields }; + }, + ); + builder.addCase( + submitSep6WithdrawCustomerInfoFieldsAction.rejected, + (state, action) => { + state.errorString = action.payload?.errorString; + state.status = ActionStatus.ERROR; + }, + ); }, }); -export const sepWithdrawSelector = (state: RootState) => - state.sep6WithdrawAsset; +export const sep6WithdrawSelector = (state: RootState) => state.sep6Withdraw; -export const { reducer } = sep6WithdrawAssetSlice; +export const { reducer } = sep6WithdrawSlice; export const { resetSep6WithdrawAction, setStatusAction } = - sep6WithdrawAssetSlice.actions; + sep6WithdrawSlice.actions; diff --git a/packages/demo-wallet-client/src/helpers/sanitizeObject.ts b/packages/demo-wallet-client/src/helpers/sanitizeObject.ts new file mode 100644 index 00000000..37743099 --- /dev/null +++ b/packages/demo-wallet-client/src/helpers/sanitizeObject.ts @@ -0,0 +1,13 @@ +import { AnyObject } from "types/types"; + +export const sanitizeObject = (obj: T) => { + return Object.keys(obj).reduce((res, param) => { + const paramValue = obj[param]; + + if (paramValue) { + return { ...res, [param]: paramValue }; + } + + return res; + }, {} as T); +}; diff --git a/packages/demo-wallet-client/src/pages/Account.tsx b/packages/demo-wallet-client/src/pages/Account.tsx index b380249b..54f5ad19 100644 --- a/packages/demo-wallet-client/src/pages/Account.tsx +++ b/packages/demo-wallet-client/src/pages/Account.tsx @@ -4,8 +4,8 @@ import { Modal } from "@stellar/design-system"; import { AccountInfo } from "components/AccountInfo"; import { Assets } from "components/Assets"; import { SendPayment } from "components/SendPayment"; -import { Sep6Deposit } from "components/Sep6/Sep6Deposit"; -import { Sep6Withdraw } from "components/Sep6/Sep6Withdraw"; +import { Sep6Deposit } from "components/Sep6Deposit"; +import { Sep6Withdraw } from "components/Sep6Withdraw"; import { Sep8Send } from "components/Sep8Send"; import { Sep31Send } from "components/Sep31Send"; import { CSS_MODAL_PARENT_ID } from "demo-wallet-shared/build/constants/settings"; diff --git a/packages/demo-wallet-client/src/types/types.ts b/packages/demo-wallet-client/src/types/types.ts index 449a6195..9ac70a6a 100644 --- a/packages/demo-wallet-client/src/types/types.ts +++ b/packages/demo-wallet-client/src/types/types.ts @@ -7,6 +7,7 @@ import { } from "@stellar/stellar-sdk"; import BigNumber from "bignumber.js"; import { Sep9Field } from "demo-wallet-shared/build/helpers/Sep9Fields"; +import { AnchorPriceItem } from "demo-wallet-shared/build/types/types"; declare global { interface Window { @@ -244,6 +245,11 @@ export interface Sep6DepositAssetInitialState { requiredCustomerInfoUpdates: AnyObject[] | undefined; instructions: SepInstructions | undefined; anchorQuoteServer: string | undefined; + buyAsset?: string; + sellAsset?: string; + depositAssets?: AnchorQuoteAsset[]; + quote?: AnchorQuote; + price?: AnchorPriceItem; }; errorString?: string; status: ActionStatus; @@ -273,6 +279,8 @@ export interface Sep6WithdrawAssetInitialState { fields: { [key: string]: AnyObject; }; + minAmount: number; + maxAmount: number; kycServer: string; token: string; transferServerUrl: string; @@ -292,6 +300,11 @@ export interface Sep6WithdrawAssetInitialState { withdrawResponse: Sep6WithdrawResponse; requiredCustomerInfoUpdates: AnyObject[] | undefined; anchorQuoteServer: string | undefined; + buyAsset?: string; + sellAsset?: string; + withdrawAssets?: AnchorQuoteAsset[]; + quote?: AnchorQuote; + price?: AnchorPriceItem; }; errorString?: string; status: ActionStatus | undefined; @@ -393,8 +406,8 @@ export interface Store { extra: ExtraInitialState; logs: LogsInitialState; sendPayment: SendPaymentInitialState; - sep6DepositAsset: Sep6DepositAssetInitialState; - sep6WithdrawAsset: Sep6WithdrawAssetInitialState; + sep6Deposit: Sep6DepositAssetInitialState; + sep6Withdraw: Sep6WithdrawAssetInitialState; sep8Send: Sep8SendInitialState; sep31Send: Sep31SendInitialState; sep38Quotes: Sep38QuotesInitialState; @@ -416,6 +429,7 @@ export enum ActionStatus { KYC_DONE = "KYC_DONE", CAN_PROCEED = "CAN_PROCEED", ANCHOR_QUOTES = "ANCHOR_QUOTES", + PRICE = "PRICE", } export interface RejectMessage { diff --git a/packages/demo-wallet-shared/methods/sep38Quotes/getPrice.ts b/packages/demo-wallet-shared/methods/sep38Quotes/getPrice.ts new file mode 100644 index 00000000..74d12afa --- /dev/null +++ b/packages/demo-wallet-shared/methods/sep38Quotes/getPrice.ts @@ -0,0 +1,77 @@ +import { log } from "../../helpers/log"; +import { AnchorPriceItem } from "../../types/types"; + +type Sep38Price = { + token: string; + anchorQuoteServerUrl: string | undefined; + options: { + /* eslint-disable camelcase */ + sell_asset: string; + buy_asset: string; + sell_amount?: string; + buy_amount?: string; + sell_delivery_method?: string; + buy_delivery_method?: string; + country_code?: string; + context: "sep6" | "sep31"; + /* eslint-enable camelcase */ + }; +}; + +export const getPrice = async ({ + token, + anchorQuoteServerUrl, + options, +}: Sep38Price): Promise => { + if (!anchorQuoteServerUrl) { + throw new Error("Anchor quote server URL is required"); + } + + const params = options + ? Object.entries(options).reduce((res: any, [key, value]) => { + if (value) { + res[key] = value; + } + + return res; + }, {}) + : undefined; + const urlParams = params ? new URLSearchParams(params) : undefined; + + log.instruction({ + title: `Checking \`/price\` endpoint for \`${anchorQuoteServerUrl}\` to get price for selected asset`, + ...(params ? { body: params } : {}), + }); + + log.request({ + title: "GET `/price`", + ...(params ? { body: params } : {}), + }); + + const result = await fetch( + `${anchorQuoteServerUrl}/price?${urlParams?.toString()}`, + { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + + if (result.status !== 200) { + const responseJson = await result.json(); + + log.error({ + title: "GET `/price` failed", + body: { status: result.status, ...responseJson }, + }); + + throw new Error(responseJson.error ?? "Something went wrong"); + } + + const resultJson = await result.json(); + + log.response({ title: "GET `/price`", body: resultJson }); + + return resultJson; +}; diff --git a/packages/demo-wallet-shared/methods/sep38Quotes/index.ts b/packages/demo-wallet-shared/methods/sep38Quotes/index.ts index 1a7d9d58..0465bb3d 100644 --- a/packages/demo-wallet-shared/methods/sep38Quotes/index.ts +++ b/packages/demo-wallet-shared/methods/sep38Quotes/index.ts @@ -1,5 +1,6 @@ import { getInfo } from "./getInfo"; +import { getPrice } from "./getPrice"; import { getPrices } from "./getPrices"; import { postQuote } from "./postQuote"; -export { getInfo, getPrices, postQuote }; +export { getInfo, getPrice, getPrices, postQuote }; diff --git a/packages/demo-wallet-shared/types/types.ts b/packages/demo-wallet-shared/types/types.ts index 643bd88b..911af3cc 100644 --- a/packages/demo-wallet-shared/types/types.ts +++ b/packages/demo-wallet-shared/types/types.ts @@ -367,7 +367,7 @@ export interface Store { claimableBalances: ClaimableBalancesInitialState; logs: LogsInitialState; sendPayment: SendPaymentInitialState; - sep6DepositAsset: Sep6DepositAssetInitialState; + sep6Deposit: Sep6DepositAssetInitialState; sep6WithdrawAsset: Sep6WithdrawAssetInitialState; sep8Send: Sep8SendInitialState; sep31Send: Sep31SendInitialState; @@ -617,6 +617,22 @@ export type AnchorQuoteAsset = { /* eslint-enable camelcase */ }; +export type AnchorPriceItem = { + total_price: string; + price: string; + sell_amount: string; + buy_amount: string; + fee: { + total: string; + asset: string; + details?: { + name: string; + amount: string; + description?: string; + }; + }; +}; + export type AnchorBuyAsset = { asset: string; price: string;