diff --git a/centrifuge-app/src/pages/Onboarding/queries/useSignAndSendDocuments.ts b/centrifuge-app/src/pages/Onboarding/queries/useSignAndSendDocuments.ts index 3276260fb7..31822fc48b 100644 --- a/centrifuge-app/src/pages/Onboarding/queries/useSignAndSendDocuments.ts +++ b/centrifuge-app/src/pages/Onboarding/queries/useSignAndSendDocuments.ts @@ -1,3 +1,4 @@ +import { useTransactions } from '@centrifuge/centrifuge-react' import { useMutation } from 'react-query' import { useOnboardingAuth } from '../../../components/OnboardingAuthProvider' import { OnboardingPool, useOnboarding } from '../../../components/OnboardingProvider' @@ -6,12 +7,21 @@ import { OnboardingUser } from '../../../types' export const useSignAndSendDocuments = () => { const { refetchOnboardingUser, pool, nextStep } = useOnboarding>() const { authToken } = useOnboardingAuth() + const { addOrUpdateTransaction } = useTransactions() + const txIdSendDocs = Math.random().toString(36).substr(2) const poolId = pool.id const trancheId = pool.trancheId const mutation = useMutation( async (transactionInfo: { txHash: string; blockNumber: string }) => { + addOrUpdateTransaction({ + id: txIdSendDocs, + title: `Send documents to issuers`, + status: 'pending', + args: [], + }) + const response = await fetch(`${import.meta.env.REACT_APP_ONBOARDING_API_URL}/signAndSendDocuments`, { method: 'POST', headers: { @@ -27,8 +37,20 @@ export const useSignAndSendDocuments = () => { }) if (response.status === 201) { + addOrUpdateTransaction({ + id: txIdSendDocs, + title: `Send documents to issuers`, + status: 'succeeded', + args: [], + }) return response } + addOrUpdateTransaction({ + id: txIdSendDocs, + title: `Send documents to issuers`, + status: 'failed', + args: ['An error occured uploading documents'], + }) throw response.statusText }, { diff --git a/centrifuge-app/src/pages/Onboarding/queries/useSignRemark.ts b/centrifuge-app/src/pages/Onboarding/queries/useSignRemark.ts index b4d60a4593..fec3b65fce 100644 --- a/centrifuge-app/src/pages/Onboarding/queries/useSignRemark.ts +++ b/centrifuge-app/src/pages/Onboarding/queries/useSignRemark.ts @@ -1,8 +1,18 @@ -import { useCentrifugeTransaction, useEvmProvider, useTransactions, useWallet } from '@centrifuge/centrifuge-react' +import { + useBalances, + useCentrifuge, + useCentrifugeTransaction, + useEvmProvider, + useTransactions, + useWallet, +} from '@centrifuge/centrifuge-react' import { Contract } from '@ethersproject/contracts' -import React from 'react' +import React, { useEffect } from 'react' import { UseMutateFunction } from 'react-query' +import { lastValueFrom } from 'rxjs' +import { useOnboardingAuth } from '../../../components/OnboardingAuthProvider' import { ethConfig } from '../../../config' +import { Dec } from '../../../utils/Decimal' import RemarkerAbi from './abi/Remarker.abi.json' export const useSignRemark = ( @@ -18,18 +28,88 @@ export const useSignRemark = ( ) => { const evmProvider = useEvmProvider() const [isEvmTxLoading, setIsEvmTxLoading] = React.useState(false) + const [isSubstrateTxLoading, setIsSubstrateTxLoading] = React.useState(false) + const centrifuge = useCentrifuge() const { updateTransaction, addOrUpdateTransaction } = useTransactions() - const { connectedType } = useWallet() + const { + connectedType, + substrate: { selectedAddress, selectedAccount }, + } = useWallet() + const [expectedTxFee, setExpectedTxFee] = React.useState(Dec(0)) + const balances = useBalances(selectedAddress || '') + const { authToken } = useOnboardingAuth() const substrateMutation = useCentrifugeTransaction('Sign remark', (cent) => cent.remark.signRemark, { onSuccess: async (_, result) => { const txHash = result.txHash.toHex() // @ts-expect-error const blockNumber = result.blockNumber.toString() - await sendDocumentsToIssuer({ txHash, blockNumber }) + try { + await sendDocumentsToIssuer({ txHash, blockNumber }) + setIsSubstrateTxLoading(false) + } catch (e) { + setIsSubstrateTxLoading(false) + } }, }) + const signSubstrateRemark = async (args: [message: string]) => { + const txIdSignRemark = Math.random().toString(36).substr(2) + setIsSubstrateTxLoading(true) + if (balances?.native.balance?.toDecimal().lt(expectedTxFee.mul(1.1))) { + addOrUpdateTransaction({ + id: txIdSignRemark, + title: `Get ${balances?.native.currency.symbol}`, + status: 'pending', + args, + }) + // add just enough native currency to be able to sign remark + const response = await fetch(`${import.meta.env.REACT_APP_ONBOARDING_API_URL}/getBalanceForSigning`, { + method: 'POST', + headers: { + Authorization: `Bearer ${authToken}`, + 'Content-Type': 'application/json', + }, + }) + + if (response.status !== 201) { + addOrUpdateTransaction({ + id: txIdSignRemark, + title: `Get ${balances?.native.currency.symbol}`, + status: 'failed', + args, + }) + setIsSubstrateTxLoading(false) + throw new Error('Insufficient funds') + } else { + addOrUpdateTransaction({ + id: txIdSignRemark, + title: `Get ${balances?.native.currency.symbol}`, + status: 'succeeded', + args, + }) + } + } + substrateMutation.execute(args) + } + + useEffect(() => { + const executePaymentInfo = async () => { + if (selectedAccount && selectedAccount.signer) { + const api = await centrifuge.connect(selectedAccount.address, selectedAccount.signer) + const paymentInfo = await lastValueFrom( + api.remark.signRemark([`Signed subscription agreement for pool: 12324565 tranche: 0xacbdefghijklmn`], { + paymentInfo: selectedAccount.address, + }) + ) + // @ts-expect-error + const txFee = paymentInfo.partialFee.toDecimal() + setExpectedTxFee(txFee) + } + } + executePaymentInfo() + }, [centrifuge, selectedAccount]) + const signEvmRemark = async (args: [message: string]) => { const txId = Math.random().toString(36).substr(2) setIsEvmTxLoading(true) @@ -71,5 +151,7 @@ export const useSignRemark = ( } } - return connectedType === 'evm' ? { execute: signEvmRemark, isLoading: isEvmTxLoading } : substrateMutation + return connectedType === 'evm' + ? { execute: signEvmRemark, isLoading: isEvmTxLoading } + : { execute: signSubstrateRemark, isLoading: isSubstrateTxLoading } } diff --git a/centrifuge-app/src/pages/Onboarding/queries/useVerifyBusiness.ts b/centrifuge-app/src/pages/Onboarding/queries/useVerifyBusiness.ts index 0df6bbde86..73420c14e7 100644 --- a/centrifuge-app/src/pages/Onboarding/queries/useVerifyBusiness.ts +++ b/centrifuge-app/src/pages/Onboarding/queries/useVerifyBusiness.ts @@ -33,7 +33,6 @@ export const useVerifyBusiness = () => { ? `${values.jurisdictionCode}_${values.regionCode}` : values.jurisdictionCode, dryRun: import.meta.env.REACT_APP_ONBOARDING_API_URL.includes('production') ? false : true, - manualReview: values?.manualReview ?? false, ...(values?.manualReview && poolId && trancheId && { poolId, trancheId }), }), headers: { diff --git a/onboarding-api/env-vars/altair.env b/onboarding-api/env-vars/altair.env index 3e3f70ddfd..41928185ad 100644 --- a/onboarding-api/env-vars/altair.env +++ b/onboarding-api/env-vars/altair.env @@ -1,5 +1,5 @@ REDIRECT_URL=https://app.altair.centrifuge.io -MEMBERLIST_ADMIN_PURE_PROXY='' +MEMBERLIST_ADMIN_PURE_PROXY=kALJqPUHFzDR2VkoQYWefPQyzjGzKznNny2smXGQpSf3aMw19 COLLATOR_WSS_URL=wss://fullnode.altair.centrifuge.io RELAY_WSS_URL=wss://kusama-rpc.polkadot.io INFURA_KEY=bf808e7d3d924fbeb74672d9341d0550 diff --git a/onboarding-api/env-vars/catalyst.env b/onboarding-api/env-vars/catalyst.env index 4ade4f2bf1..6a28036d28 100644 --- a/onboarding-api/env-vars/catalyst.env +++ b/onboarding-api/env-vars/catalyst.env @@ -1,5 +1,5 @@ REDIRECT_URL=https://app-catalyst.k-f.dev -MEMBERLIST_ADMIN_PURE_PROXY=4dn2oY5FAD1v5vQCWvVc7ErDstsPfjLKa39uHU9YgLNGdGsy +MEMBERLIST_ADMIN_PURE_PROXY=kALJqPUHFzDR2VkoQYWefPQyzjGzKznNny2smXGQpSf3aMw19 COLLATOR_WSS_URL=wss://fullnode.catalyst.cntrfg.com RELAY_WSS_URL=wss://rococo-rpc.polkadot.io INFURA_KEY=bf808e7d3d924fbeb74672d9341d0550 diff --git a/onboarding-api/env-vars/demo.env b/onboarding-api/env-vars/demo.env index 82a0e40826..38a44ff4bb 100644 --- a/onboarding-api/env-vars/demo.env +++ b/onboarding-api/env-vars/demo.env @@ -1,5 +1,5 @@ REDIRECT_URL=https://app-demo.k-f.dev -MEMBERLIST_ADMIN_PURE_PROXY=kAL8bY5Sijge4K8VbogBwjDfDFQN6ezXh24SonbXVNa1eUvK2 +MEMBERLIST_ADMIN_PURE_PROXY=kALJqPUHFzDR2VkoQYWefPQyzjGzKznNny2smXGQpSf3aMw19 COLLATOR_WSS_URL=wss://fullnode.demo.cntrfg.com RELAY_WSS_URL=wss://fullnode-relay.demo.cntrfg.com INFURA_KEY=bf808e7d3d924fbeb74672d9341d0550 diff --git a/onboarding-api/env-vars/development.env b/onboarding-api/env-vars/development.env index b31d96a689..b799327872 100644 --- a/onboarding-api/env-vars/development.env +++ b/onboarding-api/env-vars/development.env @@ -1,5 +1,5 @@ REDIRECT_URL=https://app-dev.k-f.dev -MEMBERLIST_ADMIN_PURE_PROXY=kAJJW74FALrFpNTet8PgS5f5JhepTHy2Vm3GCM51fhorPzTuF +MEMBERLIST_ADMIN_PURE_PROXY=kALJqPUHFzDR2VkoQYWefPQyzjGzKznNny2smXGQpSf3aMw19 COLLATOR_WSS_URL=wss://fullnode.development.cntrfg.com RELAY_WSS_URL=wss://fullnode-relay.development.cntrfg.com INFURA_KEY=bf808e7d3d924fbeb74672d9341d0550 diff --git a/onboarding-api/src/controllers/agreement/getBalanceForSigning.ts b/onboarding-api/src/controllers/agreement/getBalanceForSigning.ts new file mode 100644 index 0000000000..b412b676de --- /dev/null +++ b/onboarding-api/src/controllers/agreement/getBalanceForSigning.ts @@ -0,0 +1,21 @@ +import { Request, Response } from 'express' +import { checkBalanceBeforeSigningRemark } from '../../utils/centrifuge' +import { fetchUser } from '../../utils/fetchUser' +import { HttpError, reportHttpError } from '../../utils/httpError' + +export const getBalanceForSigningController = async (req: Request, res: Response) => { + try { + const { wallet } = req + const user = await fetchUser(wallet) + if (!user.globalSteps.verifyIdentity.completed) { + throw new HttpError(401, 'Unauthorized') + } + + await checkBalanceBeforeSigningRemark(wallet) + + return res.status(201).end() + } catch (e) { + const error = reportHttpError(e) + return res.status(error.code).send({ error: error.message }) + } +} diff --git a/onboarding-api/src/controllers/auth/authenticateWallet.ts b/onboarding-api/src/controllers/auth/authenticateWallet.ts index 63aad0a1af..d3c43487e6 100644 --- a/onboarding-api/src/controllers/auth/authenticateWallet.ts +++ b/onboarding-api/src/controllers/auth/authenticateWallet.ts @@ -3,7 +3,7 @@ import { Request, Response } from 'express' import * as jwt from 'jsonwebtoken' import { SiweMessage } from 'siwe' import { InferType, object, string } from 'yup' -import { centrifuge, isValidSubstrateAddress } from '../../utils/centrifuge' +import { getCentrifuge, isValidSubstrateAddress } from '../../utils/centrifuge' import { reportHttpError } from '../../utils/httpError' import { validateInput } from '../../utils/validateInput' @@ -52,7 +52,7 @@ export const authenticateWalletController = async ( const AUTHORIZED_ONBOARDING_PROXY_TYPES = ['Any', 'Invest', 'NonTransfer', 'NonProxy'] async function verifySubstrateWallet(req: Request, res: Response) { const { jw3t: token, nonce } = req.body - const { verified, payload } = await centrifuge.auth.verify(token!) + const { verified, payload } = await await getCentrifuge().auth.verify(token!) const onBehalfOf = payload?.on_behalf_of const address = payload.address @@ -68,7 +68,11 @@ async function verifySubstrateWallet(req: Request, res: Response) { res.clearCookie(`onboarding-auth-${address.toLowerCase()}`) if (verified && onBehalfOf) { - const isVerifiedProxy = await centrifuge.auth.verifyProxy(address, onBehalfOf, AUTHORIZED_ONBOARDING_PROXY_TYPES) + const isVerifiedProxy = await getCentrifuge().auth.verifyProxy( + address, + onBehalfOf, + AUTHORIZED_ONBOARDING_PROXY_TYPES + ) if (isVerifiedProxy.verified) { req.wallet.address = address } else if (verified && !onBehalfOf) { diff --git a/onboarding-api/src/controllers/kyb/manualKybCallback.ts b/onboarding-api/src/controllers/kyb/manualKybCallback.ts index d96462f8b3..770bb0d788 100644 --- a/onboarding-api/src/controllers/kyb/manualKybCallback.ts +++ b/onboarding-api/src/controllers/kyb/manualKybCallback.ts @@ -80,7 +80,7 @@ export const manualKybCallbackController = async ( throw new HttpError(400, 'Agreement not found') } - sendDocumentsMessage(wallet, query.poolId, query.trancheId, signedAgreement) + await sendDocumentsMessage(wallet, query.poolId, query.trancheId, signedAgreement) } return res.status(200).end() diff --git a/onboarding-api/src/controllers/kyb/verifyBusiness.ts b/onboarding-api/src/controllers/kyb/verifyBusiness.ts index 983b6ccbdb..09cb5ca5ea 100644 --- a/onboarding-api/src/controllers/kyb/verifyBusiness.ts +++ b/onboarding-api/src/controllers/kyb/verifyBusiness.ts @@ -3,7 +3,7 @@ import { bool, InferType, object, string } from 'yup' import { EntityUser, OnboardingUser, validateAndWriteToFirestore } from '../../database' import { sendVerifyEmailMessage } from '../../emails/sendVerifyEmailMessage' import { fetchUser } from '../../utils/fetchUser' -import { RESTRICTED_COUNTRY_CODES } from '../../utils/geographyCodes' +import { KYB_COUNTRY_CODES, RESTRICTED_COUNTRY_CODES } from '../../utils/geographyCodes' import { HttpError, reportHttpError } from '../../utils/httpError' import { shuftiProRequest } from '../../utils/shuftiProRequest' import { Subset } from '../../utils/types' @@ -17,7 +17,6 @@ const verifyBusinessInput = object({ jurisdictionCode: string() .required() .test((value) => !Object.keys(RESTRICTED_COUNTRY_CODES).includes(value!)), // country of incorporation - manualReview: bool().required(), poolId: string().optional(), trancheId: string().optional(), }) @@ -27,9 +26,9 @@ export const verifyBusinessController = async ( res: Response ) => { try { - const { body, wallet, protocol, headers } = req + const { body, wallet } = req await validateInput(body, verifyBusinessInput) - const { jurisdictionCode, registrationNumber, businessName, email, manualReview, dryRun } = body + const { jurisdictionCode, registrationNumber, businessName, email, dryRun } = body const userData = (await fetchUser(wallet, { suppressError: true })) as EntityUser @@ -45,13 +44,11 @@ export const verifyBusinessController = async ( } } - const MANUAL_KYB_REFERENCE = `MANUAL_KYB_REQUEST_${Math.random()}` - const user: EntityUser = { investorType: 'entity', address: null, kycReference: '', - manualKybReference: manualReview ? MANUAL_KYB_REFERENCE : null, + manualKybReference: null, wallet: [wallet], name: null, dateOfBirth: null, @@ -76,42 +73,12 @@ export const verifyBusinessController = async ( poolSteps: {}, } - await validateAndWriteToFirestore(wallet, user, 'entity') - - if (manualReview) { - const searchParams = new URLSearchParams({ - ...(body.poolId && body.trancheId && { poolId: body.poolId, trancheId: body.trancheId }), - }) - - const { origin, host } = headers - - if (!origin) throw new HttpError(400, 'Missing origin header') - - /* - * K_SERVICE is a GCP injected env variable that denotes the name of the google cloud function. - * This is needed in order to construct the callback url in production. More info: https://rb.gy/tqvig - */ - const callbackBaseUrl = - process.env.NODE_ENV === 'development' ? `${protocol}://${host}` : `https://${host}/${process.env.K_SERVICE}` - - const payloadKYB = { - manual_review: 1, - enable_extra_proofs: 1, - labels: ['proof_of_address', 'signed_and_dated_ownership_structure'], - verification_mode: 'any', - reference: MANUAL_KYB_REFERENCE, - email: body.email, - country: body.jurisdictionCode, - redirect_url: `${origin}/manual-kyb-redirect.html`, - callback_url: `${callbackBaseUrl}/manualKybCallback?${searchParams}`, - } - - const kyb = await shuftiProRequest(payloadKYB) - const freshUserData = await fetchUser(wallet) - - return res.status(200).send({ ...kyb, ...freshUserData }) + if (!(jurisdictionCode in KYB_COUNTRY_CODES)) { + return startManualKyb(req, res, user) } + await validateAndWriteToFirestore(wallet, user, 'entity') + const payloadAML = { reference: `BUSINESS_AML_REQUEST_${Math.random()}`, aml_for_businesses: { @@ -155,3 +122,44 @@ export const verifyBusinessController = async ( return res.status(error.code).send({ error: error.message }) } } + +// submits documents to shutfipro for manual review +// status changes made in shufti backoffice will trigger /manualKybCallback +const startManualKyb = async (req: Request, res: Response, user: EntityUser) => { + const { body, wallet, protocol, headers } = req + const MANUAL_KYB_REFERENCE = `MANUAL_KYB_REQUEST_${Math.random()}` + user.manualKybReference = MANUAL_KYB_REFERENCE + await validateAndWriteToFirestore(wallet, user, 'entity') + await sendVerifyEmailMessage(user, wallet) + + const searchParams = new URLSearchParams({ + ...(body.poolId && body.trancheId && { poolId: body.poolId, trancheId: body.trancheId }), + }) + + const { origin, host } = headers + + if (!origin) throw new HttpError(400, 'Missing origin header') + + /* + * K_SERVICE is a GCP injected env variable that denotes the name of the google cloud function. + * This is needed in order to construct the callback url in production. More info: https://rb.gy/tqvig + */ + const callbackBaseUrl = + process.env.NODE_ENV === 'development' ? `${protocol}://${host}` : `https://${host}/${process.env.K_SERVICE}` + + const payloadmanualKYB = { + manual_review: 1, + enable_extra_proofs: 1, + labels: ['proof_of_address', 'signed_and_dated_ownership_structure'], + verification_mode: 'any', + reference: MANUAL_KYB_REFERENCE, + email: body.email, + country: body.jurisdictionCode, + redirect_url: `${origin}/manual-kyb-redirect.html`, + callback_url: `${callbackBaseUrl}/manualKybCallback?${searchParams}`, + } + + const manualKyb = await shuftiProRequest(payloadmanualKYB) + const freshUserData = await fetchUser(wallet) + return res.status(200).send({ ...manualKyb, ...freshUserData }) +} diff --git a/onboarding-api/src/index.ts b/onboarding-api/src/index.ts index e4933d84cc..d3136091e4 100644 --- a/onboarding-api/src/index.ts +++ b/onboarding-api/src/index.ts @@ -1,6 +1,7 @@ const cookieParser = require('cookie-parser') import * as dotenv from 'dotenv' import express, { Express } from 'express' +import { getBalanceForSigningController } from './controllers/agreement/getBalanceForSigning' import { getSignedAgreementController } from './controllers/agreement/getSignedAgreement' import { authenticateWalletController } from './controllers/auth/authenticateWallet' import { generateNonceController } from './controllers/auth/generateNonce' @@ -64,6 +65,7 @@ onboarding.post('/updateInvestorStatus', updateInvestorStatusController) // getters onboarding.get('/getUser', verifyAuth, getUserController) onboarding.get('/getGlobalOnboardingStatus', getGlobalOnboardingStatusController) +onboarding.post('/getBalanceForSigning', verifyAuth, getBalanceForSigningController) onboarding.get('/getSignedAgreement', verifyAuth, getSignedAgreementController) onboarding.get('/getTaxInfo', verifyAuth, getTaxInfoController) diff --git a/onboarding-api/src/utils/annotateAgreementAndSignAsInvestor.ts b/onboarding-api/src/utils/annotateAgreementAndSignAsInvestor.ts index 3114ca8e93..617daa18cb 100644 --- a/onboarding-api/src/utils/annotateAgreementAndSignAsInvestor.ts +++ b/onboarding-api/src/utils/annotateAgreementAndSignAsInvestor.ts @@ -4,7 +4,7 @@ import { PDFDocument } from 'pdf-lib' import { InferType } from 'yup' import { signAndSendDocumentsInput } from '../controllers/emails/signAndSendDocuments' import { onboardingBucket } from '../database' -import { centrifuge } from './centrifuge' +import { getCentrifuge } from './centrifuge' import { getPoolById } from './getPoolById' import { HttpError } from './httpError' @@ -34,11 +34,11 @@ export const annotateAgreementAndSignAsInvestor = async ({ throw new HttpError(400, 'Signature page not found') } - const unsignedAgreementUrl = metadata?.onboarding?.agreements?.[trancheId] - ? centrifuge.metadata.parseMetadataUrl(metadata?.onboarding?.agreements?.[trancheId].ipfsHash) + const unsignedAgreementUrl = metadata?.onboarding?.agreements[trancheId] + ? getCentrifuge().metadata.parseMetadataUrl(metadata?.onboarding?.agreements?.[trancheId].uri) : wallet.network === 'substrate' - ? centrifuge.metadata.parseMetadataUrl(GENERIC_SUBSCRIPTION_AGREEMENT) - : '' + ? getCentrifuge().metadata.parseMetadataUrl(GENERIC_SUBSCRIPTION_AGREEMENT) + : null // tinlake pools that are closed for onboarding don't have agreements in their metadata if (!unsignedAgreementUrl) { diff --git a/onboarding-api/src/utils/centrifuge.ts b/onboarding-api/src/utils/centrifuge.ts index 3633c7012b..300ac8eb0d 100644 --- a/onboarding-api/src/utils/centrifuge.ts +++ b/onboarding-api/src/utils/centrifuge.ts @@ -1,27 +1,36 @@ -import Centrifuge from '@centrifuge/centrifuge-js' -import { ApiRx, WsProvider } from '@polkadot/api' +import Centrifuge, { CurrencyBalance } from '@centrifuge/centrifuge-js' import { Keyring } from '@polkadot/keyring' import { hexToU8a, isHex } from '@polkadot/util' import { cryptoWaitReady, decodeAddress, encodeAddress } from '@polkadot/util-crypto' -import { firstValueFrom, lastValueFrom, switchMap, takeWhile } from 'rxjs' +import { Request } from 'express' +import { combineLatest, firstValueFrom, lastValueFrom, switchMap, take, takeWhile } from 'rxjs' import { InferType } from 'yup' import { signAndSendDocumentsInput } from '../controllers/emails/signAndSendDocuments' import { HttpError, reportHttpError } from './httpError' const OneHundredYearsFromNow = Math.floor(Date.now() / 1000 + 100 * 365 * 24 * 60 * 60) -const PROXY_ADDRESS = process.env.MEMBERLIST_ADMIN_PURE_PROXY -export const centrifuge = new Centrifuge({ - network: 'centrifuge', - centrifugeWsUrl: process.env.COLLATOR_WSS_URL, - polkadotWsUrl: process.env.RELAY_WSS_URL, - printExtrinsics: true, -}) +export const getCentrifuge = () => + new Centrifuge({ + network: 'centrifuge', + centrifugeWsUrl: process.env.COLLATOR_WSS_URL, + polkadotWsUrl: process.env.RELAY_WSS_URL, + printExtrinsics: true, + }) + +export const getSigner = async () => { + await cryptoWaitReady() + const keyring = new Keyring({ type: 'sr25519', ss58Format: 2 }) + // the pure proxy controller (PURE_PROXY_CONTROLLER_SEED) is the wallet that controls the pure proxy being used to sign the transaction + // the pure proxy address (MEMBERLIST_ADMIN_PURE_PROXY) has to be given MemberListAdmin permissions on each pool before being able to whitelist investors + return keyring.addFromMnemonic(process.env.PURE_PROXY_CONTROLLER_SEED) +} export const getCentPoolById = async (poolId: string) => { - const pools = await firstValueFrom(centrifuge.pools.getPools()) + const cent = getCentrifuge() + const pools = await firstValueFrom(cent.pools.getPools()) const pool = pools.find((p) => p.id === poolId) - const metadata = await firstValueFrom(centrifuge.metadata.getMetadata(pool?.metadata!)) + const metadata = await firstValueFrom(cent.metadata.getMetadata(pool?.metadata!)) if (!metadata) { throw new Error(`Pool metadata not found for pool ${poolId}`) } @@ -29,12 +38,8 @@ export const getCentPoolById = async (poolId: string) => { } export const addCentInvestorToMemberList = async (walletAddress: string, poolId: string, trancheId: string) => { - await cryptoWaitReady() - const keyring = new Keyring({ type: 'sr25519', ss58Format: 2 }) - // the pure proxy controller (PURE_PROXY_CONTROLLER_SEED) is the wallet that controls the pure proxy being used to sign the transaction - // the pure proxy address (MEMBERLIST_ADMIN_PURE_PROXY) has to be given MemberListAdmin permissions on each pool before being able to whitelist investors - const signer = keyring.addFromMnemonic(process.env.PURE_PROXY_CONTROLLER_SEED) - const api = ApiRx.create({ provider: new WsProvider(process.env.COLLATOR_WSS_URL) }) + const signer = await getSigner() + const api = getCentrifuge().getApi() const tx = await lastValueFrom( api.pipe( switchMap((api) => { @@ -44,7 +49,7 @@ export const addCentInvestorToMemberList = async (walletAddress: string, poolId: { Pool: poolId }, { PoolRole: { TrancheInvestor: [trancheId, OneHundredYearsFromNow] } } ) - const proxiedSubmittable = api.tx.proxy.proxy(PROXY_ADDRESS, undefined, submittable) + const proxiedSubmittable = api.tx.proxy.proxy(process.env.MEMBERLIST_ADMIN_PURE_PROXY, undefined, submittable) return proxiedSubmittable.signAndSend(signer) }), takeWhile(({ events, isFinalized }) => { @@ -93,7 +98,7 @@ export const validateRemark = async ( ) => { try { await firstValueFrom( - centrifuge.remark.validateRemark(transactionInfo.blockNumber, transactionInfo.txHash, expectedRemark) + getCentrifuge().remark.validateRemark(transactionInfo.blockNumber, transactionInfo.txHash, expectedRemark) ) } catch (error) { reportHttpError(error) @@ -101,6 +106,62 @@ export const validateRemark = async ( } } +export const checkBalanceBeforeSigningRemark = async (wallet: Request['wallet']) => { + const signer = await getSigner() + const $api = getCentrifuge().getApi() + const $paymentInfo = $api + .pipe(switchMap((api) => api.tx.system.remarkWithEvent('Signing for pool').paymentInfo(wallet.address))) + .pipe(take(1)) + const $nativeBalance = $api.pipe(switchMap((api) => api.query.system.account(wallet.address))).pipe(take(1)) + const tx = await lastValueFrom( + combineLatest([$api, $paymentInfo, $nativeBalance]).pipe( + switchMap(([api, paymentInfo, nativeBalance]) => { + const currentNativeBalance = new CurrencyBalance( + (nativeBalance as any).data.free.toString(), + api.registry.chainDecimals[0] + ) + const txFee = new CurrencyBalance(paymentInfo.partialFee.toString(), api.registry.chainDecimals[0]) + + if (currentNativeBalance.gte(txFee.muln(1.1))) { + throw new HttpError(400, 'Bad request: balance exceeded') + } + + // add 10% buffer to the transaction fee + const submittable = api.tx.tokens.transfer({ Id: wallet.address }, 'Native', txFee.add(txFee.muln(1.1))) + const proxiedSubmittable = api.tx.proxy.proxy(process.env.MEMBERLIST_ADMIN_PURE_PROXY, undefined, submittable) + return proxiedSubmittable.signAndSend(signer) + }), + takeWhile(({ events, isFinalized }) => { + if (events.length > 0) { + events.forEach(({ event }) => { + const proxyResult = event.data[0]?.toHuman() + if (event.method === 'ProxyExecuted' && proxyResult === 'Ok') { + console.log(`Executed proxy for transfer`, { walletAddress: wallet.address, proxyResult }) + } + if (event.method === 'ExtrinsicFailed') { + console.log(`Extrinsic for transfer failed`, { walletAddress: wallet.address, proxyResult }) + throw new HttpError(400, 'Bad request: extrinsic failed') + } + if ( + event.method === 'ProxyExecuted' && + proxyResult && + typeof proxyResult === 'object' && + 'Err' in proxyResult + ) { + console.log(`An error occured executing proxy to transfer native currency`, { + proxyResult: proxyResult.Err, + }) + throw new HttpError(400, 'Bad request: proxy failed') + } + }) + } + return !isFinalized + }) + ) + ) + return tx.txHash.toString() +} + // https://polkadot.js.org/docs/util-crypto/examples/validate-address/ export const isValidSubstrateAddress = (address: string) => { try { diff --git a/onboarding-api/src/utils/geographyCodes.ts b/onboarding-api/src/utils/geographyCodes.ts index 0b85e93be6..2700a492ab 100644 --- a/onboarding-api/src/utils/geographyCodes.ts +++ b/onboarding-api/src/utils/geographyCodes.ts @@ -15,3 +15,92 @@ export const RESTRICTED_COUNTRY_CODES = { ve: 'Venezuela', zw: 'Zimbabwe', } + +// countries supported by shuftipro KYB +export const KYB_COUNTRY_CODES = { + ae_az: 'Abu Dhabi (UAE)', + al: 'Albania', + aw: 'Aruba', + au: 'Australia', + bs: 'Bahamas', + bh: 'Bahrain', + bd: 'Bangladesh', + bb: 'Barbados', + by: 'Belarus', + be: 'Belgium', + bz: 'Belize', + bm: 'Bermuda', + bo: 'Bolivia', + br: 'Brazil', + bg: 'Bulgaria', + kh: 'Cambodia', + ca: 'Canada', + hr: 'Croatia', + cw: 'Curaçao', + cy: 'Cyprus', + dk: 'Denmark', + do: 'Dominican Republic', + ae_du: 'Dubai (UAE)', + fi: 'Finland', + fr: 'France', + gf: 'French Guiana', + de: 'Germany', + gi: 'Gibraltar', + gr: 'Greece', + gl: 'Greenland', + gp: 'Guadeloupe', + gg: 'Guernsey', + hk: 'Hong Kong', + is: 'Iceland', + in: 'India', + ir: 'Iran', + ie: 'Ireland', + im: 'Isle of Man', + il: 'Israel', + jm: 'Jamaica', + jp: 'Japan', + je: 'Jersey', + lv: 'Latvia', + li: 'Liechtenstein', + lu: 'Luxembourg', + my: 'Malaysia', + mt: 'Malta', + mq: 'Martinique', + mu: 'Mauritius', + yt: 'Mayotte', + mx: 'Mexico', + md: 'Moldova', + me: 'Montenegro', + mm: 'Myanmar', + nl: 'Netherlands', + nz: 'New Zealand', + no: 'Norway', + pk: 'Pakistan', + pa: 'Panama', + pl: 'Poland', + pr: 'Puerto Rico', + ro: 'Romania', + rw: 'Rwanda', + re: 'Réunion', + bl: 'Saint Barthélemy', + mf: 'Saint Martin (French part)', + pm: 'Saint Pierre and Miquelon', + sg: 'Singapore', + sk: 'Slovakia', + si: 'Slovenia', + za: 'South Africa', + es: 'Spain', + se: 'Sweden', + ch: 'Switzerland', + tj: 'Tajikistan', + tz: 'Tanzania', + th: 'Thailand', + to: 'Tonga', + tn: 'Tunisia', + ug: 'Uganda', + ua: 'Ukraine', + gb: 'United Kingdom', + us: 'United States', + vu: 'Vanuatu', + vn: 'Viet Nam', +} diff --git a/onboarding-api/src/utils/tinlake.ts b/onboarding-api/src/utils/tinlake.ts index a9790adf08..a5073a663e 100644 --- a/onboarding-api/src/utils/tinlake.ts +++ b/onboarding-api/src/utils/tinlake.ts @@ -7,7 +7,7 @@ import { InferType } from 'yup' import { signAndSendDocumentsInput } from '../controllers/emails/signAndSendDocuments' import MemberListAdminAbi from './abi/MemberListAdmin.abi.json' import RemarkerAbi from './abi/Remarker.abi.json' -import { centrifuge } from './centrifuge' +import { getCentrifuge } from './centrifuge' import { HttpError, reportHttpError } from './httpError' export interface LaunchingPool extends BasePool {} @@ -127,7 +127,7 @@ function parsePoolsMetadata(poolsMetadata): { active: ActivePool[] } { export const getTinlakePoolById = async (poolId: string) => { const uri = ethConfig.poolsHash - const data = (await lastValueFrom(centrifuge.metadata.getMetadata(uri))) as PoolMetadataDetails + const data = (await lastValueFrom(getCentrifuge().metadata.getMetadata(uri))) as PoolMetadataDetails const pools = parsePoolsMetadata(Object.values(data)) const poolData = pools.active.find((p) => p.addresses.ROOT_CONTRACT === poolId)