diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..8364976d --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +# Ignore artifacts: +dist +build +coverage diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..d90ebd47 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,12 @@ +{ + "arrowParens:": "always", + "bracketSpacing": true, + "jsxBracketSameLine": false, + "printWidth": 80, + "proseWrap": "always", + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "all", + "useTabs": false +} diff --git a/packages/demo-wallet-client/src/components/AnchorQuotesModal.tsx b/packages/demo-wallet-client/src/components/AnchorQuotesModal.tsx new file mode 100644 index 00000000..e37cfec6 --- /dev/null +++ b/packages/demo-wallet-client/src/components/AnchorQuotesModal.tsx @@ -0,0 +1,304 @@ +import { useState } from "react"; +import { useDispatch } from "react-redux"; +import BigNumber from "bignumber.js"; +import { Button, Loader, Modal, RadioButton } from "@stellar/design-system"; +import { CSS_MODAL_PARENT_ID } from "demo-wallet-shared/build/constants/settings"; +import { useRedux } from "hooks/useRedux"; +import { + fetchSep38QuotesPricesAction, + postSep38QuoteAction, +} from "ducks/sep38Quotes"; +import { ActionStatus } from "types/types.d"; + +interface AnchorQuotesModalProps { + token: string; + onClose: () => void; + onSubmit: ( + event: React.MouseEvent, + quoteId?: string, + destinationAsset?: string, + ) => void; +} + +type QuoteAsset = { + asset: string; + sellDeliveryMethod?: string; + buyDeliveryMethod?: string; + countryCode?: string; +}; + +export const AnchorQuotesModal = ({ + token, + onClose, + onSubmit, +}: AnchorQuotesModalProps) => { + const { sep38Quotes } = useRedux("sep38Quotes"); + const { data, status, errorString: errorMessage } = sep38Quotes; + + const [quoteAsset, setQuoteAsset] = useState(); + const [assetBuyDeliveryMethod, setAssetBuyDeliveryMethod] = + useState(); + const [assetCountryCode, setAssetCountryCode] = useState(); + const [assetPrice, setAssetPrice] = useState(); + + const dispatch = useDispatch(); + + const calculateTotal = (amount: string | number, rate: string | number) => { + // TODO: Do we need to use precision from asset? + return new BigNumber(amount).div(rate).toFixed(2); + }; + + // Exclude sell asset from quote assets + const renderAssets = data.sellAsset + ? data.assets.filter((a) => a.asset !== data.sellAsset) + : []; + + const handleGetAssetRates = () => { + if (data.serverUrl && data.sellAsset && data.sellAmount) { + dispatch( + fetchSep38QuotesPricesAction({ + token, + anchorQuoteServerUrl: data.serverUrl, + options: { + sellAsset: data.sellAsset, + sellAmount: data.sellAmount, + buyDeliveryMethod: assetBuyDeliveryMethod, + countryCode: assetCountryCode, + }, + }), + ); + } + }; + + const handleGetQuote = () => { + if ( + data.serverUrl && + data.sellAsset && + data.sellAmount && + quoteAsset?.asset + ) { + dispatch( + postSep38QuoteAction({ + token, + anchorQuoteServerUrl: data.serverUrl, + sell_asset: data.sellAsset, + buy_asset: quoteAsset.asset, + sell_amount: data.sellAmount, + buy_delivery_method: assetBuyDeliveryMethod, + country_code: assetCountryCode, + context: "sep31", + }), + ); + } + }; + + const renderContent = () => { + if (status === ActionStatus.SUCCESS) { + if (data.quote) { + const { + id, + price, + expires_at, + sell_asset, + buy_asset, + sell_amount, + buy_amount, + } = data.quote; + + return ( + <> + +

Quote details

