From c08e7ff0162f9587ed592f291188cf618f2b5f28 Mon Sep 17 00:00:00 2001 From: Juanma Hidalgo Date: Mon, 22 Jan 2024 12:07:45 +0100 Subject: [PATCH] feat: Introduce FIAT Gateway (#566) * feat: wip * feat: fix ESLint issues * feat: remove old unused import * test: fix tests * feat: fix getAuthDapp select * feat: new logic to get identity * feat: fix the marketplaceAPI wert signature call * test: add tests for the new gateway actions * test: fix tests * test: add mock for Request * feat: some small refactors --- package-lock.json | 12 + package.json | 2 + src/lib/marketplaceApi.ts | 24 + src/modules/gateway/actions.spec.ts | 59 ++- src/modules/gateway/actions.ts | 42 +- src/modules/gateway/sagas.spec.ts | 145 +++++- src/modules/gateway/sagas.ts | 549 +++++++++++--------- src/modules/gateway/transak/Transak.spec.ts | 9 +- src/modules/gateway/types.ts | 40 ++ src/modules/identity/actions.ts | 27 + src/modules/identity/sagas.ts | 94 ++++ src/tests/setupTests.ts | 10 + 12 files changed, 772 insertions(+), 241 deletions(-) create mode 100644 src/lib/marketplaceApi.ts create mode 100644 src/modules/identity/actions.ts create mode 100644 src/modules/identity/sagas.ts diff --git a/package-lock.json b/package-lock.json index 11964c64..8ebe962a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,10 +12,12 @@ "@0xsequence/relayer": "^0.25.1", "@dcl/crypto": "^3.3.1", "@dcl/schemas": "^9.10.0", + "@dcl/single-sign-on-client": "^0.1.0", "@dcl/ui-env": "^1.4.0", "@transak/transak-sdk": "^1.0.31", "@types/flat": "0.0.28", "@well-known-components/fetch-component": "^2.0.1", + "@wert-io/widget-initializer": "^5.2.0", "axios": "^0.21.1", "date-fns": "^1.29.0", "dcl-catalyst-client": "^21.1.0", @@ -4551,6 +4553,11 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.3.tgz", "integrity": "sha512-wheIYdr4NYML61AjC8MKj/2jrR/kDQri/CIpVoZwldwhnIrD/j9jIU5bJ8yBKuB2VhpFV7Ab6G2XkBjv9r9Zzw==" }, + "node_modules/@wert-io/widget-initializer": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@wert-io/widget-initializer/-/widget-initializer-5.2.0.tgz", + "integrity": "sha512-Rs9XLeFtvWtZGg9kOVvMZ+PpRxFconMFox6fGQO9psET1B29bBNO6sifOeNzxQ5pMVVbLiK2ZQD5w194sqrB3A==" + }, "node_modules/abab": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.5.tgz", @@ -22158,6 +22165,11 @@ } } }, + "@wert-io/widget-initializer": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@wert-io/widget-initializer/-/widget-initializer-5.2.0.tgz", + "integrity": "sha512-Rs9XLeFtvWtZGg9kOVvMZ+PpRxFconMFox6fGQO9psET1B29bBNO6sifOeNzxQ5pMVVbLiK2ZQD5w194sqrB3A==" + }, "abab": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.5.tgz", diff --git a/package.json b/package.json index a6fd4208..22632e57 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,12 @@ "@0xsequence/relayer": "^0.25.1", "@dcl/crypto": "^3.3.1", "@dcl/schemas": "^9.10.0", + "@dcl/single-sign-on-client": "^0.1.0", "@dcl/ui-env": "^1.4.0", "@transak/transak-sdk": "^1.0.31", "@types/flat": "0.0.28", "@well-known-components/fetch-component": "^2.0.1", + "@wert-io/widget-initializer": "^5.2.0", "axios": "^0.21.1", "date-fns": "^1.29.0", "dcl-catalyst-client": "^21.1.0", diff --git a/src/lib/marketplaceApi.ts b/src/lib/marketplaceApi.ts new file mode 100644 index 00000000..c32b3ba3 --- /dev/null +++ b/src/lib/marketplaceApi.ts @@ -0,0 +1,24 @@ +import { AuthIdentity } from 'decentraland-crypto-fetch' +import { WertMessage } from '../modules/gateway/types' +import { BaseClient } from './BaseClient' + +export class MarketplaceAPI extends BaseClient { + async signWertMessage( + message: WertMessage, + identity: AuthIdentity + ): Promise { + try { + const response = await this.fetch('/v1/wert/sign', { + method: 'POST', + identity, + body: JSON.stringify(message), + headers: { + 'Content-Type': 'application/json' + } + }) + return response + } catch (error) { + throw new Error((error as Error).message) + } + } +} diff --git a/src/modules/gateway/actions.spec.ts b/src/modules/gateway/actions.spec.ts index 168d6507..969d7c51 100644 --- a/src/modules/gateway/actions.spec.ts +++ b/src/modules/gateway/actions.spec.ts @@ -29,9 +29,20 @@ import { POLL_PURCHASE_STATUS_REQUEST, POLL_PURCHASE_STATUS_SUCCESS, setPurchase, - SET_PURCHASE + SET_PURCHASE, + openFiatGatewayWidgetRequest, + OPEN_FIAT_GATEWAY_WIDGET_REQUEST, + openFiatGatewayWidgetSuccess, + OPEN_FIAT_GATEWAY_WIDGET_SUCCESS, + openFiatGatewayWidgetFailure, + OPEN_FIAT_GATEWAY_WIDGET_FAILURE } from './actions' -import { Purchase, PurchaseStatus } from './types' +import { + FiatGateway, + FiatGatewayOptions, + Purchase, + PurchaseStatus +} from './types' jest.mock('../../lib/eth') @@ -261,3 +272,47 @@ describe('when creating the action that signals failure in the poll purchase sta }) }) }) + +describe('when creating the action that signals the start of the fiat gateway widget request', () => { + let gateway: FiatGateway + let data: FiatGatewayOptions + beforeEach(() => { + gateway = FiatGateway.WERT + data = {} as FiatGatewayOptions + }) + it('should return an action signaling the rquest of the fiat gawteway widget opening', () => { + expect(openFiatGatewayWidgetRequest(gateway, data)).toEqual({ + meta: undefined, + payload: { + gateway, + data + }, + type: OPEN_FIAT_GATEWAY_WIDGET_REQUEST + }) + }) +}) + +describe('when creating the action that signals the success of the fiat gateway widget request', () => { + it('should return an action signaling the rquest of the fiat gawteway widget opening', () => { + expect(openFiatGatewayWidgetSuccess()).toEqual({ + meta: undefined, + type: OPEN_FIAT_GATEWAY_WIDGET_SUCCESS + }) + }) +}) + +describe('when creating the action that signals the failure of the fiat gateway widget request', () => { + let error: string + beforeEach(() => { + error = 'error' + }) + it('should return an action signaling the rquest of the fiat gawteway widget opening', () => { + expect(openFiatGatewayWidgetFailure(error)).toEqual({ + meta: undefined, + payload: { + error + }, + type: OPEN_FIAT_GATEWAY_WIDGET_FAILURE + }) + }) +}) diff --git a/src/modules/gateway/actions.ts b/src/modules/gateway/actions.ts index 98e97a34..8016bbb8 100644 --- a/src/modules/gateway/actions.ts +++ b/src/modules/gateway/actions.ts @@ -4,7 +4,12 @@ import { NetworkGatewayType } from 'decentraland-ui/dist/components/BuyManaWithF import { getChainIdByNetwork } from '../../lib/eth' import { buildTransactionWithFromPayload } from '../transaction/utils' import { MoonPayTransactionStatus } from './moonpay/types' -import { Purchase } from './types' +import { + FiatGateway, + FiatGatewayListeners, + FiatGatewayOptions, + Purchase +} from './types' // Open MANA-FIAT Gateway export const OPEN_BUY_MANA_WITH_FIAT_MODAL_REQUEST = @@ -155,3 +160,38 @@ export type PollPurchaseStatusSuccessAction = ReturnType< export type PollPurchaseStatusFailureAction = ReturnType< typeof pollPurchaseStatusFailure > + +// Open FIAT Gateway +export const OPEN_FIAT_GATEWAY_WIDGET_REQUEST = + '[Request] Open FIAT Gateway Widget' +export const OPEN_FIAT_GATEWAY_WIDGET_SUCCESS = + '[Success] Open FIAT Gateway Widget' +export const OPEN_FIAT_GATEWAY_WIDGET_FAILURE = + '[Failure] Open FIAT Gateway Widget' + +export const openFiatGatewayWidgetRequest = ( + gateway: FiatGateway, + data: FiatGatewayOptions, + listeners?: FiatGatewayListeners +) => + action(OPEN_FIAT_GATEWAY_WIDGET_REQUEST, { + gateway, + data, + listeners + }) + +export const openFiatGatewayWidgetSuccess = () => + action(OPEN_FIAT_GATEWAY_WIDGET_SUCCESS) + +export const openFiatGatewayWidgetFailure = (error: string) => + action(OPEN_FIAT_GATEWAY_WIDGET_FAILURE, { error }) + +export type OpenFiatGatewayWidgetRequestAction = ReturnType< + typeof openFiatGatewayWidgetRequest +> +export type OpenFiatGatewayWidgetSuccessAction = ReturnType< + typeof openFiatGatewayWidgetSuccess +> +export type OpenFiatGatewayWidgetFailureAction = ReturnType< + typeof openFiatGatewayWidgetFailure +> diff --git a/src/modules/gateway/sagas.spec.ts b/src/modules/gateway/sagas.spec.ts index 1c041c98..b065a13b 100644 --- a/src/modules/gateway/sagas.spec.ts +++ b/src/modules/gateway/sagas.spec.ts @@ -1,3 +1,4 @@ +import { AuthIdentity } from 'decentraland-crypto-fetch' import { load } from 'redux-persistence' import { call, select } from 'redux-saga/effects' import { expectSaga } from 'redux-saga-test-plan' @@ -6,8 +7,11 @@ import { ChainId } from '@dcl/schemas/dist/dapps/chain-id' import { Network } from '@dcl/schemas/dist/dapps/network' import { NetworkGatewayType } from 'decentraland-ui/dist/components/BuyManaWithFiatModal/Network' import { getChainIdByNetwork } from '../../lib/eth' -import { getAddress } from '../wallet/selectors' +import { getAddress, getData } from '../wallet/selectors' import { + openFiatGatewayWidgetFailure, + openFiatGatewayWidgetRequest, + openFiatGatewayWidgetSuccess, pollPurchaseStatusFailure, pollPurchaseStatusRequest, pollPurchaseStatusSuccess, @@ -15,6 +19,9 @@ import { } from '../gateway/actions' import { openModal } from '../modal/actions' import { fetchWalletRequest } from '../wallet/actions' +import { MarketplaceAPI } from '../../lib/marketplaceApi' +import { Wallet } from '../wallet/types' +import { getIdentityOrRedirect } from '../identity/sagas' import { addManaPurchaseAsTransaction, manaFiatGatewayPurchaseCompleted, @@ -28,19 +35,31 @@ import { } from './actions' import { MoonPay } from './moonpay' import { MoonPayTransaction, MoonPayTransactionStatus } from './moonpay/types' -import { createGatewaySaga } from './sagas' +import { NO_IDENTITY_ERROR, createGatewaySaga } from './sagas' import { Transak } from './transak' -import { ManaFiatGatewaySagasConfig, Purchase, PurchaseStatus } from './types' +import { + FiatGateway, + GatewaySagasConfig, + Purchase, + PurchaseStatus, + WertOptions +} from './types' import { getPendingManaPurchase, getPendingPurchases } from './selectors' import { OrderResponse, TransakOrderStatus } from './transak/types' +jest.mock('@wert-io/widget-initializer') +jest.mock('../../lib/marketplaceApi') jest.mock('../../lib/eth') const mockGetChainIdByNetwork = getChainIdByNetwork as jest.MockedFunction< typeof getChainIdByNetwork > -const mockConfig: ManaFiatGatewaySagasConfig = { +const mockConfig: GatewaySagasConfig = { + [FiatGateway.WERT]: { + url: 'http://wert-url.xyz', + marketplaceServerURL: 'http://marketplace-server-url.xyz' + }, [NetworkGatewayType.MOON_PAY]: { apiKey: 'api-key', apiBaseUrl: 'http://moonpay-base.url.xyz', @@ -842,3 +861,121 @@ describe('when handling the action signaling the load of the local storage into }) }) }) + +describe('when handling the action signaling the opening of the fiat gateway widget', () => { + let wallet: Wallet + let wertOptions: WertOptions + let identity: AuthIdentity + let signedMessage: string + describe('when it is the WERT gateway', () => { + beforeEach(() => { + signedMessage = 'signedMessage' + wallet = {} as Wallet + }) + + describe('and there is identity', () => { + beforeEach(() => { + identity = {} as AuthIdentity + }) + describe('and the marketplace server api call succeeds', () => { + describe('and has all the required data to sign', () => { + beforeEach(() => { + wertOptions = { + commodity: 'MANA', + commodity_amount: 100, + sc_address: '0x0', + sc_input_data: '0x0' + } as WertOptions + }) + it('should put the success action', () => { + return expectSaga(gatewaySaga) + .put(openFiatGatewayWidgetSuccess()) + .provide([ + [select(getData), [wallet]], + [call(getIdentityOrRedirect), identity], + [ + matchers.call.fn(MarketplaceAPI.prototype.signWertMessage), + signedMessage + ] + ]) + .dispatch( + openFiatGatewayWidgetRequest(FiatGateway.WERT, wertOptions) + ) + .silentRun() + }) + }) + describe('and does not have all the required data to sign', () => { + beforeEach(() => { + wertOptions = {} as WertOptions + }) + it('should put the failure action', () => { + return expectSaga(gatewaySaga) + .put( + openFiatGatewayWidgetFailure( + 'Missing data needed for the message to sign' + ) + ) + .provide([ + [select(getData), [wallet]], + [call(getIdentityOrRedirect), identity] + ]) + .dispatch( + openFiatGatewayWidgetRequest(FiatGateway.WERT, wertOptions) + ) + .silentRun() + }) + }) + }) + describe('and the marketplace server api call fails', () => { + let error: string + beforeEach(() => { + error = 'error' + wertOptions = { + commodity: 'MANA', + commodity_amount: 100, + sc_address: '0x0', + sc_input_data: '0x0' + } as WertOptions + ;(MarketplaceAPI.prototype + .signWertMessage as jest.Mock).mockRejectedValue({ message: error }) + }) + it('should put the failure action', () => { + return expectSaga(gatewaySaga) + .put(openFiatGatewayWidgetFailure(error)) + .provide([ + [select(getData), [wallet]], + [call(getIdentityOrRedirect), identity] + ]) + .dispatch( + openFiatGatewayWidgetRequest(FiatGateway.WERT, wertOptions) + ) + .silentRun() + }) + }) + }) + + describe('and there is no identity', () => { + let error: string + beforeEach(() => { + wertOptions = { + commodity: 'MANA', + commodity_amount: 100, + sc_address: '0x0', + sc_input_data: '0x0' + } as WertOptions + ;(MarketplaceAPI.prototype + .signWertMessage as jest.Mock).mockRejectedValue(error) + }) + it('should put the failure action', () => { + return expectSaga(gatewaySaga) + .put(openFiatGatewayWidgetFailure(NO_IDENTITY_ERROR)) + .provide([ + [select(getData), [wallet]], + [call(getIdentityOrRedirect), undefined] + ]) + .dispatch(openFiatGatewayWidgetRequest(FiatGateway.WERT, wertOptions)) + .silentRun() + }) + }) + }) +}) diff --git a/src/modules/gateway/sagas.ts b/src/modules/gateway/sagas.ts index 857bef48..f7dca607 100644 --- a/src/modules/gateway/sagas.ts +++ b/src/modules/gateway/sagas.ts @@ -7,10 +7,15 @@ import { takeEvery, takeLatest } from 'redux-saga/effects' +import WertWidget from '@wert-io/widget-initializer' import { LOAD } from 'redux-persistence' +import { Env, getEnv } from '@dcl/ui-env' import { ChainId } from '@dcl/schemas/dist/dapps/chain-id' import { Network } from '@dcl/schemas/dist/dapps/network' import { NetworkGatewayType } from 'decentraland-ui/dist/components/BuyManaWithFiatModal/Network' +import { AuthIdentity } from 'decentraland-crypto-fetch' +import { MarketplaceAPI } from '../../lib/marketplaceApi' +import { getIdentityOrRedirect } from '../identity/sagas' import { getChainIdByNetwork } from '../../lib/eth' import { pollPurchaseStatusFailure, @@ -20,11 +25,15 @@ import { POLL_PURCHASE_STATUS_REQUEST, setPurchase, SetPurchaseAction, - SET_PURCHASE + SET_PURCHASE, + OPEN_FIAT_GATEWAY_WIDGET_REQUEST, + OpenFiatGatewayWidgetRequestAction, + openFiatGatewayWidgetFailure, + openFiatGatewayWidgetSuccess } from '../gateway/actions' import { openModal } from '../modal/actions' import { getTransactionHref } from '../transaction/utils' -import { getAddress } from '../wallet/selectors' +import { getAddress, getData as getWalletData } from '../wallet/selectors' import { fetchWalletRequest } from '../wallet/actions' import { OPEN_MANA_FIAT_GATEWAY_REQUEST, @@ -46,35 +55,43 @@ import { MoonPayTransaction, MoonPayTransactionStatus } from './moonpay/types' import { getPendingManaPurchase, getPendingPurchases } from './selectors' import { Transak } from './transak' import { CustomizationOptions, OrderResponse } from './transak/types' -import { ManaFiatGatewaySagasConfig, Purchase, PurchaseStatus } from './types' +import { + FiatGateway, + GatewaySagasConfig, + Purchase, + PurchaseStatus, + WertMessage +} from './types' import { isManaPurchase, purchaseEventsChannel } from './utils' +import { Wallet } from '../wallet/types' +import { isErrorWithMessage } from '../../lib/error' const DEFAULT_POLLING_DELAY = 3000 const BUY_MANA_WITH_FIAT_FEEDBACK_MODAL_NAME = 'BuyManaWithFiatFeedbackModal' -export function createGatewaySaga(config: ManaFiatGatewaySagasConfig) { - return function* gatewaySaga(): IterableIterator { +export const NO_IDENTITY_ERROR = 'No identity found' +export const MISSING_DATA_ERROR = 'Missing data needed for the message to sign' + +export function createGatewaySaga(config: GatewaySagasConfig) { + function* gatewaySaga(): IterableIterator { yield takeEvery( - OPEN_BUY_MANA_WITH_FIAT_MODAL_REQUEST, - handleOpenBuyManaWithFiatModal, - config + OPEN_FIAT_GATEWAY_WIDGET_REQUEST, + handleOpenFiatGatewayWidget ) yield takeEvery( - OPEN_MANA_FIAT_GATEWAY_REQUEST, - handleOpenFiatGateway, - config + OPEN_BUY_MANA_WITH_FIAT_MODAL_REQUEST, + handleOpenBuyManaWithFiatModal ) + yield takeEvery(OPEN_MANA_FIAT_GATEWAY_REQUEST, handleOpenFiatGateway) yield takeEvery( MANA_FIAT_GATEWAY_PURCHASE_COMPLETED, - handleFiatGatewayPurchaseCompleted, - config + handleFiatGatewayPurchaseCompleted ) yield takeEvery(SET_PURCHASE, handleSetPurchase) yield takeLatest(LOAD, handleStorageLoad) yield takeEvery( POLL_PURCHASE_STATUS_REQUEST, - handlePollPurchaseStatusRequest, - config + handlePollPurchaseStatusRequest ) yield takeEvery(purchaseEventsChannel, handlePurchaseChannelEvent) @@ -83,268 +100,336 @@ export function createGatewaySaga(config: ManaFiatGatewaySagasConfig) { yield put(setPurchase(purchase)) } } -} -function* handleOpenBuyManaWithFiatModal( - config: ManaFiatGatewaySagasConfig, - action: OpenBuyManaWithFiatModalRequestAction -) { - try { - const { selectedNetwork } = action.payload - const pendingManaPurchase: Purchase | undefined = yield select( - getPendingManaPurchase - ) + function* handleOpenFiatGatewayWidget( + action: OpenFiatGatewayWidgetRequestAction + ) { + const { data, gateway, listeners } = action.payload + try { + switch (gateway) { + case FiatGateway.WERT: + const { onLoaded, onPending, onSuccess } = listeners || {} + const { marketplaceServerURL } = config[FiatGateway.WERT] + + const wallet: Wallet | null = yield select(getWalletData) + if (wallet) { + const identity: AuthIdentity | null = yield call( + getIdentityOrRedirect + ) - if (pendingManaPurchase) { - let goToUrl: string | undefined + if (!identity) { + yield put(openFiatGatewayWidgetFailure(NO_IDENTITY_ERROR)) + return + } + + const isDev = getEnv() === Env.DEVELOPMENT + const { + commodity, + commodity_amount, + sc_address, + sc_input_data + } = data + if (commodity && commodity_amount && sc_address && sc_input_data) { + const dataToSign: WertMessage = { + address: wallet.address, + commodity, + commodity_amount, + network: isDev ? 'sepolia' : 'ethereum', // will be wallet.network + sc_address, + sc_input_data + } + + const marketplaceAPI = new MarketplaceAPI(marketplaceServerURL) + + const signature: string = yield call( + [marketplaceAPI, 'signWertMessage'], + dataToSign, + identity + ) + + const wertWidget = new WertWidget({ + ...data, + ...dataToSign, + signature, + listeners: { + loaded: onLoaded, + 'payment-status': options => { + if (options.tx_id) { + // it's a success event + onSuccess?.({ data: options, type: 'payment-status' }) + } else { + onPending?.({ data: options, type: 'payment-status' }) + } + } + } + }) - if (pendingManaPurchase.gateway === NetworkGatewayType.MOON_PAY) { - goToUrl = new MoonPay(config.moonPay).getTransactionReceiptUrl( - pendingManaPurchase.id - ) + wertWidget.open() + yield put(openFiatGatewayWidgetSuccess()) + } else { + yield put(openFiatGatewayWidgetFailure(MISSING_DATA_ERROR)) + } + } } - + } catch (error) { yield put( - openModal(BUY_MANA_WITH_FIAT_FEEDBACK_MODAL_NAME, { - purchase: pendingManaPurchase, - goToUrl - }) + openFiatGatewayWidgetFailure( + isErrorWithMessage(error) ? error.message : 'Unknown' + ) ) - } else { - yield put(openModal('BuyManaWithFiatModal', { selectedNetwork })) } - - yield put(openBuyManaWithFiatModalSuccess()) - } catch (error) { - yield put(openBuyManaWithFiatModalFailure(error.message)) } -} -function* handleOpenFiatGateway( - config: ManaFiatGatewaySagasConfig, - action: OpenManaFiatGatewayRequestAction -) { - const { network, gateway } = action.payload - const { transak: transakConfig, moonPay: moonPayConfig } = config - - try { - switch (gateway) { - case NetworkGatewayType.TRANSAK: - const address: string = yield select(getAddress) - const customizationOptions: Partial = { - defaultCryptoCurrency: 'MANA', - cyptoCurrencyList: 'MANA', - fiatCurrency: '', // INR/GBP - email: '', // Your customer's email address - redirectURL: '' - } - const transak = new Transak(transakConfig, customizationOptions) - transak.openWidget(address, network) - break - case NetworkGatewayType.MOON_PAY: - const moonPay: MoonPay = new MoonPay(moonPayConfig) - const widgetUrl = moonPay.getWidgetUrl(network) - window.open(widgetUrl, '_blank', 'noopener,noreferrer') - break - default: - break - } + function* handleOpenBuyManaWithFiatModal( + action: OpenBuyManaWithFiatModalRequestAction + ) { + try { + const { selectedNetwork } = action.payload + const pendingManaPurchase: Purchase | undefined = yield select( + getPendingManaPurchase + ) - yield put(openManaFiatGatewaySuccess()) - } catch (error) { - yield put(openManaFiatGatewayFailure(network, gateway, error.message)) - } -} + if (pendingManaPurchase) { + let goToUrl: string | undefined -function* upsertPurchase( - moonPay: MoonPay, - transaction: MoonPayTransaction, - network: Network -) { - let purchase: Purchase = moonPay.createPurchase(transaction, network) - yield put(setPurchase(purchase)) -} + if (pendingManaPurchase.gateway === NetworkGatewayType.MOON_PAY) { + goToUrl = new MoonPay(config.moonPay).getTransactionReceiptUrl( + pendingManaPurchase.id + ) + } -function* handleStorageLoad() { - const pendingPurchases: ReturnType = yield select( - getPendingPurchases - ) + yield put( + openModal(BUY_MANA_WITH_FIAT_FEEDBACK_MODAL_NAME, { + purchase: pendingManaPurchase, + goToUrl + }) + ) + } else { + yield put(openModal('BuyManaWithFiatModal', { selectedNetwork })) + } + + yield put(openBuyManaWithFiatModalSuccess()) + } catch (error) { + yield put(openBuyManaWithFiatModalFailure(error.message)) + } + } + function* handleOpenFiatGateway(action: OpenManaFiatGatewayRequestAction) { + const { network, gateway } = action.payload + const { transak: transakConfig, moonPay: moonPayConfig } = config - if (pendingPurchases) { - for (const pendingPurchase of pendingPurchases) { - const { network, gateway, id } = pendingPurchase + try { switch (gateway) { case NetworkGatewayType.TRANSAK: - yield put(pollPurchaseStatusRequest(pendingPurchase)) + const address: string = yield select(getAddress) + const customizationOptions: Partial = { + defaultCryptoCurrency: 'MANA', + cyptoCurrencyList: 'MANA', + fiatCurrency: '', // INR/GBP + email: '', // Your customer's email address + redirectURL: '' + } + const transak = new Transak(transakConfig, customizationOptions) + transak.openWidget(address, network) break case NetworkGatewayType.MOON_PAY: - yield put( - manaFiatGatewayPurchaseCompleted( - network, - gateway, - id, - MoonPayTransactionStatus.PENDING - ) - ) + const moonPay: MoonPay = new MoonPay(moonPayConfig) + const widgetUrl = moonPay.getWidgetUrl(network) + window.open(widgetUrl, '_blank', 'noopener,noreferrer') + break + default: break } + + yield put(openManaFiatGatewaySuccess()) + } catch (error) { + yield put(openManaFiatGatewayFailure(network, gateway, error.message)) } } -} + function* upsertPurchase( + moonPay: MoonPay, + transaction: MoonPayTransaction, + network: Network + ) { + let purchase: Purchase = moonPay.createPurchase(transaction, network) + yield put(setPurchase(purchase)) + } + function* handleStorageLoad() { + const pendingPurchases: ReturnType = yield select( + getPendingPurchases + ) -function* handlePollPurchaseStatusRequest( - config: ManaFiatGatewaySagasConfig, - action: PollPurchaseStatusRequestAction -) { - const { purchase } = action.payload - const { gateway, id } = purchase + if (pendingPurchases) { + for (const pendingPurchase of pendingPurchases) { + const { network, gateway, id } = pendingPurchase + switch (gateway) { + case NetworkGatewayType.TRANSAK: + yield put(pollPurchaseStatusRequest(pendingPurchase)) + break + case NetworkGatewayType.MOON_PAY: + yield put( + manaFiatGatewayPurchaseCompleted( + network, + gateway, + id, + MoonPayTransactionStatus.PENDING + ) + ) + break + } + } + } + } + function* handlePollPurchaseStatusRequest( + action: PollPurchaseStatusRequestAction + ) { + const { purchase } = action.payload + const { gateway, id } = purchase + + try { + if (purchase.status !== PurchaseStatus.PENDING) { + yield put(pollPurchaseStatusSuccess()) + return + } + + switch (gateway) { + case NetworkGatewayType.TRANSAK: + const { transak: transakConfig } = config + const transak = new Transak(transakConfig) + let statusHasChanged = false + + while (!statusHasChanged) { + const { + data: { status, transactionHash, errorMessage } + }: OrderResponse = yield call([transak, transak.getOrder], id) + const newStatus: PurchaseStatus = yield call( + [transak, transak.getPurchaseStatus], + status + ) + if (newStatus !== purchase.status) { + statusHasChanged = true + yield put( + setPurchase({ + ...purchase, + status: newStatus, + txHash: transactionHash || null, + failureReason: errorMessage + }) + ) + continue + } + yield delay(transakConfig.pollingDelay || DEFAULT_POLLING_DELAY) + } + break + default: + break + } - try { - if (purchase.status !== PurchaseStatus.PENDING) { yield put(pollPurchaseStatusSuccess()) - return + } catch (error) { + yield put(pollPurchaseStatusFailure(error.message)) } + return gatewaySaga + } + function* handleFiatGatewayPurchaseCompleted( + action: ManaFiatGatewayPurchaseCompletedAction + ) { + const { network, gateway, transactionId, status } = action.payload - switch (gateway) { - case NetworkGatewayType.TRANSAK: - const { transak: transakConfig } = config - const transak = new Transak(transakConfig) - let statusHasChanged = false - - while (!statusHasChanged) { - const { - data: { status, transactionHash, errorMessage } - }: OrderResponse = yield call([transak, transak.getOrder], id) - const newStatus: PurchaseStatus = yield call( - [transak, transak.getPurchaseStatus], - status + try { + switch (gateway) { + case NetworkGatewayType.MOON_PAY: + const { moonPay: moonPayConfig } = config + const finalStatuses = [ + MoonPayTransactionStatus.COMPLETED, + MoonPayTransactionStatus.FAILED + ] + const moonPay: MoonPay = new MoonPay(moonPayConfig) + let statusHasChanged: boolean = false + let transaction: MoonPayTransaction = yield call( + [moonPay, moonPay.getTransaction], + transactionId ) - if (newStatus !== purchase.status) { - statusHasChanged = true + + if (!finalStatuses.includes(transaction.status)) { yield put( - setPurchase({ - ...purchase, - status: newStatus, - txHash: transactionHash || null, - failureReason: errorMessage + openModal(BUY_MANA_WITH_FIAT_FEEDBACK_MODAL_NAME, { + purchase: moonPay.createPurchase(transaction, network), + goToUrl: moonPay.getTransactionReceiptUrl(transactionId) }) ) - continue } - yield delay(transakConfig.pollingDelay || DEFAULT_POLLING_DELAY) - } - break - default: - break - } - yield put(pollPurchaseStatusSuccess()) - } catch (error) { - yield put(pollPurchaseStatusFailure(error.message)) - } -} - -function* handleFiatGatewayPurchaseCompleted( - config: ManaFiatGatewaySagasConfig, - action: ManaFiatGatewayPurchaseCompletedAction -) { - const { network, gateway, transactionId, status } = action.payload - - try { - switch (gateway) { - case NetworkGatewayType.MOON_PAY: - const { moonPay: moonPayConfig } = config - const finalStatuses = [ - MoonPayTransactionStatus.COMPLETED, - MoonPayTransactionStatus.FAILED - ] - const moonPay: MoonPay = new MoonPay(moonPayConfig) - let statusHasChanged: boolean = false - let transaction: MoonPayTransaction = yield call( - [moonPay, moonPay.getTransaction], - transactionId - ) + yield call(upsertPurchase, moonPay, transaction, network) - if (!finalStatuses.includes(transaction.status)) { - yield put( - openModal(BUY_MANA_WITH_FIAT_FEEDBACK_MODAL_NAME, { - purchase: moonPay.createPurchase(transaction, network), - goToUrl: moonPay.getTransactionReceiptUrl(transactionId) - }) - ) - } + while (!statusHasChanged) { + const { status: newStatus } = transaction - yield call(upsertPurchase, moonPay, transaction, network) + if (newStatus !== status || finalStatuses.includes(newStatus)) { + statusHasChanged = true + yield call(upsertPurchase, moonPay, transaction, network) + continue + } - while (!statusHasChanged) { - const { status: newStatus } = transaction + yield delay(moonPayConfig.pollingDelay || DEFAULT_POLLING_DELAY) - if (newStatus !== status || finalStatuses.includes(newStatus)) { - statusHasChanged = true - yield call(upsertPurchase, moonPay, transaction, network) - continue + transaction = yield call( + [moonPay, moonPay.getTransaction], + transactionId + ) } - - yield delay(moonPayConfig.pollingDelay || DEFAULT_POLLING_DELAY) - - transaction = yield call( - [moonPay, moonPay.getTransaction], - transactionId - ) - } - default: - break - } - } catch (error) { - yield put( - manaFiatGatewayPurchaseCompletedFailure( - network, - gateway, - transactionId, - error.message + default: + break + } + } catch (error) { + yield put( + manaFiatGatewayPurchaseCompletedFailure( + network, + gateway, + transactionId, + error.message + ) ) - ) + } } -} - -function* handleSetManaPurchase(purchase: Purchase) { - const finalStatuses = [ - PurchaseStatus.COMPLETE, - PurchaseStatus.REFUNDED, - PurchaseStatus.FAILED, - PurchaseStatus.CANCELLED - ] - const { status, network, txHash } = purchase - - if (finalStatuses.includes(status)) { - let transactionUrl: string | undefined - - if (status === PurchaseStatus.COMPLETE) { - yield put(fetchWalletRequest()) + function* handleSetManaPurchase(purchase: Purchase) { + const finalStatuses = [ + PurchaseStatus.COMPLETE, + PurchaseStatus.REFUNDED, + PurchaseStatus.FAILED, + PurchaseStatus.CANCELLED + ] + const { status, network, txHash } = purchase + + if (finalStatuses.includes(status)) { + let transactionUrl: string | undefined + + if (status === PurchaseStatus.COMPLETE) { + yield put(fetchWalletRequest()) + + if (txHash) { + const chainId: ChainId = yield call(getChainIdByNetwork, network) + transactionUrl = getTransactionHref({ txHash }, chainId) + } + } if (txHash) { - const chainId: ChainId = yield call(getChainIdByNetwork, network) - transactionUrl = getTransactionHref({ txHash }, chainId) + yield put(addManaPurchaseAsTransaction(purchase)) } - } - if (txHash) { - yield put(addManaPurchaseAsTransaction(purchase)) + yield put( + openModal(BUY_MANA_WITH_FIAT_FEEDBACK_MODAL_NAME, { + purchase, + transactionUrl + }) + ) } - - yield put( - openModal(BUY_MANA_WITH_FIAT_FEEDBACK_MODAL_NAME, { - purchase, - transactionUrl - }) - ) } -} - -export function* handleSetPurchase(action: SetPurchaseAction) { - const { purchase } = action.payload + function* handleSetPurchase(action: SetPurchaseAction) { + const { purchase } = action.payload - if (isManaPurchase(purchase)) { - yield call(handleSetManaPurchase, purchase) + if (isManaPurchase(purchase)) { + yield call(handleSetManaPurchase, purchase) + } } + return gatewaySaga } diff --git a/src/modules/gateway/transak/Transak.spec.ts b/src/modules/gateway/transak/Transak.spec.ts index 01935d1a..0734022b 100644 --- a/src/modules/gateway/transak/Transak.spec.ts +++ b/src/modules/gateway/transak/Transak.spec.ts @@ -11,7 +11,8 @@ import { getChainId } from '../../wallet/selectors' import { Transak } from '../transak/Transak' import { createGatewaySaga } from '../sagas' import { - ManaFiatGatewaySagasConfig, + FiatGateway, + GatewaySagasConfig, NFTPurchase, Purchase, PurchaseStatus @@ -24,7 +25,11 @@ const mockGetChainIdByNetwork = getChainIdByNetwork as jest.MockedFunction< typeof getChainIdByNetwork > -const mockConfig: ManaFiatGatewaySagasConfig = { +const mockConfig: GatewaySagasConfig = { + [FiatGateway.WERT]: { + url: 'http://wert-base.url.xyz', + marketplaceServerURL: 'http://marketplace-server.url.xyz' + }, [NetworkGatewayType.MOON_PAY]: { apiKey: 'api-key', apiBaseUrl: 'http://moonpay-base.url.xyz', diff --git a/src/modules/gateway/types.ts b/src/modules/gateway/types.ts index a5d8d08e..77047b5b 100644 --- a/src/modules/gateway/types.ts +++ b/src/modules/gateway/types.ts @@ -1,7 +1,40 @@ import { Network } from '@dcl/schemas/dist/dapps/network' +import { Options, WidgetEvents } from '@wert-io/widget-initializer/types' import { NetworkGatewayType } from 'decentraland-ui/dist/components/BuyManaWithFiatModal/Network' import { TradeType } from './transak/types' +export enum FiatGateway { + WERT = 'wert' +} + +export type WertOptions = Options + +export type FiatGatewayOptions = WertOptions // will be adding more options as we add more providers + +export type FiatGatewayOnPendingListener = (event: WidgetEvents) => void +export type FiatGatewayOnSuccessListener = (event: WidgetEvents) => void +export type FiatGatewayOnLoadedListener = () => void + +export type FiatGatewayListeners = { + onLoaded?: FiatGatewayOnLoadedListener + onPending?: FiatGatewayOnPendingListener + onSuccess?: FiatGatewayOnSuccessListener +} + +export type WertMessage = { + address: string + commodity: string + commodity_amount: number + network: string + sc_address: string + sc_input_data: string +} + +export type WertConfig = { + url: string + marketplaceServerURL: string +} + export type MoonPayConfig = { apiBaseUrl: string apiKey: string @@ -25,6 +58,13 @@ export type ManaFiatGatewaySagasConfig = { [NetworkGatewayType.TRANSAK]: TransakConfig } +export type FiatGatewaySagasConfig = { + [FiatGateway.WERT]: WertConfig +} + +export type GatewaySagasConfig = FiatGatewaySagasConfig & + ManaFiatGatewaySagasConfig + export enum PurchaseStatus { PENDING = 'pending', FAILED = 'failed', diff --git a/src/modules/identity/actions.ts b/src/modules/identity/actions.ts new file mode 100644 index 00000000..6473b0a2 --- /dev/null +++ b/src/modules/identity/actions.ts @@ -0,0 +1,27 @@ +import { action } from 'typesafe-actions' +import { AuthIdentity } from '@dcl/crypto' + +// Generate identity + +export const GENERATE_IDENTITY_REQUEST = '[Request] Generate Identity' +export const GENERATE_IDENTITY_SUCCESS = '[Success] Generate Identity' +export const GENERATE_IDENTITY_FAILURE = '[Failure] Generate Identity' + +export const generateIdentityRequest = (address: string) => + action(GENERATE_IDENTITY_REQUEST, { address }) +export const generateIdentitySuccess = ( + address: string, + identity: AuthIdentity +) => action(GENERATE_IDENTITY_SUCCESS, { address, identity }) +export const generateIdentityFailure = (address: string, error: string) => + action(GENERATE_IDENTITY_FAILURE, { address, error }) + +export type GenerateIdentityRequestAction = ReturnType< + typeof generateIdentityRequest +> +export type GenerateIdentitySuccessAction = ReturnType< + typeof generateIdentitySuccess +> +export type GenerateIdentityFailureAction = ReturnType< + typeof generateIdentityFailure +> diff --git a/src/modules/identity/sagas.ts b/src/modules/identity/sagas.ts new file mode 100644 index 00000000..e5616d06 --- /dev/null +++ b/src/modules/identity/sagas.ts @@ -0,0 +1,94 @@ +import { takeLatest, call, put } from 'redux-saga/effects' +import { AuthIdentity } from '@dcl/crypto' +import { + localStorageGetIdentity, + localStorageClearIdentity +} from '@dcl/single-sign-on-client' +import { + CONNECT_WALLET_SUCCESS, + DISCONNECT_WALLET, + DisconnectWalletAction +} from '../wallet/actions' +import { ConnectWalletSuccessAction } from '../wallet/actions' +import { + GENERATE_IDENTITY_REQUEST, + GenerateIdentityRequestAction, + generateIdentitySuccess +} from './actions' + +type IdentitySagaConfig = { + authURL: string + identityExpirationInMinutes?: number +} + +// Persist the address of the connected wallet. +// This is a workaround for when the user disconnects as there is no selector that provides the address at that point +let auxAddress: string | null = null +let dappAuthURL: string | null = null + +export function createIdentitySaga(options: IdentitySagaConfig) { + function* identitySaga() { + yield takeLatest(GENERATE_IDENTITY_REQUEST, handleGetIdentity) + yield takeLatest(CONNECT_WALLET_SUCCESS, handleConnectWalletSuccess) + yield takeLatest(DISCONNECT_WALLET, handleDisconnect) + } + + const { authURL } = options + dappAuthURL = authURL + + function* handleGetIdentity(action: GenerateIdentityRequestAction) { + const { address } = action.payload + const identity: AuthIdentity | null = localStorageGetIdentity(address) + if (!identity) { + window.location.replace( + `${authURL}/login?redirectTo=${window.location.href}` + ) + return + } + yield put(generateIdentitySuccess(address, identity)) + } + + function* handleConnectWalletSuccess(action: ConnectWalletSuccessAction) { + const address = action.payload.wallet.address + + yield call(setAuxAddress, address) + + const identity: AuthIdentity | null = localStorageGetIdentity(address) + if (!identity) { + window.location.replace( + `${authURL}/login?redirectTo=${encodeURIComponent( + window.location.href + )}` + ) + } + } + + function* handleDisconnect(_action: DisconnectWalletAction) { + if (auxAddress) { + localStorageClearIdentity(auxAddress) + } + } + + return identitySaga +} + +export function* getIdentityOrRedirect() { + if (!auxAddress || !dappAuthURL) { + return + } + + const identity: AuthIdentity | null = localStorageGetIdentity(auxAddress) + if (!identity) { + window.location.replace( + `${dappAuthURL}/login?redirectTo=${encodeURIComponent( + window.location.href + )}` + ) + return + } + return identity +} + +export function setAuxAddress(address: string | null) { + auxAddress = address +} diff --git a/src/tests/setupTests.ts b/src/tests/setupTests.ts index 23d50fa6..e5eed56d 100644 --- a/src/tests/setupTests.ts +++ b/src/tests/setupTests.ts @@ -2,6 +2,7 @@ import '@testing-library/jest-dom' import flatten from 'flat' import nock from 'nock' import { TextEncoder, TextDecoder } from 'util' +import fetch, { Request, Response } from 'node-fetch' import en from '../modules/translation/defaults/en.json' import { setCurrentLocale } from '../modules/translation/utils' @@ -11,3 +12,12 @@ global.TextDecoder = TextDecoder as any setCurrentLocale('en', flatten(en)) nock.disableNetConnect() + +if (!globalThis.fetch) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globalThis.fetch = fetch as any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globalThis.Request = Request as any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globalThis.Response = Response as any +}