diff --git a/.gitignore b/.gitignore index 601de9aa1..1759a0e6f 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,7 @@ dist-ssr .env.sentry-build-plugin # testing artifacts -*/failedRampStateRecovery.json -*/lastRampState.json -*/lastRampStateOnramp.json +**/failedRampStateRecovery.json +**/lastRampState.json +**/lastRampStateOnramp.json diff --git a/api/.eslintrc b/api/.eslintrc deleted file mode 100644 index ccbfcfc65..000000000 --- a/api/.eslintrc +++ /dev/null @@ -1,38 +0,0 @@ -{ - "parser": "@typescript-eslint/parser", - "plugins": ["@typescript-eslint"], - "rules": { - "no-console": 0, - "no-underscore-dangle": 0, - "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "next" }], - "no-use-before-define": "off", - "@typescript-eslint/no-use-before-define": ["error", { "variables": false }], - "no-multi-str": 0, - "import/extensions": [ - "error", - "ignorePackages", - { - "ts": "never" - } - ] - }, - "env": { - "es2020": true, - "node": true, - "mocha": true - }, - "parserOptions": { - "ecmaVersion": 8, - "sourceType": "module", - "project": "./tsconfig.json" - }, - "extends": ["airbnb-base", "prettier", "plugin:@typescript-eslint/recommended"], - "settings": { - "import/resolver": { - "node": { - "extensions": [".ts"] - } - } - } -} diff --git a/api/.eslintrc.js b/api/.eslintrc.js new file mode 100644 index 000000000..c5698455b --- /dev/null +++ b/api/.eslintrc.js @@ -0,0 +1,39 @@ +module.exports = { + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint'], + rules: { + 'no-console': 0, + 'no-underscore-dangle': 0, + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: 'next' }], + 'no-use-before-define': 'off', + '@typescript-eslint/no-use-before-define': ['error', { variables: false }], + 'no-multi-str': 0, + 'import/extensions': [ + 'error', + 'ignorePackages', + { + ts: 'never', + }, + ], + }, + env: { + es2020: true, + node: true, + mocha: true, + }, + parserOptions: { + ecmaVersion: 8, // Note: ecmaVersion 8 is ES2017. Consider updating if using newer features. + sourceType: 'module', + project: './tsconfig.json', // Path relative to tsconfigRootDir + tsconfigRootDir: __dirname, // Correctly resolves the directory of this config file + }, + extends: ['airbnb-base', 'prettier', 'plugin:@typescript-eslint/recommended'], + settings: { + 'import/resolver': { + node: { + extensions: ['.ts'], + }, + }, + }, +}; diff --git a/api/bun.lockb b/api/bun.lockb index 66a8e5dab..470048535 100755 Binary files a/api/bun.lockb and b/api/bun.lockb differ diff --git a/api/src/api/controllers/brla.controller.ts b/api/src/api/controllers/brla.controller.ts index f24557593..829ce0956 100644 --- a/api/src/api/controllers/brla.controller.ts +++ b/api/src/api/controllers/brla.controller.ts @@ -5,6 +5,7 @@ import { BrlaApiService } from '../services/brla/brlaApiService'; import { eventPoller } from '../..'; import { BrlaTeleportService } from '../services/brla/brlaTeleportService'; import { generateReferenceLabel } from '../services/brla/helpers'; +import logger from '../../config/logger'; // BRLA API requires the date in the format YYYY-MMM-DD function convertDateToBRLAFormat(dateNumber: number) { @@ -351,7 +352,7 @@ export const triggerPayIn = async ( res.status(400).json({ error: 'taxId invalid' }); return; } - console.log('Requesting teleport:', subaccount.id, amount, receiverAddress); + logger.info('Requesting teleport:', subaccount.id, amount, receiverAddress); const teleportService = BrlaTeleportService.getInstance(); await teleportService.requestTeleport(subaccount.id, Number(amount), receiverAddress); diff --git a/api/src/api/controllers/pendulum.controller.ts b/api/src/api/controllers/pendulum.controller.ts index 1cf593d63..13e4c47b6 100644 --- a/api/src/api/controllers/pendulum.controller.ts +++ b/api/src/api/controllers/pendulum.controller.ts @@ -13,6 +13,7 @@ import { fundEphemeralAccount, getFundingData } from '../services/pendulum/pendu import { ChainDecimals, multiplyByPowerOfTen, nativeToDecimal } from '../services/pendulum/helpers'; import { SlackNotifier } from '../services/slack.service'; import { ApiManager } from '../services/pendulum/apiManager'; +import logger from '../../config/logger'; // DEPRECATED export const fundEphemeralAccountController = async ( @@ -60,7 +61,7 @@ export const sendStatusWithPk = async (): Promise => { // Wait for all required token balances check. await Promise.all( Object.entries(TOKEN_CONFIG).map(async ([token, tokenConfig]: [string, StellarTokenConfig | XCMTokenConfig]) => { - console.log(`Checking token ${token} balance...`); + logger.info(`Checking token ${token} balance...`); if (!tokenConfig.pendulumCurrencyId) { throw new Error(`Token ${token} does not have a currency id.`); } @@ -80,7 +81,7 @@ export const sendStatusWithPk = async (): Promise => { if (remainingMaxSubsidiesAvailable.lt(SUBSIDY_MINIMUM_RATIO_FUND_UNITS)) { isTokensSufficient = false; - console.log(`Token ${token} balance is insufficient.`); + logger.info(`Token ${token} balance is insufficient.`); const tokenDecimals = 'decimals' in tokenConfig ? tokenConfig.decimals : ChainDecimals; slackNotifier.sendMessage({ diff --git a/api/src/api/controllers/subsidize.controller.ts b/api/src/api/controllers/subsidize.controller.ts index eff87b10e..7aba7bb53 100644 --- a/api/src/api/controllers/subsidize.controller.ts +++ b/api/src/api/controllers/subsidize.controller.ts @@ -6,6 +6,7 @@ import { StellarTokenConfig, TOKEN_CONFIG, XCMTokenConfig } from 'shared'; import { SubsidizeEndpoints } from 'shared/src/endpoints/subsidize.endpoints'; import { PENDULUM_FUNDING_SEED } from '../../constants/constants'; import { ApiManager } from '../services/pendulum/apiManager'; +import logger from '../../config/logger'; export const getFundingAccount = () => { if (!PENDULUM_FUNDING_SEED) { @@ -39,7 +40,7 @@ export const subsidizePreSwap = async ( ): Promise => { try { const { address, amountRaw, tokenToSubsidize } = req.body; - console.log('Subsidize pre swap', address, amountRaw, tokenToSubsidize); + logger.info('Subsidize pre swap', address, amountRaw, tokenToSubsidize); const config = getPendulumCurrencyConfig(tokenToSubsidize); @@ -72,7 +73,7 @@ export const subsidizePostSwap = async ( ): Promise => { try { const { address, amountRaw, token } = req.body; - console.log('Subsidize post swap', address, amountRaw, token); + logger.info('Subsidize post swap', address, amountRaw, token); const config = getPendulumCurrencyConfig(token); diff --git a/api/src/api/helpers/quote.ts b/api/src/api/helpers/quote.ts index cc28e89d1..7d3890115 100644 --- a/api/src/api/helpers/quote.ts +++ b/api/src/api/helpers/quote.ts @@ -8,11 +8,11 @@ export function calculateTotalReceive(toAmount: Big, outputCurrency: RampCurrenc const fixedFees = new Big( outputTokenDetails.offrampFeesFixedComponent ? outputTokenDetails.offrampFeesFixedComponent : 0, ); - const fees = toAmount.mul(feeBasisPoints).div(10000).add(fixedFees).round(6, 0); + const fees = toAmount.mul(feeBasisPoints).div(10000).add(fixedFees).round(2, 1); const totalReceiveRaw = toAmount.minus(fees); if (totalReceiveRaw.gt(0)) { - return totalReceiveRaw.toFixed(6, 0); + return totalReceiveRaw.toFixed(2, 0); } return '0'; } diff --git a/api/src/api/services/brla/brlaTeleportService.ts b/api/src/api/services/brla/brlaTeleportService.ts index 26b4f0043..3a94c791f 100644 --- a/api/src/api/services/brla/brlaTeleportService.ts +++ b/api/src/api/services/brla/brlaTeleportService.ts @@ -1,6 +1,7 @@ import { BrlaApiService } from './brlaApiService'; import { FastQuoteQueryParams, BrlaSupportedChain, OnchainLog, SmartContractOperationType } from './types'; import { verifyReferenceLabel } from './helpers'; +import logger from '../../../config/logger'; // This service is used to request and keep tracks of teleports (transfers) from BRLA's // controlled accounts. @@ -60,7 +61,7 @@ export class BrlaTeleportService { status: 'claimed', receiverAddress, }; - console.log('Requesting teleport:', teleport); + logger.info('Requesting teleport:', teleport); this.teleports.set(subaccountId, teleport); this.maybeStartPeriodicChecks(); } @@ -77,7 +78,7 @@ export class BrlaTeleportService { throw new Error('Teleport not in arrived state.'); } - console.log('Starting teleport:', teleport); + logger.info('Starting teleport:', teleport); const fastQuoteParams: FastQuoteQueryParams = { subaccountId: teleport.subaccountId, operation: 'swap', @@ -101,7 +102,7 @@ export class BrlaTeleportService { this.maybeStartPeriodicChecks(); } catch (e) { - console.log('Error starting teleport:', e); + logger.error('Error starting teleport:', e); this.teleports.set(subaccountId, { ...teleport, status: 'failed' }); } } @@ -141,7 +142,7 @@ export class BrlaTeleportService { if (lastContractOp.operationName === SmartContractOperationType.MINT && lastContractOp.executed === true) { this.completedTeleports.set(subaccountId, { ...teleport, status: 'completed' }); - console.log('Teleport completed:', teleport); + logger.info('Teleport completed:', teleport); this.teleports.delete(subaccountId); } } diff --git a/api/src/api/services/brla/webhooks.ts b/api/src/api/services/brla/webhooks.ts index 69f40004f..611fd37cd 100644 --- a/api/src/api/services/brla/webhooks.ts +++ b/api/src/api/services/brla/webhooks.ts @@ -1,5 +1,6 @@ import { WEBHOOKS_CACHE_URL } from '../../../constants/constants'; import { BrlaApiService } from './brlaApiService'; +import logger from '../../../config/logger'; type SubscriptionType = 'BURN' | 'BALANCE-UPDATE' | 'MONEY-TRANSFER' | 'MINT' | 'KYC'; @@ -97,7 +98,7 @@ export class EventPoller { // async acknowledge events if (eventsToAcknowledge.length > 0) { this.brlaApiService.acknowledgeEvents(eventsToAcknowledge.flatMap((event) => event.id)).catch((error) => { - console.log('Poll: Error while acknowledging events: ', error); + logger.error('Poll: Error while acknowledging events: ', error); }); } } diff --git a/api/src/api/services/moonbeam/balance.ts b/api/src/api/services/moonbeam/balance.ts index 42f937b19..4095df98c 100644 --- a/api/src/api/services/moonbeam/balance.ts +++ b/api/src/api/services/moonbeam/balance.ts @@ -10,6 +10,7 @@ import { MOONBEAM_EPHEMERAL_STARTING_BALANCE_UNITS, MOONBEAM_FUNDING_PRIVATE_KEY import { multiplyByPowerOfTen } from '../pendulum/helpers'; import { privateKeyToAccount } from 'viem/accounts'; import { createMoonbeamClientsAndConfig } from './createServices'; +import logger from '../../../config/logger'; export function checkMoonbeamBalancePeriodically( tokenAddress: string, @@ -34,7 +35,7 @@ export function checkMoonbeamBalancePeriodically( args: [brlaEvmAddress], })) as string; - console.log(`Moonbeam balance check: ${result.toString()} / ${amountDesiredRaw.toString()}`); + logger.info(`Moonbeam balance check: ${result.toString()} / ${amountDesiredRaw.toString()}`); const someBalanceBig = new Big(result.toString()); const amountDesiredUnitsBig = new Big(amountDesiredRaw); diff --git a/api/src/api/services/pendulum/apiManager.ts b/api/src/api/services/pendulum/apiManager.ts index 894880e2f..7b83ec3e4 100644 --- a/api/src/api/services/pendulum/apiManager.ts +++ b/api/src/api/services/pendulum/apiManager.ts @@ -2,6 +2,7 @@ import { ApiPromise, WsProvider } from '@polkadot/api'; import { SubmittableExtrinsic } from '@polkadot/api/types'; import { ISubmittableResult } from '@polkadot/types/types'; import { KeyringPair } from '@polkadot/keyring/types'; +import logger from '../../../config/logger'; export type SubstrateApiNetwork = 'assethub' | 'pendulum' | 'moonbeam'; @@ -94,10 +95,10 @@ export class ApiManager { public async populateApi(networkName: SubstrateApiNetwork): Promise { const network = this.getNetworkConfig(networkName); - console.log(`Connecting to node ${network.wsUrl}...`); + logger.info(`Connecting to node ${network.wsUrl}...`); const newApi = await this.connectApi(networkName); this.apiInstances.set(networkName, newApi); - console.log(`Connected to node ${network.wsUrl}`); + logger.info(`Connected to node ${network.wsUrl}`); if (!newApi.api.isConnected) await newApi.api.connect(); await newApi.api.isReady; @@ -123,7 +124,7 @@ export class ApiManager { const previousSpecVersion = this.previousSpecVersions.get(networkName) ?? 0; if (currentSpecVersion !== previousSpecVersion) { - console.log(`Spec version changed for ${networkName}, refreshing the api...`); + logger.info(`Spec version changed for ${networkName}, refreshing the api...`); return await this.populateApi(networkName); } @@ -158,7 +159,7 @@ export class ApiManager { return nonceRpc; } - console.log( + logger.info( `Nonce mismatch detected on ${networkName}. RPC: ${nonceRpc}, ApiManager: ${lastUsedNonce}, sending transaction with nonce ${ lastUsedNonce + 1 }`, @@ -184,12 +185,12 @@ export class ApiManager { try { const nonce = await this.getNonce(senderKeypair, networkName); - console.log(`Sending transaction on ${networkName} with nonce ${nonce}`); + logger.info(`Sending transaction on ${networkName} with nonce ${nonce}`); return call.signAndSend(senderKeypair, { nonce }); } catch (initialError: any) { // Only retry if the error is regarding bad signature error if (initialError.name === 'RpcError' && initialError.message.includes('Transaction has a bad signature')) { - console.log( + logger.info( `Bad signature error encountered while sending transaction on ${networkName}, attempting to refresh the api...`, ); diff --git a/api/src/api/services/phases/handlers/brla-payout-moonbeam-handler.ts b/api/src/api/services/phases/handlers/brla-payout-moonbeam-handler.ts index 70fe9ed6c..bccffcdda 100644 --- a/api/src/api/services/phases/handlers/brla-payout-moonbeam-handler.ts +++ b/api/src/api/services/phases/handlers/brla-payout-moonbeam-handler.ts @@ -6,6 +6,7 @@ import { StateMetadata } from '../meta-state-types'; import { BasePhaseHandler } from '../base-phase-handler'; import { BrlaApiService } from '../../brla/brlaApiService'; import { checkMoonbeamBalancePeriodically } from '../../moonbeam/balance'; +import logger from '../../../../config/logger'; export class BrlaPayoutOnMoonbeamPhaseHandler extends BasePhaseHandler { public getPhaseName(): RampPhase { @@ -49,7 +50,7 @@ export class BrlaPayoutOnMoonbeamPhaseHandler extends BasePhaseHandler { if (balanceCheckError.message === 'Balance did not meet the limit within the specified time') { throw new Error(`BrlaPayoutOnMoonbeamPhaseHandler: balanceCheckError ${balanceCheckError.message}`); } else { - console.log('Error checking Moonbeam balance:', balanceCheckError); + logger.error('Error checking Moonbeam balance:', balanceCheckError); throw new Error(`Error checking Moonbeam balance`); } } diff --git a/api/src/api/services/phases/handlers/brla-teleport-handler.ts b/api/src/api/services/phases/handlers/brla-teleport-handler.ts index 14bb568ce..cb473c6ae 100644 --- a/api/src/api/services/phases/handlers/brla-teleport-handler.ts +++ b/api/src/api/services/phases/handlers/brla-teleport-handler.ts @@ -7,6 +7,7 @@ import { BasePhaseHandler } from '../base-phase-handler'; import { BrlaApiService } from '../../brla/brlaApiService'; import { checkMoonbeamBalancePeriodically } from '../../moonbeam/balance'; import { BrlaTeleportService } from '../../brla/brlaTeleportService'; +import logger from '../../../../config/logger'; export class BrlaTeleportPhaseHandler extends BasePhaseHandler { public getPhaseName(): RampPhase { @@ -30,7 +31,7 @@ export class BrlaTeleportPhaseHandler extends BasePhaseHandler { if (!subaccount) { throw new Error('Subaccount not found'); } - console.log('Requesting teleport:', subaccount.id, inputAmountBrla, moonbeamEphemeralAddress); + logger.info('Requesting teleport:', subaccount.id, inputAmountBrla, moonbeamEphemeralAddress); const teleportService = BrlaTeleportService.getInstance(); await teleportService.requestTeleport( subaccount.id, @@ -62,7 +63,7 @@ export class BrlaTeleportPhaseHandler extends BasePhaseHandler { if (balanceCheckError.message === 'Balance did not meet the limit within the specified time') { throw new Error(`BrlaTeleportPhaseHandler: balanceCheckError ${balanceCheckError.message}`); } else { - console.log('Error checking Moonbeam balance:', balanceCheckError); + logger.error('Error checking Moonbeam balance:', balanceCheckError); throw new Error(`BrlaTeleportPhaseHandler: Error checking Moonbeam balance`); } } diff --git a/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts b/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts index 50aae14d3..9db1ed38e 100644 --- a/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts +++ b/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts @@ -10,6 +10,7 @@ import { multiplyByPowerOfTen } from '../../pendulum/helpers'; import { GLMR_FUNDING_AMOUNT_RAW, PENDULUM_EPHEMERAL_STARTING_BALANCE_UNITS } from '../../../../constants/constants'; import { TOKEN_CONFIG } from 'shared'; import { fundMoonbeamEphemeralAccount } from '../../moonbeam/balance'; +import logger from '../../../../config/logger'; export class FundEphemeralPhaseHandler extends BasePhaseHandler { public getPhaseName(): RampPhase { @@ -39,7 +40,7 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { } if (!isPendulumFunded) { - console.log('Funding pen ephemeral...'); + logger.info('Funding pen ephemeral...'); if (state.type === 'on' && state.to !== 'assethub') { await fundEphemeralAccount('pendulum', pendulumEphemeralAddress, true); } else if (state.state.outputCurrency === FiatToken.BRL) { @@ -48,11 +49,11 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { await fundEphemeralAccount('pendulum', pendulumEphemeralAddress, false); } } else { - console.log('Pendulum ephemeral address already funded.'); + logger.info('Pendulum ephemeral address already funded.'); } if (state.type === 'on' && !isMoonbeamFunded) { - console.log('Funding moonbeam ephemeral...'); + logger.info('Funding moonbeam ephemeral...'); await fundMoonbeamEphemeralAccount(moonbeamEphemeralAddress); } } catch (e) { diff --git a/api/src/api/services/phases/handlers/initial-phase-handler.ts b/api/src/api/services/phases/handlers/initial-phase-handler.ts index 39ffee476..619e738a3 100644 --- a/api/src/api/services/phases/handlers/initial-phase-handler.ts +++ b/api/src/api/services/phases/handlers/initial-phase-handler.ts @@ -1,12 +1,12 @@ import { HORIZON_URL, RampPhase } from 'shared'; +import { Transaction, Horizon, NetworkError, Networks } from 'stellar-sdk'; import { BasePhaseHandler } from '../base-phase-handler'; import RampState from '../../../../models/rampState.model'; import logger from '../../../../config/logger'; -import { Transaction } from 'stellar-sdk'; -import { Horizon, NetworkError, Networks } from 'stellar-sdk'; export const horizonServer = new Horizon.Server(HORIZON_URL); const NETWORK_PASSPHRASE = Networks.PUBLIC; + /** * Handler for the initial phase */ @@ -26,31 +26,45 @@ export class InitialPhaseHandler extends BasePhaseHandler { protected async executePhase(state: RampState): Promise { logger.info(`Executing initial phase for ramp ${state.id}`); - //Only stellar case requires an initial operation, sending the create ephemeral transaction + // Check if signed_transactions are present for offramps. If they are not, return early. + if (state.type === 'off' && (state.presignedTxs === null || state.presignedTxs.length === 0)) { + throw new Error('InitialPhaseHandler: No signed transactions found. Cannot proceed.'); + } + + // Only stellar case requires an initial operation, sending the create ephemeral transaction if (state.state.stellarTarget) { - try { - const { txData: stellarCreationTransactionXDR } = this.getPresignedTransaction(state, 'stellarCreateAccount'); + const { txData: stellarCreationTransactionXDR } = this.getPresignedTransaction(state, 'stellarCreateAccount'); + if (typeof stellarCreationTransactionXDR !== 'string') { + throw new Error( + 'InitialPhaseHandler: `stellarCreateAccount` transaction is not a string -> not an encoded Stellar transaction.', + ); + } + try { const stellarCreationTransaction = new Transaction(stellarCreationTransactionXDR, NETWORK_PASSPHRASE); await horizonServer.submitTransaction(stellarCreationTransaction); return this.transitionToNextPhase(state, 'fundEphemeral'); } catch (e) { - const horizonError = e as { response: { data: { extras: any } } }; - console.log( - `Could not submit the stellar account creation transaction ${JSON.stringify( - horizonError.response.data.extras.result_codes, - )}`, - ); + const horizonError = e as NetworkError; + if (horizonError.response.data?.status === 400) { + logger.info( + `Could not submit the stellar account creation transaction ${JSON.stringify( + horizonError.response.data.extras.result_codes, + )}`, + ); - // TODO this error may need adjustment, as the `tx_bad_seq` may be due to parallel ramps and ephemeral creations. - if (horizonError.response.data.extras.result_codes.transaction === 'tx_bad_seq') { - console.log('Recovery mode: Creation already performed.'); + // TODO this error may need adjustment, as the `tx_bad_seq` may be due to parallel ramps and ephemeral creations. + if (horizonError.response.data.extras.result_codes.transaction === 'tx_bad_seq') { + logger.info('Recovery mode: Creation already performed.'); - return this.transitionToNextPhase(state, 'fundEphemeral'); - } else { + return this.transitionToNextPhase(state, 'fundEphemeral'); + } console.error(horizonError.response.data.extras); throw new Error('Could not submit the stellar creation transaction'); + } else { + console.error(horizonError.response.data); + throw new Error('Could not submit the stellar creation transaction'); } } } diff --git a/api/src/api/services/phases/handlers/moonbeam-to-pendulum-handler.ts b/api/src/api/services/phases/handlers/moonbeam-to-pendulum-handler.ts index 0c7b77765..505369c74 100644 --- a/api/src/api/services/phases/handlers/moonbeam-to-pendulum-handler.ts +++ b/api/src/api/services/phases/handlers/moonbeam-to-pendulum-handler.ts @@ -16,6 +16,7 @@ import { MOONBEAM_EXECUTOR_PRIVATE_KEY, MOONBEAM_RECEIVER_CONTRACT_ADDRESS } fro import { createMoonbeamClientsAndConfig } from '../../moonbeam/createServices'; import splitReceiverABI from '../../../../../../mooncontracts/splitReceiverABI.json'; import { waitUntilTrue } from '../../../helpers/functions'; +import logger from '../../../../config/logger'; export class MoonbeamToPendulumPhaseHandler extends BasePhaseHandler { public getPhaseName(): RampPhase { @@ -76,7 +77,7 @@ export class MoonbeamToPendulumPhaseHandler extends BasePhaseHandler { await waitUntilTrue(isHashRegisteredInSplitReceiver); } } catch (e) { - console.log(e); + logger.error(e); throw new Error('MoonbeamToPendulumPhaseHandler: Failed to wait for hash registration in split receiver.'); } diff --git a/api/src/api/services/phases/handlers/moonbeam-to-pendulum-xcm-handler.ts b/api/src/api/services/phases/handlers/moonbeam-to-pendulum-xcm-handler.ts index 3ffef4762..20223e52b 100644 --- a/api/src/api/services/phases/handlers/moonbeam-to-pendulum-xcm-handler.ts +++ b/api/src/api/services/phases/handlers/moonbeam-to-pendulum-xcm-handler.ts @@ -7,6 +7,7 @@ import { StateMetadata } from '../meta-state-types'; import { ApiManager } from '../../pendulum/apiManager'; import { waitUntilTrue } from '../../../helpers/functions'; import { submitMoonbeamXcm, submitXcm } from '../../xcm/send'; +import logger from '../../../../config/logger'; export class MoonbeamToPendulumXcmPhaseHandler extends BasePhaseHandler { public getPhaseName(): RampPhase { @@ -55,7 +56,7 @@ export class MoonbeamToPendulumXcmPhaseHandler extends BasePhaseHandler { } try { - console.log('waiting for token to arrive on pendulum...'); + logger.info('waiting for token to arrive on pendulum...'); await waitUntilTrue(didInputTokenArrivedOnPendulum, 5000); } catch (e) { console.error('Error while waiting for transaction receipt:', e); diff --git a/api/src/api/services/phases/handlers/nabla-approve-handler.ts b/api/src/api/services/phases/handlers/nabla-approve-handler.ts index 6ddf2f545..332aae7b8 100644 --- a/api/src/api/services/phases/handlers/nabla-approve-handler.ts +++ b/api/src/api/services/phases/handlers/nabla-approve-handler.ts @@ -4,6 +4,7 @@ import { BasePhaseHandler } from '../base-phase-handler'; import RampState from '../../../../models/rampState.model'; import { ApiManager } from '../../pendulum/apiManager'; +import logger from '../../../../config/logger'; export class NablaApprovePhaseHandler extends BasePhaseHandler { public getPhaseName(): RampPhase { @@ -30,7 +31,7 @@ export class NablaApprovePhaseHandler extends BasePhaseHandler { const result = await submitExtrinsic(approvalExtrinsic); if (result.status.type === 'error') { - console.log(`Could not approve token: ${result.status.error.toString()}`); + logger.error(`Could not approve token: ${result.status.error.toString()}`); throw new Error('Could not approve token'); } @@ -45,7 +46,7 @@ export class NablaApprovePhaseHandler extends BasePhaseHandler { } else { errorMessage = 'Something went wrong'; } - console.log(`Could not approve the required amount of token: ${errorMessage}`); + logger.error(`Could not approve the required amount of token: ${errorMessage}`); throw e; } diff --git a/api/src/api/services/phases/handlers/nabla-swap-handler.ts b/api/src/api/services/phases/handlers/nabla-swap-handler.ts index 850347d46..5f3fee643 100644 --- a/api/src/api/services/phases/handlers/nabla-swap-handler.ts +++ b/api/src/api/services/phases/handlers/nabla-swap-handler.ts @@ -9,6 +9,7 @@ import { ApiManager } from '../../pendulum/apiManager'; import { routerAbi } from '../../../../contracts/Router'; import { defaultReadLimits } from '../../../helpers/contracts'; import { StateMetadata } from '../meta-state-types'; +import logger from '../../../../config/logger'; export class NablaSwapPhaseHandler extends BasePhaseHandler { public getPhaseName(): RampPhase { @@ -41,7 +42,7 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { try { const { txData: nablaSwapTransaction } = this.getPresignedTransaction(state, 'nablaSwap'); - console.log('before RESPONSE prepareNablaSwapTransaction'); + logger.info('before RESPONSE prepareNablaSwapTransaction'); // get an up to date quote for the AMM const response = await readMessage({ abi: new Abi(routerAbi), @@ -64,7 +65,7 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { const ouputAmountQuoteRaw = Big(response.value[0].toString()); if (ouputAmountQuoteRaw.lt(Big(nablaSoftMinimumOutputRaw))) { - console.log( + logger.info( `The estimated output amount is too low to swap. Expected: ${nablaSoftMinimumOutputRaw}, got: ${ouputAmountQuoteRaw}`, ); throw new Error("Won't execute the swap now. The estimated output amount is too low."); @@ -74,7 +75,7 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { const result = await submitExtrinsic(swapExtrinsic); if (result.status.type === 'error') { - console.log(`Could not swap token: ${result.status.error.toString()}`); + logger.error(`Could not swap token: ${result.status.error.toString()}`); throw new Error('Could not swap token'); } } catch (e) { diff --git a/api/src/api/services/phases/handlers/pendulum-to-assethub-phase-handler.ts b/api/src/api/services/phases/handlers/pendulum-to-assethub-phase-handler.ts new file mode 100644 index 000000000..9b31c78ab --- /dev/null +++ b/api/src/api/services/phases/handlers/pendulum-to-assethub-phase-handler.ts @@ -0,0 +1,49 @@ +import { RampPhase, decodeSubmittableExtrinsic, getAddressForFormat } from 'shared'; +import { BasePhaseHandler } from '../base-phase-handler'; +import RampState from '../../../../models/rampState.model'; + +import { submitXTokens } from '../../xcm/send'; + +import { ApiManager } from '../../pendulum/apiManager'; +import { StateMetadata } from '../meta-state-types'; + +export class PendulumToAssethubXCMPhaseHandler extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return 'pendulumToAssethub'; + } + + protected async executePhase(state: RampState): Promise { + const apiManager = ApiManager.getInstance(); + const networkName = 'pendulum'; + const pendulumNode = await apiManager.getApi(networkName); + + const { pendulumEphemeralAddress } = state.state as StateMetadata; + + if (!pendulumEphemeralAddress) { + throw new Error('Pendulum ephemeral address is not defined in the state. This is a bug.'); + } + + try { + const { txData: pendulumToAssethubTransaction } = this.getPresignedTransaction(state, 'pendulumToAssethub'); + + const xcmExtrinsic = decodeSubmittableExtrinsic(pendulumToAssethubTransaction as string, pendulumNode.api); + const { hash } = await submitXTokens( + getAddressForFormat(pendulumEphemeralAddress, pendulumNode.ss58Format), + xcmExtrinsic, + ); + + state.state = { + ...state.state, + pendulumToAssethubXcmHash: hash, + }; + await state.update({ state: state.state }); + + return this.transitionToNextPhase(state, 'complete'); + } catch (e) { + console.error('Error in PendulumToAssethubPhase:', e); + throw e; + } + } +} + +export default new PendulumToAssethubXCMPhaseHandler(); diff --git a/api/src/api/services/phases/handlers/spacewalk-redeem-handler.ts b/api/src/api/services/phases/handlers/spacewalk-redeem-handler.ts index 0624b28c7..be510a231 100644 --- a/api/src/api/services/phases/handlers/spacewalk-redeem-handler.ts +++ b/api/src/api/services/phases/handlers/spacewalk-redeem-handler.ts @@ -1,13 +1,14 @@ -import { EventListener, RampPhase, decodeSubmittableExtrinsic } from 'shared'; +import { decodeSubmittableExtrinsic, EventListener, RampPhase } from 'shared'; +import Big from 'big.js'; import { BasePhaseHandler } from '../base-phase-handler'; import RampState from '../../../../models/rampState.model'; import { ApiManager } from '../../pendulum/apiManager'; import { StateMetadata } from '../meta-state-types'; -import Big from 'big.js'; import { checkBalancePeriodically } from '../../stellar/checkBalance'; import { createVaultService } from '../../stellar/vaultService'; +import logger from '../../../../config/logger'; const maxWaitingTimeMinutes = 10; const maxWaitingTimeMs = maxWaitingTimeMinutes * 60 * 1000; @@ -40,9 +41,14 @@ export class SpacewalkRedeemPhaseHandler extends BasePhaseHandler { throw new Error('SpacewalkRedeemPhaseHandler: State metadata corrupted. This is a bug.'); } - try { - const { txData: spacewalkRedeemTransaction } = this.getPresignedTransaction(state, 'spacewalkRedeem'); + const { txData: spacewalkRedeemTransaction } = this.getPresignedTransaction(state, 'spacewalkRedeem'); + if (typeof spacewalkRedeemTransaction !== 'string') { + throw new Error( + 'SpacewalkRedeemPhaseHandler: Presigned transaction is not a string -> not an encoded Stellar transaction.', + ); + } + try { const accountData = await pendulumNode.api.query.system.account(pendulumEphemeralAddress); const currentEphemeralAccountNonce = await accountData.nonce.toNumber(); @@ -62,12 +68,12 @@ export class SpacewalkRedeemPhaseHandler extends BasePhaseHandler { stellarTarget.stellarTokenDetails.stellarAsset.issuer.hex, outputAmountBeforeFees.raw, ); - console.log(`Requesting redeem of ${outputAmountBeforeFees.units} tokens for vault ${vaultService.vaultId}`); + logger.info(`Requesting redeem of ${outputAmountBeforeFees.units} tokens for vault ${vaultService.vaultId}`); const redeemExtrinsic = decodeSubmittableExtrinsic(spacewalkRedeemTransaction, pendulumNode.api); const redeemRequestEvent = await vaultService.submitRedeem(pendulumEphemeralAddress, redeemExtrinsic); - console.log(`Successfully posed redeem request ${redeemRequestEvent.redeemId} for vault ${vaultService.vaultId}`); + logger.info(`Successfully posed redeem request ${redeemRequestEvent.redeemId} for vault ${vaultService.vaultId}`); // TODO we may want to use a singleton for the event listener across the backend. const eventListener = EventListener.getEventListener(pendulumNode.api); @@ -77,18 +83,18 @@ export class SpacewalkRedeemPhaseHandler extends BasePhaseHandler { } catch (e) { // This is a potentially recoverable error (due to redeem request done before app shut down, but not registered) if ((e as Error).message.includes('AmountExceedsUserBalance')) { - console.log(`Recovery mode: Redeem already performed. Waiting for execution and Stellar balance arrival.`); + logger.info(`Recovery mode: Redeem already performed. Waiting for execution and Stellar balance arrival.`); await this.waitForOutputTokensToArriveOnStellar( outputAmountBeforeFees.units, stellarEphemeralAccountId, stellarTarget.stellarTokenDetails.stellarAsset.code.string, ); return this.transitionToNextPhase(state, 'stellarPayment'); - } else { - // Generic failure of the extrinsic itself OR lack of funds to even make the transaction - console.log(`Failed to request redeem: ${e}`); - throw new Error(`Failed to request redeem`); } + + // Generic failure of the extrinsic itself OR lack of funds to even make the transaction + logger.error(`Failed to request redeem: ${e}`); + throw new Error(`Failed to request redeem`); } } @@ -110,7 +116,7 @@ export class SpacewalkRedeemPhaseHandler extends BasePhaseHandler { stellarPollingTimeMs, maxWaitingTimeMs, ); - console.log('Balance check completed successfully.'); + logger.info('Balance check completed successfully.'); } catch (balanceCheckError) { throw new Error(`Stellar balance did not arrive on time`); } diff --git a/api/src/api/services/phases/handlers/stellar-payment-handler.ts b/api/src/api/services/phases/handlers/stellar-payment-handler.ts index d0e006798..4efdd3e87 100644 --- a/api/src/api/services/phases/handlers/stellar-payment-handler.ts +++ b/api/src/api/services/phases/handlers/stellar-payment-handler.ts @@ -1,8 +1,8 @@ import { HORIZON_URL, RampPhase } from 'shared'; -import { Horizon, Networks, Transaction } from 'stellar-sdk'; - +import { Horizon, NetworkError, Networks, Transaction } from 'stellar-sdk'; import { BasePhaseHandler } from '../base-phase-handler'; import RampState from '../../../../models/rampState.model'; +import logger from '../../../../config/logger'; const NETWORK_PASSPHRASE = Networks.PUBLIC; const horizonServer = new Horizon.Server(HORIZON_URL); @@ -13,26 +13,34 @@ export class StellarPaymentPhaseHandler extends BasePhaseHandler { } protected async executePhase(state: RampState): Promise { - try { - const { txData: offrampingTransactionXDR } = this.getPresignedTransaction(state, 'stellarPayment'); + const { txData: offrampingTransactionXDR } = this.getPresignedTransaction(state, 'stellarPayment'); + if (typeof offrampingTransactionXDR !== 'string') { + throw new Error('Invalid transaction data'); + } + try { const offrampingTransaction = new Transaction(offrampingTransactionXDR, NETWORK_PASSPHRASE); await horizonServer.submitTransaction(offrampingTransaction); return this.transitionToNextPhase(state, 'complete'); } catch (e) { - const horizonError = e as { response: { data: { extras: any } } }; - - console.log( - `Could not submit the offramp transaction ${JSON.stringify(horizonError.response.data.extras.result_codes)}`, - ); - // check https://developers.stellar.org/docs/data/horizon/api-reference/errors/result-codes/transactions - if (horizonError.response.data.extras.result_codes.transaction === 'tx_bad_seq') { - console.log('Assuming offramp was already performed.'); - return this.transitionToNextPhase(state, 'complete'); - } else { + const horizonError = e as NetworkError; + + if (horizonError.response.data?.status === 400) { + logger.error( + `Could not submit the offramp transaction ${JSON.stringify(horizonError.response.data.extras.result_codes)}`, + ); + // check https://developers.stellar.org/docs/data/horizon/api-reference/errors/result-codes/transactions + if (horizonError.response.data.extras.result_codes.transaction === 'tx_bad_seq') { + logger.info('Assuming offramp was already performed.'); + return this.transitionToNextPhase(state, 'complete'); + } + console.error(horizonError.response.data.extras); throw new Error('Could not submit the offramping transaction'); + } else { + console.error('Error while submitting the offramp transaction', e); + throw new Error('Could not submit the offramping transaction'); } } } diff --git a/api/src/api/services/phases/handlers/subsidize-post-swap-handler.ts b/api/src/api/services/phases/handlers/subsidize-post-swap-handler.ts index eb816d10b..77332c8b9 100644 --- a/api/src/api/services/phases/handlers/subsidize-post-swap-handler.ts +++ b/api/src/api/services/phases/handlers/subsidize-post-swap-handler.ts @@ -5,6 +5,7 @@ import Big from 'big.js'; import { StateMetadata } from '../meta-state-types'; import { ApiManager } from '../../pendulum/apiManager'; import { getFundingAccount } from '../../../controllers/subsidize.controller'; +import logger from '../../../../config/logger'; export class SubsidizePostSwapPhaseHandler extends BasePhaseHandler { public getPhaseName(): RampPhase { @@ -37,7 +38,7 @@ export class SubsidizePostSwapPhaseHandler extends BasePhaseHandler { const requiredAmount = Big(outputAmountBeforeFees.raw).sub(currentBalance); if (requiredAmount.gt(Big(0))) { // Do the actual subsidizing. - console.log('Subsidizing post-swap with', requiredAmount.toString()); + logger.info('Subsidizing post-swap with', requiredAmount.toString()); const fundingAccountKeypair = getFundingAccount(); await pendulumNode.api.tx.tokens .transfer( diff --git a/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts b/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts index fe671b816..d443d0bf6 100644 --- a/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts +++ b/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts @@ -1,10 +1,11 @@ +import { RampPhase } from 'shared'; +import Big from 'big.js'; import { BasePhaseHandler } from '../base-phase-handler'; -import { FiatToken, RampPhase } from 'shared'; import RampState from '../../../../models/rampState.model'; -import Big from 'big.js'; import { StateMetadata } from '../meta-state-types'; import { ApiManager } from '../../pendulum/apiManager'; import { getFundingAccount } from '../../../controllers/subsidize.controller'; +import logger from '../../../../config/logger'; export class SubsidizePreSwapPhaseHandler extends BasePhaseHandler { public getPhaseName(): RampPhase { @@ -37,8 +38,8 @@ export class SubsidizePreSwapPhaseHandler extends BasePhaseHandler { const requiredAmount = Big(inputAmountBeforeSwapRaw).sub(currentBalance); if (requiredAmount.gt(Big(0))) { // Do the actual subsidizing. - console.log('Subsidizing pre-swap with', requiredAmount.toString()); - console.log( + logger.info('Subsidizing pre-swap with', requiredAmount.toString()); + logger.info( 'Target value: ', inputAmountBeforeSwapRaw.toString(), 'Current value: ', @@ -58,7 +59,7 @@ export class SubsidizePreSwapPhaseHandler extends BasePhaseHandler { return this.transitionToNextPhase(state, 'nablaApprove'); } catch (e) { console.error('Error in subsidizePreSwap:', e); - throw new Error('SubsidizePreSwapPhaseHandler: Failed to subsidize post swap.'); + throw new Error('SubsidizePreSwapPhaseHandler: Failed to subsidize pre swap.'); } } } diff --git a/api/src/api/services/phases/meta-state-types.ts b/api/src/api/services/phases/meta-state-types.ts index be3db08b3..1a8bfbe2e 100644 --- a/api/src/api/services/phases/meta-state-types.ts +++ b/api/src/api/services/phases/meta-state-types.ts @@ -25,4 +25,5 @@ export interface StateMetadata { destinationAddress: string; receiverTaxId: string; moonbeamEphemeralAddress: string; + pendulumToAssethubXcmHash: string; } diff --git a/api/src/api/services/phases/phase-processor.integration.test.ts b/api/src/api/services/phases/phase-processor.integration.test.ts index 7a48613e9..c95ca898e 100644 --- a/api/src/api/services/phases/phase-processor.integration.test.ts +++ b/api/src/api/services/phases/phase-processor.integration.test.ts @@ -1,5 +1,5 @@ // eslint-disable-next-line import/no-unresolved -import { describe, expect, it, mock, beforeAll, afterAll } from 'bun:test'; +import { describe, it, mock } from 'bun:test'; import fs from 'node:fs'; import path from 'node:path'; import { PhaseProcessor } from './phase-processor'; @@ -7,19 +7,8 @@ import RampState from '../../../models/rampState.model'; import QuoteTicket from '../../../models/quoteTicket.model'; import { RampService } from '../ramp/ramp.service'; import { BrlaApiService } from '../brla/brlaApiService'; -import { - AccountMeta, - Networks, - RampEndpoints, - EvmToken, - FiatToken, - signUnsignedTransactions, - EvmTransactionData, - getNetworkId, -} from 'shared'; -import { v4 as uuidv4 } from 'uuid'; +import { AccountMeta, Networks, EvmToken, FiatToken, signUnsignedTransactions, EvmTransactionData } from 'shared'; import { SubaccountData } from '../brla/types'; -import { APIError } from '../../errors/api-error'; import { QuoteService } from '../ramp/quote.service'; import { EphemeralAccount } from 'shared'; import { Keyring } from '@polkadot/api'; diff --git a/api/src/api/services/phases/phase-processor.onramp.integration.test.ts b/api/src/api/services/phases/phase-processor.onramp.integration.test.ts index eec0f0da2..f7f294767 100644 --- a/api/src/api/services/phases/phase-processor.onramp.integration.test.ts +++ b/api/src/api/services/phases/phase-processor.onramp.integration.test.ts @@ -23,13 +23,13 @@ const TAX_ID = process.env.TAX_ID; // BACKEND_TEST_STARTER_ACCOUNT = "sleep...... al" // This is the derivation obtained using mnemonicToSeedSync(BACKEND_TEST_STARTER_ACCOUNT!) and HDKey.fromMasterSeed(seed) const EVM_TESTING_ADDRESS = '0x30a300612ab372CC73e53ffE87fB73d62Ed68Da3'; -const EVM_DESTINATION_ADDRESS = '0x7ba99e99bc669b3508aff9cc0a898e869459f877'; // Controlled by us, so funds can arrive here during tests. +const EVM_DESTINATION_ADDRESS = '12mkWe8Lfsk4Qx6EEocvRDpzmA6SQQHBA4Fq3b9T9cyPr7Td'; // Controlled by us, so funds can arrive here during tests. const TEST_INPUT_AMOUNT = '1'; const TEST_INPUT_CURRENCY = FiatToken.BRL; const TEST_OUTPUT_CURRENCY = EvmToken.USDC; -const QUOTE_TO = 'polygon'; +const QUOTE_TO = 'assethub'; const QUOTE_FROM = 'pix'; const filePath = path.join(__dirname, 'lastRampStateOnramp.json'); @@ -210,13 +210,13 @@ describe('Onramp PhaseProcessor Integration Test', () => { // END - MIMIC THE UI - // const startedRamp = await rampService.startRamp({ rampId: registeredRamp.id, presignedTxs }); + const startedRamp = await rampService.startRamp({ rampId: registeredRamp.id, presignedTxs }); - // const finalRampState = await waitForCompleteRamp(registeredRamp.id); + const finalRampState = await waitForCompleteRamp(registeredRamp.id); - // // Some sanity checks. - // expect(finalRampState.currentPhase).toBe('complete'); - // expect(finalRampState.phaseHistory.length).toBeGreaterThan(1); + // Some sanity checks. + expect(finalRampState.currentPhase).toBe('complete'); + expect(finalRampState.phaseHistory.length).toBeGreaterThan(1); } catch (error) { console.error('Error during test execution:', error); fs.writeFileSync(filePath, JSON.stringify(rampState, null, 2)); diff --git a/api/src/api/services/phases/phase-processor.ts b/api/src/api/services/phases/phase-processor.ts index 36d1c964b..7173d99d7 100644 --- a/api/src/api/services/phases/phase-processor.ts +++ b/api/src/api/services/phases/phase-processor.ts @@ -78,10 +78,10 @@ export class PhaseProcessor { private isLockExpired(state: RampState): boolean { const lockDuration = 15 * 60 * 1000; // 15 minutes const now = new Date(); - const lockTime = new Date(state.processingLock.lockedAt || 0); - if (!lockTime) { - return true; // No lock time means it's not locked + if (!state.processingLock?.lockedAt) { + return true; // No lock time means it's not locked or has expired } + const lockTime = new Date(state.processingLock.lockedAt); return now.getTime() - lockTime.getTime() > lockDuration; } diff --git a/api/src/api/services/phases/post-process/stellar-post-process-handler.ts b/api/src/api/services/phases/post-process/stellar-post-process-handler.ts index 95c092680..11d44b322 100644 --- a/api/src/api/services/phases/post-process/stellar-post-process-handler.ts +++ b/api/src/api/services/phases/post-process/stellar-post-process-handler.ts @@ -1,5 +1,5 @@ -import { HORIZON_URL, Networks as AppNetworks, PresignedTx, RampPhase, FiatToken, CleanupPhase } from 'shared'; -import { Horizon, Networks as StellarNetworks, Transaction } from 'stellar-sdk'; +import { CleanupPhase, FiatToken, HORIZON_URL } from 'shared'; +import { Horizon, NetworkError, Networks as StellarNetworks, Transaction } from 'stellar-sdk'; import logger from '../../../../config/logger'; import RampState from '../../../../models/rampState.model'; import { BasePostProcessHandler } from './base-post-process-handler'; @@ -53,18 +53,25 @@ export class StellarPostProcessHandler extends BasePostProcessHandler { return [true, null]; } catch (e) { try { - const horizonError = e as { response: { data: { extras: any } } }; - logger.info( - `Could not submit the cleanup transaction ${JSON.stringify(horizonError.response.data.extras.result_codes)}`, - ); + const horizonError = e as NetworkError; + if (horizonError.response.data?.status === 400) { + logger.info( + `Could not submit the cleanup transaction ${JSON.stringify( + horizonError.response?.data?.extras.result_codes, + )}`, + ); + + if (horizonError.response.data.extras.result_codes.transaction === 'tx_bad_seq') { + logger.info('Recovery mode: Cleanup already performed.'); + return [true, null]; + } - if (horizonError.response.data.extras.result_codes.transaction === 'tx_bad_seq') { - logger.info('Recovery mode: Cleanup already performed.'); - return [true, null]; - } else { logger.error(horizonError.response.data.extras); return [false, this.createErrorObject('Could not submit the cleanup transaction')]; } + + logger.error('Error while submitting the cleanup transaction', e); + return [false, this.createErrorObject('Could not submit the cleanup transaction')]; } catch (parseError) { // If we can't parse the error as a Horizon error, it's a different type of error return [false, this.createErrorObject(e instanceof Error ? e : String(e))]; diff --git a/api/src/api/services/phases/register-handlers.ts b/api/src/api/services/phases/register-handlers.ts index 134230d1f..356e6ce4e 100644 --- a/api/src/api/services/phases/register-handlers.ts +++ b/api/src/api/services/phases/register-handlers.ts @@ -15,6 +15,7 @@ import moonbeamToPendulumXcmHandler from './handlers/moonbeam-to-pendulum-xcm-ha import fundEphemeralHandler from './handlers/fund-ephemeral-handler'; import brlaTeleportHandler from './handlers/brla-teleport-handler'; import completePhaseHandler from './handlers/complete-phase-handler'; +import pendulumToAssethubPhaseHandler from './handlers/pendulum-to-assethub-phase-handler'; /** * Register all phase handlers */ @@ -37,6 +38,7 @@ export function registerPhaseHandlers(): void { phaseRegistry.registerHandler(moonbeamToPendulumXcmHandler); phaseRegistry.registerHandler(fundEphemeralHandler); phaseRegistry.registerHandler(brlaTeleportHandler); + phaseRegistry.registerHandler(pendulumToAssethubPhaseHandler); logger.info('Phase handlers registered'); } diff --git a/api/src/api/services/ramp/quote.service.ts b/api/src/api/services/ramp/quote.service.ts index 82179e089..957394386 100644 --- a/api/src/api/services/ramp/quote.service.ts +++ b/api/src/api/services/ramp/quote.service.ts @@ -164,43 +164,58 @@ export class QuoteService extends BaseRampService { rampType: 'on' | 'off', from: DestinationType, to: DestinationType, - ): Promise<{ receiveAmount: string; fees: string; outputAmountBeforeFees: string; outputAmountMoonbeamRaw: string, inputAmountAfterFees: string }> { + ): Promise<{ + receiveAmount: string; + fees: string; + outputAmountBeforeFees: string; + outputAmountMoonbeamRaw: string; + inputAmountAfterFees: string; + }> { const apiManager = ApiManager.getInstance(); const networkName = 'pendulum'; const apiInstance = await apiManager.getApi(networkName); - try { - const fromNetwork = getNetworkFromDestination(from); - const toNetwork = getNetworkFromDestination(to); - if (rampType === 'on' && !toNetwork) { - throw new APIError({ - status: httpStatus.BAD_REQUEST, - message: 'Invalid toNetwork for onramp.', - }); - } - if (rampType === 'off' && !fromNetwork) { + const fromNetwork = getNetworkFromDestination(from); + const toNetwork = getNetworkFromDestination(to); + if (rampType === 'on' && !toNetwork) { + throw new APIError({ + status: httpStatus.BAD_REQUEST, + message: 'Invalid toNetwork for onramp.', + }); + } + if (rampType === 'off' && !fromNetwork) { + throw new APIError({ + status: httpStatus.BAD_REQUEST, + message: 'Invalid fromNetwork for offramp.', + }); + } + const outTokenDetails = toNetwork ? getOnChainTokenDetails(toNetwork, outputCurrency as OnChainToken) : undefined; + if (rampType === 'on') { + if (!outTokenDetails) { throw new APIError({ status: httpStatus.BAD_REQUEST, - message: 'Invalid fromNetwork for offramp.', + message: 'Invalid token details for onramp', }); } + } + if (Big(inputAmount).lte(0)) { + throw new APIError({ + status: httpStatus.BAD_REQUEST, + message: 'Invalid input amount', + }); + } + + try { const inputTokenPendulumDetails = rampType === 'on' ? getPendulumDetails(inputCurrency) : getPendulumDetails(inputCurrency, fromNetwork); const outputTokenPendulumDetails = rampType === 'on' ? getPendulumDetails(outputCurrency, toNetwork) : getPendulumDetails(outputCurrency); - if (Big(inputAmount).lte(0)) { - throw new APIError({ - status: httpStatus.BAD_REQUEST, - message: 'Invalid input amount', - }); - } - const inputAmountAfterFees = rampType === 'on' ? calculateTotalReceiveOnramp(new Big(inputAmount), inputCurrency) : inputAmount; - let amountOut = await getTokenOutAmount({ + const amountOut = await getTokenOutAmount({ api: apiInstance.api, fromAmountString: inputAmountAfterFees, inputTokenDetails: inputTokenPendulumDetails, @@ -209,7 +224,7 @@ export class QuoteService extends BaseRampService { // if onramp, adjust for axlUSDC price difference. const outputAmountMoonbeamRaw: string = amountOut.preciseQuotedAmountOut.rawBalance.toFixed(); // Store the value before the adjustment. - if (rampType === 'on') { + if (rampType === 'on' && to !== 'assethub') { const outTokenDetails = getOnChainTokenDetails(getNetworkFromDestination(to)!, outputCurrency as OnChainToken); if (!outTokenDetails || !isEvmTokenDetails(outTokenDetails)) { throw new APIError({ @@ -221,24 +236,24 @@ export class QuoteService extends BaseRampService { const routeParams = createOnrampRouteParams( '0x30a300612ab372cc73e53ffe87fb73d62ed68da3', // It does not matter. amountOut.preciseQuotedAmountOut.rawBalance.toFixed(), - outTokenDetails, + outTokenDetails!, getNetworkFromDestination(to)!, '0x30a300612ab372cc73e53ffe87fb73d62ed68da3', ); const routeResult = await getRoute(routeParams); const { route } = routeResult.data; - const toAmountMin = route.estimate.toAmountMin; + const { toAmountMin } = route.estimate; amountOut.preciseQuotedAmountOut = parseContractBalanceResponse( - outTokenDetails.pendulumDecimals, + outTokenDetails!.pendulumDecimals, BigInt(toAmountMin), ); - (amountOut.roundedDownQuotedAmountOut = amountOut.preciseQuotedAmountOut.preciseBigDecimal.round(2, 0)), - (amountOut.effectiveExchangeRate = stringifyBigWithSignificantDecimals( - amountOut.preciseQuotedAmountOut.preciseBigDecimal.div(new Big(inputAmountAfterFees)), - 4, - )); + amountOut.roundedDownQuotedAmountOut = amountOut.preciseQuotedAmountOut.preciseBigDecimal.round(2, 0); + amountOut.effectiveExchangeRate = stringifyBigWithSignificantDecimals( + amountOut.preciseQuotedAmountOut.preciseBigDecimal.div(new Big(inputAmountAfterFees)), + 4, + ); } const outputAmountAfterFees = @@ -248,7 +263,7 @@ export class QuoteService extends BaseRampService { const effectiveFeesOfframp = amountOut.preciseQuotedAmountOut.preciseBigDecimal .minus(outputAmountAfterFees) - .toFixed(6, 0); + .toFixed(2, 0); const effectiveFeesOnrampBrl = new Big(inputAmount).minus(inputAmountAfterFees); const effectiveFeesOnramp = effectiveFeesOnrampBrl.mul(amountOut.effectiveExchangeRate).toFixed(6, 0); diff --git a/api/src/api/services/stellar/checkBalance.ts b/api/src/api/services/stellar/checkBalance.ts index 85814760a..4e28822a7 100644 --- a/api/src/api/services/stellar/checkBalance.ts +++ b/api/src/api/services/stellar/checkBalance.ts @@ -2,6 +2,7 @@ import { Horizon } from 'stellar-sdk'; import Big from 'big.js'; import { HORIZON_URL } from '../../../constants/constants'; +import logger from '../../../config/logger'; export function checkBalancePeriodically( stellarTargetAccountId: string, @@ -15,7 +16,7 @@ export function checkBalancePeriodically( const intervalId = setInterval(async () => { try { const someBalanceUnits = await getStellarBalanceUnits(stellarTargetAccountId, stellarAssetCode); - console.log(`Balance check: ${someBalanceUnits.toString()} / ${amountDesiredUnitsBig.toString()}`); + logger.info(`Balance check: ${someBalanceUnits.toString()} / ${amountDesiredUnitsBig.toString()}`); if (someBalanceUnits.gte(amountDesiredUnitsBig)) { clearInterval(intervalId); @@ -51,7 +52,7 @@ const getStellarBalanceUnits = async (publicKey: string, assetCode: string): Pro return new Big(balanceUnits); } catch (error) { - console.log(error); + logger.error(error) throw new Error('Error Reading Stellar Balance'); } }; diff --git a/api/src/api/services/stellar/getVaults.ts b/api/src/api/services/stellar/getVaults.ts index efed92f68..be855d204 100644 --- a/api/src/api/services/stellar/getVaults.ts +++ b/api/src/api/services/stellar/getVaults.ts @@ -1,5 +1,6 @@ import { ApiPromise } from '@polkadot/api'; import Big from 'big.js'; +import logger from '../../../config/logger'; function vaultHasEnoughRedeemable(vault: any, redeemableAmount: string): boolean { // issuedTokens - toBeRedeemedTokens = redeemableTokens @@ -33,7 +34,7 @@ export async function getVaultsForCurrency( if (vaultsForCurrency.length === 0) { const errorMessage = `No vaults found for currency ${assetCodeHex} and amount ${redeemableAmountRaw}`; - console.log(errorMessage); + logger.error(errorMessage); throw new Error(errorMessage); } diff --git a/api/src/api/services/stellar/loadAccount.ts b/api/src/api/services/stellar/loadAccount.ts index f3f0c2634..1ac79f37a 100644 --- a/api/src/api/services/stellar/loadAccount.ts +++ b/api/src/api/services/stellar/loadAccount.ts @@ -1,5 +1,6 @@ import { Horizon } from 'stellar-sdk'; import { HORIZON_URL } from '../../../constants/constants'; +import logger from '../../../config/logger'; const horizonServer = new Horizon.Server(HORIZON_URL); @@ -24,7 +25,7 @@ export async function loadAccountWithRetry( // The account does not exist return null; } - console.log(`Attempt ${i + 1} to load account ${ephemeralAccountId} failed: ${err}`); + logger.info(`Attempt ${i + 1} to load account ${ephemeralAccountId} failed: ${err}`); lastError = err; } } diff --git a/api/src/api/services/stellar/vaultService.ts b/api/src/api/services/stellar/vaultService.ts index 69303151e..bfd4f3914 100644 --- a/api/src/api/services/stellar/vaultService.ts +++ b/api/src/api/services/stellar/vaultService.ts @@ -1,10 +1,11 @@ import { SpacewalkPrimitivesVaultId } from '@pendulum-chain/types/interfaces'; import { SubmittableExtrinsic } from '@polkadot/api-base/types'; -import { API } from '../pendulum/apiManager'; -import { getVaultsForCurrency } from './getVaults'; import { ISubmittableResult } from '@polkadot/types/types'; import { getAddressForFormat, parseEventRedeemRequest, SpacewalkRedeemRequestEvent } from 'shared'; +import { API } from '../pendulum/apiManager'; +import { getVaultsForCurrency } from './getVaults'; +import logger from '../../../config/logger'; export async function createVaultService( apiComponents: API, @@ -44,33 +45,30 @@ export class VaultService { const { status, events, dispatchError } = submissionResult; if (status.isFinalized) { - console.log(`Requested redeem for vault ${this.vaultId} with status ${status.type}`); + logger.info(`Requested redeem for vault ${this.vaultId} with status ${status.type}`); // Try to find a 'system.ExtrinsicFailed' event - const systemExtrinsicFailedEvent = events.find((record) => { - return record.event.section === 'system' && record.event.method === 'ExtrinsicFailed'; - }); + const systemExtrinsicFailedEvent = events.find( + (record) => record.event.section === 'system' && record.event.method === 'ExtrinsicFailed', + ); if (dispatchError) { reject(this.handleDispatchError(dispatchError, systemExtrinsicFailedEvent, 'Redeem Request')); } - //find all redeem request events and filter the one that matches the requester - const redeemEvents = events.filter((event) => { - return ( - event.event.section.toLowerCase() === 'redeem' && event.event.method.toLowerCase() === 'requestredeem' - ); - }); + // find all redeem request events and filter the one that matches the requester + const redeemEvents = events.filter( + (event) => + event.event.section.toLowerCase() === 'redeem' && event.event.method.toLowerCase() === 'requestredeem', + ); const event = redeemEvents .map((event) => parseEventRedeemRequest(event)) - .filter((event) => { - return event.redeemer === getAddressForFormat(senderAddress, this.apiComponents!.ss58Format); - }); + .filter((event) => event.redeemer === getAddressForFormat(senderAddress, this.apiComponents!.ss58Format)); if (event.length == 0) { reject(new Error(`No redeem event found for account ${senderAddress}`)); } - //we should only find one event corresponding to the issue request + // we should only find one event corresponding to the issue request if (event.length != 1) { reject(new Error('Inconsistent amount of redeem request events for account')); } @@ -91,7 +89,8 @@ export class VaultService { const { name, section, method } = decoded; return new Error(`Dispatch error: ${section}.${method}:: ${name}`); - } else if (systemExtrinsicFailedEvent) { + } + if (systemExtrinsicFailedEvent) { const eventName = systemExtrinsicFailedEvent?.event.data && systemExtrinsicFailedEvent?.event.data.length > 0 ? systemExtrinsicFailedEvent?.event.data[0].toString() @@ -101,12 +100,12 @@ export class VaultService { phase, event: { method, section }, } = systemExtrinsicFailedEvent; - console.log(`Extrinsic failed in phase ${phase.toString()} with ${section}.${method}:: ${eventName}`); + logger.error(`Extrinsic failed in phase ${phase.toString()} with ${section}.${method}:: ${eventName}`); return new Error(`Failed to dispatch ${extrinsicCalled}`); - } else { - console.log('Encountered some other error: ', dispatchError?.toString(), JSON.stringify(dispatchError)); - return new Error(`Unknown error during ${extrinsicCalled}`); } + + logger.error('Encountered some other error: ', dispatchError?.toString(), JSON.stringify(dispatchError)); + return new Error(`Unknown error during ${extrinsicCalled}`); } } diff --git a/api/src/api/services/transactions/nabla/approve.ts b/api/src/api/services/transactions/nabla/approve.ts index 5c705b9f2..d88685cae 100644 --- a/api/src/api/services/transactions/nabla/approve.ts +++ b/api/src/api/services/transactions/nabla/approve.ts @@ -12,6 +12,7 @@ import { defaultWriteLimits, parseContractBalanceResponse, } from '../../../helpers/contracts'; +import logger from '../../../../config/logger'; export interface PrepareNablaApproveParams { inputTokenDetails: PendulumDetails; @@ -37,7 +38,7 @@ async function createApproveExtrinsic({ contractAbi, callerAddress, }: CreateApproveExtrinsicOptions) { - console.log('write', `call approve ${token} for ${spender} with amount ${amount} `); + logger.info('write', `call approve ${token} for ${spender} with amount ${amount} `); const { execution, result: readMessageResult } = await createExecuteMessageExtrinsic({ abi: contractAbi, @@ -82,7 +83,7 @@ export async function prepareNablaApproveTransaction({ if (response.type !== 'success') { const message = 'Could not load token allowance'; - console.log(message); + logger.info(message); throw new Error(message); } @@ -91,7 +92,7 @@ export async function prepareNablaApproveTransaction({ // maybe do allowance if (currentAllowance === undefined || currentAllowance.rawBalance.lt(Big(amountRaw))) { try { - console.log(`Preparing transaction to approve tokens: ${amountRaw} ${inputTokenDetails.pendulumAssetSymbol}`); + logger.info(`Preparing transaction to approve tokens: ${amountRaw} ${inputTokenDetails.pendulumAssetSymbol}`); return createApproveExtrinsic({ api, amount: amountRaw, @@ -101,7 +102,7 @@ export async function prepareNablaApproveTransaction({ callerAddress: pendulumEphemeralAddress, }); } catch (e) { - console.log(`Could not approve token: ${e}`); + logger.info(`Could not approve token: ${e}`); return Promise.reject('Could not approve token'); } } diff --git a/api/src/api/services/transactions/nabla/swap.ts b/api/src/api/services/transactions/nabla/swap.ts index 1a70b5f2d..aead42056 100644 --- a/api/src/api/services/transactions/nabla/swap.ts +++ b/api/src/api/services/transactions/nabla/swap.ts @@ -6,6 +6,7 @@ import { createWriteOptions, defaultWriteLimits } from '../../../helpers/contrac import { API } from '../../pendulum/apiManager'; import { config } from '../../../../config'; import { routerAbi } from '../../../../contracts/Router'; +import logger from '../../../../config/logger'; export interface PrepareNablaSwapParams { inputTokenDetails: PendulumDetails; @@ -88,7 +89,7 @@ export async function prepareNablaSwapTransaction({ callerAddress: pendulumEphemeralAddress, }); } catch (e) { - console.log(`Error creating swap extrinsic: ${e}`); + logger.error(`Error creating swap extrinsic: ${e}`); throw Error("Couldn't create swap extrinsic"); } } diff --git a/api/src/api/services/transactions/offrampTransactions.ts b/api/src/api/services/transactions/offrampTransactions.ts index 552f8af1e..dae0286f0 100644 --- a/api/src/api/services/transactions/offrampTransactions.ts +++ b/api/src/api/services/transactions/offrampTransactions.ts @@ -1,5 +1,8 @@ import { + AccountMeta, + addAdditionalTransactionsToMeta, AMM_MINIMUM_OUTPUT_SOFT_MARGIN, + encodeSubmittableExtrinsic, FiatToken, getAnyFiatTokenDetails, getNetworkFromDestination, @@ -11,11 +14,9 @@ import { isOnChainToken, isStellarOutputTokenDetails, Networks, - AccountMeta, - encodeSubmittableExtrinsic, - addAdditionalTransactionsToMeta, + PaymentData, + UnsignedTx, } from 'shared'; -import { UnsignedTx, PaymentData } from 'shared'; import Big from 'big.js'; import { Keypair } from 'stellar-sdk'; @@ -30,6 +31,7 @@ import { createPendulumToMoonbeamTransfer } from './xcm/pendulumToMoonbeam'; import { StateMetadata } from '../phases/meta-state-types'; import { preparePendulumCleanupTransaction } from './pendulum/cleanup'; import { createAssethubToPendulumXCM } from './xcm/assethubToPendulum'; +import logger from '../../../config/logger'; interface OfframpTransactionParams { quote: QuoteTicketAttributes; @@ -130,7 +132,6 @@ export async function prepareOfframpTransactions({ pendulumAddressDestination: pendulumEphemeralEntry.address, fromAddress: userAddress, }); - console.log('squid txs done'); unsignedTxs.push({ txData: encodeEvmTransactionData(approveData) as any, phase: 'squidrouterApprove', @@ -162,7 +163,7 @@ export async function prepareOfframpTransactions({ 'usdc', inputAmountRaw, ); - console.log('assethub to pendulum txs done'); + logger.info('assethub to pendulum txs done'); unsignedTxs.push({ txData: encodeSubmittableExtrinsic(assethubToPendulumTransaction), phase: 'assethubToPendulum', @@ -174,7 +175,7 @@ export async function prepareOfframpTransactions({ // Create unsigned transactions for each ephemeral account for (const account of signingAccounts) { - console.log(`Processing account ${account.address} on network ${account.network}`); + logger.info(`Processing account ${account.address} on network ${account.network}`); const accountNetworkId = getNetworkId(account.network); if (!isOnChainToken(quote.inputCurrency)) { @@ -223,7 +224,6 @@ export async function prepareOfframpTransactions({ nonce: 1, signer: account.address, }); - console.log('nabla txs done'); stateMeta = { ...stateMeta, nablaSoftMinimumOutputRaw, @@ -234,7 +234,6 @@ export async function prepareOfframpTransactions({ inputTokenPendulumDetails.pendulumCurrencyId, outputTokenPendulumDetails.pendulumCurrencyId, ); - console.log('pendulum cleanup done'); unsignedTxs.push({ txData: encodeSubmittableExtrinsic(pendulumCleanupTransaction), phase: 'pendulumCleanup', @@ -255,7 +254,6 @@ export async function prepareOfframpTransactions({ outputAmountBeforeFeesRaw, outputTokenDetails.pendulumCurrencyId, ); - console.log('pendulum to moonbeam txs done'); unsignedTxs.push({ txData: encodeSubmittableExtrinsic(pendulumToMoonbeamTransaction), phase: 'pendulumToMoonbeam', diff --git a/api/src/api/services/transactions/onrampTransactions.ts b/api/src/api/services/transactions/onrampTransactions.ts index 84befdaab..1c5226763 100644 --- a/api/src/api/services/transactions/onrampTransactions.ts +++ b/api/src/api/services/transactions/onrampTransactions.ts @@ -1,5 +1,7 @@ import { + AccountMeta, AMM_MINIMUM_OUTPUT_SOFT_MARGIN, + encodeSubmittableExtrinsic, getAnyFiatTokenDetails, getNetworkFromDestination, getNetworkId, @@ -12,8 +14,6 @@ import { isOnChainTokenDetails, Networks, UnsignedTx, - AccountMeta, - encodeSubmittableExtrinsic, } from 'shared'; import Big from 'big.js'; import { QuoteTicketAttributes, QuoteTicketMetadata } from '../../../models/quoteTicket.model'; @@ -23,7 +23,7 @@ import { createMoonbeamToPendulumXCM } from './xcm/moonbeamToPendulum'; import { createPendulumToMoonbeamTransfer } from './xcm/pendulumToMoonbeam'; import { multiplyByPowerOfTen } from '../pendulum/helpers'; import { createPendulumToAssethubTransfer } from './xcm/pendulumToAssethub'; -import { createNablaTransactionsForOnramp, createNablaTransactionsForQuote } from './nabla'; +import { createNablaTransactionsForOnramp } from './nabla'; import { preparePendulumCleanupTransaction } from './pendulum/cleanup'; import { prepareMoonbeamCleanupTransaction } from './moonbeam/cleanup'; import { StateMetadata } from '../phases/meta-state-types'; @@ -76,15 +76,12 @@ export async function prepareOnrampTransactions( } // For BRLA, fee is charged after minting, so we always work with the amount after anchor fees. - const inputAmountUnits = new Big(quote.metadata.onrampInputAmountUnits) + const inputAmountUnits = new Big(quote.metadata.onrampInputAmountUnits); const inputAmountRaw = multiplyByPowerOfTen(inputAmountUnits, inputTokenDetails.decimals).toFixed(0, 0); - + // The output amount to be obtained on Moonbeam, differs from the amount to be obtained on destination evm chain. const outputAmountRaw = (quote.metadata as QuoteTicketMetadata).onrampOutputAmountMoonbeamRaw; - const outputAmount = multiplyByPowerOfTen( - new Big(outputAmountRaw), - -outputTokenDetails.decimals, - ); + const outputAmount = multiplyByPowerOfTen(new Big(outputAmountRaw), -outputTokenDetails.decimals); const inputTokenPendulumDetails = getPendulumDetails(quote.inputCurrency); const outputTokenPendulumDetails = getPendulumDetails(quote.outputCurrency, toNetwork); diff --git a/api/src/api/services/transactions/stellar/offrampTransaction.ts b/api/src/api/services/transactions/stellar/offrampTransaction.ts index dc779aecc..c4a4e8cef 100644 --- a/api/src/api/services/transactions/stellar/offrampTransaction.ts +++ b/api/src/api/services/transactions/stellar/offrampTransaction.ts @@ -2,6 +2,7 @@ import { Account, Asset, Horizon, Keypair, Memo, Networks, Operation, Transactio import { FUNDING_SECRET, STELLAR_BASE_FEE, SEQUENCE_TIME_WINDOW_IN_SECONDS } from '../../../../constants/constants'; import { StellarTokenDetails, PaymentData, HORIZON_URL, STELLAR_EPHEMERAL_STARTING_BALANCE_UNITS } from 'shared'; import Big from 'big.js'; +import logger from '../../../../config/logger'; // Define HorizonServer type type HorizonServer = Horizon.Server; @@ -36,7 +37,7 @@ export async function buildPaymentAndMergeTx({ const NUMBER_OF_PRESIGNED_TXS = 3; if (!FUNDING_SECRET) { - console.log('Secret not defined'); + logger.error('Stellar funding secret not defined'); throw new Error('Stellar funding secret not defined'); } diff --git a/api/src/api/services/transactions/xcm/pendulumToAssethub.ts b/api/src/api/services/transactions/xcm/pendulumToAssethub.ts index 98e50c742..7f6061257 100644 --- a/api/src/api/services/transactions/xcm/pendulumToAssethub.ts +++ b/api/src/api/services/transactions/xcm/pendulumToAssethub.ts @@ -2,16 +2,19 @@ import { SubmittableExtrinsic } from '@polkadot/api-base/types'; import { ISubmittableResult } from '@polkadot/types/types'; import { PendulumCurrencyId } from 'shared'; import { ApiManager } from '../../pendulum/apiManager'; +import { decodeAddress } from '@polkadot/util-crypto'; +import { u8aToHex } from '@polkadot/util'; export async function createPendulumToAssethubTransfer( destinationAddress: string, currencyId: PendulumCurrencyId, rawAmount: string, ): Promise> { + const receiverId = u8aToHex(decodeAddress(destinationAddress)); const destination = { V3: { parents: 1, - interior: { X2: [{ Parachain: 1000 }, { AccountKey20: { network: undefined, key: destinationAddress } }] }, + interior: { X2: [{ Parachain: 1000 }, { AccountId32: { network: undefined, id: receiverId } }] }, }, }; diff --git a/api/src/api/workers/cleanup.worker.test.ts b/api/src/api/workers/cleanup.worker.test.ts index 06f0b8e36..5decb31b0 100644 --- a/api/src/api/workers/cleanup.worker.test.ts +++ b/api/src/api/workers/cleanup.worker.test.ts @@ -1,6 +1,6 @@ // eslint-disable-next-line import/no-unresolved import { describe, expect, it, mock, beforeEach } from 'bun:test'; -import { CleanupWorker } from './cleanup.worker'; +import CleanupWorker from './cleanup.worker'; import RampState from '../../models/rampState.model'; mock.module('../../config/logger', () => ({ diff --git a/api/src/api/workers/cleanup.worker.ts b/api/src/api/workers/cleanup.worker.ts index 9a55f2e55..97b26d030 100644 --- a/api/src/api/workers/cleanup.worker.ts +++ b/api/src/api/workers/cleanup.worker.ts @@ -1,71 +1,74 @@ +import { CronJob } from 'cron'; +import { CleanupPhase } from 'shared'; // <-- Import CleanupPhase import logger from '../../config/logger'; -import quoteService from '../services/ramp/quote.service'; import { BaseRampService } from '../services/ramp/base.service'; import RampState from '../../models/rampState.model'; -import { postProcessHandlers } from '../services/phases/post-process'; +import { postProcessHandlers, BasePostProcessHandler } from '../services/phases/post-process'; + +interface HandlerError { + name: CleanupPhase; // <-- Use CleanupPhase type + error: string; +} /** - * Worker to clean up expired quotes + * Worker to clean up expired quotes and post-process completed ramps */ -export class CleanupWorker { - private readonly rampService: BaseRampService; +class CleanupWorker { + private job: CronJob; - private readonly intervalMs: number; - - private interval: NodeJS.Timeout | null = null; + private readonly rampService: BaseRampService; - constructor(intervalMs = 60000) { - // Default to 1 minute + constructor(cronTime = '*/5 * * * *') { this.rampService = new BaseRampService(); - this.intervalMs = intervalMs; + + // Run immediately and then according to schedule + this.job = new CronJob( + cronTime, + this.cleanup.bind(this), + null, // onComplete + false, // start + undefined, // timeZone + null, // context + true, // runOnInit - Run immediately on start + ); } /** * Start the cleanup worker */ public start(): void { - if (this.interval) { - return; - } - logger.info('Starting cleanup worker'); - - this.interval = setInterval(async () => { - try { - await this.cleanup(); - } catch (error) { - logger.error('Error in cleanup worker:', error); - } - }, this.intervalMs); + this.job.start(); } /** * Stop the cleanup worker */ public stop(): void { - if (!this.interval) { - return; - } - logger.info('Stopping cleanup worker'); - - clearInterval(this.interval); - this.interval = null; + this.job.stop(); } /** * Run the cleanup process */ + // eslint-disable-next-line class-methods-use-this private async cleanup(): Promise { - // Clean up expired quotes - const expiredQuotesCount = await this.rampService.cleanupExpiredQuotes(); - if (expiredQuotesCount > 0) { - logger.info(`Cleaned up ${expiredQuotesCount} expired quotes`); - } + logger.info('Running cleanup worker cycle'); + try { + // Clean up expired quotes + const expiredQuotesCount = await this.rampService.cleanupExpiredQuotes(); + if (expiredQuotesCount > 0) { + logger.info(`Cleaned up ${expiredQuotesCount} expired quotes`); + } - // Post-process completed RampStates - await this.postProcessCompletedStates(); + // Post-process completed RampStates + await this.postProcessCompletedStates(); + logger.info('Cleanup worker cycle completed'); + } catch (error) { + logger.error('Error during cleanup worker cycle:', error); + } // TODO should we remove expired quotes from the database eventually? Maybe after 1 day or so? } @@ -86,6 +89,7 @@ export class CleanupWorker { }); if (states.length === 0) { + logger.info('No completed RampStates found needing post-processing.'); return; } @@ -94,21 +98,31 @@ export class CleanupWorker { const processPromises = states.map(async (state) => { try { await this.processCleanup(state); + return { status: 'fulfilled', stateId: state.id }; } catch (error) { - logger.error(`Error post-processing state ${state.id}:`, error); + logger.error(`Error processing cleanup for state ${state.id}:`, error); + // Don't update the state here, processCleanup handles its own updates + return { status: 'rejected', stateId: state.id, reason: error }; } }); - await Promise.all(processPromises); + // Use allSettled to allow individual state processing to fail without stopping others + const results = await Promise.allSettled(processPromises); + const successful = results.filter((r) => r.status === 'fulfilled').length; + const failed = results.length - successful; + logger.info( + `Post-processing attempt completed for ${states.length} states. Successful: ${successful}, Failed: ${failed}`, + ); } catch (error) { - logger.error('Error in postProcessCompletedStates:', error); + logger.error('Error fetching states in postProcessCompletedStates:', error); } } /** - * Process a state with appropriate cleanup handlers + * Process a single state with appropriate cleanup handlers * @param state The state to process */ + // eslint-disable-next-line class-methods-use-this private async processCleanup(state: RampState): Promise { // Identify which handlers should process this state const applicableHandlers = postProcessHandlers.filter((handler) => handler.shouldProcess(state)); @@ -181,4 +195,4 @@ export class CleanupWorker { } } -export default new CleanupWorker(); +export default CleanupWorker; diff --git a/api/src/api/workers/ramp-recovery.worker.ts b/api/src/api/workers/ramp-recovery.worker.ts index 5d4da5b95..d08c021bd 100644 --- a/api/src/api/workers/ramp-recovery.worker.ts +++ b/api/src/api/workers/ramp-recovery.worker.ts @@ -4,15 +4,25 @@ import RampState from '../../models/rampState.model'; import logger from '../../config/logger'; import phaseProcessor from '../services/phases/phase-processor'; +const TEN_MINUTES_IN_MS = 10 * 60 * 1000; + /** * Worker to recover failed ramp states */ class RampRecoveryWorker { private job: CronJob; - constructor() { - // Run every 5 minutes - this.job = new CronJob('*/5 * * * *', this.recover.bind(this)); + constructor(cronTime = '*/5 * * * *') { + // Run immediately and then according to schedule + this.job = new CronJob( + cronTime, + this.recover.bind(this), + null, // onComplete + false, // start + undefined, // timeZone + null, // context + true, // runOnInit - This makes it run immediately + ); } /** @@ -34,6 +44,7 @@ class RampRecoveryWorker { /** * Recover failed ramp states */ + // eslint-disable-next-line class-methods-use-this private async recover(): Promise { try { logger.info('Running ramp recovery worker'); @@ -46,44 +57,65 @@ class RampRecoveryWorker { [Op.ne]: 'complete', }, updatedAt: { - [Op.lt]: new Date(Date.now() - 10 * 60 * 1000), // 10 minutes ago + [Op.lt]: new Date(Date.now() - TEN_MINUTES_IN_MS), // 10 minutes ago }, presignedTxs: { [Op.not]: null }, }, }); - logger.info(`Found ${staleStates.length} stale ramp states`); + if (staleStates.length === 0) { + logger.info('No stale ramp states found.'); + return; + } + + logger.info(`Found ${staleStates.length} stale ramp states to process.`); - // Process each stale state - for (const state of staleStates) { + // Process each stale state concurrently + const recoveryPromises = staleStates.map(async (state) => { try { - logger.info(`Recovering ramp state ${state.id} in phase ${state.currentPhase}`); - + logger.info(`Attempting recovery for ramp state ${state.id} in phase ${state.currentPhase}`); // Process the state await phaseProcessor.processRamp(state.id); + logger.info(`Successfully processed ramp state ${state.id}`); + return { status: 'fulfilled', stateId: state.id }; } catch (error: any) { logger.error(`Error recovering ramp state ${state.id}:`, error); - // Add error to the state - const errorLogs = [ - ...(state.errorLogs || []), - { - phase: state.currentPhase, - timestamp: new Date().toISOString(), - error: error.message || 'Unknown error', - details: error.stack || {}, - }, - ]; + // Prepare error log entry + const errorLogEntry = { + phase: state.currentPhase, + timestamp: new Date().toISOString(), + error: error.message || 'Unknown error during recovery', + details: error.stack || 'No stack trace available', + }; - await state.update({ errorLogs }); + // Attempt to update the state with the error log + try { + const errorLogs = [...(state.errorLogs || []), errorLogEntry]; + await state.update({ errorLogs }); + logger.info(`Updated ramp state ${state.id} with error log.`); + } catch (updateError: any) { + logger.error(`Failed to update ramp state ${state.id} with error log:`, updateError); + // Log the original error as well if the update fails + logger.error(`Original recovery error for state ${state.id}:`, error); + } + // Return a rejected status for Promise.allSettled + return { status: 'rejected', stateId: state.id, reason: error }; } - } + }); + + // Wait for all recovery attempts to settle + const results = await Promise.allSettled(recoveryPromises); - logger.info('Ramp recovery worker completed'); + // Log summary of results + const successfulRecoveries = results.filter((r) => r.status === 'fulfilled').length; + const failedRecoveries = results.length - successfulRecoveries; + logger.info(`Ramp recovery attempt completed. Successful: ${successfulRecoveries}, Failed: ${failedRecoveries}`); } catch (error) { - logger.error('Error in ramp recovery worker:', error); + // Catch errors from the initial findAll or other unexpected issues + logger.error('Critical error in ramp recovery worker:', error); } } } -export default new RampRecoveryWorker(); +export default RampRecoveryWorker; diff --git a/api/src/database/migrator.ts b/api/src/database/migrator.ts index 1bafcb05f..a6fd344b9 100644 --- a/api/src/database/migrator.ts +++ b/api/src/database/migrator.ts @@ -91,7 +91,7 @@ if (require.main === module) { process.exit(0); } catch (error) { - console.error("Error performing action:", error); + console.error('Error performing action:', error); process.exit(1); } })(); diff --git a/api/src/index.ts b/api/src/index.ts index 4d05c0bb3..e7061fab3 100755 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -19,8 +19,8 @@ import { ApiManager } from './api/services/pendulum/apiManager'; import { testDatabaseConnection } from './config/database'; import { runMigrations } from './database/migrator'; import './models'; // Initialize models -import cleanupWorker from './api/workers/cleanup.worker'; -import rampRecoveryWorker from './api/workers/ramp-recovery.worker'; +import CleanupWorker from './api/workers/cleanup.worker'; +import RampRecoveryWorker from './api/workers/ramp-recovery.worker'; import registerPhaseHandlers from './api/services/phases/register-handlers'; import { EventPoller } from './api/services/brla/webhooks'; @@ -59,8 +59,8 @@ const initializeApp = async () => { await apiManager.populateAllApis(); // Start background workers - cleanupWorker.start(); - rampRecoveryWorker.start(); + new CleanupWorker().start(); + new RampRecoveryWorker().start(); // Register phase handlers registerPhaseHandlers(); diff --git a/frontend/src/components/BrlaComponents/BrlaExtendedForm.tsx b/frontend/src/components/BrlaComponents/BrlaExtendedForm.tsx index bc5d8110c..ef609bdb8 100644 --- a/frontend/src/components/BrlaComponents/BrlaExtendedForm.tsx +++ b/frontend/src/components/BrlaComponents/BrlaExtendedForm.tsx @@ -1,4 +1,3 @@ -import { RefObject } from 'react'; import { useTranslation } from 'react-i18next'; import { useKYCProcess } from '../../hooks/brla/useBRLAKYCProcess'; @@ -8,11 +7,7 @@ import { VerificationStatus } from './VerificationStatus'; import { BrlaFieldProps, ExtendedBrlaFieldOptions } from './BrlaField'; import { KYCForm } from './KYCForm'; -interface PIXKYCFormProps { - feeComparisonRef: RefObject; -} - -export const PIXKYCForm = ({ feeComparisonRef }: PIXKYCFormProps) => { +export const PIXKYCForm = () => { const { verificationStatus, statusMessage, handleFormSubmit, handleBackClick, isSubmitted } = useKYCProcess(); const { kycForm } = useKYCForm(); @@ -96,12 +91,7 @@ export const PIXKYCForm = ({ feeComparisonRef }: PIXKYCFormProps) => { return (
{!isSubmitted ? ( - + ) : ( )} diff --git a/frontend/src/components/BrlaComponents/BrlaSwapFields/index.tsx b/frontend/src/components/BrlaComponents/BrlaSwapFields/index.tsx index ca18ad174..5c79bb16e 100644 --- a/frontend/src/components/BrlaComponents/BrlaSwapFields/index.tsx +++ b/frontend/src/components/BrlaComponents/BrlaSwapFields/index.tsx @@ -20,9 +20,7 @@ const OFFRAMP_FIELDS = [ { id: StandardBrlaFieldOptions.PIX_ID, label: 'PIX', index: 1 }, ]; -const ONRAMP_FIELDS = [ - { id: StandardBrlaFieldOptions.TAX_ID, label: 'CPF', index: 0 } -] +const ONRAMP_FIELDS = [{ id: StandardBrlaFieldOptions.TAX_ID, label: 'CPF', index: 0 }]; /** * BrlaSwapFields component @@ -37,9 +35,10 @@ export const BrlaSwapFields: FC = () => { const fiatToken = useFiatToken(); - const rampDirection = useRampDirection(); + const rampDirection = useRampDirection(); + const isOnramp = rampDirection === RampDirection.ONRAMP; - const FIELDS = rampDirection === RampDirection.OFFRAMP ? OFFRAMP_FIELDS : ONRAMP_FIELDS; + const FIELDS = isOnramp ? ONRAMP_FIELDS : OFFRAMP_FIELDS; return ( @@ -56,9 +55,15 @@ export const BrlaSwapFields: FC = () => { /> ))}
- - CPF must belong to you. - + {isOnramp ? ( + + CPF must belong to you. + + ) : ( + + CPF and Pix key need to belong to the same person. + + )}
)} diff --git a/frontend/src/components/CopyButton/index.tsx b/frontend/src/components/CopyButton/index.tsx index be019d8ac..c33fcae3c 100644 --- a/frontend/src/components/CopyButton/index.tsx +++ b/frontend/src/components/CopyButton/index.tsx @@ -1,3 +1,5 @@ +import { useClipboard } from '../../hooks/useClipboard'; + interface CopyButtonProps { text: string; className?: string; @@ -5,8 +7,13 @@ interface CopyButtonProps { onClick?: () => void; } -export const CopyButton = ({ text, className, onClick }: CopyButtonProps) => ( - -); +export const CopyButton = (props: CopyButtonProps) => { + const clipboard = useClipboard(); + const onClick = props.onClick || (() => clipboard.copyToClipboard(props.text)); + + return ( + + ); +}; diff --git a/frontend/src/components/EmailForm/index.tsx b/frontend/src/components/EmailForm/index.tsx index 4d6d7fcac..7717f18ab 100644 --- a/frontend/src/components/EmailForm/index.tsx +++ b/frontend/src/components/EmailForm/index.tsx @@ -57,7 +57,7 @@ export const EmailForm = ({ transactionId, transactionSuccess }: EmailFormProps) return ( <> -
+
@@ -76,10 +76,10 @@ export const EmailForm = ({ transactionId, transactionSuccess }: EmailFormProps) return (
-
-

{t('components.emailForm.title')}

-

{t('components.emailForm.description')}

-

{t('components.emailForm.noNewslettersNoSpam')}

+
+

{t('components.emailForm.title')}

{/* Changed text-blue-700 to text-gray-700 */} +