+ +
+
+ +
{id}
+
+ +
+ +
{price}
+
+ +
+ +
{expires_at}
+
+ +
+ +
{sell_asset}
+
+ +
+ +
{sell_amount}
+
+ +
+ +
{buy_asset}
+
+ +
+ +
{buy_amount}
+
+ +
+ +
{}
+
+
+
+ + + + + + ); + } + + if (data.prices?.length > 0) { + const sellAssetCode = data.sellAsset?.split(":")[1]; + const buyAssetCode = quoteAsset?.asset.split(":")[1]; + + return ( + <> + +

Rates (not final)

+ +
+ {data.prices.map((p) => ( + { + setAssetPrice(p.price); + }} + /> + ))} +
+ + {data.sellAmount && assetPrice ? ( +
{`Estimated total of ${calculateTotal( + data.sellAmount, + assetPrice, + )} ${buyAssetCode} for ${ + data.sellAmount + } ${sellAssetCode}`}
+ ) : null} +
+ + + + + + ); + } + + if (renderAssets.length > 0) { + return ( + <> + +
+ {/* TODO: handle no assets case */} + {/* TODO: could pre-selected asset if there is only one */} + {renderAssets.map((a) => ( +
+ { + setQuoteAsset({ + asset: a.asset, + }); + }} + checked={a.asset === quoteAsset?.asset} + /> + + {/* TODO: Better UI */} +
+
Country codes
+
+ {a.country_codes?.map((c) => ( + { + setAssetCountryCode(c); + }} + checked={c === assetCountryCode} + /> + ))} +
+ + <> +
Buy delivery methods
+
+ {a.buy_delivery_methods?.map((b) => ( + { + setAssetBuyDeliveryMethod(b.name); + }} + checked={b.name === assetBuyDeliveryMethod} + /> + ))} +
+ +
+
+ ))} +
+
+ + + + + + ); + } + } + + if (status === ActionStatus.ERROR) { + return ( + +

{errorMessage}

+
+ ); + } + + return null; + }; + + return ( + + Anchor Quotes + + {status === ActionStatus.PENDING ? : renderContent()} + + ); +}; diff --git a/packages/demo-wallet-client/src/components/Sep31Send.tsx b/packages/demo-wallet-client/src/components/Sep31Send.tsx index 10b479b7..d463e65b 100644 --- a/packages/demo-wallet-client/src/components/Sep31Send.tsx +++ b/packages/demo-wallet-client/src/components/Sep31Send.tsx @@ -10,6 +10,8 @@ import { DetailsTooltip, } from "@stellar/design-system"; import { CSS_MODAL_PARENT_ID } from "demo-wallet-shared/build/constants/settings"; +import { AnchorQuotesModal } from "components/AnchorQuotesModal"; + import { fetchAccountAction } from "ducks/account"; import { resetActiveAssetAction } from "ducks/activeAsset"; import { @@ -17,7 +19,13 @@ import { submitSep31SendTransactionAction, setCustomerTypesAction, fetchSendFieldsAction, + setStatusAction, } from "ducks/sep31Send"; +import { + fetchSep38QuotesInfoAction, + resetSep38QuotesAction, +} from "ducks/sep38Quotes"; + import { capitalizeString } from "demo-wallet-shared/build/helpers/capitalizeString"; import { useRedux } from "hooks/useRedux"; import { ActionStatus } from "types/types.d"; @@ -59,6 +67,7 @@ export const Sep31Send = () => { }), ); dispatch(resetSep31SendAction()); + dispatch(resetSep38QuotesAction()); } } }, [ @@ -101,9 +110,34 @@ export const Sep31Send = () => { const handleSubmit = ( event: React.MouseEvent, + quoteId?: string, + destinationAsset?: string, + ) => { + event.preventDefault(); + dispatch( + submitSep31SendTransactionAction({ + ...formData, + quoteId, + destinationAsset, + }), + ); + }; + + const handleQuotes = ( + event: React.MouseEvent, ) => { event.preventDefault(); - dispatch(submitSep31SendTransactionAction({ ...formData })); + + const { assetCode, assetIssuer } = sep31Send.data; + + dispatch( + fetchSep38QuotesInfoAction({ + anchorQuoteServerUrl: sep31Send.data?.anchorQuoteServer, + sellAsset: `stellar:${assetCode}:${assetIssuer}`, + sellAmount: formData.amount.amount, + }), + ); + dispatch(setStatusAction(ActionStatus.ANCHOR_QUOTES)); }; const handleSelectTypes = ( @@ -133,6 +167,7 @@ export const Sep31Send = () => { resetLocalState(); dispatch(resetSep31SendAction()); dispatch(resetActiveAssetAction()); + dispatch(resetSep38QuotesAction()); }; const renderSenderOptions = () => { @@ -199,6 +234,16 @@ export const Sep31Send = () => { )); }; + if (sep31Send.status === ActionStatus.ANCHOR_QUOTES) { + return ( + + ); + } + if (sep31Send.status === ActionStatus.NEEDS_INPUT) { // Select customer types if (!data.isTypeSelected) { @@ -279,24 +324,41 @@ export const Sep31Send = () => { } return ( -
- {capitalizeString(sectionTitle)} - {Object.entries(sectionItems || {}).map(([id, input]) => ( - // TODO: if input.choices, render Select - - ))} -
- )})} +
+ {capitalizeString(sectionTitle)} + {Object.entries(sectionItems || {}).map(([id, input]) => ( + // TODO: if input.choices, render Select + + ))} +
+ ); + })} - + {data.anchorQuoteSupported ? ( + data.anchorQuoteRequired ? ( + + ) : ( + <> + + + + ) + ) : ( + + )} ); diff --git a/packages/demo-wallet-client/src/config/store.ts b/packages/demo-wallet-client/src/config/store.ts index 308144a0..c9e39910 100644 --- a/packages/demo-wallet-client/src/config/store.ts +++ b/packages/demo-wallet-client/src/config/store.ts @@ -1,6 +1,5 @@ import { configureStore, - getDefaultMiddleware, isPlain, createAction, CombinedState, @@ -20,6 +19,7 @@ import { reducer as sep8Send } from "ducks/sep8Send"; import { reducer as sep24DepositAsset } from "ducks/sep24DepositAsset"; import { reducer as sep24WithdrawAsset } from "ducks/sep24WithdrawAsset"; import { reducer as sep31Send } from "ducks/sep31Send"; +import { reducer as sep38Quotes } from "ducks/sep38Quotes"; import { reducer as logs } from "ducks/logs"; import { reducer as sendPayment } from "ducks/sendPayment"; import { reducer as settings } from "ducks/settings"; @@ -55,6 +55,7 @@ const reducers = combineReducers({ sep24DepositAsset, sep24WithdrawAsset, sep31Send, + sep38Quotes, settings, trustAsset, untrustedAssets, @@ -69,13 +70,12 @@ const rootReducer = (state: CombinedState, action: Action) => { export const store = configureStore({ reducer: rootReducer, - middleware: [ - ...getDefaultMiddleware({ + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ serializableCheck: { isSerializable, }, }), - ], }); export const walletBackendEndpoint: string = diff --git a/packages/demo-wallet-client/src/ducks/sep31Send.ts b/packages/demo-wallet-client/src/ducks/sep31Send.ts index 72f9cc5e..ad60c325 100644 --- a/packages/demo-wallet-client/src/ducks/sep31Send.ts +++ b/packages/demo-wallet-client/src/ducks/sep31Send.ts @@ -101,6 +101,21 @@ export const initiateSendAction = createAsyncThunk< // Check info const infoResponse = await checkInfo({ assetCode, sendServer }); + let anchorQuoteServer; + + // Check SEP-38 quote server key in toml, if supported + if (infoResponse.quotesSupported) { + const tomlSep38Response = await checkTomlForFields({ + sepName: "SEP-38 Anchor RFQ", + assetIssuer, + requiredKeys: [TomlFields.ANCHOR_QUOTE_SERVER], + networkUrl: networkConfig.url, + homeDomain, + }); + + anchorQuoteServer = tomlSep38Response.ANCHOR_QUOTE_SERVER; + } + // If there are multiple sender or receiver types the status will be // returned NEEDS_INPUT, which will show modal for user to select types. @@ -126,6 +141,9 @@ export const initiateSendAction = createAsyncThunk< !infoResponse.multipleSenderTypes && !infoResponse.multipleReceiverTypes, ), + anchorQuoteSupported: infoResponse.quotesSupported, + anchorQuoteRequired: infoResponse.quotesRequired, + anchorQuoteServer, }; } catch (error) { const errorMessage = getErrorMessage(error); @@ -272,6 +290,8 @@ interface SubmitSep31SendTransactionActionProps { transaction: AnyObject; sender: AnyObject; receiver: AnyObject; + quoteId?: string; + destinationAsset?: string; } export const submitSep31SendTransactionAction = createAsyncThunk< @@ -281,7 +301,7 @@ export const submitSep31SendTransactionAction = createAsyncThunk< >( "sep31Send/submitSep31SendTransactionAction", async ( - { amount, transaction, sender, receiver }, + { amount, transaction, sender, receiver, quoteId, destinationAsset }, { rejectWithValue, getState }, ) => { try { @@ -320,6 +340,8 @@ export const submitSep31SendTransactionAction = createAsyncThunk< transactionFormData: transaction || {}, sendServer, token, + quoteId, + destinationAsset, }); // Poll transaction until ready @@ -398,6 +420,9 @@ const initialState: Sep31SendInitialState = { sendServer: "", kycServer: "", serverSigningKey: "", + anchorQuoteSupported: undefined, + anchorQuoteRequired: undefined, + anchorQuoteServer: undefined, }, errorString: undefined, status: undefined, @@ -408,6 +433,9 @@ const sep31SendSlice = createSlice({ initialState, reducers: { resetSep31SendAction: () => initialState, + setStatusAction: (state, action) => { + state.status = action.payload; + }, }, extraReducers: (builder) => { builder.addCase(initiateSendAction.pending, (state = initialState) => { @@ -468,4 +496,4 @@ const sep31SendSlice = createSlice({ export const sep31SendSelector = (state: RootState) => state.sep31Send; export const { reducer } = sep31SendSlice; -export const { resetSep31SendAction } = sep31SendSlice.actions; +export const { resetSep31SendAction, setStatusAction } = sep31SendSlice.actions; diff --git a/packages/demo-wallet-client/src/ducks/sep38Quotes.ts b/packages/demo-wallet-client/src/ducks/sep38Quotes.ts new file mode 100644 index 00000000..c1727fd7 --- /dev/null +++ b/packages/demo-wallet-client/src/ducks/sep38Quotes.ts @@ -0,0 +1,249 @@ +import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; +import { RootState } from "config/store"; +import { getErrorMessage } from "demo-wallet-shared/build/helpers/getErrorMessage"; +import { log } from "demo-wallet-shared/build/helpers/log"; + +import { + getInfo, + getPrices, + postQuote, +} from "demo-wallet-shared/build/methods/sep38Quotes"; + +import { + ActionStatus, + AnchorBuyAsset, + AnchorQuote, + AnchorQuoteAsset, + AnchorQuoteRequest, + RejectMessage, + Sep38QuotesInitialState, +} from "types/types.d"; + +export const fetchSep38QuotesInfoAction = createAsyncThunk< + { + assets: AnchorQuoteAsset[]; + sellAsset: string; + sellAmount: string; + serverUrl: string | undefined; + }, + { + anchorQuoteServerUrl: string | undefined; + sellAsset: string; + sellAmount: string; + }, + { rejectValue: RejectMessage; state: RootState } +>( + "sep38Quotes/fetchSep38QuotesInfoAction", + async ( + { anchorQuoteServerUrl, sellAsset, sellAmount }, + { rejectWithValue }, + ) => { + try { + const result = await getInfo(anchorQuoteServerUrl, { + sell_asset: sellAsset, + sell_amount: sellAmount, + }); + + return { + assets: result.assets, + sellAsset, + sellAmount, + serverUrl: anchorQuoteServerUrl, + }; + } catch (error) { + const errorMessage = getErrorMessage(error); + + log.error({ + title: errorMessage, + }); + return rejectWithValue({ + errorString: errorMessage, + }); + } + }, +); + +export const fetchSep38QuotesPricesAction = createAsyncThunk< + AnchorBuyAsset[], + { + token: string; + anchorQuoteServerUrl: string; + options: { + sellAsset: string; + sellAmount: string; + sellDeliveryMethod?: string; + buyDeliveryMethod?: string; + countryCode?: string; + }; + }, + { rejectValue: RejectMessage; state: RootState } +>( + "sep38Quotes/fetchSep38QuotesPricesAction", + async ({ token, anchorQuoteServerUrl, options }, { rejectWithValue }) => { + const { + sellAsset, + sellAmount, + sellDeliveryMethod, + buyDeliveryMethod, + countryCode, + } = options; + + try { + const result = await getPrices({ + token, + anchorQuoteServerUrl, + options: { + sell_asset: sellAsset, + sell_amount: sellAmount, + sell_delivery_method: sellDeliveryMethod, + buy_delivery_method: buyDeliveryMethod, + country_code: countryCode, + }, + }); + + return result.buy_assets; + } catch (error) { + const errorMessage = getErrorMessage(error); + + log.error({ + title: errorMessage, + }); + return rejectWithValue({ + errorString: errorMessage, + }); + } + }, +); + +export const postSep38QuoteAction = createAsyncThunk< + AnchorQuote, + AnchorQuoteRequest, + { rejectValue: RejectMessage; state: RootState } +>( + "sep38Quotes/postSep38QuoteAction", + async ( + { + anchorQuoteServerUrl, + token, + sell_asset, + buy_asset, + sell_amount, + buy_amount, + expire_after, + sell_delivery_method, + buy_delivery_method, + country_code, + context, + }, + { rejectWithValue }, + ) => { + try { + const result = await postQuote({ + token, + anchorQuoteServerUrl, + sell_asset, + buy_asset, + sell_amount, + buy_amount, + expire_after, + sell_delivery_method, + buy_delivery_method, + country_code, + context, + }); + + return result; + } catch (error) { + const errorMessage = getErrorMessage(error); + + log.error({ + title: errorMessage, + }); + return rejectWithValue({ + errorString: errorMessage, + }); + } + }, +); + +const initialState: Sep38QuotesInitialState = { + data: { + serverUrl: undefined, + sellAsset: undefined, + sellAmount: undefined, + assets: [], + prices: [], + quote: undefined, + }, + errorString: undefined, + status: undefined, +}; + +const sep38QuotesSlice = createSlice({ + name: "sep38Quotes", + initialState, + reducers: { + resetSep38QuotesAction: () => initialState, + }, + extraReducers: (builder) => { + builder.addCase( + fetchSep38QuotesInfoAction.pending, + (state = initialState) => { + state.status = ActionStatus.PENDING; + state.data = { ...state.data, prices: [], quote: undefined }; + }, + ); + builder.addCase(fetchSep38QuotesInfoAction.fulfilled, (state, action) => { + state.data = { + ...state.data, + assets: action.payload.assets, + sellAsset: action.payload.sellAsset, + sellAmount: action.payload.sellAmount, + serverUrl: action.payload.serverUrl, + }; + state.status = ActionStatus.SUCCESS; + }); + builder.addCase(fetchSep38QuotesInfoAction.rejected, (state, action) => { + state.errorString = action.payload?.errorString; + state.status = ActionStatus.ERROR; + }); + + builder.addCase( + fetchSep38QuotesPricesAction.pending, + (state = initialState) => { + state.status = ActionStatus.PENDING; + }, + ); + builder.addCase(fetchSep38QuotesPricesAction.fulfilled, (state, action) => { + state.data = { + ...state.data, + prices: action.payload, + }; + state.status = ActionStatus.SUCCESS; + }); + builder.addCase(fetchSep38QuotesPricesAction.rejected, (state, action) => { + state.errorString = action.payload?.errorString; + state.status = ActionStatus.ERROR; + }); + + builder.addCase(postSep38QuoteAction.pending, (state = initialState) => { + state.status = ActionStatus.PENDING; + }); + builder.addCase(postSep38QuoteAction.fulfilled, (state, action) => { + state.data = { + ...state.data, + quote: action.payload, + }; + state.status = ActionStatus.SUCCESS; + }); + builder.addCase(postSep38QuoteAction.rejected, (state, action) => { + state.errorString = action.payload?.errorString; + state.status = ActionStatus.ERROR; + }); + }, +}); + +export const sep38QuotesSelector = (state: RootState) => state.sep38Quotes; + +export const { reducer } = sep38QuotesSlice; +export const { resetSep38QuotesAction } = sep38QuotesSlice.actions; diff --git a/packages/demo-wallet-client/src/types/types.d.ts b/packages/demo-wallet-client/src/types/types.d.ts index 0cf6ce48..e639c795 100644 --- a/packages/demo-wallet-client/src/types/types.d.ts +++ b/packages/demo-wallet-client/src/types/types.d.ts @@ -17,6 +17,7 @@ export enum AssetCategory { export enum TomlFields { ACCOUNTS = "ACCOUNTS", + ANCHOR_QUOTE_SERVER = "ANCHOR_QUOTE_SERVER", AUTH_SERVER = "AUTH_SERVER", DIRECT_PAYMENT_SERVER = "DIRECT_PAYMENT_SERVER", FEDERATION_SERVER = "FEDERATION_SERVER", @@ -277,6 +278,22 @@ export interface Sep31SendInitialState { sendServer: string; kycServer: string; serverSigningKey: string; + anchorQuoteSupported: boolean | undefined; + anchorQuoteRequired: boolean | undefined; + anchorQuoteServer: string | undefined; + }; + errorString?: string; + status: ActionStatus | undefined; +} + +export interface Sep38QuotesInitialState { + data: { + serverUrl: string | undefined; + sellAsset: string | undefined; + sellAmount: string | undefined; + assets: AnchorQuoteAsset[]; + prices: AnchorBuyAsset[]; + quote: AnchorQuote | undefined; }; errorString?: string; status: ActionStatus | undefined; @@ -335,6 +352,7 @@ export interface Store { sep6WithdrawAsset: Sep6WithdrawAssetInitialState; sep8Send: Sep8SendInitialState; sep31Send: Sep31SendInitialState; + sep38Quotes: Sep38QuotesInitialState; sep24DepositAsset: Sep24DepositAssetInitialState; sep24WithdrawAsset: Sep24WithdrawAssetInitialState; settings: SettingsInitialState; @@ -350,6 +368,7 @@ export enum ActionStatus { SUCCESS = "SUCCESS", NEEDS_INPUT = "NEEDS_INPUT", CAN_PROCEED = "CAN_PROCEED", + ANCHOR_QUOTES = "ANCHOR_QUOTES", } export interface RejectMessage { @@ -557,3 +576,59 @@ export enum Sep12CustomerFieldStatus { REJECTED = "REJECTED", VERIFICATION_REQUIRED = "VERIFICATION_REQUIRED", } + +export type AnchorDeliveryMethod = { + name: string; + description: string; +}; + +export type AnchorQuoteAsset = { + asset: string; + sell_delivery_methods?: AnchorDeliveryMethod[]; + buy_delivery_methods?: AnchorDeliveryMethod[]; + country_codes?: string[]; +}; + +export type AnchorBuyAsset = { + asset: string; + price: string; + decimals: number; +}; + +export type AnchorQuote = { + id: string; + expires_at: string; + total_price: string; + price: string; + sell_asset: string; + sell_amount: string; + buy_asset: string; + buy_amount: string; + fee: AnchorFee; +}; + +export type AnchorFee = { + total: string; + asset: string; + details?: AnchorFeeDetail[]; +}; + +export type AnchorFeeDetail = { + name: string; + description?: string; + amount: string; +}; + +export type AnchorQuoteRequest = { + anchorQuoteServerUrl: string; + token: string; + sell_asset: string; + buy_asset: string; + sell_amount?: string; + buy_amount?: string; + expire_after?: string; + sell_delivery_method?: string; + buy_delivery_method?: string; + country_code?: string; + context: "sep6" | "sep31"; +}; diff --git a/packages/demo-wallet-shared/methods/sep31Send/checkInfo.ts b/packages/demo-wallet-shared/methods/sep31Send/checkInfo.ts index 8481d669..4dbab2de 100644 --- a/packages/demo-wallet-shared/methods/sep31Send/checkInfo.ts +++ b/packages/demo-wallet-shared/methods/sep31Send/checkInfo.ts @@ -130,11 +130,27 @@ export const checkInfo = async ({ body: asset.fields, }); + // SEP-38 quotes + const quotesSupported = Boolean(asset.quotes_supported); + const quotesRequired = Boolean(asset.quotes_required); + + const quotesRequiredMessage = quotesSupported + ? `${quotesRequired ? ", and it is required" : ", but it is not required"}` + : ""; + + log.instruction({ + title: `The receiving anchor ${ + quotesSupported ? "supports" : "does not support" + } SEP-38 Anchor RFQ${quotesRequiredMessage}`, + }); + return { fields: asset.fields, senderType, receiverType, multipleSenderTypes, multipleReceiverTypes, + quotesSupported, + quotesRequired, }; }; diff --git a/packages/demo-wallet-shared/methods/sep31Send/postTransaction.ts b/packages/demo-wallet-shared/methods/sep31Send/postTransaction.ts index 738808ad..24446b7e 100644 --- a/packages/demo-wallet-shared/methods/sep31Send/postTransaction.ts +++ b/packages/demo-wallet-shared/methods/sep31Send/postTransaction.ts @@ -8,6 +8,8 @@ interface PostTransactionProps { transactionFormData: any; assetCode: string; amount: string; + quoteId?: string; + destinationAsset?: string; } export const postTransaction = async ({ @@ -18,6 +20,8 @@ export const postTransaction = async ({ transactionFormData, assetCode, amount, + quoteId, + destinationAsset, }: PostTransactionProps) => { log.instruction({ title: "POST relevant field info to create a new payment", @@ -29,7 +33,10 @@ export const postTransaction = async ({ fields: { transaction: transactionFormData }, asset_code: assetCode, amount, + quote_id: quoteId, + destination_asset: destinationAsset, }; + log.request({ title: "POST `/transactions`", body }); const result = await fetch(`${sendServer}/transactions`, { diff --git a/packages/demo-wallet-shared/methods/sep38Quotes/getInfo.ts b/packages/demo-wallet-shared/methods/sep38Quotes/getInfo.ts new file mode 100644 index 00000000..388d8697 --- /dev/null +++ b/packages/demo-wallet-shared/methods/sep38Quotes/getInfo.ts @@ -0,0 +1,49 @@ +import { log } from "../../helpers/log"; +import { AnchorQuoteAsset } from "../../types/types"; + +export const getInfo = async ( + anchorQuoteServerUrl: string | undefined, + options?: { + /* eslint-disable camelcase */ + sell_asset: string; + sell_amount: string; + sell_delivery_method?: string; + buy_delivery_method?: string; + country_code?: string; + /* eslint-enable camelcase */ + }, +): Promise<{ assets: AnchorQuoteAsset[] }> => { + 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 \`/info\` endpoint for \`${anchorQuoteServerUrl}\` to get anchor quotes details`, + ...(params ? { body: params } : {}), + }); + + log.request({ + title: "GET `/info`", + ...(params ? { body: params } : {}), + }); + + const result = await fetch( + `${anchorQuoteServerUrl}/info?${urlParams?.toString()}`, + ); + const resultJson = await result.json(); + + log.response({ title: "GET `/info`", body: resultJson }); + + return resultJson; +}; diff --git a/packages/demo-wallet-shared/methods/sep38Quotes/getPrices.ts b/packages/demo-wallet-shared/methods/sep38Quotes/getPrices.ts new file mode 100644 index 00000000..fbc17092 --- /dev/null +++ b/packages/demo-wallet-shared/methods/sep38Quotes/getPrices.ts @@ -0,0 +1,73 @@ +import { log } from "../../helpers/log"; +import { AnchorBuyAsset } from "../../types/types"; + +type Sep38Prices = { + token: string; + anchorQuoteServerUrl: string | undefined; + options?: { + /* eslint-disable camelcase */ + sell_asset: string; + sell_amount: string; + sell_delivery_method?: string; + buy_delivery_method?: string; + country_code?: string; + /* eslint-enable camelcase */ + }; +}; + +export const getPrices = async ({ + token, + anchorQuoteServerUrl, + options, +}: Sep38Prices): // eslint-disable-next-line camelcase +Promise<{ buy_assets: AnchorBuyAsset[] }> => { + 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 \`/prices\` endpoint for \`${anchorQuoteServerUrl}\` to get prices for selected asset`, + ...(params ? { body: params } : {}), + }); + + log.request({ + title: "GET `/prices`", + ...(params ? { body: params } : {}), + }); + + const result = await fetch( + `${anchorQuoteServerUrl}/prices?${urlParams?.toString()}`, + { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + + if (result.status !== 200) { + log.error({ + title: "GET `/prices` failed", + body: { status: result.status }, + }); + + throw new Error("Something went wrong"); + } + + const resultJson = await result.json(); + + log.response({ title: "GET `/prices`", body: resultJson }); + + return resultJson; +}; diff --git a/packages/demo-wallet-shared/methods/sep38Quotes/index.ts b/packages/demo-wallet-shared/methods/sep38Quotes/index.ts new file mode 100644 index 00000000..1a7d9d58 --- /dev/null +++ b/packages/demo-wallet-shared/methods/sep38Quotes/index.ts @@ -0,0 +1,5 @@ +import { getInfo } from "./getInfo"; +import { getPrices } from "./getPrices"; +import { postQuote } from "./postQuote"; + +export { getInfo, getPrices, postQuote }; diff --git a/packages/demo-wallet-shared/methods/sep38Quotes/postQuote.ts b/packages/demo-wallet-shared/methods/sep38Quotes/postQuote.ts new file mode 100644 index 00000000..fcc83b6c --- /dev/null +++ b/packages/demo-wallet-shared/methods/sep38Quotes/postQuote.ts @@ -0,0 +1,66 @@ +import { log } from "../../helpers/log"; +import { AnchorQuote } from "../../types/types"; + +type Sep38QuoteRequest = { + anchorQuoteServerUrl: string; + token: string; + /* eslint-disable camelcase */ + sell_asset: string; + buy_asset: string; + sell_amount?: string; + buy_amount?: string; + expire_after?: string; + sell_delivery_method?: string; + buy_delivery_method?: string; + country_code?: string; + /* eslint-enable camelcase */ + context: "sep6" | "sep31"; +}; + +export const postQuote = async ({ + anchorQuoteServerUrl, + token, + /* eslint-disable camelcase */ + sell_asset, + buy_asset, + sell_amount, + buy_amount, + expire_after, + sell_delivery_method, + buy_delivery_method, + country_code, + /* eslint-disable camelcase */ + context, +}: Sep38QuoteRequest): Promise => { + const bodyObj = { + sell_asset, + buy_asset, + sell_amount, + buy_amount, + expire_after, + sell_delivery_method, + buy_delivery_method, + country_code, + context, + }; + + log.request({ + title: "POST `/quote`", + body: bodyObj, + }); + + const result = await fetch(`${anchorQuoteServerUrl}/quote`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(bodyObj), + }); + + const resultJson = await result.json(); + + log.response({ title: "POST `/quote`", body: resultJson }); + + return resultJson; +}; diff --git a/packages/demo-wallet-shared/types/types.ts b/packages/demo-wallet-shared/types/types.ts index 0bad80a9..28173024 100644 --- a/packages/demo-wallet-shared/types/types.ts +++ b/packages/demo-wallet-shared/types/types.ts @@ -17,6 +17,7 @@ export enum AssetCategory { export enum TomlFields { ACCOUNTS = "ACCOUNTS", + ANCHOR_QUOTE_SERVER = "ANCHOR_QUOTE_SERVER", AUTH_SERVER = "AUTH_SERVER", DIRECT_PAYMENT_SERVER = "DIRECT_PAYMENT_SERVER", FEDERATION_SERVER = "FEDERATION_SERVER", @@ -575,3 +576,50 @@ export enum Sep12CustomerFieldStatus { REJECTED = "REJECTED", VERIFICATION_REQUIRED = "VERIFICATION_REQUIRED", } + +// Anchor quotes +export type AnchorDeliveryMethod = { + name: string; + description: string; +}; + +export type AnchorQuoteAsset = { + asset: string; + /* eslint-disable camelcase */ + sell_delivery_methods?: AnchorDeliveryMethod[]; + buy_delivery_methods?: AnchorDeliveryMethod[]; + country_codes?: string[]; + /* eslint-enable camelcase */ +}; + +export type AnchorBuyAsset = { + asset: string; + price: string; + decimals: number; +}; + +export type AnchorQuote = { + id: string; + price: string; + fee: AnchorFee; + /* eslint-disable camelcase */ + expires_at: string; + total_price: string; + sell_asset: string; + sell_amount: string; + buy_asset: string; + buy_amount: string; + /* eslint-enable camelcase */ +}; + +export type AnchorFee = { + total: string; + asset: string; + details?: AnchorFeeDetail[]; +}; + +export type AnchorFeeDetail = { + name: string; + description?: string; + amount: string; +}; diff --git a/prettier.config.js b/prettier.config.js deleted file mode 100644 index 4b355cb3..00000000 --- a/prettier.config.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require("@stellar/prettier-config");