import queryString from "query-string"; import { TransactionStatus, TransferResponseType, } from "../constants/transfers"; import { AnchorUSDKycStatus, DepositAssetInfo, DepositAssetInfoMap, DepositRequest, Field, FieldPayload, InteractiveKycNeededResponse, KycStatus, TransferError, TransferResponse, } from "../types"; import { fetchKycInBrowser } from "./fetchKycInBrowser"; import { getKycUrl } from "./getKycUrl"; import { TransferProvider } from "./TransferProvider"; const emailRegex: RegExp = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; // used for field validation function validateEmail(email: string): boolean { return !!email.match(emailRegex); } /** * DepositProvider provides methods to interact with a transfer server. At a * high level, you'll need to: * - Fetch the supported assets. * - Collect information from the user. * - Confirm the deposit/show them how much fee they'll pay. * - Start the deposit. * - If KYC is needed, you'll have to do some additional logic. * * ```js * const depositProvider = new DepositProvider(transferServerUrl, account); * const asset = await depositProvider.fetchSupportedAssets(); * * // user provides information, picks asset * * const fee = await depositProvider.fetchFinalFee({ * asset_code, * amount, * type, * }); * * // show fee to user, confirm amount and destination * * const depositResult = await depositProvider.startDeposit({ * asset_code, * // more optional properties * }); * * // We've gotten a result, but it might be one of several types. * switch (depositResult.type) { * case TransferResponseType.ok: * // The deposit request succeeded, so show it to the user. * MyApp.showMessage(depositResult); * break; * * case TransferResponseType.interactiveKyc: * // See `fetchKycInBrowser` for additional example code * break; * * case TransferResponseType.nonInteractiveKyc: * // TODO: SEP-12 data submission * break; * * case TransferResponseType.kycStatus: * // The KYC information was previously submitted, but hasn't been approved * // yet. Should show the user the pending status and any supplemental * // information returned * break; * * default: * // There was an error. * } * ``` * * There's a helper method for interactive KYC in the browser, * `fetchKycInBrowser`, and a helper function for constructing callback URLs for * serverside/native apps, `getKycUrl`. */ export class DepositProvider extends TransferProvider { public response?: TransferResponse; public request?: DepositRequest; constructor( transferServer: string, account: string, lang: string = "en", ) { super(transferServer, account, lang, "deposit"); } /** * Start a deposit request. * * Note that all arguments must be in snake_case (which is what transfer * servers expect)! */ public async startDeposit( params: DepositRequest, shouldUseNewEndpoints: boolean = false, headers: { [key: string]: string } = {}, ): Promise { const request: DepositRequest & { account: string } = { ...params, account: this.account, lang: this.lang, }; const isAuthRequired = this.getAuthStatus("deposit", params.asset_code); let response; let json; if (shouldUseNewEndpoints) { const body = new FormData(); Object.keys(request).forEach((key: string) => { body.append(key, request[key]); }); response = await fetch( `${this.transferServer}/transactions/deposit/interactive`, { method: "POST", body, headers: isAuthRequired ? this.getHeaders(headers) : undefined, }, ); } else { const qs = queryString.stringify(request); response = await fetch(`${this.transferServer}/deposit?${qs}`, { headers: isAuthRequired ? this.getHeaders(headers) : undefined, }); } const isAnchorUSDSep6 = !shouldUseNewEndpoints && this.transferServer.includes("anchorusd.com"); if (!response.ok && !isAnchorUSDSep6) { const responseText = await response.text(); try { const { error } = JSON.parse(responseText); throw new Error( `Error starting deposit to ${this.transferServer}: error ${error}`, ); } catch (e: any) { throw new Error( `Error starting deposit to ${this.transferServer}: error code ${response.status}, status text: "${responseText}"`, ); } } const text = await response.text(); try { json = JSON.parse(text) as TransferResponse; } catch (e: any) { throw new Error(`Error parsing the deposit response as JSON: ${text}`); } if (json.error) { const error: TransferError = new Error( typeof json.error === "string" ? json.error : JSON.stringify(json.error), ); error.originalResponse = json; throw error; } // if this was an auth-required token, insert the JWT if ( isAuthRequired && json.type === TransferResponseType.interactive_customer_info_needed && json.url && json.url.indexOf("jwt") === -1 ) { const { origin, pathname, search, hash } = new URL(json.url); json.url = `${origin}${pathname}${search}${search ? "&" : "?"}jwt=${ this.authToken }${hash}`; } this.request = request; this.response = json; return json; } /** * Fetch the assets that the deposit provider supports, along with details * about depositing that asset. */ public async fetchSupportedAssets(): Promise { const { deposit } = await this.fetchInfo(); // seed internal registry objects with supported assets Object.keys(deposit).forEach((code) => { this._watchOneTransactionRegistry[code] = this._watchOneTransactionRegistry[code] || {}; this._watchAllTransactionsRegistry[code] = false; this._transactionsRegistry[code] = this._transactionsRegistry[code] || {}; this._transactionsIgnoredRegistry[code] = this._transactionsIgnoredRegistry[code] || {}; }); return deposit; } /** * Get one supported asset by code. */ public getAssetInfo(asset_code: string): DepositAssetInfo { if (!this.info || !this.info[this.operation]) { throw new Error(`Run fetchSupportedAssets before running getAssetInfo!`); } if (!this.info[this.operation][asset_code]) { throw new Error(`Asset not supported: ${asset_code}`); } return (this.info[this.operation] as DepositAssetInfoMap)[asset_code]; } /** * `fetchKycInBrowser` expects the original request parameters, the response * object from `depositProvider.startDeposit()`, and a window instance. * * Because pop-up blockers prevent new windows from being created, your app * will need to create one with `const popup = window.open()` and pass in the * result. More information on * [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Window/open). * * Server-side applications or native apps should use the `getKycUrl` helper * function. * * ```js * if (depositResponse === TransferResponseType.interactiveKyc) { * // To avoid popup blockers, the new window has to be opened directly in * // response to a user click event, so we need consumers to provide us a * // window instance that they created previously. This could also be done * // in an iframe or something. * // This exact example would likely be blocked by popup blockers. * const popup = window.open(''); * * const kycResult = await depositProvider.fetchKycInBrowser({ * response: depositResult, * request: depositRequest, * window: popup, * }); * * // showUser(kycResult); * } * ``` * * @param {object} options An object of all needed options * @param {DepositRequest} options.request The original request options. * @param {InteractiveKycNeededResponse} options.response The result of * starting the deposit. * @param {window} window A window object created with `window.open()`. This * helper will navigate to the correct page, so the window can be empty when * you pass it in. * @returns {Promise} A promise with the result of the KYC attempt. If it * succeeds, this will be the information needed to complete a deposit. If it * fails, it will contain information about the KYC failure from the anchor. */ public async fetchKycInBrowser(windowContext: Window): Promise { if (!this.response || !this.request) { throw new Error(`Run startDeposit before calling fetchKycInBrowser!`); } if ( this.response.type !== TransferResponseType.interactive_customer_info_needed || !this.response.url ) { throw new Error(`KYC not needed for this deposit!`); } const kycResult = await fetchKycInBrowser({ response: this.response as InteractiveKycNeededResponse, request: this.request, window: windowContext, }); // AnchorUSD has a specific response shape // It can be detected if the response has `type` === "customer_info_status" if ( (kycResult as AnchorUSDKycStatus).type === TransferResponseType.customer_info_status ) { switch (kycResult.status) { case "denied": return Promise.reject(kycResult); case "pending": return Promise.reject(kycResult); case "success": return Promise.resolve(kycResult); default: throw new Error( `Invalid KYC response received: '${kycResult.status}'.`, ); } } // treat the other ones like standard, SEP-24 responses if (kycResult.status === TransactionStatus.completed) { return Promise.resolve(kycResult); } return Promise.reject(kycResult); } /** * Return the KYC url. Only run this after starting a transfer. */ public getKycUrl(callback_url?: string) { if (!this.response || !this.request) { throw new Error(`Run startDeposit before calling getKycUrl!`); } if ( this.response.type !== TransferResponseType.interactive_customer_info_needed || !this.response.url ) { throw new Error(`KYC not needed for this deposit!`); } return getKycUrl({ response: this.response as InteractiveKycNeededResponse, request: this.request, callback_url, }); } /** * Validate a payload to make sure it conforms with the anchor's data * requirements. */ public validateFields(asset_code: string, payload: FieldPayload) { if (!this.info || !this.info[this.operation]) { throw new Error("Run fetchSupportedAssets before running fetchFinalFee!"); } const assetInfo = this.info[this.operation][asset_code] as DepositAssetInfo; if (!assetInfo) { throw new Error(`Can't get fee for an unsupported asset, '${asset_code}`); } const fields = assetInfo.fields || []; interface ChoiceMap { [name: string]: boolean; } return fields.reduce((isValid: boolean, field: Field): boolean => { if (!isValid) { return isValid; } if (field.optional) { return isValid; } const response = payload[field.name]; if (!response) { return false; } if (field.choices) { // make a map of choices, it's easier const choiceMap: ChoiceMap = field.choices.reduce( (memo, choice) => ({ ...memo, [choice]: true }), {}, ); if (!choiceMap[response]) { return false; } } if ( field.name === "email" || (field.name === "email_address" && !validateEmail(response)) ) { return false; } if (field.name === "amount" && isNaN(parseFloat(response))) { return false; } // if we're still here, keep it goin return isValid; }, true); } }