{t('components.emailForm.description')}

{/* Changed text-blue-700 to text-gray-700 */} +

{t('components.emailForm.noNewslettersNoSpam')}

{/* Changed text-blue-700 to text-gray-700 */}
diff --git a/frontend/src/components/OfframpSummaryDialog/index.tsx b/frontend/src/components/OfframpSummaryDialog/index.tsx deleted file mode 100644 index 478d668a5..000000000 --- a/frontend/src/components/OfframpSummaryDialog/index.tsx +++ /dev/null @@ -1,325 +0,0 @@ -import { ArrowDownIcon, ArrowTopRightOnSquareIcon } from '@heroicons/react/20/solid'; -import { useState, FC, useMemo } from 'react'; -import Big from 'big.js'; - -import { - getOnChainTokenDetailsOrDefault, - OnChainTokenDetails, - BaseFiatTokenDetails, - isStellarOutputTokenDetails, - getAnyFiatTokenDetails, - TokenType, - FiatTokenDetails, - isFiatTokenDetails, - Networks, -} from 'shared'; -import { useGetAssetIcon } from '../../hooks/useGetAssetIcon'; -import { useNetwork } from '../../contexts/network'; - -import { ExchangeRate } from '../ExchangeRate'; -import { FiatIcon } from '../FiatIcon'; -import { NetworkIcon } from '../NetworkIcon'; -import { Dialog } from '../Dialog'; -import { Spinner } from '../Spinner'; -import { useRampActions, useRampState, useRampExecutionInput, useRampSummaryVisible } from '../../stores/offrampStore'; -import { useRampSubmission } from '../../hooks/ramp/useRampSubmission'; -import { useSep24StoreCachedAnchorUrl } from '../../stores/sep24Store'; -import { useRampDirection } from '../../stores/rampDirectionStore'; -import { RampDirection } from '../../components/RampToggle'; -import { RampExecutionInput } from '../../types/phases'; -import { useTranslation } from 'react-i18next'; -import { useFiatToken, useOnChainToken } from '../../stores/ramp/useRampFormStore'; -import { QRCodeSVG } from 'qrcode.react'; -import { CopyButton } from '../CopyButton'; - -interface AssetDisplayProps { - amount: string; - symbol: string; - iconSrc: string; - iconAlt: string; -} - -const AssetDisplay = ({ amount, symbol, iconSrc, iconAlt }: AssetDisplayProps) => ( -
- - {amount} {symbol} - - {iconAlt} -
-); - -interface FeeDetailsProps { - network: Networks; - feesCost: string; - fiatSymbol: string; - exchangeRate: string; - fromToken: OnChainTokenDetails | FiatTokenDetails; - toToken: OnChainTokenDetails | FiatTokenDetails; - partnerUrl: string; - direction: RampDirection; -} - -const FeeDetails = ({ - network, - feesCost, - fiatSymbol, - fromToken, - toToken, - exchangeRate, - partnerUrl, - direction, -}: FeeDetailsProps) => { - const { t } = useTranslation(); - - const isOfframp = direction === RampDirection.OFFRAMP; - - const fiatToken = (isOfframp ? toToken : fromToken) as FiatTokenDetails; - if (!isFiatTokenDetails(fiatToken)) { - throw new Error('Invalid fiat token details'); - } - - return ( -
-
-

- {isOfframp - ? t('components.dialogs.OfframpSummaryDialog.offrampFee') - : t('components.dialogs.OfframpSummaryDialog.onrampFee')}{' '} - ({`${fiatToken.offrampFeesBasisPoints / 100}%`} - {fiatToken.offrampFeesFixedComponent ? ` + ${fiatToken.offrampFeesFixedComponent} ${fiatSymbol}` : ''}) -

-

- {isOfframp ? ( - - ) : ( - - )} - - {feesCost} {isOfframp ? (toToken as OnChainTokenDetails).assetSymbol : fiatSymbol} - -

-
-
-

{t('components.dialogs.OfframpSummaryDialog.quote')}

-

- -

-
-
-

{t('components.dialogs.OfframpSummaryDialog.partner')}

- - {partnerUrl} - -
-
- ); -}; - -const BRLOnrampDetails = () => { - const rampDirection = useRampDirection(); - const { t } = useTranslation(); - const rampState = useRampState(); - - if (rampDirection !== RampDirection.ONRAMP) return null; - if (!rampState?.ramp?.brCode) return null; - - return ( -
-
-

{t('components.dialogs.OfframpSummaryDialog.BRLOnrampDetails.title')}

-

- {t('components.dialogs.OfframpSummaryDialog.BRLOnrampDetails.description')} -

-

{t('components.dialogs.OfframpSummaryDialog.BRLOnrampDetails.qrCode')}

-
-
- -
-
-

{t('components.dialogs.OfframpSummaryDialog.BRLOnrampDetails.copyCode')}

-

{t('components.dialogs.OfframpSummaryDialog.BRLOnrampDetails.pixCode')}:

- -
- ); -}; - -interface TransactionTokensDisplayProps { - executionInput: RampExecutionInput; - isOnramp: boolean; - selectedNetwork: Networks; - rampDirection: RampDirection; -} - -const TransactionTokensDisplay: FC = ({ - executionInput, - isOnramp, - selectedNetwork, - rampDirection, -}) => { - const fromToken = isOnramp - ? getAnyFiatTokenDetails(executionInput.fiatToken) - : getOnChainTokenDetailsOrDefault(selectedNetwork, executionInput.onChainToken); - - const toToken = isOnramp - ? getOnChainTokenDetailsOrDefault(selectedNetwork, executionInput.onChainToken) - : getAnyFiatTokenDetails(executionInput.fiatToken); - - const fromIcon = useGetAssetIcon( - isOnramp ? (fromToken as BaseFiatTokenDetails).fiat.assetIcon : (fromToken as OnChainTokenDetails).networkAssetIcon, - ); - const toIcon = useGetAssetIcon( - isOnramp ? (toToken as OnChainTokenDetails).networkAssetIcon : (toToken as BaseFiatTokenDetails).fiat.assetIcon, - ); - - const getPartnerUrl = (): string => { - const fiatToken = (isOnramp ? fromToken : toToken) as FiatTokenDetails; - return isStellarOutputTokenDetails(fiatToken) ? fiatToken.anchorHomepageUrl : fiatToken.partnerUrl; - }; - - const fiatSymbol = isOnramp - ? (fromToken as BaseFiatTokenDetails).fiat.symbol - : (toToken as BaseFiatTokenDetails).fiat.symbol; - - return ( -
- - - - - -
- ); -}; - -export const OfframpSummaryDialog: FC = () => { - const { t } = useTranslation(); - - const [isSubmitted, setIsSubmitted] = useState(false); - - const { selectedNetwork } = useNetwork(); - const { setRampExecutionInput, setRampInitiating, setRampStarted, setRampSummaryVisible, setRampPaymentConfirmed } = - useRampActions(); - const offrampState = useRampState(); - const executionInput = useRampExecutionInput(); - const visible = useRampSummaryVisible(); - const { onRampConfirm } = useRampSubmission(); - const anchorUrl = useSep24StoreCachedAnchorUrl(); - const rampDirection = useRampDirection(); - const isOnramp = rampDirection === RampDirection.ONRAMP; - const fiatToken = useFiatToken(); - const onChainToken = useOnChainToken(); - - const submitButtonDisabled = useMemo(() => { - if (!executionInput) return true; - - if (!isOnramp) { - if (!anchorUrl && getAnyFiatTokenDetails(fiatToken).type === TokenType.Stellar) return true; - if (!executionInput.brlaEvmAddress && getAnyFiatTokenDetails(fiatToken).type === 'moonbeam') return true; - } - - return isSubmitted; - }, [anchorUrl, executionInput, fiatToken, isOnramp, isSubmitted]); - - if (!visible) return null; - if (!executionInput) return null; - - const toToken = isOnramp - ? getOnChainTokenDetailsOrDefault(selectedNetwork, onChainToken) - : getAnyFiatTokenDetails(fiatToken); - - const onClose = () => { - setIsSubmitted(false); - setRampExecutionInput(undefined); - setRampStarted(false); - setRampInitiating(false); - setRampSummaryVisible(false); - }; - - const onSubmit = () => { - setIsSubmitted(true); - - if (executionInput.quote.rampType === 'on') { - setRampPaymentConfirmed(true); - } else { - onRampConfirm(); - } - - if (!isOnramp && (toToken as FiatTokenDetails).type !== 'moonbeam' && anchorUrl) { - window.open(anchorUrl, '_blank'); - } - }; - - const headerText = isOnramp - ? t('components.dialogs.OfframpSummaryDialog.headerText.buy') - : t('components.dialogs.OfframpSummaryDialog.headerText.sell'); - - const actions = ( - - ); - - const content = ( - - ); - - return ; -}; diff --git a/frontend/src/components/RampSubmitButtons/index.tsx b/frontend/src/components/RampSubmitButtons/index.tsx index 100ebb2bc..0ed37ac86 100644 --- a/frontend/src/components/RampSubmitButtons/index.tsx +++ b/frontend/src/components/RampSubmitButtons/index.tsx @@ -2,7 +2,7 @@ import { FC, useCallback } from 'react'; import Big from 'big.js'; import { useEventsContext } from '../../contexts/events'; import { useFeeComparisonStore } from '../../stores/feeComparison'; -import { useRampSummaryVisible } from '../../stores/offrampStore'; +import { useRampSummaryVisible } from '../../stores/rampStore'; import { useRampValidation } from '../../hooks/ramp/useRampValidation'; import { SwapSubmitButton } from '../buttons/SwapSubmitButton'; import { useFiatToken, useInputAmount, useOnChainToken } from '../../stores/ramp/useRampFormStore'; @@ -20,7 +20,7 @@ export const RampSubmitButtons: FC = ({ toAmount }) => { const { feeComparisonRef } = useFeeComparisonStore(); const { trackEvent } = useEventsContext(); const { getCurrentErrorMessage, initializeFailedMessage } = useRampValidation(); - const isOfframpSummaryDialogVisible = useRampSummaryVisible(); + const isRampSummaryDialogVisible = useRampSummaryVisible(); const inputAmount = useInputAmount(); const fiatToken = useFiatToken(); const onChainToken = useOnChainToken(); @@ -45,14 +45,14 @@ export const RampSubmitButtons: FC = ({ toAmount }) => { ); const getButtonState = (): string => { - if (isOfframpSummaryDialogVisible) { + if (isRampSummaryDialogVisible) { return t('components.swapSubmitButton.processing'); } return t('components.swapSubmitButton.confirm'); }; const isSubmitButtonDisabled = Boolean(getCurrentErrorMessage()) || !toAmount || !!initializeFailedMessage; - const isSubmitButtonPending = isOfframpSummaryDialogVisible; + const isSubmitButtonPending = isRampSummaryDialogVisible; return (
diff --git a/frontend/src/components/RampSummaryDialog/AssetDisplay.tsx b/frontend/src/components/RampSummaryDialog/AssetDisplay.tsx new file mode 100644 index 000000000..c05057a68 --- /dev/null +++ b/frontend/src/components/RampSummaryDialog/AssetDisplay.tsx @@ -0,0 +1,17 @@ +import { FC } from 'react'; + +interface AssetDisplayProps { + amount: string; + symbol: string; + iconSrc: string; + iconAlt: string; +} + +export const AssetDisplay: FC = ({ amount, symbol, iconSrc, iconAlt }) => ( +
+ + {amount} {symbol} + + {iconAlt} +
+); diff --git a/frontend/src/components/RampSummaryDialog/BRLOnrampDetails.tsx b/frontend/src/components/RampSummaryDialog/BRLOnrampDetails.tsx new file mode 100644 index 000000000..8aae933d9 --- /dev/null +++ b/frontend/src/components/RampSummaryDialog/BRLOnrampDetails.tsx @@ -0,0 +1,34 @@ +import { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import { QRCodeSVG } from 'qrcode.react'; +import { CopyButton } from '../CopyButton'; +import { useRampDirection } from '../../stores/rampDirectionStore'; +import { useRampState } from '../../stores/rampStore'; +import { RampDirection } from '../RampToggle'; + +export const BRLOnrampDetails: FC = () => { + const rampDirection = useRampDirection(); + const { t } = useTranslation(); + const rampState = useRampState(); + + if (rampDirection !== RampDirection.ONRAMP) return null; + if (!rampState?.ramp?.brCode) return null; + + return ( +
+
+

{t('components.dialogs.RampSummaryDialog.BRLOnrampDetails.title')}

+

+ {t('components.dialogs.RampSummaryDialog.BRLOnrampDetails.description')} +

+

{t('components.dialogs.RampSummaryDialog.BRLOnrampDetails.qrCode')}

+
+
+ +
+
+

{t('components.dialogs.RampSummaryDialog.BRLOnrampDetails.copyCode')}

+ +
+ ); +}; diff --git a/frontend/src/components/RampSummaryDialog/FeeDetails.tsx b/frontend/src/components/RampSummaryDialog/FeeDetails.tsx new file mode 100644 index 000000000..84b3685b6 --- /dev/null +++ b/frontend/src/components/RampSummaryDialog/FeeDetails.tsx @@ -0,0 +1,74 @@ +import { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FiatTokenDetails, isFiatTokenDetails, Networks, OnChainTokenDetails } from 'shared'; + +import { ExchangeRate } from '../ExchangeRate'; +import { NetworkIcon } from '../NetworkIcon'; +import { RampDirection } from '../RampToggle'; + +interface FeeDetailsProps { + network: Networks; + feesCost: string; + fiatSymbol: string; + exchangeRate: string; + fromToken: OnChainTokenDetails | FiatTokenDetails; + toToken: OnChainTokenDetails | FiatTokenDetails; + partnerUrl: string; + direction: RampDirection; +} + +export const FeeDetails: FC = ({ + network, + feesCost, + fiatSymbol, + fromToken, + toToken, + exchangeRate, + partnerUrl, + direction, +}) => { + const { t } = useTranslation(); + + const isOfframp = direction === RampDirection.OFFRAMP; + + const fiatToken = (isOfframp ? toToken : fromToken) as FiatTokenDetails; + if (!isFiatTokenDetails(fiatToken)) { + throw new Error('Invalid fiat token details'); + } + + return ( +
+
+

+ {isOfframp + ? t('components.dialogs.RampSummaryDialog.offrampFee') + : t('components.dialogs.RampSummaryDialog.onrampFee')}{' '} + ({`${fiatToken.offrampFeesBasisPoints / 100}%`} + {fiatToken.offrampFeesFixedComponent ? ` + ${fiatToken.offrampFeesFixedComponent} ${fiatSymbol}` : ''}) +

+

+ + + {feesCost} {(toToken as OnChainTokenDetails).assetSymbol} + +

+
+
+

{t('components.dialogs.RampSummaryDialog.quote')}

+

+ +

+
+
+

{t('components.dialogs.RampSummaryDialog.partner')}

+ + {partnerUrl} + +
+
+ ); +}; diff --git a/frontend/src/components/RampSummaryDialog/RampSummaryButton.tsx b/frontend/src/components/RampSummaryDialog/RampSummaryButton.tsx new file mode 100644 index 000000000..e93fefb6b --- /dev/null +++ b/frontend/src/components/RampSummaryDialog/RampSummaryButton.tsx @@ -0,0 +1,172 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ArrowTopRightOnSquareIcon } from '@heroicons/react/20/solid'; +import { + FiatToken, + FiatTokenDetails, + getAnyFiatTokenDetails, + getOnChainTokenDetailsOrDefault, + TokenType, +} from 'shared'; +import { useCanRegisterRamp, useRampExecutionInput, useRampState } from '../../stores/rampStore'; +import { useRampDirection } from '../../stores/rampDirectionStore'; +import { RampDirection } from '../RampToggle'; +import { useIsQuoteExpired, useRampSummaryStore } from '../../stores/rampSummary'; +import { useRampActions } from '../../stores/rampStore'; +import { useRampSubmission } from '../../hooks/ramp/useRampSubmission'; +import { useSep24StoreCachedAnchorUrl } from '../../stores/sep24Store'; +import { useFiatToken, useOnChainToken } from '../../stores/ramp/useRampFormStore'; +import { useNetwork } from '../../contexts/network'; +import { Spinner } from '../Spinner'; + +interface UseButtonContentProps { + isSubmitted: boolean; + toToken: FiatTokenDetails; + submitButtonDisabled: boolean; +} + +export const useButtonContent = ({ isSubmitted, toToken, submitButtonDisabled }: UseButtonContentProps) => { + const rampState = useRampState(); + const { t } = useTranslation(); + const rampDirection = useRampDirection(); + const isQuoteExpired = useIsQuoteExpired(); + const canRegisterRamp = useCanRegisterRamp(); + + return useMemo(() => { + const isOnramp = rampDirection === RampDirection.ONRAMP; + const isOfframp = rampDirection === RampDirection.OFFRAMP; + const isBRCodeReady = Boolean(rampState?.ramp?.brCode); + + // BRL offramp has no redirect, it is the only with type moonbeam + const isAnchorWithoutRedirect = toToken.type === 'moonbeam'; + const isAnchorWithRedirect = !isAnchorWithoutRedirect; + + if ((isOnramp && isBRCodeReady && isQuoteExpired) || (isOfframp && isQuoteExpired)) { + return { + text: t('components.dialogs.RampSummaryDialog.quoteExpired'), + icon: null, + }; + } + + if (submitButtonDisabled) { + return { + text: t('components.swapSubmitButton.processing'), + icon: , + }; + } + + if (isOfframp && isAnchorWithoutRedirect && !canRegisterRamp) { + return { + text: t('components.dialogs.RampSummaryDialog.confirm'), + icon: null, + }; + } + + if (isOfframp && rampState !== undefined) { + return { + text: t('components.dialogs.RampSummaryDialog.processing'), + icon: , + }; + } + + if (isOnramp && isBRCodeReady) { + return { + text: t('components.swapSubmitButton.confirmPayment'), + icon: null, + }; + } + + if (isOfframp && isAnchorWithRedirect) { + if (isSubmitted) { + return { + text: t('components.dialogs.RampSummaryDialog.continueOnPartnersPage'), + icon: , + }; + } else { + return { + text: t('components.dialogs.RampSummaryDialog.continueWithPartner'), + icon: , + }; + } + } + + return { + text: t('components.swapSubmitButton.processing'), + icon: , + }; + }, [submitButtonDisabled, isQuoteExpired, rampDirection, rampState, t, isSubmitted, canRegisterRamp, toToken]); +}; + +export const RampSummaryButton = () => { + const [isSubmitted, setIsSubmitted] = useState(false); + const { setRampPaymentConfirmed } = useRampActions(); + const rampState = useRampState(); + const { onRampConfirm } = useRampSubmission(); + const anchorUrl = useSep24StoreCachedAnchorUrl(); + const rampDirection = useRampDirection(); + const isOfframp = rampDirection === RampDirection.OFFRAMP; + const isOnramp = rampDirection === RampDirection.ONRAMP; + const { isQuoteExpired } = useRampSummaryStore(); + const fiatToken = useFiatToken(); + const onChainToken = useOnChainToken(); + const { selectedNetwork } = useNetwork(); + const executionInput = useRampExecutionInput(); + const { setCanRegisterRamp } = useRampActions(); + + const toToken = isOnramp + ? getOnChainTokenDetailsOrDefault(selectedNetwork, onChainToken) + : getAnyFiatTokenDetails(fiatToken); + + const submitButtonDisabled = useMemo(() => { + if (!executionInput) return true; + if (isQuoteExpired) return true; + + if (isOfframp) { + if (!anchorUrl && getAnyFiatTokenDetails(fiatToken).type === TokenType.Stellar) return true; + if (!executionInput.brlaEvmAddress && getAnyFiatTokenDetails(fiatToken).type === 'moonbeam') return true; + } + + const isBRCodeReady = Boolean(isOnramp && rampState?.ramp?.brCode); + if (isOnramp && !isBRCodeReady) return true; + + return isSubmitted; + }, [executionInput, isQuoteExpired, isOfframp, isOnramp, rampState?.ramp?.brCode, isSubmitted, anchorUrl, fiatToken]); + + const buttonContent = useButtonContent({ + isSubmitted, + toToken: toToken as FiatTokenDetails, + submitButtonDisabled, + }); + + const onSubmit = () => { + setIsSubmitted(true); + + // For BRL offramps, set canRegisterRamp to true + if (isOfframp && fiatToken === FiatToken.BRL && executionInput?.quote.rampType === 'off') { + setCanRegisterRamp(true); + } + + if (executionInput?.quote.rampType === 'on') { + setRampPaymentConfirmed(true); + } else { + onRampConfirm(); + } + + if (!isOnramp && (toToken as FiatTokenDetails).type !== 'moonbeam' && anchorUrl) { + window.open(anchorUrl, '_blank'); + } + }; + + return ( + + ); +}; diff --git a/frontend/src/components/RampSummaryDialog/TransactionTokensDisplay.tsx b/frontend/src/components/RampSummaryDialog/TransactionTokensDisplay.tsx new file mode 100644 index 000000000..5b048f6c0 --- /dev/null +++ b/frontend/src/components/RampSummaryDialog/TransactionTokensDisplay.tsx @@ -0,0 +1,155 @@ +import { FC, useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import Big from 'big.js'; +import { ArrowDownIcon } from '@heroicons/react/20/solid'; +import { + getOnChainTokenDetailsOrDefault, + getAnyFiatTokenDetails, + OnChainTokenDetails, + BaseFiatTokenDetails, + isStellarOutputTokenDetails, + FiatTokenDetails, + Networks, +} from 'shared'; +import { useGetAssetIcon } from '../../hooks/useGetAssetIcon'; +import { useRampState } from '../../stores/rampStore'; +import { RampDirection } from '../RampToggle'; +import { RampExecutionInput } from '../../types/phases'; +import { useRampSummaryActions } from '../../stores/rampSummary'; +import { AssetDisplay } from './AssetDisplay'; +import { FeeDetails } from './FeeDetails'; +import { BRLOnrampDetails } from './BRLOnrampDetails'; + +// Define onramp expiry time in minutes. This is not arbitrary, but based on the assumptions imposed by the backend. +const ONRAMP_EXPIRY_MINUTES = 5; + +interface TransactionTokensDisplayProps { + executionInput: RampExecutionInput; + isOnramp: boolean; + selectedNetwork: Networks; + rampDirection: RampDirection; +} + +export const TransactionTokensDisplay: FC = ({ + executionInput, + isOnramp, + selectedNetwork, + rampDirection, +}) => { + const { t } = useTranslation(); + const rampState = useRampState(); + const [timeLeft, setTimeLeft] = useState({ minutes: ONRAMP_EXPIRY_MINUTES, seconds: 0 }); + const [targetTimestamp, setTargetTimestamp] = useState(null); + const { setIsQuoteExpired } = useRampSummaryActions(); + + useEffect(() => { + let targetTimestamp: number | null = null; + + if (isOnramp) { + // Onramp: Use ramp creation time + expiry duration + const createdAt = rampState?.ramp?.createdAt; + if (createdAt) { + targetTimestamp = new Date(createdAt).getTime() + ONRAMP_EXPIRY_MINUTES * 60 * 1000; + } + } else { + // Offramp: Use quote expiry time directly + const expiresAt = executionInput.quote.expiresAt; + targetTimestamp = new Date(expiresAt).getTime(); + } + + setTargetTimestamp(targetTimestamp); + + if (targetTimestamp === null) { + // If no valid timestamp, mark as expired immediately + setTimeLeft({ minutes: 0, seconds: 0 }); + setIsQuoteExpired(true); + return; + } + + const intervalId = setInterval(() => { + const now = Date.now(); + const diff = targetTimestamp - now; + + if (diff <= 0) { + setTimeLeft({ minutes: 0, seconds: 0 }); + setIsQuoteExpired(true); + clearInterval(intervalId); + return; + } + + const minutes = Math.floor((diff / (1000 * 60)) % 60); + const seconds = Math.floor((diff / 1000) % 60); + setTimeLeft({ minutes, seconds }); + setIsQuoteExpired(false); + }, 1000); + + return () => clearInterval(intervalId); + }, [isOnramp, rampState?.ramp?.createdAt, executionInput.quote.expiresAt, setIsQuoteExpired]); + + const formattedTime = `${timeLeft.minutes}:${timeLeft.seconds < 10 ? '0' : ''}${timeLeft.seconds}`; + + const fromToken = isOnramp + ? getAnyFiatTokenDetails(executionInput.fiatToken) + : getOnChainTokenDetailsOrDefault(selectedNetwork, executionInput.onChainToken); + + const toToken = isOnramp + ? getOnChainTokenDetailsOrDefault(selectedNetwork, executionInput.onChainToken) + : getAnyFiatTokenDetails(executionInput.fiatToken); + + const fromIcon = useGetAssetIcon( + isOnramp ? (fromToken as BaseFiatTokenDetails).fiat.assetIcon : (fromToken as OnChainTokenDetails).networkAssetIcon, + ); + + const toIcon = useGetAssetIcon( + isOnramp ? (toToken as OnChainTokenDetails).networkAssetIcon : (toToken as BaseFiatTokenDetails).fiat.assetIcon, + ); + + const getPartnerUrl = (): string => { + const fiatToken = (isOnramp ? fromToken : toToken) as FiatTokenDetails; + return isStellarOutputTokenDetails(fiatToken) ? fiatToken.anchorHomepageUrl : fiatToken.partnerUrl; + }; + + const fiatSymbol = isOnramp + ? (fromToken as BaseFiatTokenDetails).fiat.symbol + : (toToken as BaseFiatTokenDetails).fiat.symbol; + + return ( +
+ + + + + + {targetTimestamp !== null && ( +
+ {t('components.dialogs.RampSummaryDialog.BRLOnrampDetails.timerLabel')} {formattedTime} +
+ )} +
+ ); +}; diff --git a/frontend/src/components/RampSummaryDialog/index.tsx b/frontend/src/components/RampSummaryDialog/index.tsx new file mode 100644 index 000000000..468f6a7ea --- /dev/null +++ b/frontend/src/components/RampSummaryDialog/index.tsx @@ -0,0 +1,55 @@ +import { FC } from 'react'; +import Big from 'big.js'; +import { useTranslation } from 'react-i18next'; +import { Dialog } from '../Dialog'; +import { useRampActions, useRampExecutionInput, useRampSummaryVisible } from '../../stores/rampStore'; +import { useFiatToken, useOnChainToken } from '../../stores/ramp/useRampFormStore'; +import { useQuoteStore } from '../../stores/ramp/useQuoteStore'; +import { useNetwork } from '../../contexts/network'; +import { RampDirection } from '../RampToggle'; +import { TransactionTokensDisplay } from './TransactionTokensDisplay'; +import { RampSummaryButton } from './RampSummaryButton'; +import { useRampDirection } from '../../stores/rampDirectionStore'; + +export const RampSummaryDialog: FC = () => { + const { t } = useTranslation(); + const { selectedNetwork } = useNetwork(); + const { resetRampState } = useRampActions(); + const executionInput = useRampExecutionInput(); + const visible = useRampSummaryVisible(); + const rampDirection = useRampDirection(); + const isOnramp = rampDirection === RampDirection.ONRAMP; + const fiatToken = useFiatToken(); + const onChainToken = useOnChainToken(); + const { quote, fetchQuote } = useQuoteStore(); + + if (!visible) return null; + if (!executionInput) return null; + + const onClose = () => { + resetRampState(); + fetchQuote({ + rampType: isOnramp ? 'on' : 'off', + inputAmount: Big(quote?.inputAmount || '0'), + onChainToken, + fiatToken, + selectedNetwork, + }); + }; + + const headerText = isOnramp + ? t('components.dialogs.RampSummaryDialog.headerText.buy') + : t('components.dialogs.RampSummaryDialog.headerText.sell'); + + const actions = ; + const content = ( + + ); + + return ; +}; diff --git a/frontend/src/components/SigningBox/index.tsx b/frontend/src/components/SigningBox/index.tsx index 4624ceb16..043752e24 100644 --- a/frontend/src/components/SigningBox/index.tsx +++ b/frontend/src/components/SigningBox/index.tsx @@ -7,7 +7,7 @@ import { isNetworkEVM } from 'shared'; import { useNetwork } from '../../contexts/network'; import { Spinner } from '../Spinner'; import { RampSigningPhase } from '../../types/phases'; -import { useRampSigningPhase } from '../../stores/offrampStore'; +import { useRampSigningPhase } from '../../stores/rampStore'; import { useTranslation } from 'react-i18next'; type ProgressConfig = { diff --git a/frontend/src/contexts/network.tsx b/frontend/src/contexts/network.tsx index 2e098c1f1..83844c8a4 100644 --- a/frontend/src/contexts/network.tsx +++ b/frontend/src/contexts/network.tsx @@ -2,7 +2,7 @@ import { createContext, ReactNode, useContext, useState, useEffect, useCallback import { useAccount, useSwitchChain } from 'wagmi'; import { useLocalStorage, LocalStorageKeys } from '../hooks/useLocalStorage'; import { WALLETCONNECT_ASSETHUB_ID } from '../constants/constants'; -import { useRampActions } from '../stores/offrampStore'; +import { useRampActions } from '../stores/rampStore'; import { getNetworkId, isNetworkEVM, Networks } from 'shared'; import { useSep24Actions } from '../stores/sep24Store'; diff --git a/frontend/src/hooks/brla/useBRLAKYCProcess/index.tsx b/frontend/src/hooks/brla/useBRLAKYCProcess/index.tsx index ed00e6f1f..ca2b17cb6 100644 --- a/frontend/src/hooks/brla/useBRLAKYCProcess/index.tsx +++ b/frontend/src/hooks/brla/useBRLAKYCProcess/index.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQueryClient } from '@tanstack/react-query'; -import { useRampActions } from '../../../stores/offrampStore'; +import { useRampActions } from '../../../stores/rampStore'; import { useKycStatusQuery } from '../useKYCStatusQuery'; import { KYCFormData } from '../useKYCForm'; import { createSubaccount, KycStatus } from '../../../services/signingService'; diff --git a/frontend/src/hooks/offramp/useRampService/useRegisterRamp.ts b/frontend/src/hooks/offramp/useRampService/useRegisterRamp.ts index 328af6fe5..4482afcd5 100644 --- a/frontend/src/hooks/offramp/useRampService/useRegisterRamp.ts +++ b/frontend/src/hooks/offramp/useRampService/useRegisterRamp.ts @@ -1,4 +1,4 @@ -import { useRampExecutionInput, useRampStore } from '../../../stores/offrampStore'; +import { useRampExecutionInput, useRampStore } from '../../../stores/rampStore'; import { useVortexAccount } from '../../useVortexAccount'; import { RampService } from '../../../services/api'; import { AccountMeta, FiatToken, getAddressForFormat, Networks, signUnsignedTransactions } from 'shared'; @@ -63,7 +63,8 @@ export const useRegisterRamp = () => { rampRegistered, rampState, rampStarted, - actions: { setRampRegistered, setRampState, setRampSigningPhase }, + canRegisterRamp, + actions: { setRampRegistered, setRampState, setRampSigningPhase, setCanRegisterRamp }, } = useRampStore(); const { address } = useVortexAccount(); @@ -77,11 +78,11 @@ export const useRegisterRamp = () => { const prepareOfframpSubmission = useSubmitOfframp(); const handleOnAnchorWindowOpen = useAnchorWindowHandler(); - // This flag is used to track if the user signaled to start the ramp process - const [canRegisterRamp, setCanRegisterRamp] = useState(false); // TODO if user declined signing, do something const [userDeclinedSigning, setUserDeclinedSigning] = useState(false); + // This should be called for onramps, when the user opens the summary dialog, and for offramps, when the user + // clicks on the Continue button in the form (BRL) or comes back from the anchor page. const registerRamp = async (executionInput: RampExecutionInput) => { prepareOfframpSubmission(executionInput); @@ -92,8 +93,13 @@ export const useRegisterRamp = () => { await handleOnAnchorWindowOpen(); } - // For other ramps, we can continue registering right away - setCanRegisterRamp(true); + if (executionInput.quote.rampType === 'off' && executionInput.fiatToken === FiatToken.BRL) { + // Waiting for user input (the ramp summary dialog should show the 'Confirm' button and once clicked, + // We setCanRegisterRamp to true inside of the RampSummaryButton + } else { + // For other ramps, we can continue registering right away + setCanRegisterRamp(true); + } }; const { checkLock, verifyLock, releaseLock } = useProcessLock(REGISTER_KEY_LOCAL_STORAGE); @@ -111,6 +117,10 @@ export const useRegisterRamp = () => { const { processRef } = lockResult; const registerRampProcess = async () => { + if (!canRegisterRamp) { + throw new Error('Cannot proceed with ramp registration, canRegisterRamp is false'); + } + // Verify we still own the lock before proceeding if (!verifyLock(processRef)) { return; diff --git a/frontend/src/hooks/offramp/useRampService/useStartRamp.ts b/frontend/src/hooks/offramp/useRampService/useStartRamp.ts index 4e028f1d9..475ac1aea 100644 --- a/frontend/src/hooks/offramp/useRampService/useStartRamp.ts +++ b/frontend/src/hooks/offramp/useRampService/useStartRamp.ts @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { useRampStore } from '../../../stores/offrampStore'; +import { useRampStore } from '../../../stores/rampStore'; import { RampService } from '../../../services/api'; export const useStartRamp = () => { @@ -48,6 +48,7 @@ export const useStartRamp = () => { }) .catch((err) => { console.error('Error starting ramp:', err); + // TODO this can fail if the ramp 'expired'. We should handle this case and show a message to the user }); }, [rampPaymentConfirmed, rampStarted, rampState, setRampStarted]); }; diff --git a/frontend/src/hooks/offramp/useSEP24/useAnchorWindowHandler.ts b/frontend/src/hooks/offramp/useSEP24/useAnchorWindowHandler.ts index 53ed4314a..07f2a7d0e 100644 --- a/frontend/src/hooks/offramp/useSEP24/useAnchorWindowHandler.ts +++ b/frontend/src/hooks/offramp/useSEP24/useAnchorWindowHandler.ts @@ -8,7 +8,7 @@ import { sep24Second } from '../../../services/anchor/sep24/second'; import { useTrackSEP24Events } from './useTrackSEP24Events'; import { usePendulumNode } from '../../../contexts/polkadotNode'; -import { useRampActions, useRampStore } from '../../../stores/offrampStore'; +import { useRampActions, useRampStore } from '../../../stores/rampStore'; import { useSep24AnchorSessionParams, useSep24InitialResponse } from '../../../stores/sep24Store'; import { useVortexAccount } from '../../useVortexAccount'; import { useToastMessage } from '../../../helpers/notifications'; diff --git a/frontend/src/hooks/offramp/useSubmitOfframp.ts b/frontend/src/hooks/offramp/useSubmitOfframp.ts index 53d0c49b1..26fdb9663 100644 --- a/frontend/src/hooks/offramp/useSubmitOfframp.ts +++ b/frontend/src/hooks/offramp/useSubmitOfframp.ts @@ -6,13 +6,12 @@ import { useVortexAccount } from '../useVortexAccount'; import { useNetwork } from '../../contexts/network'; import { useEventsContext } from '../../contexts/events'; import { useSiweContext } from '../../contexts/siwe'; -import { getOnChainTokenDetailsOrDefault, getAnyFiatTokenDetails, getTokenDetailsSpacewalk, FiatToken } from 'shared'; +import { FiatToken, getAnyFiatTokenDetails, getOnChainTokenDetailsOrDefault, getTokenDetailsSpacewalk } from 'shared'; import { fetchTomlValues } from '../../services/stellar'; import { sep24First } from '../../services/anchor/sep24/first'; import { sep10 } from '../../services/anchor/sep10'; -import { useRampActions } from '../../stores/offrampStore'; +import { useRampActions } from '../../stores/rampStore'; import { useSep24Actions } from '../../stores/sep24Store'; -import { usePendulumNode } from '../../contexts/polkadotNode'; import { SIGNING_SERVICE_URL } from '../../constants/constants'; import { RampExecutionInput } from '../../types/phases'; import { useToastMessage } from '../../helpers/notifications'; diff --git a/frontend/src/hooks/ramp/useRampNavigation.ts b/frontend/src/hooks/ramp/useRampNavigation.ts index e46dcd120..6b8ba6e7c 100644 --- a/frontend/src/hooks/ramp/useRampNavigation.ts +++ b/frontend/src/hooks/ramp/useRampNavigation.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react'; import { ReactNode } from 'react'; -import { useRampState, useRampKycStarted, useRampStarted } from '../../stores/offrampStore'; +import { useRampState, useRampKycStarted, useRampStarted } from '../../stores/rampStore'; export const useRampNavigation = ( successComponent: ReactNode, diff --git a/frontend/src/hooks/ramp/useRampSubmission.ts b/frontend/src/hooks/ramp/useRampSubmission.ts index acb32c7bc..baf71746e 100644 --- a/frontend/src/hooks/ramp/useRampSubmission.ts +++ b/frontend/src/hooks/ramp/useRampSubmission.ts @@ -4,7 +4,7 @@ import { useRampFormStore } from '../../stores/ramp/useRampFormStore'; import { useQuoteStore } from '../../stores/ramp/useQuoteStore'; import { useVortexAccount } from '../useVortexAccount'; import { useNetwork } from '../../contexts/network'; -import { useRampActions } from '../../stores/offrampStore'; +import { useRampActions } from '../../stores/rampStore'; import { useEventsContext } from '../../contexts/events'; import { createMoonbeamEphemeral, @@ -141,7 +141,7 @@ export const useRampSubmission = () => { onRampConfirm, isExecutionPreparing: executionPreparing, finishOfframping: () => { - resetRampState() + resetRampState(); }, validateSubmissionData, }; diff --git a/frontend/src/hooks/ramp/useRampValidation.ts b/frontend/src/hooks/ramp/useRampValidation.ts index 7e2233bd2..8d7d0ebc3 100644 --- a/frontend/src/hooks/ramp/useRampValidation.ts +++ b/frontend/src/hooks/ramp/useRampValidation.ts @@ -4,6 +4,7 @@ import { FiatTokenDetails, getAnyFiatTokenDetails, getOnChainTokenDetailsOrDefault, + Networks, OnChainTokenDetails, QuoteEndpoints, } from 'shared'; @@ -18,16 +19,21 @@ import { config } from '../../config'; import { useRampDirection } from '../../stores/rampDirectionStore'; import { RampDirection } from '../../components/RampToggle'; import { useVortexAccount } from '../useVortexAccount'; +import { useTranslation } from 'react-i18next'; +import { TFunction } from 'i18next'; -function validateOnramp({ - inputAmount, - fromToken, - trackEvent, -}: { - inputAmount: Big; - fromToken: FiatTokenDetails; - trackEvent: (event: TrackableEvent) => void; -}): string | null { +function validateOnramp( + t: TFunction<'translation', undefined>, + { + inputAmount, + fromToken, + trackEvent, + }: { + inputAmount: Big; + fromToken: FiatTokenDetails; + trackEvent: (event: TrackableEvent) => void; + }, +): string | null { const maxAmountUnits = multiplyByPowerOfTen(Big(fromToken.maxWithdrawalAmountRaw), -fromToken.decimals); const minAmountUnits = multiplyByPowerOfTen(Big(fromToken.minWithdrawalAmountRaw), -fromToken.decimals); @@ -37,9 +43,10 @@ function validateOnramp({ error_message: 'more_than_maximum_withdrawal', input_amount: inputAmount ? inputAmount.toString() : '0', }); - return `Maximum onramp amount is ${stringifyBigWithSignificantDecimals(maxAmountUnits, 2)} ${ - fromToken.fiat.symbol - }.`; + return t('pages.swap.error.moreThanMaximumWithdrawal.buy', { + maxAmountUnits: stringifyBigWithSignificantDecimals(maxAmountUnits, 2), + assetSymbol: fromToken.fiat.symbol, + }); } if (inputAmount && !inputAmount.eq(0) && minAmountUnits.gt(inputAmount)) { @@ -48,29 +55,33 @@ function validateOnramp({ error_message: 'less_than_minimum_withdrawal', input_amount: inputAmount ? inputAmount.toString() : '0', }); - return `Minimum onramp amount is ${stringifyBigWithSignificantDecimals(minAmountUnits, 2)} ${ - fromToken.fiat.symbol - }.`; + return t('pages.swap.error.lessThanMinimumWithdrawal.buy', { + minAmountUnits: stringifyBigWithSignificantDecimals(minAmountUnits, 2), + assetSymbol: fromToken.fiat.symbol, + }); } return null; } -function validateOfframp({ - inputAmount, - fromToken, - toToken, - quote, - userInputTokenBalance, - trackEvent, -}: { - inputAmount: Big; - fromToken: OnChainTokenDetails; - toToken: FiatTokenDetails; - quote: QuoteEndpoints.QuoteResponse; - userInputTokenBalance: string | null; - trackEvent: (event: TrackableEvent) => void; -}): string | null { +function validateOfframp( + t: TFunction<'translation', undefined>, + { + inputAmount, + fromToken, + toToken, + quote, + userInputTokenBalance, + trackEvent, + }: { + inputAmount: Big; + fromToken: OnChainTokenDetails; + toToken: FiatTokenDetails; + quote: QuoteEndpoints.QuoteResponse; + userInputTokenBalance: string | null; + trackEvent: (event: TrackableEvent) => void; + }, +): string | null { if (typeof userInputTokenBalance === 'string') { if (Big(userInputTokenBalance).lt(inputAmount ?? 0)) { trackEvent({ @@ -78,7 +89,10 @@ function validateOfframp({ error_message: 'insufficient_balance', input_amount: inputAmount ? inputAmount.toString() : '0', }); - return `Insufficient balance. Your balance is ${userInputTokenBalance} ${fromToken?.assetSymbol}.`; + return t('pages.swap.error.insufficientFunds', { + userInputTokenBalance, + assetSymbol: fromToken?.assetSymbol, + }); } } @@ -92,9 +106,10 @@ function validateOfframp({ error_message: 'more_than_maximum_withdrawal', input_amount: inputAmount ? inputAmount.toString() : '0', }); - return `Maximum withdrawal amount is ${stringifyBigWithSignificantDecimals(maxAmountUnits, 2)} ${ - toToken.fiat.symbol - }.`; + return t('pages.swap.error.moreThanMinimumWithdrawal.sell', { + minAmountUnits: stringifyBigWithSignificantDecimals(minAmountUnits, 2), + assetSymbol: toToken.fiat.symbol, + }); } const amountOut = quote ? Big(quote.outputAmount) : Big(0); @@ -106,9 +121,11 @@ function validateOfframp({ error_message: 'less_than_minimum_withdrawal', input_amount: inputAmount ? inputAmount.toString() : '0', }); - return `Minimum withdrawal amount is ${stringifyBigWithSignificantDecimals(minAmountUnits, 2)} ${ - toToken.fiat.symbol - }.`; + + return t('pages.swap.error.lessThanMinimumWithdrawal.sell', { + minAmountUnits: stringifyBigWithSignificantDecimals(minAmountUnits, 2), + assetSymbol: toToken.fiat.symbol, + }); } } @@ -116,6 +133,8 @@ function validateOfframp({ } export const useRampValidation = () => { + const { t } = useTranslation(); + const { inputAmount: inputAmountString, onChainToken, fiatToken } = useRampFormStore(); const { quote, loading: quoteLoading } = useQuoteStore(); const { selectedNetwork } = useNetwork(); @@ -145,13 +164,13 @@ export const useRampValidation = () => { let validationError = null; if (isOnramp) { - validationError = validateOnramp({ + validationError = validateOnramp(t, { inputAmount, fromToken: fromToken as FiatTokenDetails, trackEvent, }); } else { - validationError = validateOfframp({ + validationError = validateOfframp(t, { inputAmount, fromToken: fromToken as OnChainTokenDetails, toToken: toToken as FiatTokenDetails, @@ -162,19 +181,21 @@ export const useRampValidation = () => { } if (validationError) return validationError; - if (quoteLoading) return 'Calculating quote...'; + if (quoteLoading) return t('components.swap.validation.calculatingQuote') return null; }, [ + isDisconnected, isOnramp, + quoteLoading, + selectedNetwork, + t, inputAmount, fromToken, + trackEvent, toToken, quote, - userInputTokenBalance, - quoteLoading, - trackEvent, - isDisconnected, + userInputTokenBalance?.balance, ]); const setInitializeFailed = useCallback((message?: string | null) => { diff --git a/frontend/src/hooks/useSignChallenge.ts b/frontend/src/hooks/useSignChallenge.ts index 7b4335f87..bf2f04f28 100644 --- a/frontend/src/hooks/useSignChallenge.ts +++ b/frontend/src/hooks/useSignChallenge.ts @@ -5,7 +5,7 @@ import { DEFAULT_LOGIN_EXPIRATION_TIME_HOURS } from '../constants/constants'; import { SIGNING_SERVICE_URL } from '../constants/constants'; import { storageKeys } from '../constants/localStorage'; import { useVortexAccount } from './useVortexAccount'; -import { useRampActions } from '../stores/offrampStore'; +import { useRampActions } from '../stores/rampStore'; import { useEffect } from 'react'; export interface SiweSignatureData { diff --git a/frontend/src/pages/failure/index.tsx b/frontend/src/pages/failure/index.tsx index 5232375f5..4e11f6ef8 100644 --- a/frontend/src/pages/failure/index.tsx +++ b/frontend/src/pages/failure/index.tsx @@ -5,7 +5,7 @@ import { Box } from '../../components/Box'; import { EmailForm } from '../../components/EmailForm'; import { config } from '../../config'; import { useRampSubmission } from '../../hooks/ramp/useRampSubmission'; -import { useRampState } from '../../stores/offrampStore'; +import { useRampState } from '../../stores/rampStore'; const ErrorIcon = () => (
@@ -24,19 +24,19 @@ export const FailurePage = () => {

{t('pages.failure.title')}

- +

{t('pages.failure.recoverable.description')}

{t('pages.failure.recoverable.cta')}

- + - +
diff --git a/frontend/src/pages/progress/index.tsx b/frontend/src/pages/progress/index.tsx index 17d4908a5..f5cd8a340 100644 --- a/frontend/src/pages/progress/index.tsx +++ b/frontend/src/pages/progress/index.tsx @@ -8,7 +8,7 @@ import { useEventsContext } from '../../contexts/events'; import { useNetwork } from '../../contexts/network'; import { isNetworkEVM, RampPhase } from 'shared'; import { GotQuestions } from '../../sections'; -import { useRampActions, useRampState, useRampStore } from '../../stores/offrampStore'; +import { useRampActions, useRampState, useRampStore } from '../../stores/rampStore'; import { RampService } from '../../services/api'; import { getMessageForPhase } from './phaseMessages'; import { config } from '../../config'; @@ -67,7 +67,7 @@ const useProgressUpdate = ( export const ONRAMPING_PHASE_SECONDS: Record = { initial: 0, fundEphemeral: 20, - brlaTeleport: 30, + brlaTeleport: 90, moonbeamToPendulumXcm: 30, subsidizePreSwap: 24, nablaApprove: 24, diff --git a/frontend/src/pages/progress/phaseMessages.ts b/frontend/src/pages/progress/phaseMessages.ts index f45093f72..182dc5b0b 100644 --- a/frontend/src/pages/progress/phaseMessages.ts +++ b/frontend/src/pages/progress/phaseMessages.ts @@ -1,59 +1,59 @@ import { FiatToken, getAnyFiatTokenDetails, getNetworkDisplayName, getNetworkFromDestination, getOnChainTokenDetailsOrDefault, isNetworkEVM, Networks, OnChainToken, RampPhase } from 'shared'; import { RampState } from '../../types/phases'; - -// TODO type t translation function -export function getMessageForPhase(ramp: RampState | undefined, t: any): string { - - if (!ramp || !ramp.ramp) return t('pages.progress.initial'); - - const currentState = ramp.ramp! - const quote = ramp.quote; - const currentPhase = currentState.currentPhase; - - const fromNetwork = getNetworkFromDestination(quote.from); - const toNetwork = getNetworkFromDestination(quote.to); - - const inputAssetSymbol = currentState.type === 'off' ? getOnChainTokenDetailsOrDefault(fromNetwork!, quote.inputCurrency as OnChainToken).assetSymbol : getAnyFiatTokenDetails(quote.inputCurrency as FiatToken).assetSymbol; - const outputAssetSymbol = currentState.type === 'off' ? getAnyFiatTokenDetails(quote.outputCurrency as FiatToken).assetSymbol : getOnChainTokenDetailsOrDefault(toNetwork!, quote.outputCurrency as OnChainToken).assetSymbol; - - - // For offramp, network means the starting network. For onramp, network referres the destination network. - const network = currentState.type === 'off' ? getNetworkDisplayName(fromNetwork!) : getNetworkDisplayName(toNetwork!); - const isEVM = currentState.type === 'off' ? isNetworkEVM(fromNetwork!) : isNetworkEVM(toNetwork!); - - if (currentPhase === 'complete') return t('pages.progress.success'); - - const getSwappingMessage = () => t('pages.progress.swappingTo', { assetSymbol: outputAssetSymbol }); - const getApproveMessage = () => t('pages.progress.nablaApprove', { assetSymbol: inputAssetSymbol }); - const getMoonbeamToPendulumMessage = () => t('pages.progress.moonbeamToPendulum', { assetSymbol: inputAssetSymbol }); - const getSquidrouterSwapMessage = () => t('pages.progress.squidrouterSwap', { assetSymbol: outputAssetSymbol, network: toNetwork }); - - const getTransferringMessage = () => t('pages.progress.transferringToLocalPartner'); - - const messages: Record = { - initial: t('pages.progress.initial'), - stellarCreateAccount: t('pages.progress.createStellarAccount'), - fundEphemeral: t('pages.progress.fundEphemeral'), - nablaApprove: getApproveMessage(), - nablaSwap: getSwappingMessage(), - subsidizePreSwap: getSwappingMessage(), - subsidizePostSwap: getSwappingMessage(), - moonbeamToPendulum: getMoonbeamToPendulumMessage(), - moonbeamToPendulumXcm: getMoonbeamToPendulumMessage(), - assethubToPendulum: t('pages.progress.assethubToPendulum', { assetSymbol: inputAssetSymbol }), - pendulumToMoonbeam: t('pages.progress.pendulumToMoonbeam', { assetSymbol: outputAssetSymbol }), - spacewalkRedeem: t('pages.progress.executeSpacewalkRedeem', { assetSymbol: outputAssetSymbol }), - brlaPayoutOnMoonbeam: getTransferringMessage(), - stellarPayment: t('pages.progress.stellarPayment', { assetSymbol: outputAssetSymbol }), - squidrouterApprove: getSquidrouterSwapMessage(), - squidrouterSwap: getSquidrouterSwapMessage(), - pendulumToAssethub: t('pages.progress.pendulumToAssethub', { assetSymbol: outputAssetSymbol }), - brlaTeleport: t('pages.progress.brlaTeleport'), - failed: '', // Not relevant for progress page - complete: '', // Not relevant for progress page - timedOut: '', // Not relevant for progress page - }; - - return messages[currentPhase]; +import { TFunction } from 'i18next'; + +export function getMessageForPhase(ramp: RampState | undefined, t: TFunction<'translation', undefined>): string { + if (!ramp || !ramp.ramp) return t('pages.progress.initial'); + + const currentState = ramp.ramp!; + const quote = ramp.quote; + const currentPhase = currentState.currentPhase; + + const fromNetwork = getNetworkFromDestination(quote.from); + const toNetwork = getNetworkFromDestination(quote.to); + + const inputAssetSymbol = + currentState.type === 'off' + ? getOnChainTokenDetailsOrDefault(fromNetwork!, quote.inputCurrency as OnChainToken).assetSymbol + : getAnyFiatTokenDetails(quote.inputCurrency as FiatToken).assetSymbol; + const outputAssetSymbol = + currentState.type === 'off' + ? getAnyFiatTokenDetails(quote.outputCurrency as FiatToken).assetSymbol + : getOnChainTokenDetailsOrDefault(toNetwork!, quote.outputCurrency as OnChainToken).assetSymbol; + + if (currentPhase === 'complete') return t('pages.progress.success'); + + const getSwappingMessage = () => t('pages.progress.swappingTo', { assetSymbol: outputAssetSymbol }); + const getMoonbeamToPendulumMessage = () => t('pages.progress.moonbeamToPendulum', { assetSymbol: inputAssetSymbol }); + const getSquidrouterSwapMessage = () => + t('pages.progress.squidrouterSwap', { assetSymbol: outputAssetSymbol, network: toNetwork }); + + const getTransferringMessage = () => t('pages.progress.transferringToLocalPartner'); + + const messages: Record = { + initial: t('pages.progress.initial'), + stellarCreateAccount: t('pages.progress.createStellarAccount'), + fundEphemeral: t('pages.progress.fundEphemeral'), + nablaApprove: getSwappingMessage(), + nablaSwap: getSwappingMessage(), + subsidizePreSwap: getSwappingMessage(), + subsidizePostSwap: getSwappingMessage(), + moonbeamToPendulum: getMoonbeamToPendulumMessage(), + moonbeamToPendulumXcm: getMoonbeamToPendulumMessage(), + assethubToPendulum: t('pages.progress.assethubToPendulum', { assetSymbol: inputAssetSymbol }), + pendulumToMoonbeam: t('pages.progress.pendulumToMoonbeam', { assetSymbol: outputAssetSymbol }), + spacewalkRedeem: t('pages.progress.executeSpacewalkRedeem', { assetSymbol: outputAssetSymbol }), + brlaPayoutOnMoonbeam: getTransferringMessage(), + stellarPayment: t('pages.progress.stellarPayment', { assetSymbol: outputAssetSymbol }), + squidrouterApprove: getSquidrouterSwapMessage(), + squidrouterSwap: getSquidrouterSwapMessage(), + pendulumToAssethub: t('pages.progress.pendulumToAssethub', { assetSymbol: outputAssetSymbol }), + brlaTeleport: t('pages.progress.brlaTeleport'), + failed: '', // Not relevant for progress page + complete: '', // Not relevant for progress page + timedOut: '', // Not relevant for progress page + }; + + return messages[currentPhase]; } diff --git a/frontend/src/pages/ramp-form/index.tsx b/frontend/src/pages/ramp-form/index.tsx index b501f9387..0439c5b5a 100644 --- a/frontend/src/pages/ramp-form/index.tsx +++ b/frontend/src/pages/ramp-form/index.tsx @@ -1,9 +1,9 @@ import { SigningBox } from '../../components/SigningBox'; -import { OfframpSummaryDialog } from '../../components/OfframpSummaryDialog'; +import { RampSummaryDialog } from '../../components/RampSummaryDialog'; import { PIXKYCForm } from '../../components/BrlaComponents/BrlaExtendedForm'; import { Offramp } from '../../components/Ramp/Offramp'; import { motion } from 'motion/react'; -import { useRampKycStarted } from '../../stores/offrampStore'; +import { useRampKycStarted } from '../../stores/rampStore'; import { PoolSelectorModal } from '../../components/InputKeys/SelectionModal'; import { useRampDirection, useRampDirectionToggle } from '../../stores/rampDirectionStore'; import { RampDirection, RampToggle } from '../../components/RampToggle'; @@ -18,7 +18,7 @@ export const RampForm = () => { return (
- + { > - {/* {offrampKycStarted ? : } */} - {activeSwapDirection === RampDirection.ONRAMP ? : } + {offrampKycStarted ? : activeSwapDirection === RampDirection.ONRAMP ? : }
diff --git a/frontend/src/pages/success/index.tsx b/frontend/src/pages/success/index.tsx index c658000a7..a325f3bb8 100644 --- a/frontend/src/pages/success/index.tsx +++ b/frontend/src/pages/success/index.tsx @@ -6,12 +6,12 @@ import { EmailForm } from '../../components/EmailForm'; import { Rating } from '../../components/Rating'; import { FiatToken } from 'shared'; import { useRampSubmission } from '../../hooks/ramp/useRampSubmission'; -import { useRampExecutionInput } from '../../stores/offrampStore'; +import { useRampExecutionInput } from '../../stores/rampStore'; import { useRampFormStore } from '../../stores/ramp/useRampFormStore'; const Checkmark = () => (
- + {/* Changed pink to blue */}
); @@ -33,13 +33,20 @@ export const SuccessPage = () => { return (
- - -

{t('pages.success.title')}

-
-

{arrivalText}

-
- + {/* Removed items-center from Box for overall container */} + + {/* Centering container for Checkmark */} +
+ +
+ {/* Wrapper div for left-aligned content with padding */} +
{/* Added padding to match EmailForm */} +

{t('pages.success.title')}

{/* Changed text-center to text-left */} + {/* Removed pink divider */} +

{arrivalText}

{/* Changed text-center to text-left, updated color/style */} +
+ +
diff --git a/frontend/src/stores/ramp/useQuoteStore.ts b/frontend/src/stores/ramp/useQuoteStore.ts index 5bad6434d..638f9eefd 100644 --- a/frontend/src/stores/ramp/useQuoteStore.ts +++ b/frontend/src/stores/ramp/useQuoteStore.ts @@ -132,7 +132,7 @@ export const useQuoteStore = create((set) => ({ } catch (error) { console.error('Error fetching quote:', error); const errorMessage = error instanceof Error ? error.message : 'Failed to get quote'; - set({ error: errorMessage, loading: false }); + set({ error: errorMessage, loading: false, quote: undefined, outputAmount: undefined }); } }, diff --git a/frontend/src/stores/offrampStore.ts b/frontend/src/stores/rampStore.ts similarity index 93% rename from frontend/src/stores/offrampStore.ts rename to frontend/src/stores/rampStore.ts index e0d65c43c..31ce27a95 100644 --- a/frontend/src/stores/offrampStore.ts +++ b/frontend/src/stores/rampStore.ts @@ -30,6 +30,7 @@ export const useRampStore = create()((set, get) => { rampExecutionInput: undefined, rampSummaryVisible: false, initializeFailedMessage: undefined, + canRegisterRamp: false, ...loadInitialState(), }; @@ -46,6 +47,7 @@ export const useRampStore = create()((set, get) => { rampSigningPhase: state.rampSigningPhase, rampExecutionInput: state.rampExecutionInput, rampSummaryVisible: state.rampSummaryVisible, + canRegisterRamp: state.canRegisterRamp, }; storageService.set(LocalStorageKeys.RAMPING_STATE, stateToSave); }; @@ -97,7 +99,10 @@ export const useRampStore = create()((set, get) => { set({ initializeFailedMessage: displayMessage }); saveState(); }, - + setCanRegisterRamp: (canRegister: boolean) => { + set({ canRegisterRamp: canRegister }); + saveState(); + }, resetRampState: () => { clearRampingState(); @@ -112,10 +117,10 @@ export const useRampStore = create()((set, get) => { rampExecutionInput: undefined, rampSummaryVisible: false, initializeFailedMessage: undefined, + canRegisterRamp: false, }); // No need to save state here as we just cleared it }, - clearInitializeFailedMessage: () => { set({ initializeFailedMessage: undefined }); saveState(); @@ -134,6 +139,7 @@ export const useRampKycStarted = () => useRampStore((state) => state.rampKycStar export const useRampPaymentConfirmed = () => useRampStore((state) => state.rampPaymentConfirmed); export const useInitializeFailedMessage = () => useRampStore((state) => state.initializeFailedMessage); export const useRampSummaryVisible = () => useRampStore((state) => state.rampSummaryVisible); +export const useCanRegisterRamp = () => useRampStore((state) => state.canRegisterRamp); export const clearInitializeFailedMessage = () => useRampStore.getState().actions.clearInitializeFailedMessage(); export const useRampActions = () => useRampStore((state) => state.actions); diff --git a/frontend/src/stores/rampSummary/index.ts b/frontend/src/stores/rampSummary/index.ts new file mode 100644 index 000000000..fc673e109 --- /dev/null +++ b/frontend/src/stores/rampSummary/index.ts @@ -0,0 +1,18 @@ +import { create } from 'zustand'; + +interface RampSummaryState { + isQuoteExpired: boolean; + actions: { + setIsQuoteExpired: (expired: boolean) => void; + }; +} + +export const useRampSummaryStore = create((set) => ({ + isQuoteExpired: false, + actions: { + setIsQuoteExpired: (expired: boolean) => set({ isQuoteExpired: expired }), + }, +})); + +export const useIsQuoteExpired = () => useRampSummaryStore((state) => state.isQuoteExpired); +export const useRampSummaryActions = () => useRampSummaryStore((state) => state.actions); diff --git a/frontend/src/translations/en.json b/frontend/src/translations/en.json index b8dec9ed0..f730f3b3a 100644 --- a/frontend/src/translations/en.json +++ b/frontend/src/translations/en.json @@ -56,8 +56,14 @@ "noApi": "No API found." }, "insufficientFunds": "Exceeds balance. Your balance is {{userInputTokenBalance}} {{assetSymbol}}", - "moreThanMaximumWithdrawal": "Maximum withdrawal amount is {{maxAmountUnits}} {{assetSymbol}}.", - "lessThanMinimumWithdrawal": "Minimum withdrawal amount is {{minAmountUnits}} {{assetSymbol}}.", + "moreThanMaximumWithdrawal": { + "buy": "Maximum buy amount is {{maxAmountUnits}} {{assetSymbol}}.", + "sell": "Maximum sell amount is {{maxAmountUnits}} {{assetSymbol}}." + }, + "lessThanMinimumWithdrawal": { + "buy": "Minimum buy amount is {{minAmountUnits}} {{assetSymbol}}.", + "sell": "Minimum sell amount is {{minAmountUnits}} {{assetSymbol}}." + }, "insufficientLiquidity": "The amount is temporarily not available. Please, try with a smaller amount." }, "developedBy": "Developed by" @@ -181,13 +187,14 @@ "placeholder": "Search" }, "dialogs": { - "OfframpSummaryDialog": { + "RampSummaryDialog": { "BRLOnrampDetails": { "title": "Pay with Pix", "description": "Continue in your bank's app", "qrCode": "Select Pix in your bank's app and scan the QR code below.", "copyCode": "Or copy the Pix code below, select Pix in your bank's app and paste the code provided.", - "pixCode": "Pix code" + "pixCode": "Pix code", + "timerLabel": "Quote expires in:" }, "headerText": { "buy": "You're buying", @@ -197,10 +204,12 @@ "onrampFee": "Onramp fee", "continueWithPartner": "Continue with Partner", "continueOnPartnersPage": "Continue on Partner's page", + "quoteExpired": "Quote expired. Please close the dialog and try again.", "processing": "Processing", "continue": "Continue", "quote": "Quote", - "partner": "Partner" + "partner": "Partner", + "confirm": "Confirm" }, "selectionModal": { "title": "Select a token", @@ -253,7 +262,9 @@ "pixId": { "required": "PIX key is required when transferring BRL", "format": "PIX key does not match any of the valid formats" - } + }, + "calculatingQuote": "Calculating quote...", + "assetHubNotSupported": "Please select a different network. Buy is currently not available on AssetHub." } }, "benefitsList": { @@ -262,7 +273,8 @@ }, "brlaSwapField": { "placeholder": "Enter your {{label}}", - "disclaimer": "CPF and Pix key need to belong to the <1>same person." + "disclaimerOfframp": "CPF and Pix key need to belong to the <1>same person.", + "disclaimerOnramp": "CPF must belong to <1>you." }, "swapSubmitButton": { "confirming": "Confirming", diff --git a/frontend/src/translations/pt.json b/frontend/src/translations/pt.json index 98b8fe2ce..e5c6d4e22 100644 --- a/frontend/src/translations/pt.json +++ b/frontend/src/translations/pt.json @@ -56,8 +56,14 @@ "noApi": "Nenhuma API encontrada." }, "insufficientFunds": "Saldo insuficiente. Seu saldo é {{userInputTokenBalance}} {{assetSymbol}}", - "moreThanMaximumWithdrawal": "O valor máximo de saque é {{maxAmountUnits}} {{assetSymbol}}.", - "lessThanMinimumWithdrawal": "O valor mínimo de saque é {{minAmountUnits}} {{assetSymbol}}.", + "moreThanMaximumWithdrawal": { + "sell": "O valor máximo de compra é {{maxAmountUnits}} {{assetSymbol}}.", + "sell": "O valor máximo de venda é {{maxAmountUnits}} {{assetSymbol}}." + }, + "lessThanMinimumWithdrawal": { + "buy": "O valor mínimo de compra é {{minAmountUnits}} {{assetSymbol}}.", + "sell": "O valor mínimo de venda é {{minAmountUnits}} {{assetSymbol}}." + }, "insufficientLiquidity": "O valor está temporariamente indisponível. Por favor, tente com um valor menor." }, "developedBy": "Desenvolvido por" @@ -181,13 +187,14 @@ "placeholder": "Pesquisar" }, "dialogs": { - "OfframpSummaryDialog": { + "RampSummaryDialog": { "BRLOnrampDetails": { "title": "Pagar com PIX", "description": "Continue no app do seu banco", "qrCode": "Selecione PIX no app do seu banco e escaneie o QR code abaixo.", "copyCode": "Ou copie o código PIX abaixo, selecione PIX no app do seu banco e cole o código fornecido.", - "pixCode": "Código PIX" + "pixCode": "Código PIX", + "timerLabel": "Cotação expira em:" }, "headerText": { "buy": "Você está comprando", @@ -197,10 +204,12 @@ "onrampFee": "Taxa de compra", "continueWithPartner": "Continuar com Parceiro", "continueOnPartnersPage": "Continuar na página do Parceiro", + "quoteExpired": "Cotação expirada. Feche o diálogo e tente novamente.", "processing": "Processando", "continue": "Continuar", "quote": "Cotação", - "partner": "Parceiro" + "partner": "Parceiro", + "confirm": "Confirmar" }, "selectionModal": { "title": "Selecione um token", @@ -252,7 +261,9 @@ "pixId": { "required": "Chave PIX é obrigatória para transferências em BRL", "format": "A chave PIX não corresponde a nenhum dos formatos válidos" - } + }, + "calculatingQuote": "Calculando cotação...", + "assetHubNotSupported": "Por favor, selecione uma rede diferente. A compra não está disponível no momento no AssetHub." } }, "benefitsList": { @@ -261,7 +272,8 @@ }, "brlaSwapField": { "placeholder": "Digite seu {{label}}", - "disclaimer": "CPF e chave Pix precisam pertencer à <1>mesma pessoa." + "disclaimerOfframp": "CPF e chave Pix precisam pertencer à <1>mesma pessoa.", + "disclaimerOnramp": "CPF deve pertencer a <1>você." }, "swapSubmitButton": { "confirming": "Confirmando", diff --git a/frontend/src/types/phases.ts b/frontend/src/types/phases.ts index 327303a30..071835edd 100644 --- a/frontend/src/types/phases.ts +++ b/frontend/src/types/phases.ts @@ -42,6 +42,7 @@ export interface RampZustand { rampPaymentConfirmed: boolean; initializeFailedMessage: string | undefined; rampSummaryVisible: boolean; + canRegisterRamp: boolean; } export interface RampActions { @@ -57,4 +58,5 @@ export interface RampActions { setRampSummaryVisible: (visible: boolean) => void; clearInitializeFailedMessage: () => void; resetRampState: () => void; + setCanRegisterRamp: (canRegister: boolean) => void; } diff --git a/shared/src/helpers/signUnsigned.ts b/shared/src/helpers/signUnsigned.ts index 1bcfaca39..e3c3a380e 100644 --- a/shared/src/helpers/signUnsigned.ts +++ b/shared/src/helpers/signUnsigned.ts @@ -1,9 +1,9 @@ -import { createWalletClient, http } from "viem"; -import { privateKeyToAccount } from "viem/accounts"; -import { Keyring } from "@polkadot/api"; -import { Transaction, Keypair, Networks as StellarNetworks } from "stellar-sdk"; -import { ApiPromise } from "@polkadot/api"; -import { moonbeam } from "viem/chains"; +import {createWalletClient, http} from "viem"; +import {privateKeyToAccount} from "viem/accounts"; +import {Keyring} from "@polkadot/api"; +import {Transaction, Keypair, Networks as StellarNetworks} from "stellar-sdk"; +import {ApiPromise} from "@polkadot/api"; +import {moonbeam} from "viem/chains"; import { isEvmTransactionData, PresignedTx, @@ -11,8 +11,9 @@ import { EphemeralAccount, decodeSubmittableExtrinsic, } from "../index"; -import { u8aToHex } from "@polkadot/util"; -import { hdEthereum, mnemonicToLegacySeed } from "@polkadot/util-crypto"; +import {u8aToHex} from "@polkadot/util"; +import {hdEthereum, mnemonicToLegacySeed} from "@polkadot/util-crypto"; +import {cryptoWaitReady} from '@polkadot/util-crypto'; // Number of transactions to pre-sign for each transaction const NUMBER_OF_PRESIGNED_TXS = 3; @@ -24,9 +25,9 @@ export function addAdditionalTransactionsToMeta( if (multiSignedTxs.length <= 1) { return primaryTx; } - + const additionalTxs: Record = {}; - + for (let i = 1; i < multiSignedTxs.length; i++) { const additionalTx = multiSignedTxs[i]; const nonceOffset = i; @@ -34,16 +35,16 @@ export function addAdditionalTransactionsToMeta( const txName = `${primaryTx.phase}${nonceOffset}`; additionalTxs[txName] = additionalTx; } - + return { ...primaryTx, - meta: { ...primaryTx.meta, additionalTxs } + meta: {...primaryTx.meta, additionalTxs} }; } /** * Signs multiple Stellar transactions with increasing sequence numbers - * + * * @param tx - The original backend-signed transaction. Can contain meta field with multiple-nonce transactions. * @param keypair - The Stellar keypair to sign with * @param networkPassphrase - The Stellar network passphrase @@ -58,12 +59,12 @@ async function signMultipleStellarTransactions( const transaction = new Transaction(tx.txData as string, networkPassphrase); transaction.sign(keypair); - + const primarySignedTxData = transaction .toEnvelope() .toXDR() .toString("base64"); - + const signedTx: PresignedTx = { ...tx, txData: primarySignedTxData @@ -84,14 +85,15 @@ async function signMultipleStellarTransactions( .toString("base64"); signedTx.meta.additionalTxs[key].txData = extraTransactionSigned; - }; - + } + ; + return signedTx; } /** * Signs multiple Substrate (Pendulum) transactions with increasing nonces - * + * * @param tx - The original unsigned transaction * @param keypair - The keypair to sign with * @param api - The Polkadot API instance @@ -105,29 +107,29 @@ async function signMultipleSubstrateTransactions( startingNonce: number ): Promise { const signedTxs: PresignedTx[] = []; - + for (let i = 0; i < NUMBER_OF_PRESIGNED_TXS; i++) { const currentNonce = startingNonce + i; const extrinsic = decodeSubmittableExtrinsic(tx.txData as string, api); - - await extrinsic.signAsync(keypair, { nonce: currentNonce, era: 0 }); - + + await extrinsic.signAsync(keypair, {nonce: currentNonce, era: 0}); + const signedTxData = extrinsic.toHex(); - const signedTx: PresignedTx = { + const signedTx: PresignedTx = { ...tx, nonce: currentNonce, txData: signedTxData }; - + signedTxs.push(signedTx); } - + return signedTxs; } /** * Signs multiple EVM (Moonbeam) transactions with increasing nonces - * + * * @param tx - The original unsigned transaction * @param walletClient - The viem wallet client * @param startingNonce - The starting nonce value @@ -139,16 +141,16 @@ async function signMultipleEvmTransactions( startingNonce: number ): Promise { const signedTxs: PresignedTx[] = []; - + if (!isEvmTransactionData(tx.txData)) { throw new Error("Invalid EVM transaction data format"); } for (let i = 0; i < NUMBER_OF_PRESIGNED_TXS; i++) { const currentNonce = startingNonce + i; - + // Ensure the transaction data is in the correct format - const txData = { + const txData = { to: tx.txData.to, data: tx.txData.data, value: BigInt(tx.txData.value), @@ -157,18 +159,18 @@ async function signMultipleEvmTransactions( maxFeePerGas: tx.txData.maxFeePerGas ? BigInt(tx.txData.maxFeePerGas) * 5n : BigInt(187500000000), maxPriorityFeePerGas: tx.txData.maxPriorityFeePerGas ? BigInt(tx.txData.maxPriorityFeePerGas) * 5n : BigInt(187500000000), }; - + const signedTxData = await walletClient.signTransaction(txData); - + const signedTx: PresignedTx = { ...tx, nonce: currentNonce, txData: signedTxData }; - + signedTxs.push(signedTx); } - + return signedTxs; } @@ -219,6 +221,9 @@ export async function signUnsignedTransactions( pendulumApi: ApiPromise, moonbeamApi: ApiPromise, ): Promise { + // Wait for initialization of crypto libraries + await cryptoWaitReady(); + const signedTxs: PresignedTx[] = []; try { @@ -260,7 +265,7 @@ export async function signUnsignedTransactions( throw new Error("Invalid Pendulum transaction data format"); } - const keyring = new Keyring({ type: "sr25519" }); + const keyring = new Keyring({type: "sr25519"}); const keypair = keyring.addFromUri(ephemerals.pendulumEphemeral.secret); const multiSignedTxs = await signMultipleSubstrateTransactions( @@ -269,11 +274,11 @@ export async function signUnsignedTransactions( pendulumApi, tx.nonce ); - + const primaryTx = multiSignedTxs[0]; - + const txWithMeta = addAdditionalTransactionsToMeta(primaryTx, multiSignedTxs); - + signedTxs.push(txWithMeta); } @@ -282,16 +287,16 @@ export async function signUnsignedTransactions( if (!ephemerals.moonbeamEphemeral) { throw new Error("Missing EVM ephemeral account"); } - + const ethDerPath = `m/44'/60'/${0}'/${0}/${0}`; - + if (isEvmTransactionData(tx.txData)) { const privateKey = u8aToHex( hdEthereum(mnemonicToLegacySeed(ephemerals.moonbeamEphemeral.secret, '', false, 64), ethDerPath) .secretKey ); const evmAccount = privateKeyToAccount(privateKey); - + const walletClient = createWalletClient({ account: evmAccount, chain: moonbeam, @@ -303,27 +308,27 @@ export async function signUnsignedTransactions( walletClient, tx.nonce ); - + const primaryTx = multiSignedTxs[0]; - + const txWithMeta = addAdditionalTransactionsToMeta(primaryTx, multiSignedTxs); - + signedTxs.push(txWithMeta); } else { - const keyring = new Keyring({ type: 'ethereum' }); + const keyring = new Keyring({type: 'ethereum'}); const keypair = keyring.addFromUri(`${ephemerals.moonbeamEphemeral.secret}/${ethDerPath}`); - + const multiSignedTxs = await signMultipleSubstrateTransactions( tx, keypair, moonbeamApi, tx.nonce ); - + const primaryTx = multiSignedTxs[0]; - + const txWithMeta = addAdditionalTransactionsToMeta(primaryTx, multiSignedTxs); - + signedTxs.push(txWithMeta); } }