diff --git a/.gitignore b/.gitignore index 89bd1ba39..a97f19a87 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,8 @@ dist-ssr **/failedRampStateRecovery.json **/lastRampState.json **/lastRampStateOnramp.json +**/lastRampStateMykoboEur.json +**/lastRampStateMykoboEurOnramp.json *storybook.log diff --git a/apps/api/.env.example b/apps/api/.env.example index 7596c38e8..f2698d3ba 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -51,10 +51,6 @@ COINGECKO_API_KEY=your-coingecko-api-key COINGECKO_API_URL=https://pro-api.coingecko.com/api/v3 SUBSCAN_API_KEY=your-subscan-api-key -# EUR / Monerium -MONERIUM_CLIENT_ID_APP=your-monerium-client-id -MONERIUM_CLIENT_SECRET=your-monerium-client-secret - # Price Feed Cache Configuration CRYPTO_CACHE_TTL_MS=300000 FIAT_CACHE_TTL_MS=300000 diff --git a/apps/api/.gitignore b/apps/api/.gitignore index 58b011667..b1fa2d19f 100644 --- a/apps/api/.gitignore +++ b/apps/api/.gitignore @@ -49,3 +49,5 @@ jspm_packages */failedRampStateRecovery.json */lastRampState.json */lastRampStateOnramp.json +*/lastRampStateMykoboEur.json +*/lastRampStateMykoboEurOnramp.json diff --git a/apps/api/src/api/controllers/monerium.controller.ts b/apps/api/src/api/controllers/monerium.controller.ts deleted file mode 100644 index ba8180c0a..000000000 --- a/apps/api/src/api/controllers/monerium.controller.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Networks } from "@vortexfi/shared"; -import { Request, Response } from "express"; -import httpStatus from "http-status"; -import logger from "../../config/logger"; -import { checkAddressExists } from "../services/monerium"; - -export const checkAddressExistsController = async (req: Request, res: Response): Promise => { - const { address, network } = req.query; - - if (!address || typeof address !== "string") { - res.status(httpStatus.BAD_REQUEST).json({ error: "Invalid address parameter" }); - return; - } - - if (!network || typeof network !== "string") { - res.status(httpStatus.BAD_REQUEST).json({ error: "Invalid network parameter" }); - return; - } - - try { - const result = await checkAddressExists(address, network as Networks); - if (result) { - res.json(result); - } else { - res.status(httpStatus.NOT_FOUND).json({ error: "Address not found" }); - } - } catch (error) { - logger.error("Error in checkAddressExistsController:", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ error: "Internal Server Error" }); - } -}; diff --git a/apps/api/src/api/controllers/mykobo.controller.ts b/apps/api/src/api/controllers/mykobo.controller.ts new file mode 100644 index 000000000..0812d0457 --- /dev/null +++ b/apps/api/src/api/controllers/mykobo.controller.ts @@ -0,0 +1,131 @@ +import { MykoboApiError, MykoboApiService, MykoboProfile } from "@vortexfi/shared"; +import { Request, Response } from "express"; +import httpStatus from "http-status"; +import { isAddress } from "viem"; +import logger from "../../config/logger"; +import { upsertMykoboCustomerFromProfile } from "../services/mykobo/mykobo-customer.service"; + +const PROFILE_TEXT_FIELDS = [ + "first_name", + "last_name", + "additional_name", + "email_address", + "mobile_number", + "birth_date", + "birth_country_code", + "address_line_1", + "city", + "id_country_code", + "id_type", + "bank_account_number", + "bank_number", + "wallet_address", + "source_of_funds", + "tax_country", + "tax_id", + "tax_id_name", + "memo" +] as const; + +const PROFILE_FILE_FIELDS = ["front", "back", "face", "utility_bill"] as const; + +const toFrontendProfile = (p: MykoboProfile) => ({ + bankAccountNumber: p.bank_account_number, + createdAt: p.created_at, + emailAddress: p.email_address, + firstName: p.first_name, + kycStatus: { + receivedAt: p.kyc_status.received_at, + reviewStatus: p.kyc_status.review_status + }, + lastName: p.last_name +}); + +const emailsMatch = (a: string | undefined, b: string | undefined): boolean => + !!a && !!b && a.trim().toLowerCase() === b.trim().toLowerCase(); + +export const getProfileController = async (req: Request, res: Response): Promise => { + const { email, memo } = req.query; + const userEmail = req.userEmail; + + if (!userEmail) { + res.status(httpStatus.UNAUTHORIZED).json({ error: "Authenticated user email missing" }); + return; + } + if (!email || typeof email !== "string" || !emailsMatch(email, userEmail)) { + res.status(httpStatus.BAD_REQUEST).json({ error: "Invalid email parameter" }); + return; + } + + try { + const memoParam = typeof memo === "string" && memo.length > 0 ? memo : undefined; + const { profile } = await MykoboApiService.getInstance().getProfileByEmail(userEmail, memoParam); + if (!emailsMatch(profile.email_address, userEmail)) { + res.status(httpStatus.NOT_FOUND).json({ error: "Profile not found" }); + return; + } + if (req.userId) { + try { + await upsertMykoboCustomerFromProfile(req.userId, userEmail, profile); + } catch (mirrorError) { + logger.error("Failed to update Mykobo customer mirror in getProfileController:", mirrorError); + } + } + res.json({ profile: toFrontendProfile(profile) }); + } catch (error) { + if (error instanceof MykoboApiError && error.status === 404) { + res.status(httpStatus.NOT_FOUND).json({ error: "Profile not found" }); + return; + } + logger.error("Error in getProfileController:", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ error: "Internal Server Error" }); + } +}; + +export const createProfileController = async (req: Request, res: Response): Promise => { + const userEmail = req.userEmail; + if (!userEmail) { + res.status(httpStatus.UNAUTHORIZED).json({ error: "Authenticated user email missing" }); + return; + } + + try { + const body = (req.body ?? {}) as Record; + + const walletAddress = body.wallet_address; + if (typeof walletAddress !== "string" || !isAddress(walletAddress)) { + res.status(httpStatus.BAD_REQUEST).json({ error: "Invalid wallet_address" }); + return; + } + + const formData = new FormData(); + for (const field of PROFILE_TEXT_FIELDS) { + if (field === "email_address") continue; + const value = body[field]; + if (typeof value === "string" && value.length > 0) { + formData.append(field, value); + } + } + formData.append("email_address", userEmail); + + const files = (req as Request & { files?: Record }).files; + if (files && typeof files === "object") { + for (const fieldname of PROFILE_FILE_FIELDS) { + const file = files[fieldname]?.[0]; + if (!file) continue; + formData.append(fieldname, new Blob([new Uint8Array(file.buffer)], { type: file.mimetype }), file.originalname); + } + } + + const { profile } = await MykoboApiService.getInstance().createProfile(formData); + res.status(httpStatus.CREATED).json({ profile: toFrontendProfile(profile) }); + } catch (error) { + if (error instanceof MykoboApiError) { + logger.warn(`Mykobo /profiles upstream error: status=${error.status}`); + res.status(error.status).json({ error: "Mykobo profile creation failed" }); + return; + } + logger.error("Error in createProfileController:", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ error: "Internal Server Error" }); + } +}; diff --git a/apps/api/src/api/controllers/pendulum.controller.ts b/apps/api/src/api/controllers/pendulum.controller.ts index 61717da30..5b4f869df 100644 --- a/apps/api/src/api/controllers/pendulum.controller.ts +++ b/apps/api/src/api/controllers/pendulum.controller.ts @@ -3,7 +3,6 @@ import { PendulumFundEphemeralErrorResponse, PendulumFundEphemeralRequest, PendulumFundEphemeralResponse, - StellarTokenConfig, TOKEN_CONFIG, XCMTokenConfig } from "@vortexfi/shared"; @@ -66,7 +65,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]) => { + Object.entries(TOKEN_CONFIG).map(async ([token, tokenConfig]: [string, XCMTokenConfig]) => { logger.info(`Checking token ${token} balance...`); if (!tokenConfig.pendulumCurrencyId) { throw new Error(`Token ${token} does not have a currency id.`); diff --git a/apps/api/src/api/controllers/ramp.controller.ts b/apps/api/src/api/controllers/ramp.controller.ts index e0d3498a8..63bf81294 100644 --- a/apps/api/src/api/controllers/ramp.controller.ts +++ b/apps/api/src/api/controllers/ramp.controller.ts @@ -15,6 +15,7 @@ import { NextFunction, Request, Response } from "express"; import httpStatus from "http-status"; import logger from "../../config/logger"; import { APIError } from "../errors/api-error"; +import { enrichAdditionalDataWithClientIp } from "../helpers/clientIp"; import { assertQuoteOwnership, assertRampOwnership } from "../middlewares/ownershipAuth"; import rampService from "../services/ramp/ramp.service"; @@ -36,9 +37,10 @@ export const registerRamp = async (req: Request, res: Response, nex await assertQuoteOwnership(req, quoteId); - // Start ramping process + const enrichedAdditionalData = await enrichAdditionalDataWithClientIp(additionalData, req); + const ramp = await rampService.registerRamp({ - additionalData, + additionalData: enrichedAdditionalData, quoteId, signingAccounts, userId: req.userId diff --git a/apps/api/src/api/controllers/stellar.controller.ts b/apps/api/src/api/controllers/stellar.controller.ts deleted file mode 100644 index 06a7627a0..000000000 --- a/apps/api/src/api/controllers/stellar.controller.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { - CreateStellarTransactionRequest, - CreateStellarTransactionResponse, - GetSep10MasterPKResponse, - SignSep10ChallengeRequest, - SignSep10ChallengeResponse, - StellarErrorResponse -} from "@vortexfi/shared"; -import { NextFunction, Request, Response } from "express"; -import httpStatus from "http-status"; -import { Keypair } from "stellar-sdk"; -import logger from "../../config/logger"; -import { config, SEP10_MASTER_SECRET } from "../../config/vars"; -import { STELLAR_FUNDING_AMOUNT_UNITS } from "../../constants/constants"; -import { signSep10Challenge } from "../services/sep10/sep10.service"; -import { SlackNotifier } from "../services/slack.service"; -import { buildCreationStellarTx, horizonServer } from "../services/stellar.service"; - -const FUNDING_PUBLIC_KEY = config.secrets.stellarFundingSecret - ? Keypair.fromSecret(config.secrets.stellarFundingSecret).publicKey() - : ""; - -export const createStellarTransactionHandler = async ( - req: Request, - res: Response, - _next: NextFunction -): Promise => { - try { - if (!config.secrets.stellarFundingSecret) { - throw new Error("FUNDING_SECRET is not configured"); - } - const { signature, sequence } = await buildCreationStellarTx( - config.secrets.stellarFundingSecret, - req.body.accountId, - req.body.maxTime, - req.body.assetCode, - req.body.baseFee - ); - res.json({ public: FUNDING_PUBLIC_KEY, sequence, signature }); - return; - } catch (error) { - logger.error("Error in createStellarTransaction:", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - details: (error as Error).message, - error: "Failed to create transaction" - }); - } -}; - -export const signSep10ChallengeHandler = async ( - req: Request, - res: Response, - _next: NextFunction -): Promise => { - try { - const { masterClientSignature, masterClientPublic, clientSignature, clientPublic } = await signSep10Challenge( - req.body.challengeXDR, - req.body.outToken, - req.body.clientPublicKey, - req.derivedMemo - ); - res.json({ - clientPublic: clientPublic ?? "", - clientSignature: clientSignature ?? "", - masterClientPublic: masterClientPublic ?? "", - masterClientSignature: masterClientSignature ?? "" - }); - return; - } catch (error) { - logger.error("Error in signSep10Challenge:", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - details: (error as Error).message, - error: "Failed to sign challenge" - }); - } -}; - -export const getSep10MasterPKHandler = async ( - _: Request, - res: Response, - _next: NextFunction -): Promise => { - try { - if (!SEP10_MASTER_SECRET) { - throw new Error("SEP10_MASTER_SECRET is not configured"); - } - const masterSep10Public = Keypair.fromSecret(SEP10_MASTER_SECRET).publicKey(); - res.json({ masterSep10Public }); - return; - } catch (error) { - logger.error("Error in getSep10MasterPK:", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - details: (error as Error).message, - error: "Failed to get master public key" - }); - } -}; - -interface StatusResult { - status: boolean; - public: string; -} - -export async function sendStatusWithPk(): Promise { - const slackNotifier = new SlackNotifier(); - - try { - const account = await horizonServer.loadAccount(FUNDING_PUBLIC_KEY); - const stellarBalance = account.balances.find( - (balance: { asset_type: string; balance: string }) => balance.asset_type === "native" - ); - - if (!stellarBalance || Number(stellarBalance.balance) < Number(STELLAR_FUNDING_AMOUNT_UNITS)) { - await slackNotifier.sendMessage({ - text: `Current balance of funding account is ${ - stellarBalance?.balance ?? 0 - } XLM please charge the account ${FUNDING_PUBLIC_KEY}.` - }); - return { public: FUNDING_PUBLIC_KEY, status: false }; - } - - return { public: FUNDING_PUBLIC_KEY, status: true }; - } catch (error) { - logger.error("Couldn't load Stellar account:", error); - return { public: FUNDING_PUBLIC_KEY, status: false }; - } -} diff --git a/apps/api/src/api/controllers/storage.controller.ts b/apps/api/src/api/controllers/storage.controller.ts index fd9fa9c48..0c115fc5f 100644 --- a/apps/api/src/api/controllers/storage.controller.ts +++ b/apps/api/src/api/controllers/storage.controller.ts @@ -19,22 +19,6 @@ export const DUMP_SHEET_COMMON_HEADERS = [ "outputTokenType" ]; -export const DUMP_SHEET_HEADER_VALUES_ASSETHUB_TO_STELLAR = [ - ...DUMP_SHEET_COMMON_HEADERS, - "offramperAddress", - "stellarEphemeralPublicKey", - "spacewalkRedeemTx", - "stellarOfframpTx", - "stellarCleanupTx" -]; - -export const DUMP_SHEET_HEADER_VALUES_EVM_TO_STELLAR = [ - ...DUMP_SHEET_COMMON_HEADERS, - "offramperAddress", - "squidRouterReceiverId", - "squidRouterReceiverHash" -]; - export const DUMP_SHEET_HEADER_VALUES_ASSETHUB_TO_BRLA = [ ...DUMP_SHEET_COMMON_HEADERS, "offramperAddress", @@ -65,11 +49,9 @@ export const DUMP_SHEET_HEADER_VALUES_BRLA_TO_ASSETHUB = [ export const FLOW_HEADERS: Record = { "assethub-to-brla": DUMP_SHEET_HEADER_VALUES_ASSETHUB_TO_BRLA, - "assethub-to-stellar": DUMP_SHEET_HEADER_VALUES_ASSETHUB_TO_STELLAR, "brla-to-assethub": DUMP_SHEET_HEADER_VALUES_BRLA_TO_ASSETHUB, "brla-to-evm": DUMP_SHEET_HEADER_VALUES_BRLA_TO_EVM, - "evm-to-brla": DUMP_SHEET_HEADER_VALUES_EVM_TO_BRLA, - "evm-to-stellar": DUMP_SHEET_HEADER_VALUES_EVM_TO_STELLAR + "evm-to-brla": DUMP_SHEET_HEADER_VALUES_EVM_TO_BRLA }; export const storeData = async (req: Request, res: Response): Promise => { if (!spreadsheet.storageSheetId) { diff --git a/apps/api/src/api/controllers/subsidize.controller.ts b/apps/api/src/api/controllers/subsidize.controller.ts index f1b273c41..f8d5db4ca 100644 --- a/apps/api/src/api/controllers/subsidize.controller.ts +++ b/apps/api/src/api/controllers/subsidize.controller.ts @@ -1,7 +1,6 @@ import { Keyring } from "@polkadot/api"; import { ApiManager, - StellarTokenConfig, SubsidizeErrorResponse, SubsidizePostSwapRequest, SubsidizePostSwapResponse, @@ -42,7 +41,7 @@ const validateSubsidyAmount = (amount: string, maxAmount: string) => { } }; -const getPendulumCurrencyConfig = (token: string): StellarTokenConfig | XCMTokenConfig => { +const getPendulumCurrencyConfig = (token: string): XCMTokenConfig => { const normalizedToken = token.toUpperCase() as keyof typeof TOKEN_CONFIG; const config = TOKEN_CONFIG[normalizedToken]; diff --git a/apps/api/src/api/helpers/anchors.ts b/apps/api/src/api/helpers/anchors.ts deleted file mode 100644 index 5e96ef412..000000000 --- a/apps/api/src/api/helpers/anchors.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { fetchWithTimeout } from "./fetchWithTimeout"; - -interface TomlValues { - signingKey: string | undefined; - webAuthEndpoint: string | undefined; - sep24Url: string | undefined; - sep6Url: string | undefined; - kycServer: string | undefined; -} - -const TOML_KEYS = { - KYC_SERVER: "KYC_SERVER", - SIGNING_KEY: "SIGNING_KEY", - TRANSFER_SERVER: "TRANSFER_SERVER", - TRANSFER_SERVER_SEP0024: "TRANSFER_SERVER_SEP0024", - WEB_AUTH_ENDPOINT: "WEB_AUTH_ENDPOINT" -} as const; - -const fetchTomlValues = async (tomlFileUrl: string): Promise => { - const response = await fetchWithTimeout(tomlFileUrl); - if (!response.ok) { - throw new Error(`Failed to fetch TOML file: ${response.statusText}`); - } - - const tomlFileContent = (await response.text()).split("\n"); - const findValueInToml = (key: string): string | undefined => { - const keyValue = tomlFileContent.find(line => line.includes(key)); - return keyValue?.split("=")[1]?.trim().replace(/"/g, ""); - }; - - return { - kycServer: findValueInToml(TOML_KEYS.KYC_SERVER), - sep6Url: findValueInToml(TOML_KEYS.TRANSFER_SERVER), - sep24Url: findValueInToml(TOML_KEYS.TRANSFER_SERVER_SEP0024), - signingKey: findValueInToml(TOML_KEYS.SIGNING_KEY), - webAuthEndpoint: findValueInToml(TOML_KEYS.WEB_AUTH_ENDPOINT) - }; -}; - -export { fetchTomlValues, type TomlValues }; diff --git a/apps/api/src/api/helpers/clientIp.test.ts b/apps/api/src/api/helpers/clientIp.test.ts new file mode 100644 index 000000000..8b0434978 --- /dev/null +++ b/apps/api/src/api/helpers/clientIp.test.ts @@ -0,0 +1,37 @@ +import {describe, expect, it} from "bun:test"; +import {enrichAdditionalDataWithClientIp, normalizeClientIp} from "./clientIp"; + +describe("normalizeClientIp", () => { + it("normalizes IPv6 localhost to IPv4 localhost", () => { + expect(normalizeClientIp("::1")).toBe("127.0.0.1"); + }); + + it("normalizes IPv4-mapped IPv6 addresses to IPv4", () => { + expect(normalizeClientIp("::ffff:203.0.113.42")).toBe("203.0.113.42"); + }); + + it("keeps regular IPv4 addresses unchanged", () => { + expect(normalizeClientIp("198.51.100.24")).toBe("198.51.100.24"); + }); +}); + +describe("enrichAdditionalDataWithClientIp", () => { + it("adds the normalized request IP when additional data does not include one", async () => { + const additionalData = await enrichAdditionalDataWithClientIp({ email: "user@example.com" }, { ip: "::1" }); + + expect(additionalData?.email).toBe("user@example.com"); + expect(typeof additionalData?.ipAddress).toBe("string"); + }); + + it("keeps a provided IPv4 address over the request IP", async () => { + const additionalData = await enrichAdditionalDataWithClientIp({ ipAddress: "198.51.100.24" }, { ip: "::1" }); + + expect(additionalData?.ipAddress).toBe("198.51.100.24"); + }); + + it("normalizes a provided IPv4-mapped address", async () => { + const additionalData = await enrichAdditionalDataWithClientIp({ ipAddress: "::ffff:198.51.100.24" }, { ip: "::1" }); + + expect(additionalData?.ipAddress).toBe("198.51.100.24"); + }); +}); diff --git a/apps/api/src/api/helpers/clientIp.ts b/apps/api/src/api/helpers/clientIp.ts new file mode 100644 index 000000000..5c8ebf825 --- /dev/null +++ b/apps/api/src/api/helpers/clientIp.ts @@ -0,0 +1,101 @@ +import { isIP } from "node:net"; +import { RegisterRampRequest } from "@vortexfi/shared"; +import logger from "../../config/logger"; +import { config } from "../../config/vars"; + +const IPV4_MAPPED_IPV6_PREFIX = "::ffff:"; +const PUBLIC_IP_LOOKUP_URL = "https://api.ipify.org?format=json"; +const PUBLIC_IP_LOOKUP_TIMEOUT_MS = 2000; +const PUBLIC_IP_CACHE_TTL_MS = 10 * 60 * 1000; + +interface RequestIpSource { + ip?: string; +} + +let cachedHostPublicIp: { value: string; expiresAt: number } | undefined; + +function isLoopbackIp(ipAddress: string): boolean { + return ( + ipAddress === "127.0.0.1" || + ipAddress.startsWith("10.") || + /^192\.168\./.test(ipAddress) || + /^172\.(1[6-9]|2\d|3[01])\./.test(ipAddress) + ); +} + +async function fetchHostPublicIp(): Promise { + const now = Date.now(); + if (cachedHostPublicIp && cachedHostPublicIp.expiresAt > now) { + return cachedHostPublicIp.value; + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), PUBLIC_IP_LOOKUP_TIMEOUT_MS); + const response = await fetch(PUBLIC_IP_LOOKUP_URL, { signal: controller.signal }); + clearTimeout(timeoutId); + + if (!response.ok) { + logger.warn(`Public IP lookup returned status ${response.status}`); + return undefined; + } + + const body = (await response.json()) as { ip?: unknown }; + const ipAddress = typeof body.ip === "string" ? body.ip : undefined; + + if (!ipAddress || isIP(ipAddress) === 0) { + logger.warn(`Public IP lookup returned invalid payload: ${JSON.stringify(body)}`); + return undefined; + } + + cachedHostPublicIp = { expiresAt: now + PUBLIC_IP_CACHE_TTL_MS, value: ipAddress }; + return ipAddress; + } catch (error) { + logger.warn(`Public IP lookup failed: ${(error as Error).message}`); + return undefined; + } +} + +export function normalizeClientIp(ipAddress: string | undefined): string | undefined { + const trimmedIpAddress = ipAddress?.trim(); + + if (!trimmedIpAddress) { + return undefined; + } + + if (trimmedIpAddress === "::1") { + return "127.0.0.1"; + } + + if (trimmedIpAddress.toLowerCase().startsWith(IPV4_MAPPED_IPV6_PREFIX)) { + const mappedIpv4Address = trimmedIpAddress.slice(IPV4_MAPPED_IPV6_PREFIX.length); + + if (isIP(mappedIpv4Address) === 4) { + return mappedIpv4Address; + } + } + + return trimmedIpAddress; +} + +export async function enrichAdditionalDataWithClientIp( + additionalData: RegisterRampRequest["additionalData"], + request: RequestIpSource +): Promise { + const providedIpAddress = + typeof additionalData?.ipAddress === "string" ? normalizeClientIp(additionalData.ipAddress) : undefined; + + let resolvedIpAddress = providedIpAddress ?? normalizeClientIp(request.ip); + + if (config.deploymentEnv !== "production" && (!resolvedIpAddress || isLoopbackIp(resolvedIpAddress))) { + const hostPublicIp = await fetchHostPublicIp(); + if (hostPublicIp) { + resolvedIpAddress = hostPublicIp; + } + } + + return { + ...(additionalData ?? {}), + ipAddress: resolvedIpAddress + }; +} diff --git a/apps/api/src/api/middlewares/validators.ts b/apps/api/src/api/middlewares/validators.ts index 7373c0e39..95c947cc1 100644 --- a/apps/api/src/api/middlewares/validators.ts +++ b/apps/api/src/api/middlewares/validators.ts @@ -57,12 +57,6 @@ interface SwapBody { token?: keyof TokenConfig; } -interface Sep10Body { - challengeXDR: string; - outToken: string; - clientPublicKey: string; -} - interface SiweCreateBody { walletAddress: string; } @@ -319,26 +313,6 @@ export const validatePostSwapSubsidizationInput: RequestHandler = (req, res, nex next(); }; -export const validateSep10Input: RequestHandler = (req, res, next) => { - const { challengeXDR, outToken, clientPublicKey } = req.body as Sep10Body; - - if (!challengeXDR) { - res.status(httpStatus.BAD_REQUEST).json({ error: "Missing Anchor challenge: challengeXDR" }); - return; - } - - if (!outToken) { - res.status(httpStatus.BAD_REQUEST).json({ error: "Missing offramp token identifier: outToken" }); - return; - } - - if (!clientPublicKey) { - res.status(httpStatus.BAD_REQUEST).json({ error: "Missing Stellar ephemeral public key: clientPublicKey" }); - return; - } - next(); -}; - export const validateSiweCreate: RequestHandler = (req, res, next) => { const { walletAddress } = req.body as SiweCreateBody; diff --git a/apps/api/src/api/routes/v1/index.ts b/apps/api/src/api/routes/v1/index.ts index 293774ea4..b7e6d6e0e 100644 --- a/apps/api/src/api/routes/v1/index.ts +++ b/apps/api/src/api/routes/v1/index.ts @@ -1,7 +1,6 @@ import { Request, Response, Router } from "express"; import { sendStatusWithPk as sendMoonbeamStatusWithPk } from "../../controllers/moonbeam.controller"; import { sendStatusWithPk as sendPendulumStatusWithPk } from "../../controllers/pendulum.controller"; -import { sendStatusWithPk as sendStellarStatusWithPk } from "../../controllers/stellar.controller"; import partnerApiKeysRoutes from "./admin/partner-api-keys.route"; import alfredpayRoutes from "./alfredpay.route"; import authRoutes from "./auth.route"; @@ -13,7 +12,7 @@ import emailRoutes from "./email.route"; import fiatRoutes from "./fiat.route"; import maintenanceRoutes from "./maintenance.route"; import metricsRoutes from "./metrics.route"; -import moneriumRoutes from "./monerium.route"; +import mykoboRoutes from "./mykobo.route"; import paymentMethodsRoutes from "./payment-methods.route"; import priceRoutes from "./price.route"; import publicKeyRoutes from "./public-key.route"; @@ -22,12 +21,10 @@ import rampRoutes from "./ramp.route"; import ratingRoutes from "./rating.route"; import sessionRoutes from "./session.route"; import siweRoutes from "./siwe.route"; -import stellarRoutes from "./stellar.route"; import storageRoutes from "./storage.route"; import webhookRoutes from "./webhook.route"; type ChainStatus = { - stellar: unknown; pendulum: unknown; moonbeam: unknown; }; @@ -37,8 +34,7 @@ const router: Router = Router({ mergeParams: true }); async function sendStatusWithPk(_: Request, res: Response): Promise { const chainStatus: ChainStatus = { moonbeam: await sendMoonbeamStatusWithPk(), - pendulum: await sendPendulumStatusWithPk(), - stellar: await sendStellarStatusWithPk() + pendulum: await sendPendulumStatusWithPk() }; res.json(chainStatus); @@ -65,11 +61,6 @@ router.use("/prices", priceRoutes); */ router.use("/quotes", quoteRoutes); -/** - * POST v1/stellar - */ -router.use("/stellar", stellarRoutes); - /** * POST v1/storage */ @@ -153,9 +144,10 @@ router.use("/auth", authRoutes); router.use("/alfredpay", alfredpayRoutes); /** - * GET v1/monerium + * GET v1/mykobo/profiles + * POST v1/mykobo/profiles */ -router.use("/monerium", moneriumRoutes); +router.use("/mykobo", mykoboRoutes); /** * POST v1/webhook diff --git a/apps/api/src/api/routes/v1/monerium.route.ts b/apps/api/src/api/routes/v1/monerium.route.ts deleted file mode 100644 index af54ff49a..000000000 --- a/apps/api/src/api/routes/v1/monerium.route.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Router } from "express"; -import * as moneriumController from "../../controllers/monerium.controller"; - -const router: Router = Router({ mergeParams: true }); - -router.route("/address-exists").get(moneriumController.checkAddressExistsController); - -export default router; diff --git a/apps/api/src/api/routes/v1/mykobo.route.ts b/apps/api/src/api/routes/v1/mykobo.route.ts new file mode 100644 index 000000000..d5fa77e7e --- /dev/null +++ b/apps/api/src/api/routes/v1/mykobo.route.ts @@ -0,0 +1,18 @@ +import { Router } from "express"; +import multer from "multer"; +import * as mykoboController from "../../controllers/mykobo.controller"; +import { requireAuth } from "../../middlewares/supabaseAuth"; + +const router: Router = Router({ mergeParams: true }); +const upload = multer({ limits: { fileSize: 10 * 1024 * 1024 }, storage: multer.memoryStorage() }); +const profileUpload = upload.fields([ + { maxCount: 1, name: "front" }, + { maxCount: 1, name: "back" }, + { maxCount: 1, name: "face" }, + { maxCount: 1, name: "utility_bill" } +]); + +router.route("/profiles").get(requireAuth, mykoboController.getProfileController); +router.route("/profiles").post(requireAuth, profileUpload, mykoboController.createProfileController); + +export default router; diff --git a/apps/api/src/api/routes/v1/stellar.route.ts b/apps/api/src/api/routes/v1/stellar.route.ts deleted file mode 100644 index 9df107089..000000000 --- a/apps/api/src/api/routes/v1/stellar.route.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Router } from "express"; -import * as stellarController from "../../controllers/stellar.controller"; -import { getMemoFromCookiesMiddleware } from "../../middlewares/auth"; -import { requireAuth } from "../../middlewares/supabaseAuth"; -import { validateCreationInput, validateSep10Input } from "../../middlewares/validators"; - -const router: Router = Router({ mergeParams: true }); - -router.route("/create").post(requireAuth, validateCreationInput, stellarController.createStellarTransactionHandler); - -// Only authorized route. Does not reject the request, but rather passes the memo (if any) derived from a valid cookie in the request. -router - .route("/sep10") - .post([validateSep10Input, getMemoFromCookiesMiddleware], stellarController.signSep10ChallengeHandler) - .get(stellarController.getSep10MasterPKHandler); - -export default router; diff --git a/apps/api/src/api/services/monerium/index.ts b/apps/api/src/api/services/monerium/index.ts deleted file mode 100644 index a304967a0..000000000 --- a/apps/api/src/api/services/monerium/index.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { - AddressExistsResponse, - AuthContext, - BeneficiaryDetails, - EvmNetworks, - FetchIbansParams, - FetchProfileParams, - IbanData, - IbanDataResponse, - MoneriumAddressStatus, - MoneriumErrors, - MoneriumResponse, - MoneriumTokenResponse, - MoneriumUserProfile, - Networks -} from "@vortexfi/shared"; -import logger from "../../../config/logger"; -import { config } from "../../../config/vars"; -import { fetchWithTimeout } from "../../helpers/fetchWithTimeout"; - -const MONERIUM_API_URL = config.sandboxEnabled ? "https://api.monerium.dev" : "https://api.monerium.app"; -export const MONERIUM_MINT_CHAIN = config.sandboxEnabled ? "amoy" : "polygon"; -const HEADER_ACCEPT_V2 = { Accept: "application/vnd.monerium.api-v2+json" }; -const HEADER_CONTENT_TYPE_FORM = { "Content-Type": "application/x-www-form-urlencoded" }; - -const authorize = async (): Promise => { - const url = `${MONERIUM_API_URL}/auth/token`; - const headers = HEADER_CONTENT_TYPE_FORM; - const body = new URLSearchParams({ - client_id: config.integrations.monerium.clientId || "", - client_secret: config.integrations.monerium.clientSecret || "", - grant_type: "client_credentials" - }); - - const response = await fetchWithTimeout(url, { - body, - headers, - method: "POST" - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return response.json(); -}; - -export const checkAddressExists = async (address: string, network: Networks): Promise => { - const { access_token } = await authorize(); - const url = `${MONERIUM_API_URL}/addresses/${address}`; - const headers = { - ...HEADER_ACCEPT_V2, - Authorization: `Bearer ${access_token}` - }; - - try { - const response = await fetchWithTimeout(url, { headers }); - if (!response.ok) { - if (response.status === 404) { - return null; - } - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data: AddressExistsResponse = await response.json(); - if (data.chains.includes(network)) { - return data; - } - return null; - } catch (error) { - logger.error("Failed to fetch address:", error); - return null; - } -}; - -export const getFirstMoneriumLinkedAddress = async (token: string): Promise => { - const url = `${MONERIUM_API_URL}/addresses`; - const headers = { - ...HEADER_ACCEPT_V2, - Authorization: `Bearer ${token}` - }; - - try { - const response = await fetchWithTimeout(url, { headers }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data: MoneriumResponse = await response.json(); - - if (data.addresses && data.addresses.length > 0) { - const mostRecentAddress = data.addresses[data.addresses.length - 1]; // Ordered by creation date, so last is the most recent. - - if (mostRecentAddress.status === MoneriumAddressStatus.REQUESTED) { - throw new Error(MoneriumErrors.USER_MINT_ADDRESS_IS_NOT_READY); - } - - return mostRecentAddress.address; - } else { - logger.info("No addresses found in the response."); - return null; - } - } catch (error) { - logger.error("Failed to fetch addresses:", error); - throw error; - } -}; - -export const getAuthContext = async (authToken: string): Promise => { - const url = `${MONERIUM_API_URL}/auth/context`; - const headers = { - ...HEADER_ACCEPT_V2, - Authorization: `Bearer ${authToken}` - }; - - try { - const response = await fetchWithTimeout(url, { headers }); - - if (!response.ok) { - throw new Error(`No auth context found: ${response.status} ${response.statusText}`); - } - - return response.json(); - } catch (error) { - logger.error("Failed to fetch auth context:", error); - throw error; - } -}; - -export const getMoneriumEvmDefaultMintAddress = async (token: string): Promise => { - // Assumption is the first linked address is the default mint address for Monerium EVM transactions. - // TODO: this needs to be confirmed. - return getFirstMoneriumLinkedAddress(token); -}; - -export const getIbanForAddress = async (walletAddress: string, authToken: string, network: EvmNetworks): Promise => { - const approvedAddresses = await getMoneriumLinkedIbans(authToken); - - // Check if the wallet address is in the list of approved addresses - // and that it matches polygon/amoy network. - const ibanData = approvedAddresses.find( - item => item.address.toLowerCase() === walletAddress.toLowerCase() && item.chain === MONERIUM_MINT_CHAIN - ); - - if (!ibanData) { - throw new Error(MoneriumErrors.USER_MINT_ADDRESS_NOT_FOUND); - } - return ibanData; -}; - -export const getMoneriumUserIban = async ({ authToken, profileId }: FetchIbansParams): Promise => { - const baseUrl = `${MONERIUM_API_URL}/ibans`; - const url = new URL(baseUrl); - - url.searchParams.append("profile", profileId); - const headers = new Headers({ - ...HEADER_ACCEPT_V2, - Authorization: `Bearer ${authToken}` - }); - - try { - const response = await fetchWithTimeout(url.toString(), { - headers: headers, - method: "GET" - }); - - if (!response.ok) { - throw new Error(`API request failed with status ${response.status}: ${response.statusText}`); - } - const data: IbanDataResponse = await response.json(); - // Look for the IBAN data specifically for the Polygon chain. - // We choose Polygon as the default chain for Monerium EUR minting, - // so user registered with us should always have a Polygon-linked address. - const ibanData = data.ibans.find(item => item.chain === "polygon"); - if (!ibanData) { - throw new Error("No IBAN found for the specified chain (polygon)"); - } - - return ibanData; - } catch (error) { - logger.error("Error fetching IBANs:", error); - throw error; - } -}; - -export const getMoneriumLinkedIbans = async (authToken: string): Promise => { - const authContext = await getAuthContext(authToken); - const profileId = authContext.defaultProfile; - - const baseUrl = `${MONERIUM_API_URL}/ibans`; - const url = new URL(baseUrl); - - url.searchParams.append("profile", profileId); - const headers = new Headers({ - ...HEADER_ACCEPT_V2, - Authorization: `Bearer ${authToken}` - }); - - try { - const response = await fetchWithTimeout(url.toString(), { - headers: headers, - method: "GET" - }); - if (!response.ok) { - throw new Error(`API request failed with status ${response.status}: ${response.statusText}`); - } - - const data = (await response.json()) as { ibans: Array }; - const approvedIbans = data.ibans.filter(item => item.state === "approved" && item.iban !== undefined && item.iban !== null); - - if (approvedIbans.length === 0) { - throw new Error(MoneriumErrors.USER_MINT_ADDRESS_NOT_FOUND); - } - - return approvedIbans; - } catch (error) { - logger.error("Error fetching linked IBANs:", error); - throw error; - } -}; - -export const getMoneriumUserProfile = async ({ authToken, profileId }: FetchProfileParams): Promise => { - const profileUrl = `${MONERIUM_API_URL}/profiles/${profileId}`; - const headers = new Headers({ - ...HEADER_ACCEPT_V2, - Authorization: `Bearer ${authToken}` - }); - - try { - const profileResponse = await fetchWithTimeout(profileUrl, { - headers: headers, - method: "GET" - }); - - if (!profileResponse.ok) { - throw new Error(`Profile API request failed with status ${profileResponse.status}: ${profileResponse.statusText}`); - } - - const profileData: MoneriumUserProfile = await profileResponse.json(); - return profileData; - } catch (error) { - logger.error("Error fetching user profile:", error); - throw error; - } -}; - -export function createEpcQrCodeData(details: BeneficiaryDetails): string { - const { name, iban, bic, amount } = details; - - if (!name || !iban || !bic || !amount) { - throw new Error("Beneficiary name, IBAN, and BIC are required to create EPC QR code data."); - } - - // EPC QR code data format; https://en.wikipedia.org/wiki/EPC_QR_code. - const data = ["BCD", "001", "1", "SCT", bic, name, iban, `EUR${amount}`]; - - return data.join("\n"); -} diff --git a/apps/api/src/api/services/mykobo/mykobo-customer.service.ts b/apps/api/src/api/services/mykobo/mykobo-customer.service.ts new file mode 100644 index 000000000..767570fa0 --- /dev/null +++ b/apps/api/src/api/services/mykobo/mykobo-customer.service.ts @@ -0,0 +1,47 @@ +import { MykoboApiError, MykoboApiService, MykoboCustomerStatus, MykoboProfile, mapMykoboReviewStatus } from "@vortexfi/shared"; +import logger from "../../../config/logger"; +import MykoboCustomer from "../../../models/mykoboCustomer.model"; + +interface UpsertArgs { + userId: string; + email: string; + status: MykoboCustomerStatus; + statusExternal: string | null; +} + +async function upsertMykoboCustomer({ userId, email, status, statusExternal }: UpsertArgs): Promise { + const existing = await MykoboCustomer.findOne({ where: { userId } }); + if (existing) { + await existing.update({ email, status, statusExternal }); + return; + } + await MykoboCustomer.create({ email, status, statusExternal, userId }); +} + +export async function upsertMykoboCustomerFromProfile(userId: string, email: string, profile: MykoboProfile): Promise { + const reviewStatus = profile.kyc_status?.review_status ?? null; + await upsertMykoboCustomer({ + email, + status: mapMykoboReviewStatus(reviewStatus), + statusExternal: reviewStatus, + userId + }); +} + +export async function syncMykoboCustomerKyc(userId: string, email: string): Promise { + try { + const { profile } = await MykoboApiService.getInstance().getProfileByEmail(email); + await upsertMykoboCustomerFromProfile(userId, email, profile); + } catch (error) { + if (error instanceof MykoboApiError && error.status === 404) { + await upsertMykoboCustomer({ + email, + status: MykoboCustomerStatus.CONSULTED, + statusExternal: null, + userId + }); + return; + } + logger.error("Failed to sync Mykobo customer KYC mirror:", error); + } +} diff --git a/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts b/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts index fc639b609..634ac6de9 100644 --- a/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts +++ b/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts @@ -11,6 +11,7 @@ import { decodeFunctionData, erc20Abi, parseTransaction } from "viem"; import logger from "../../../../config/logger"; import QuoteTicket from "../../../../models/quoteTicket.model"; import RampState from "../../../../models/rampState.model"; +import { UnrecoverablePhaseError } from "../../../errors/phase-error"; import { BasePhaseHandler } from "../base-phase-handler"; import { StateMetadata } from "../meta-state-types"; @@ -104,6 +105,43 @@ export class DestinationTransferHandler extends BasePhaseHandler { } } + // Nonce-gap guard: a presigned nonce ahead of the live ephemeral nonce can never be mined and would + // silently retry until the processor gives up, stranding user funds. Raise it for manual review. + // Reading the live nonce is best-effort: an RPC failure must not block the happy path. + if (!destinationTransferTxHash && state.state.evmEphemeralAddress) { + try { + const presignedNonce = parseTransaction(destinationTransfer as `0x${string}`).nonce; + if (presignedNonce !== undefined) { + try { + const liveNonce = await evmClientManager.getClient(destinationNetwork).getTransactionCount({ + address: state.state.evmEphemeralAddress as `0x${string}`, + blockTag: "pending" + }); + if (presignedNonce > liveNonce) { + throw this.createUnrecoverableError( + `DestinationTransferHandler: presigned nonce ${presignedNonce} is ahead of the ephemeral live nonce ${liveNonce}. ` + + "The transfer can never broadcast (nonce gap); manual review required." + ); + } + } catch (error) { + if (error instanceof UnrecoverablePhaseError) { + throw error; + } + logger.warn( + `DestinationTransferHandler: could not verify ephemeral nonce before broadcast - ${(error as Error).message}` + ); + } + } + } catch (error) { + if (error instanceof UnrecoverablePhaseError) { + throw error; + } + logger.warn( + `DestinationTransferHandler: could not parse presigned destination transfer for nonce check - ${(error as Error).message}` + ); + } + } + // main phase execution loop: try { await checkEvmBalanceForToken({ diff --git a/apps/api/src/api/services/phases/handlers/distribute-fees-handler.ts b/apps/api/src/api/services/phases/handlers/distribute-fees-handler.ts index 463720e3f..b88456f7f 100644 --- a/apps/api/src/api/services/phases/handlers/distribute-fees-handler.ts +++ b/apps/api/src/api/services/phases/handlers/distribute-fees-handler.ts @@ -78,8 +78,8 @@ export class DistributeFeesHandler extends BasePhaseHandler { // Check if we already have a hash stored const existingHash = state.state.distributeFeeHash || null; - // For BRL flows, distribution happens on EVM (Base). - const isEvmTransaction = quote.inputCurrency === "BRL" || quote.outputCurrency === "BRL"; + // For EVM-ephemeral flows (BRL, Mykobo EUR, ...), distribution happens on EVM (Base). + const isEvmTransaction = !!quote.metadata.nablaSwapEvm; const evmNetwork = isEvmTransaction ? (Networks.Base as EvmNetworks) : undefined; if (existingHash) { diff --git a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts index 24fa55e9b..2baef7318 100644 --- a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts +++ b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts @@ -28,6 +28,7 @@ import { MAX_FINAL_SETTLEMENT_SUBSIDY_USD } from "../../../../constants/constant import QuoteTicket from "../../../../models/quoteTicket.model"; import RampState from "../../../../models/rampState.model"; import { priceFeedService } from "../../priceFeed.service"; +import { isBrlToBrlaBaseDirect, isEurToEurcBaseDirect } from "../../quote/utils"; import { BasePhaseHandler } from "../base-phase-handler"; import { getEvmFundingAccount } from "../evm-funding"; @@ -62,6 +63,12 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { protected async executePhase(state: RampState): Promise { logger.debug(`FinalSettlementSubsidyHandler: Starting phase execution for ramp ${state.id}, type=${state.type}`); + + if (state.state.isDirectTransfer === true) { + logger.info(`FinalSettlementSubsidyHandler: Skipping subsidy for direct-transfer ramp ${state.id}`); + return this.transitionToNextPhase(state, "destinationTransfer"); + } + const evmClientManager = EvmClientManager.getInstance(); const fundingAccount = getEvmFundingAccount(Networks.Moonbeam); @@ -73,6 +80,14 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { `FinalSettlementSubsidyHandler: Quote found. inputCurrency=${quote.inputCurrency}, outputCurrency=${quote.outputCurrency}, network=${quote.network}` ); + if ( + isEurToEurcBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network) || + isBrlToBrlaBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network) + ) { + logger.info(`FinalSettlementSubsidyHandler: Skipping subsidy for Base direct-transfer route (ramp ${state.id})`); + return this.transitionToNextPhase(state, "destinationTransfer"); + } + const isAlfredpaySell = state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken); const outTokenDetails = @@ -138,7 +153,7 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { `FinalSettlementSubsidyHandler: Polling ephemeral balance for ${ephemeralAddress} on ${destinationNetwork} (timeout=${EVM_BALANCE_CHECK_TIMEOUT_MS}ms, interval=${BALANCE_POLLING_TIME_MS}ms)` ); const actualBalance = await checkEvmBalanceForToken({ - amountDesiredRaw: "1", // If we passed expectedAmountRaw, we might timeout if the bridge slipped and delivered slightly less. + amountDesiredRaw: expectedAmountRaw.mul(0.9).toFixed(0, 0), // Wait for >=90% of expected bridge delivery to absorb slippage while still waiting for actual bridge arrival. chain: destinationNetwork, intervalMs: BALANCE_POLLING_TIME_MS, ownerAddress: ephemeralAddress, @@ -147,6 +162,10 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { }); logger.debug(`FinalSettlementSubsidyHandler: Ephemeral balance=${actualBalance.toString()}`); + const preBalance = new Big(state.state.preSettlementBalance ?? "0"); + const deliveredRaw = actualBalance.minus(preBalance); + const delivered = deliveredRaw.gte(0) ? deliveredRaw : new Big(0); + // 3. Check funding account balance (handles both native and ERC-20 automatically) logger.debug(`FinalSettlementSubsidyHandler: Checking funding account balance at ${fundingAccount.address}`); const actualBalanceFundingAccount = await getEvmBalance({ @@ -156,14 +175,14 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { }); logger.debug(`FinalSettlementSubsidyHandler: Funding account balance=${actualBalanceFundingAccount.toString()}`); - const subsidyAmountRaw = expectedAmountRaw.minus(actualBalance); + const subsidyAmountRaw = expectedAmountRaw.minus(delivered); logger.debug( - `FinalSettlementSubsidyHandler: subsidyAmountRaw=${subsidyAmountRaw.toString()} (expected=${expectedAmountRaw.toString()} - actual=${actualBalance.toString()})` + `FinalSettlementSubsidyHandler: subsidyAmountRaw=${subsidyAmountRaw.toString()} (expected=${expectedAmountRaw.toString()} - delivered=${delivered.toString()}, actualBalance=${actualBalance.toString()}, preSettlementBalance=${preBalance.toString()})` ); if (subsidyAmountRaw.lte(0)) { logger.info( - `FinalSettlementSubsidyHandler: Actual balance (${actualBalance.toString()}) meets expected amount. No subsidy needed.` + `FinalSettlementSubsidyHandler: Delivered amount (${delivered.toString()}) meets expected amount with actualBalance=${actualBalance.toString()} and preSettlementBalance=${preBalance.toString()}. No subsidy needed.` ); return this.transitionToNextPhase(state, this.getNextPhase(state, quote)); } diff --git a/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts b/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts index 0e413f750..a5e7b7fa9 100644 --- a/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts +++ b/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts @@ -11,7 +11,6 @@ import { RampPhase, waitUntilTrueWithTimeout } from "@vortexfi/shared"; -import { NetworkError, Transaction } from "stellar-sdk"; import { type Hex, parseTransaction } from "viem"; import logger from "../../../../config/logger"; import { @@ -24,32 +23,19 @@ import RampState from "../../../../models/rampState.model"; import { UnrecoverablePhaseError } from "../../../errors/phase-error"; import { multiplyByPowerOfTen } from "../../pendulum/helpers"; import { fundEphemeralAccount } from "../../pendulum/pendulum.service"; +import { isBrlToBrlaBaseDirect, isEurToEurcBaseDirect } from "../../quote/utils"; import { BasePhaseHandler } from "../base-phase-handler"; import { getEvmFundingAccount } from "../evm-funding"; -import { validateStellarPaymentSequenceNumber } from "../helpers/stellar-sequence-validator"; import { verifyUserSubmittedTxByHash } from "../helpers/user-tx-verifier"; import { StateMetadata } from "../meta-state-types"; import { DESTINATION_EVM_FUNDING_AMOUNTS, - horizonServer, isBaseEphemeralFunded, isDestinationEvmEphemeralFunded, isPendulumEphemeralFunded, - isPolygonEphemeralFunded, - isStellarEphemeralFunded, - NETWORK_PASSPHRASE + isPolygonEphemeralFunded } from "./helpers"; -export function isStellarNetworkError(error: unknown): error is NetworkError { - return ( - error instanceof Error && - "response" in error && - error.response !== null && - typeof error.response === "object" && - "data" in error.response - ); -} - function isOnramp(state: RampState): boolean { return state.type === RampDirection.BUY; } @@ -60,13 +46,11 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { } protected getRequiresPendulumEphemeralAddress(state: RampState, inputCurrency?: string, outputCurrency?: string): boolean { - // Pendulum ephemeral address is required for all cases except when doing a Monerium/Alfredpay to EVM onramp, - // or alfredpay offramp - if ( - isOnramp(state) && - (inputCurrency === FiatToken.EURC || isAlfredpayToken(inputCurrency as FiatToken)) && - state.to !== Networks.AssetHub - ) { + if (inputCurrency === FiatToken.EURC || outputCurrency === FiatToken.EURC) { + return false; + } + + if (isOnramp(state) && isAlfredpayToken(inputCurrency as FiatToken) && state.to !== Networks.AssetHub) { return false; } @@ -81,8 +65,8 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { } protected getRequiresPolygonEphemeralAddress(state: RampState, inputCurrency?: string, outputCurrency?: string): boolean { - // Only required for Monerium and Alfredpay onramps and offramps. - if (isOnramp(state) && (inputCurrency === FiatToken.EURC || isAlfredpayToken(inputCurrency as FiatToken))) { + // Only required for Alfredpay onramps and offramps. Mykobo (EUR) runs on Base, not Polygon. + if (isOnramp(state) && isAlfredpayToken(inputCurrency as FiatToken)) { return true; } if (!isOnramp(state) && isAlfredpayToken(outputCurrency as FiatToken)) { @@ -93,10 +77,12 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { } protected getRequiresBaseEphemeralAddress(inputCurrency?: string, outputCurrency?: string): boolean { - // Only required for BRLA onramps. if (inputCurrency === FiatToken.BRL || outputCurrency === FiatToken.BRL) { return true; } + if (inputCurrency === FiatToken.EURC || outputCurrency === FiatToken.EURC) { + return true; + } return false; } @@ -124,6 +110,23 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { const fromNetwork = state.from as EvmNetworks; if (!isNetworkEVM(fromNetwork)) return; + // Base+USDC direct path: the user broadcasts a single ERC20 transfer instead of squid + // approve+swap. Verify that hash before we fund the ephemeral and spend gas on Nabla. + const hasNoPermitTransferBlueprint = state.unsignedTxs.some(tx => tx.phase === "squidRouterNoPermitTransfer"); + if (hasNoPermitTransferBlueprint) { + await verifyUserSubmittedTxByHash({ + fromNetwork, + hash: state.state.squidRouterNoPermitTransferHash as `0x${string}` | undefined, + label: "User direct USDC transfer to ephemeral", + presignedPhase: "squidRouterNoPermitTransfer", + state + }); + return; + } + + const hasSquidApproveBlueprint = state.unsignedTxs.some(tx => tx.phase === "squidRouterApprove"); + if (!hasSquidApproveBlueprint) return; + await verifyUserSubmittedTxByHash({ fromNetwork, hash: state.state.squidRouterApproveHash as `0x${string}` | undefined, @@ -191,16 +194,6 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { ? await isDestinationEvmEphemeralFunded(evmEphemeralAddress, destinationNetwork) : true; - if (state.state.stellarTarget) { - const isFunded = await isStellarEphemeralFunded( - state.state.stellarEphemeralAccountId, - state.state.stellarTarget.stellarTokenDetails - ); - if (!isFunded) { - await this.fundStellarEphemeralAccount(state); - } - } - if (!isPendulumFunded) { logger.info(`Funding PEN ephemeral account ${substrateEphemeralAddress}`); if (isOnramp(state) && state.to !== Networks.AssetHub) { @@ -248,18 +241,27 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { } protected nextPhaseSelector(state: RampState, quote: QuoteTicket): RampPhase { + if ( + state.state.isDirectTransfer === true || + (isOnramp(state) && + (isEurToEurcBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network) || + isBrlToBrlaBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network))) + ) { + return "destinationTransfer"; + } + // brla onramp case if (isOnramp(state) && quote.inputCurrency === FiatToken.BRL) { return "subsidizePreSwap"; } + // mykobo (EURC) onramp case + if (isOnramp(state) && quote.inputCurrency === FiatToken.EURC) { + return "subsidizePreSwap"; + } // alfredpay onramp case if (isOnramp(state) && isAlfredpayToken(quote.inputCurrency as FiatToken)) { return "subsidizePreSwap"; } - // monerium onramp case - if (isOnramp(state) && quote.inputCurrency === FiatToken.EURC) { - return "moneriumOnrampSelfTransfer"; - } // off ramp cases if (state.type === RampDirection.SELL && state.from === Networks.AssetHub) { @@ -268,70 +270,13 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { return "finalSettlementSubsidy"; } else if (state.type === RampDirection.SELL && quote.outputCurrency === FiatToken.BRL) { return "distributeFees"; + } else if (state.type === RampDirection.SELL && quote.outputCurrency === FiatToken.EURC) { + return "distributeFees"; } else { return "moonbeamToPendulum"; // Via contract.subsidizePreSwap } } - protected async fundStellarEphemeralAccount(state: RampState): Promise { - const { txData: stellarCreationTransactionXDR } = this.getPresignedTransaction(state, "stellarCreateAccount"); - if (typeof stellarCreationTransactionXDR !== "string") { - throw new Error( - "FundEphemeralHandler: `stellarCreateAccount` transaction is not a string -> not an encoded Stellar transaction." - ); - } - - try { - const stellarCreationTransaction = new Transaction(stellarCreationTransactionXDR, NETWORK_PASSPHRASE); - logger.info( - `Submitting stellar account creation transaction to create ephemeral account: ${state.state.stellarEphemeralAccountId}` - ); - await horizonServer.submitTransaction(stellarCreationTransaction); - - logger.info("Validating stellar payment sequence number after account creation"); - try { - await validateStellarPaymentSequenceNumber(state, state.state.stellarEphemeralAccountId); - } catch (validationError) { - logger.error(`Stellar payment sequence validation failed after account creation: ${validationError}`); - throw this.createUnrecoverableError("Stellar payment sequence validation failed after account creation"); - } - } catch (e) { - if (e instanceof UnrecoverablePhaseError) { - throw e; - } - - // when validateStellarPaymentSequenceNumber throws an error, it's not NetworkError - if (isStellarNetworkError(e)) { - if (e.response.data?.status === 400) { - logger.info( - `Could not submit the stellar account creation transaction ${JSON.stringify(e.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 (e.response.data.extras.result_codes.transaction === "tx_bad_seq") { - logger.info("Recovery mode: Creation already performed."); - - try { - logger.info("Validating stellar payment sequence number in recovery mode"); - await validateStellarPaymentSequenceNumber(state, state.state.stellarEphemeralAccountId); - } catch (validationError) { - logger.error(`Sequence number validation failed in recovery mode: ${validationError}`); - throw this.createUnrecoverableError("Stellar payment sequence validation failed after account creation recovery"); - } - } - logger.error(`Could not submit the stellar creation transaction: ${e.response.data.extras}`); - throw new Error("Could not submit the stellar creation transaction"); - } else { - logger.error(`Could not submit the stellar creation transaction: ${e.response.data}`); - throw new Error("Could not submit the stellar creation transaction"); - } - } else { - logger.error(`Error in stellar account creation: ${e}`); - throw new Error("Could not submit the stellar creation transaction"); - } - } - } - protected async fundEvmEphemeralAccount(state: RampState, network: EvmNetworks): Promise { try { const evmClientManager = EvmClientManager.getInstance(); diff --git a/apps/api/src/api/services/phases/handlers/helpers.ts b/apps/api/src/api/services/phases/handlers/helpers.ts index bdf637285..f745eed0d 100644 --- a/apps/api/src/api/services/phases/handlers/helpers.ts +++ b/apps/api/src/api/services/phases/handlers/helpers.ts @@ -1,16 +1,6 @@ -import { - API, - EvmClientManager, - EvmNetworks, - HORIZON_URL, - StellarTokenDetails, - Networks as VortexNetworks -} from "@vortexfi/shared"; +import { API, EvmClientManager, EvmNetworks, Networks as VortexNetworks } from "@vortexfi/shared"; import Big from "big.js"; -import { Horizon, Networks } from "stellar-sdk"; import { base, polygon } from "viem/chains"; -import logger from "../../../../config/logger"; -import { config } from "../../../../config/vars"; import { BASE_EPHEMERAL_STARTING_BALANCE_UNITS, GLMR_FUNDING_AMOUNT_RAW, @@ -19,32 +9,6 @@ import { } from "../../../../constants/constants"; import { multiplyByPowerOfTen } from "../../pendulum/helpers"; -export const horizonServer = new Horizon.Server(HORIZON_URL); -export const NETWORK_PASSPHRASE = config.sandboxEnabled ? Networks.TESTNET : Networks.PUBLIC; - -export async function isStellarEphemeralFunded(accountId: string, stellarTokenDetails: StellarTokenDetails): Promise { - try { - // We check if the Stellar target account exists and has the respective trustline. - const account = await horizonServer.loadAccount(accountId); - - const trustlineExists = account.balances.some( - balance => - balance.asset_type === "credit_alphanum4" && - balance.asset_code === stellarTokenDetails.stellarAsset.code.string && - balance.asset_issuer === stellarTokenDetails.stellarAsset.issuer.stellarEncoding - ); - return trustlineExists; - } catch (error) { - if (error?.toString().includes("NotFoundError")) { - logger.info(`Stellar target account ${accountId} does not exist.`); - return false; - } else { - // We return an error here to ensure that the phase fails and can be retried. - throw new Error(`${error?.toString()} while checking Stellar target account.`); - } - } -} - export async function isPendulumEphemeralFunded(pendulumEphemeralAddress: string, pendulumNode: API): Promise { const fundingAmountUnits = Big(PENDULUM_EPHEMERAL_STARTING_BALANCE_UNITS); const fundingAmountRaw = multiplyByPowerOfTen(fundingAmountUnits, pendulumNode.decimals).toFixed(); diff --git a/apps/api/src/api/services/phases/handlers/initial-phase-handler.ts b/apps/api/src/api/services/phases/handlers/initial-phase-handler.ts index 0711ad87c..d3ccd8d34 100644 --- a/apps/api/src/api/services/phases/handlers/initial-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/initial-phase-handler.ts @@ -37,7 +37,7 @@ export class InitialPhaseHandler extends BasePhaseHandler { if (state.type === RampDirection.BUY && quote.inputCurrency === FiatToken.BRL) { return this.transitionToNextPhase(state, "brlaOnrampMint"); } else if (state.type === RampDirection.BUY && quote.inputCurrency === FiatToken.EURC) { - return this.transitionToNextPhase(state, "moneriumOnrampMint"); + return this.transitionToNextPhase(state, "mykoboOnrampDeposit"); } else if (state.type === RampDirection.BUY && isAlfredpayToken(quote.inputCurrency as FiatToken)) { return this.transitionToNextPhase(state, "alfredpayOnrampMint"); } else if (state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken)) { diff --git a/apps/api/src/api/services/phases/handlers/monerium-onramp-mint-handler.ts b/apps/api/src/api/services/phases/handlers/monerium-onramp-mint-handler.ts deleted file mode 100644 index eea5dbc1c..000000000 --- a/apps/api/src/api/services/phases/handlers/monerium-onramp-mint-handler.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { - BalanceCheckError, - BalanceCheckErrorType, - checkEvmBalancePeriodically, - ERC20_EURE_POLYGON_V2, - Networks, - RampPhase -} from "@vortexfi/shared"; -import logger from "../../../../config/logger"; -import QuoteTicket from "../../../../models/quoteTicket.model"; -import RampState from "../../../../models/rampState.model"; -import { BasePhaseHandler } from "../base-phase-handler"; -import { StateMetadata } from "../meta-state-types"; - -// Same rationale as in brla-onramp-mint-handler.ts -const PAYMENT_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes -const EVM_BALANCE_CHECK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes - -export class MoneriumOnrampMintPhaseHandler extends BasePhaseHandler { - public getPhaseName(): RampPhase { - return "moneriumOnrampMint"; - } - - protected async executePhase(state: RampState): Promise { - const { moneriumWalletAddress: walletAddress } = state.state as StateMetadata; - - if (!walletAddress) { - throw new Error("MoneriumOnrampMintPhaseHandler: State metadata corrupted. This is a bug."); - } - - const quote = await QuoteTicket.findByPk(state.quoteId); - if (!quote) { - throw new Error("Quote not found for the given state"); - } - - if (!quote.metadata.moneriumMint?.outputAmountRaw) { - throw new Error("MoneriumOnrampMintPhaseHandler: Missing moneriumMint metadata."); - } - - const inputAmountBeforeSwapRaw = quote.metadata.moneriumMint.outputAmountRaw; - - try { - const pollingTimeMs = 1000; - - await checkEvmBalancePeriodically( - ERC20_EURE_POLYGON_V2, - walletAddress, - inputAmountBeforeSwapRaw, - pollingTimeMs, - EVM_BALANCE_CHECK_TIMEOUT_MS, - Networks.Polygon - ); - - // Add delay to ensure the transaction is settled - await new Promise(resolve => setTimeout(resolve, 30000)); // 30 seconds. - } catch (error) { - if (!(error instanceof BalanceCheckError)) throw error; - - const isCheckTimeout = error.type === BalanceCheckErrorType.Timeout; - if (isCheckTimeout && this.isPaymentTimeoutReached(state)) { - logger.error("Payment timeout. Cancelling ramp."); - - return this.transitionToNextPhase(state, "failed"); - } - - throw isCheckTimeout - ? this.createRecoverableError(`MoneriumOnrampMintPhaseHandler: ${error}`) - : new Error(`Error checking Moonbeam balance: ${error}`); - } - - return this.transitionToNextPhase(state, "fundEphemeral"); - } - - protected isPaymentTimeoutReached(state: RampState): boolean { - const thisPhaseEntry = state.phaseHistory.find(phaseHistoryEntry => phaseHistoryEntry.phase === this.getPhaseName()); - if (!thisPhaseEntry) { - throw new Error("MoneriumOnrampMintPhaseHandler: Phase not found in history. State corrupted."); - } - - const initialTimestamp = new Date(thisPhaseEntry.timestamp); - if (initialTimestamp.getTime() + PAYMENT_TIMEOUT_MS < Date.now()) { - return true; - } - return false; - } -} - -export default new MoneriumOnrampMintPhaseHandler(); diff --git a/apps/api/src/api/services/phases/handlers/monerium-onramp-self-transfer-handler.ts b/apps/api/src/api/services/phases/handlers/monerium-onramp-self-transfer-handler.ts deleted file mode 100644 index 40e5db926..000000000 --- a/apps/api/src/api/services/phases/handlers/monerium-onramp-self-transfer-handler.ts +++ /dev/null @@ -1,428 +0,0 @@ -import { - ERC20_EURE_POLYGON_TOKEN_NAME, - ERC20_EURE_POLYGON_V2, - EvmClientManager, - getEvmTokenBalance, - getNetworkId, - Networks, - RampDirection, - RampPhase -} from "@vortexfi/shared"; -import Big from "big.js"; -import { encodeFunctionData, isAddress, PublicClient, TransactionReceipt } from "viem"; -import { privateKeyToAccount } from "viem/accounts"; -import logger from "../../../../config/logger"; -import { config } from "../../../../config/vars"; -import erc20ABI from "../../../../contracts/ERC20"; -import { permitAbi } from "../../../../contracts/PermitAbi"; -import QuoteTicket from "../../../../models/quoteTicket.model"; -import RampState from "../../../../models/rampState.model"; -import { analyzeMoneriumPermitPreflight, MoneriumPermitDiagnostics } from "../../ramp/monerium-permit"; -import { inspectMoneriumSelfTransferTransaction, moneriumTransferFromAbi } from "../../ramp/monerium-self-transfer"; -import { BasePhaseHandler } from "../base-phase-handler"; - -const permitNonceAbi = [ - { - inputs: [{ name: "owner", type: "address" }], - name: "nonces", - outputs: [{ name: "", type: "uint256" }], - stateMutability: "view", - type: "function" - } -] as const; - -/** - * Handler for the monerium self-transfer phase - */ -export class MoneriumOnrampSelfTransferHandler extends BasePhaseHandler { - private polygonClient: PublicClient; - private evmClientManager: EvmClientManager; - - constructor() { - super(); - this.evmClientManager = EvmClientManager.getInstance(); - this.polygonClient = this.evmClientManager.getClient(Networks.Polygon); - } - - /** - * Get the phase name - */ - public getPhaseName(): RampPhase { - return "moneriumOnrampSelfTransfer"; - } - - /** - * Execute the phase - * @param state The current ramp state - * @returns The updated ramp state - */ - protected async executePhase(state: RampState): Promise { - logger.info(`Executing moneriumOnrampSelfTransfer phase for ramp ${state.id}`); - - if (state.type === RampDirection.SELL) { - logger.info("MoneriumOnrampSelfTransfer phase is not supported for off-ramp"); - return state; - } - - const quote = await QuoteTicket.findByPk(state.quoteId); - if (!quote) { - throw new Error("Quote not found for the given state"); - } - - if (!quote.metadata.moneriumMint?.outputAmountRaw) { - throw new Error("MoneriumOnrampSelfTransfer: Missing moneriumMint metadata."); - } - - const { evmEphemeralAddress, moneriumOnrampPermit, moneriumWalletAddress } = state.state; - if (!evmEphemeralAddress) { - throw new Error("MoneriumOnrampSelfTransfer: Polygon ephemeral address not defined in the state. This is a bug."); - } - if (!moneriumOnrampPermit) { - throw new Error("MoneriumOnrampSelfTransfer: Missing Monerium permit in state metadata. State corrupted."); - } - if (!moneriumWalletAddress) { - throw new Error("MoneriumOnrampSelfTransfer: Missing Monerium wallet address in state metadata. State corrupted."); - } - - const mintedAmountRaw = quote.metadata.moneriumMint.outputAmountRaw; - - const didTokensArriveOnEvm = async () => { - const balance = await getEvmTokenBalance({ - chain: Networks.Polygon, - ownerAddress: evmEphemeralAddress as `0x${string}`, - tokenAddress: ERC20_EURE_POLYGON_V2 - }); - return balance.gte(Big(mintedAmountRaw)); - }; - - try { - if (await didTokensArriveOnEvm()) { - logger.info(`Tokens have arrived on Polygon ephemeral address: ${evmEphemeralAddress}. Skipping self-transfer.`); - return this.transitionToNextPhase(state, "squidRouterSwap"); - } - } catch (error) { - // inability to check balance is not a critical error and should be temporal, we can proceed throw a recoverable. - throw this.createRecoverableError(`MoneriumOnrampSelfTransferHandler: Error checking Polygon balance: ${error}`); - } - - try { - const account = privateKeyToAccount(config.secrets.moonbeamExecutorPrivateKey as `0x${string}`); - if (!isAddress(account.address)) { - throw new Error(`Configured executor account produced invalid EVM address ${account.address}`); - } - const executorAddress = account.address as `0x${string}`; - let permitHash: string; - - if (state.state.permitTxHash) { - logger.info(`Permit transaction already sent with hash: ${state.state.permitTxHash}. Skipping permit sending.`); - permitHash = state.state.permitTxHash; - } else { - const owner = moneriumWalletAddress as `0x${string}`; - const spender = evmEphemeralAddress as `0x${string}`; - const permitExpectation = { - expectedOwner: owner, - expectedSpender: spender, - expectedTokenAddress: ERC20_EURE_POLYGON_V2, - expectedTokenName: ERC20_EURE_POLYGON_TOKEN_NAME, - expectedValueRaw: mintedAmountRaw, - network: Networks.Polygon - }; - const permitDiagnostics = await this.getPermitDiagnostics(owner, spender); - const signedPermitContext = moneriumOnrampPermit.context; - logger.info( - `[${state.id}] Monerium permit preflight: ${JSON.stringify({ - allowanceRaw: permitDiagnostics.allowanceRaw.toString(), - balanceRaw: permitDiagnostics.balanceRaw.toString(), - deadline: moneriumOnrampPermit.context?.deadline ?? moneriumOnrampPermit.deadline, - deadlineIso: new Date( - Number(moneriumOnrampPermit.context?.deadline ?? moneriumOnrampPermit.deadline) * 1000 - ).toISOString(), - executor: executorAddress, - expectedValueRaw: mintedAmountRaw, - nonce: permitDiagnostics.nonce.toString(), - owner, - signedChainId: signedPermitContext?.chainId, - signedNonce: signedPermitContext?.nonce, - signedTokenAddress: signedPermitContext?.tokenAddress, - signedTokenName: signedPermitContext?.tokenName, - signedTokenVersion: signedPermitContext?.tokenVersion, - signedValueRaw: signedPermitContext?.valueRaw, - spender, - tokenAddress: ERC20_EURE_POLYGON_V2, - tokenName: permitDiagnostics.tokenName - })}` - ); - - const permitPreflight = analyzeMoneriumPermitPreflight(moneriumOnrampPermit, permitExpectation, permitDiagnostics); - if (!permitPreflight.shouldSendPermit) { - logger.info( - `[${state.id}] Existing Monerium allowance covers ${mintedAmountRaw}. Skipping permit transaction (${permitPreflight.reason}).` - ); - } else if (permitDiagnostics.balanceRaw < BigInt(mintedAmountRaw)) { - logger.warn( - `[${state.id}] Monerium wallet balance ${permitDiagnostics.balanceRaw.toString()} is below expected transfer amount ${mintedAmountRaw}. Permit may still succeed, but transferFrom will wait for sufficient balance.` - ); - } - - const permitArgs = [ - owner, - spender, - BigInt(mintedAmountRaw), - moneriumOnrampPermit.deadline, - moneriumOnrampPermit.v, - moneriumOnrampPermit.r, - moneriumOnrampPermit.s - ] as const; - - if (!permitPreflight.shouldSendPermit) { - permitHash = ""; - } else { - await this.simulatePermit(state.id, executorAddress, permitArgs); - - const walletClient = this.evmClientManager.getWalletClient(Networks.Polygon, account); - permitHash = await walletClient.sendTransaction({ - data: encodeFunctionData({ - abi: permitAbi, - args: permitArgs, - functionName: "permit" - }), - to: ERC20_EURE_POLYGON_V2 - }); - } - } - - if (permitHash) { - logger.info(`Permit transaction executed with hash: ${permitHash}`); - - await this.waitForTransactionConfirmation(permitHash); - logger.info(`Permit transaction confirmed: ${permitHash}`); - - state.state.permitTxHash = permitHash; - await state.update({ state: state.state }); - } - - const transferTransaction = this.getPresignedTransaction(state, "moneriumOnrampSelfTransfer"); - - if (!transferTransaction) { - throw new Error("Missing presigned transactions for moneriumOnrampSelfTransfer phase. State corrupted."); - } - - let transferHash = state.state.moneriumOnrampSelfTransferHash; - if (transferHash) { - logger.info(`Transfer transaction already sent with hash: ${transferHash}. Waiting for confirmation.`); - } else { - await this.preflightSignedSelfTransfer( - state.id, - transferTransaction.txData as string, - moneriumWalletAddress as `0x${string}`, - evmEphemeralAddress as `0x${string}`, - mintedAmountRaw - ); - - // Execute the transfer transaction - transferHash = await this.executeTransaction(transferTransaction.txData as string); - state.state.moneriumOnrampSelfTransferHash = transferHash; - await state.update({ state: state.state }); - logger.info(`Transfer transaction executed with hash: ${transferHash}`); - } - - await this.waitForTransactionConfirmation(transferHash); - logger.info(`TransferFrom transaction confirmed: ${transferHash}`); - - // RPC nodes occasionally lag behind the chain tip; the next phase reads the ephemeral's - // EURe balance and would otherwise race against an under-replicated read replica. - logger.info("Waiting 30 seconds to ensure balance is updated..."); - await new Promise(resolve => setTimeout(resolve, 30000)); - - // Transition to the next phase - return this.transitionToNextPhase(state, "squidRouterSwap"); - } catch (error: unknown) { - logger.error(`Error in self-transfer phase for ramp ${state.id}:`, error); - throw this.createRecoverableError( - `MoneriumOnrampSelfTransferHandler: Error while sending self-transfer transaction: ${error}` - ); - } - } - - private async preflightSignedSelfTransfer( - rampId: string, - txData: string, - expectedOwner: `0x${string}`, - expectedSpender: `0x${string}`, - expectedAmountRaw: string - ): Promise { - const transfer = await inspectMoneriumSelfTransferTransaction(txData, { - expectedAmountRaw, - expectedChainId: getNetworkId(Networks.Polygon), - expectedOwner, - expectedRecipient: expectedSpender, - expectedSigner: expectedSpender, - expectedTokenAddress: ERC20_EURE_POLYGON_V2, - rampId - }); - const expectedAmount = BigInt(expectedAmountRaw); - - const transferDiagnostics = await this.getPermitDiagnostics(expectedOwner, expectedSpender); - const currentNonce = await this.polygonClient.getTransactionCount({ address: transfer.signer }); - let estimatedGas: bigint; - try { - estimatedGas = await this.polygonClient.estimateContractGas({ - abi: moneriumTransferFromAbi, - account: transfer.signer, - address: ERC20_EURE_POLYGON_V2, - args: [transfer.owner, transfer.recipient, transfer.amountRaw], - functionName: "transferFrom" - }); - } catch (error) { - throw new Error( - `[${rampId}] Self-transfer gas estimate failed before broadcast: ${error instanceof Error ? error.message : error}` - ); - } - - logger.info( - `[${rampId}] Monerium self-transfer preflight: ${JSON.stringify({ - allowanceRaw: transferDiagnostics.allowanceRaw.toString(), - amountRaw: expectedAmountRaw, - balanceRaw: transferDiagnostics.balanceRaw.toString(), - currentNonce, - estimatedGas: estimatedGas.toString(), - owner: transfer.owner, - recipient: transfer.recipient, - signedGas: transfer.signedGas.toString(), - signedNonce: transfer.signedNonce, - signer: transfer.signer, - tokenAddress: ERC20_EURE_POLYGON_V2 - })}` - ); - - if (currentNonce !== transfer.signedNonce) { - // Strict equality: a gap (currentNonce < signedNonce) would leave the broadcast tx stuck pending - // in mempool forever because the ephemeral account will never fill the missing nonces. - // A past nonce (currentNonce > signedNonce) means the tx was already consumed. - const reason = - currentNonce > transfer.signedNonce - ? `signed nonce ${transfer.signedNonce} has already been consumed (current nonce ${currentNonce}). Do not resend this raw transaction; regenerate the presigned self-transfer transaction or inspect the previous nonce-${transfer.signedNonce} transaction` - : `signed nonce ${transfer.signedNonce} is ahead of current account nonce ${currentNonce}. Broadcasting would stall the tx in mempool until the missing nonces are filled (which will never happen for an ephemeral account). Regenerate the presigned self-transfer transaction`; - throw new Error(`[${rampId}] Self-transfer ${reason} for signer ${transfer.signer}.`); - } - if (transferDiagnostics.allowanceRaw < expectedAmount) { - throw new Error( - `[${rampId}] Self-transfer allowance ${transferDiagnostics.allowanceRaw.toString()} is below expected ${expectedAmountRaw}` - ); - } - if (transferDiagnostics.balanceRaw < expectedAmount) { - throw new Error( - `[${rampId}] Self-transfer balance ${transferDiagnostics.balanceRaw.toString()} is below expected ${expectedAmountRaw}` - ); - } - if (transfer.signedGas < estimatedGas) { - throw new Error( - `[${rampId}] Self-transfer signed gas limit ${transfer.signedGas.toString()} is below estimated gas ${estimatedGas.toString()}` - ); - } - - try { - await this.polygonClient.simulateContract({ - abi: moneriumTransferFromAbi, - account: transfer.signer, - address: ERC20_EURE_POLYGON_V2, - args: [transfer.owner, transfer.recipient, transfer.amountRaw], - functionName: "transferFrom", - gas: transfer.signedGas - }); - } catch (error) { - throw new Error( - `[${rampId}] Self-transfer simulation failed before broadcast: ${error instanceof Error ? error.message : error}` - ); - } - } - - private async getPermitDiagnostics(owner: `0x${string}`, spender: `0x${string}`): Promise { - const [allowanceRaw, balanceRaw, nonce, tokenName] = await Promise.all([ - this.evmClientManager.readContractWithRetry(Networks.Polygon, { - abi: erc20ABI, - address: ERC20_EURE_POLYGON_V2, - args: [owner, spender], - functionName: "allowance" - }), - this.evmClientManager.readContractWithRetry(Networks.Polygon, { - abi: erc20ABI, - address: ERC20_EURE_POLYGON_V2, - args: [owner], - functionName: "balanceOf" - }), - this.evmClientManager.readContractWithRetry(Networks.Polygon, { - abi: permitNonceAbi, - address: ERC20_EURE_POLYGON_V2, - args: [owner], - functionName: "nonces" - }), - this.evmClientManager.readContractWithRetry(Networks.Polygon, { - abi: erc20ABI, - address: ERC20_EURE_POLYGON_V2, - functionName: "name" - }) - ]); - - return { allowanceRaw, balanceRaw, nonce, tokenName }; - } - - private async simulatePermit( - rampId: string, - executorAddress: `0x${string}`, - permitArgs: readonly [`0x${string}`, `0x${string}`, bigint, number, number, `0x${string}`, `0x${string}`] - ): Promise { - try { - await this.polygonClient.simulateContract({ - abi: permitAbi, - account: executorAddress, - address: ERC20_EURE_POLYGON_V2, - args: permitArgs, - functionName: "permit" - }); - } catch (error) { - throw new Error( - `[${rampId}] Monerium permit simulation failed before broadcast: ${error instanceof Error ? error.message : error}` - ); - } - } - - /** - * Execute a transaction - * @param txData The transaction data - * @returns The transaction hash - */ - private async executeTransaction(txData: string): Promise { - try { - const evmClientManager = EvmClientManager.getInstance(); - const txHash = await evmClientManager.sendRawTransactionWithRetry(Networks.Polygon, txData as `0x${string}`); - return txHash; - } catch (error) { - logger.error("Error sending raw transaction", error); - throw new Error("Failed to send transaction"); - } - } - - /** - * Wait for a transaction to be confirmed - * @param txHash The transaction hash - * @param chainId The chain ID - */ - private async waitForTransactionConfirmation(txHash: string): Promise { - try { - const receipt = await this.polygonClient.waitForTransactionReceipt({ - hash: txHash as `0x${string}` - }); - if (!receipt || receipt.status !== "success") { - throw new Error( - `moneriumOnrampSelfTransferHandler: Transaction ${txHash} failed or was not found (status: ${receipt?.status ?? "missing"}, block: ${receipt?.blockNumber?.toString() ?? "unknown"}, gasUsed: ${receipt?.gasUsed?.toString() ?? "unknown"})` - ); - } - return receipt; - } catch (error) { - throw new Error(`moneriumOnrampSelfTransferHandler: Error waiting for transaction confirmation: ${error}`); - } - } -} - -export default new MoneriumOnrampSelfTransferHandler(); diff --git a/apps/api/src/api/services/phases/handlers/mykobo-onramp-deposit-handler.ts b/apps/api/src/api/services/phases/handlers/mykobo-onramp-deposit-handler.ts new file mode 100644 index 000000000..ade234875 --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/mykobo-onramp-deposit-handler.ts @@ -0,0 +1,145 @@ +import { + BalanceCheckError, + BalanceCheckErrorType, + checkEvmBalancePeriodically, + EvmAddress, + EvmToken, + evmTokenConfig, + getEvmTokenBalance, + Networks, + RampPhase +} from "@vortexfi/shared"; +import Big from "big.js"; +import logger from "../../../../config/logger"; +import QuoteTicket from "../../../../models/quoteTicket.model"; +import RampState from "../../../../models/rampState.model"; +import { BasePhaseHandler } from "../base-phase-handler"; +import { StateMetadata } from "../meta-state-types"; + +// Mykobo SEPA settlement can take significantly longer than card-based onramps. +// 24h is a generous upper bound matching SEPA business-day cutoffs. +const PAYMENT_TIMEOUT_MS = 24 * 60 * 60 * 1000; +const EVM_BALANCE_CHECK_TIMEOUT_MS = 5 * 60 * 1000; +const POLL_INTERVAL_MS = 5000; + +// The pre-computed deliveredEurc value stored at quote-creation time can be slightly +// higher than the amount actually transferred due to fee differences at execution time. +// Allow 5% tolerance in the recovery shortcut so an already-funded ephemeral is not missed. +const EPHEMERAL_FUNDED_TOLERANCE_FACTOR = 0.95; + +// Phase description: wait for the EURC to arrive at the Base ephemeral address from Mykobo's +// SEPA→on-chain settlement. If the timeout is reached, we assume the user has NOT made the +// SEPA transfer and we cancel the ramp. +export class MykoboOnrampDepositHandler extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "mykoboOnrampDeposit"; + } + + protected async executePhase(state: RampState): Promise { + const { evmEphemeralAddress } = state.state as StateMetadata; + + if (!evmEphemeralAddress) { + throw new Error("MykoboOnrampDepositHandler: Missing evmEphemeralAddress in state. This is a bug."); + } + + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) { + throw new Error("MykoboOnrampDepositHandler: Quote not found for the given state."); + } + + if (!quote.metadata.mykoboMint?.outputAmountRaw) { + throw new Error("MykoboOnrampDepositHandler: Missing 'mykoboMint.outputAmountRaw' in quote metadata."); + } + + const tokenDetails = evmTokenConfig[Networks.Base][EvmToken.EURC]; + if (!tokenDetails) { + throw new Error("MykoboOnrampDepositHandler: EURC token details not found for Base network."); + } + + const expectedAmountRaw = quote.metadata.mykoboMint.outputAmountRaw; + + // Recovery shortcut: a previous run may have already received Mykobo's settlement on the + // ephemeral. Accept a balance of at least 95% of the pre-computed expected amount to account + // for any fee variance between quote-creation time and settlement. + const recoveryThresholdRaw = new Big(expectedAmountRaw).times(EPHEMERAL_FUNDED_TOLERANCE_FACTOR).toFixed(0, 0); + + if (await this.ephemeralAlreadyFunded(tokenDetails.erc20AddressSourceChain, evmEphemeralAddress, recoveryThresholdRaw)) { + logger.info( + `MykoboOnrampDepositHandler: Ephemeral ${evmEphemeralAddress} already holds at least 95% of the expected ${expectedAmountRaw} EURC (threshold: ${recoveryThresholdRaw}). Skipping deposit wait.` + ); + return this.transitionToNextPhase(state, "fundEphemeral"); + } + + logger.info( + `MykoboOnrampDepositHandler: Waiting for ${expectedAmountRaw} (raw, ${tokenDetails.decimals} decimals) EURC ` + + `on Base at ephemeral address ${evmEphemeralAddress}.` + ); + + try { + await checkEvmBalancePeriodically( + tokenDetails.erc20AddressSourceChain, + evmEphemeralAddress, + expectedAmountRaw, + POLL_INTERVAL_MS, + EVM_BALANCE_CHECK_TIMEOUT_MS, + Networks.Base + ); + } catch (error) { + if (!(error instanceof BalanceCheckError)) { + throw new Error(`MykoboOnrampDepositHandler: Error checking Base EURC balance: ${error}`); + } + + const isCheckTimeout = error.type === BalanceCheckErrorType.Timeout; + if (isCheckTimeout && this.isPaymentTimeoutReached(state)) { + logger.error("MykoboOnrampDepositHandler: Payment timeout reached. Cancelling ramp."); + return this.transitionToNextPhase(state, "failed"); + } + + throw isCheckTimeout + ? this.createRecoverableError( + `MykoboOnrampDepositHandler: balance-check timeout reached waiting for Mykobo settlement: ${error}` + ) + : new Error(`MykoboOnrampDepositHandler: Error checking Base EURC balance: ${error}`); + } + + logger.info( + `MykoboOnrampDepositHandler: EURC deposit received on Base ephemeral ${evmEphemeralAddress}. Proceeding to fundEphemeral.` + ); + + return this.transitionToNextPhase(state, "fundEphemeral"); + } + + private async ephemeralAlreadyFunded( + tokenAddress: string, + ownerAddress: string, + expectedAmountRaw: string + ): Promise { + try { + const balance = await getEvmTokenBalance({ + chain: Networks.Base, + ownerAddress: ownerAddress as EvmAddress, + tokenAddress: tokenAddress as EvmAddress + }); + return balance.gte(new Big(expectedAmountRaw)); + } catch (error) { + // Treat read failures as "not funded" so we fall through to the regular flow + // rather than aborting the phase on a transient RPC error. + logger.warn( + `MykoboOnrampDepositHandler: ephemeral balance pre-check failed for ${ownerAddress}, falling back to wait loop: ${error}` + ); + return false; + } + } + + protected isPaymentTimeoutReached(state: RampState): boolean { + const thisPhaseEntry = state.phaseHistory.find(phaseHistoryEntry => phaseHistoryEntry.phase === this.getPhaseName()); + if (!thisPhaseEntry) { + throw new Error("MykoboOnrampDepositHandler: Phase not found in history. This is a bug."); + } + + const initialTimestamp = new Date(thisPhaseEntry.timestamp); + return initialTimestamp.getTime() + PAYMENT_TIMEOUT_MS < Date.now(); + } +} + +export default new MykoboOnrampDepositHandler(); diff --git a/apps/api/src/api/services/phases/handlers/mykobo-payout-handler.ts b/apps/api/src/api/services/phases/handlers/mykobo-payout-handler.ts new file mode 100644 index 000000000..ad07596eb --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/mykobo-payout-handler.ts @@ -0,0 +1,112 @@ +import { EvmClientManager, MykoboApiService, MykoboTransactionStatus, Networks, RampPhase } from "@vortexfi/shared"; +import logger from "../../../../config/logger"; +import RampState from "../../../../models/rampState.model"; +import { PhaseError } from "../../../errors/phase-error"; +import { BasePhaseHandler } from "../base-phase-handler"; +import { StateMetadata } from "../meta-state-types"; + +const POLL_INTERVAL_MS = 5_000; +const POLL_TIMEOUT_MS = 10 * 60 * 1000; + +export class MykoboPayoutOnBasePhaseHandler extends BasePhaseHandler { + public getPhaseName(): RampPhase { + return "mykoboPayoutOnBase"; + } + + protected async executePhase(state: RampState): Promise { + const { mykoboTransactionId, mykoboPayoutTxHash } = state.state as StateMetadata; + + if (!mykoboTransactionId) { + throw new Error("MykoboPayoutOnBasePhaseHandler: mykoboTransactionId missing in state. This is a bug."); + } + + await this.sendMykoboPayoutTransaction(state, mykoboPayoutTxHash); + await this.pollMykoboUntilCompleted(mykoboTransactionId); + + return this.transitionToNextPhase(state, "complete"); + } + + private async sendMykoboPayoutTransaction(state: RampState, mykoboPayoutTxHash?: `0x${string}`): Promise { + try { + const evmClientManager = EvmClientManager.getInstance(); + const baseClient = evmClientManager.getClient(Networks.Base); + const { txData: payoutTx } = this.getPresignedTransaction(state, "mykoboPayoutOnBase"); + + if (!payoutTx) { + throw new Error("Missing presigned transaction for mykoboPayoutOnBase"); + } + + if (mykoboPayoutTxHash) { + logger.info(`MykoboPayoutOnBasePhaseHandler: Found existing tx ${mykoboPayoutTxHash}. Waiting for receipt...`); + const receipt = await baseClient.waitForTransactionReceipt({ hash: mykoboPayoutTxHash }); + if (receipt.status === "success") { + logger.info(`MykoboPayoutOnBasePhaseHandler: Existing tx ${mykoboPayoutTxHash} succeeded.`); + return; + } + logger.warn(`MykoboPayoutOnBasePhaseHandler: Existing tx ${mykoboPayoutTxHash} failed. Re-sending.`); + } + + const txHash = (await evmClientManager.sendRawTransactionWithRetry( + Networks.Base, + payoutTx as `0x${string}` + )) as `0x${string}`; + logger.info(`MykoboPayoutOnBasePhaseHandler: Sent EURC transfer tx ${txHash}. Waiting for receipt...`); + + const receipt = await baseClient.waitForTransactionReceipt({ hash: txHash }); + if (receipt.status !== "success") { + throw new Error(`Transaction ${txHash} failed on chain`); + } + + await state.update({ + state: { + ...state.state, + mykoboPayoutTxHash: txHash + } + }); + logger.info(`MykoboPayoutOnBasePhaseHandler: Transaction ${txHash} confirmed.`); + } catch (error) { + logger.error("MykoboPayoutOnBasePhaseHandler: Failed to send Mykobo payout tx.", error); + throw this.createRecoverableError("Failed to send Mykobo payout transaction"); + } + } + + private async pollMykoboUntilCompleted(transactionId: string): Promise { + const mykobo = MykoboApiService.getInstance(); + const startTime = Date.now(); + let lastError: unknown; + + while (Date.now() - startTime < POLL_TIMEOUT_MS) { + try { + const { transaction } = await mykobo.getTransaction(transactionId); + logger.debug(`MykoboPayoutOnBasePhaseHandler: tx ${transactionId} status=${transaction.status}`); + + if (transaction.status === MykoboTransactionStatus.COMPLETED) { + return; + } + if ( + transaction.status === MykoboTransactionStatus.FAILED || + transaction.status === MykoboTransactionStatus.CANCELLED || + transaction.status === MykoboTransactionStatus.EXPIRED + ) { + throw this.createUnrecoverableError( + `MykoboPayoutOnBasePhaseHandler: Mykobo transaction ${transactionId} ended with status ${transaction.status}` + ); + } + } catch (error) { + if (error instanceof PhaseError) throw error; + lastError = error; + logger.warn("MykoboPayoutOnBasePhaseHandler: Polling Mykobo transaction failed. Retrying...", error); + } + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)); + } + + if (lastError) { + throw this.createRecoverableError( + `MykoboPayoutOnBasePhaseHandler: Polling timed out with transient error: ${(lastError as Error).message}` + ); + } + throw this.createRecoverableError("MykoboPayoutOnBasePhaseHandler: Polling for Mykobo transaction status timed out."); + } +} + +export default new MykoboPayoutOnBasePhaseHandler(); diff --git a/apps/api/src/api/services/phases/handlers/nabla-approve-handler.ts b/apps/api/src/api/services/phases/handlers/nabla-approve-handler.ts index defed096a..052dcb785 100644 --- a/apps/api/src/api/services/phases/handlers/nabla-approve-handler.ts +++ b/apps/api/src/api/services/phases/handlers/nabla-approve-handler.ts @@ -1,14 +1,6 @@ import { createExecuteMessageExtrinsic, ExecuteMessageResult, submitExtrinsic } from "@pendulum-chain/api-solang"; import { Abi } from "@polkadot/api-contract"; -import { - ApiManager, - decodeSubmittableExtrinsic, - EvmClientManager, - FiatToken, - NABLA_ROUTER, - Networks, - RampPhase -} from "@vortexfi/shared"; +import { ApiManager, decodeSubmittableExtrinsic, EvmClientManager, NABLA_ROUTER, Networks, RampPhase } from "@vortexfi/shared"; import Big from "big.js"; import logger from "../../../../config/logger"; import { erc20WrapperAbi } from "../../../../contracts/ERC20Wrapper"; @@ -35,13 +27,15 @@ export class NablaApprovePhaseHandler extends BasePhaseHandler { const { substrateEphemeralAddress } = state.state as StateMetadata; - // BRL flows, use evm instance of Nabla. - if (quote.inputCurrency === FiatToken.BRL || quote.outputCurrency === FiatToken.BRL) { + // EVM-ephemeral flows (BRL, Mykobo EUR, ...) use the EVM Nabla instance. + if (quote.metadata.nablaSwapEvm) { return this.executeEvmApprove(state); } else if (substrateEphemeralAddress) { return this.executeSubstrateApprove(state, quote); } else { - throw new Error("NablaApprovePhaseHandler: Invalid state. Missing substrate ephemeral address for a non-BRL quote."); + throw new Error( + "NablaApprovePhaseHandler: Invalid state. Missing substrate ephemeral address for a non-EVM-ephemeral quote." + ); } } diff --git a/apps/api/src/api/services/phases/handlers/nabla-swap-handler.ts b/apps/api/src/api/services/phases/handlers/nabla-swap-handler.ts index 6693682bc..d2b616138 100644 --- a/apps/api/src/api/services/phases/handlers/nabla-swap-handler.ts +++ b/apps/api/src/api/services/phases/handlers/nabla-swap-handler.ts @@ -8,7 +8,6 @@ import { EvmClientManager, EvmTokenDetails, evmTokenConfig, - FiatToken, NABLA_ROUTER, Networks, RampDirection, @@ -36,12 +35,14 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { const { substrateEphemeralAddress } = state.state as StateMetadata; - if (quote.inputCurrency === FiatToken.BRL || quote.outputCurrency === FiatToken.BRL) { + if (quote.metadata.nablaSwapEvm) { return this.executeEvmSwap(state, quote); } else if (substrateEphemeralAddress) { return this.executeSubstrateSwap(state, quote); } else { - throw new Error("NablaSwapPhaseHandler: Invalid state. Missing substrate ephemeral address for a non-BRL quote."); + throw new Error( + "NablaSwapPhaseHandler: Invalid state. Missing substrate ephemeral address for a non-EVM-ephemeral quote." + ); } } diff --git a/apps/api/src/api/services/phases/handlers/spacewalk-redeem-handler.ts b/apps/api/src/api/services/phases/handlers/spacewalk-redeem-handler.ts deleted file mode 100644 index 7f9953fbf..000000000 --- a/apps/api/src/api/services/phases/handlers/spacewalk-redeem-handler.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { ApiManager, decodeSubmittableExtrinsic, RampPhase } from "@vortexfi/shared"; -import Big from "big.js"; -import logger from "../../../../config/logger"; -import QuoteTicket from "../../../../models/quoteTicket.model"; -import RampState from "../../../../models/rampState.model"; -import { checkBalancePeriodically } from "../../stellar/checkBalance"; -import { createVaultService } from "../../stellar/vaultService"; -import { BasePhaseHandler } from "../base-phase-handler"; -import { validateStellarPaymentSequenceNumber } from "../helpers/stellar-sequence-validator"; -import { StateMetadata } from "../meta-state-types"; -import { isStellarEphemeralFunded } from "./helpers"; - -const maxWaitingTimeMinutes = 10; -const maxWaitingTimeMs = maxWaitingTimeMinutes * 60 * 1000; - -export class SpacewalkRedeemPhaseHandler extends BasePhaseHandler { - public getPhaseName(): RampPhase { - return "spacewalkRedeem"; - } - - protected async executePhase(state: RampState): Promise { - const apiManager = ApiManager.getInstance(); - const networkName = "pendulum"; - const pendulumNode = await apiManager.getApi(networkName); - - const quote = await QuoteTicket.findByPk(state.quoteId); - - const { substrateEphemeralAddress, stellarTarget, executeSpacewalkNonce, stellarEphemeralAccountId } = - state.state as StateMetadata; - - if (!substrateEphemeralAddress || !stellarTarget || !executeSpacewalkNonce || !stellarEphemeralAccountId) { - logger.error("SpacewalkRedeemPhaseHandler: State metadata corrupted. This is a bug."); - return this.transitionToNextPhase(state, "failed"); - } - - if (!quote) { - throw new Error("Quote not found for the given state"); - } - - if (!quote.metadata.pendulumToStellar?.outputAmountDecimal || !quote.metadata.pendulumToStellar?.outputAmountRaw) { - throw new Error("Missing output amount for Spacewalk Redeem in quote metadata"); - } - - const outputAmountUnits = quote.metadata.pendulumToStellar?.outputAmountDecimal.toString(); - const outputAmountRaw = quote.metadata.pendulumToStellar?.outputAmountRaw; - - // Check if Stellar target account exists on the network and has the respective trustline. - // Otherwise, the redeem will end up with a 'claimable-payment' operation on Stellar that we cannot claim. - if (!(await isStellarEphemeralFunded(stellarEphemeralAccountId, stellarTarget.stellarTokenDetails))) { - logger.error( - `SpacewalkRedeemPhaseHandler: Stellar target account ${stellarEphemeralAccountId} does not exist or does not have the required trustline.` - ); - return this.transitionToNextPhase(state, "failed"); - } - - try { - logger.info("Validating stellar payment sequence number before spacewalk redeem"); - await validateStellarPaymentSequenceNumber(state, stellarEphemeralAccountId); - } catch (validationError) { - logger.error(`Stellar payment sequence validation failed before spacewalk redeem: ${validationError}`); - return this.transitionToNextPhase(state, "failed"); - } - - const { txData: spacewalkRedeemTransaction } = this.getPresignedTransaction(state, "spacewalkRedeem"); - if (typeof spacewalkRedeemTransaction !== "string") { - logger.error("SpacewalkRedeemPhaseHandler: Presigned transaction is not a string -> not an encoded Stellar transaction."); - return this.transitionToNextPhase(state, "failed"); - } - - try { - const accountData = await pendulumNode.api.query.system.account(substrateEphemeralAddress); - const accountJson = accountData.toJSON() as { nonce?: number } | null; - const currentEphemeralAccountNonce = accountJson?.nonce; - - // Re-execution guard - if (currentEphemeralAccountNonce !== undefined && currentEphemeralAccountNonce > executeSpacewalkNonce) { - await this.waitForOutputTokensToArriveOnStellar( - outputAmountUnits, - stellarEphemeralAccountId, - stellarTarget.stellarTokenDetails.stellarAsset.code.string - ); - return this.transitionToNextPhase(state, "stellarPayment"); - } - - const vaultService = await createVaultService( - pendulumNode, - stellarTarget.stellarTokenDetails.stellarAsset.code.hex, - stellarTarget.stellarTokenDetails.stellarAsset.issuer.hex, - outputAmountRaw - ); - logger.info(`Requesting redeem of ${outputAmountUnits} tokens for vault ${vaultService.vaultId}`); - - const redeemExtrinsic = decodeSubmittableExtrinsic(spacewalkRedeemTransaction, pendulumNode.api); - const redeemRequestEvent = await vaultService.submitRedeem(substrateEphemeralAddress, redeemExtrinsic); - - logger.info(`Successfully posed redeem request ${redeemRequestEvent.redeemId} for vault ${vaultService.vaultId}`); - - await this.waitForOutputTokensToArriveOnStellar( - outputAmountUnits, - stellarEphemeralAccountId, - stellarTarget.stellarTokenDetails.stellarAsset.code.string - ); - - return this.transitionToNextPhase(state, "stellarPayment"); - } 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")) { - logger.info("Recovery mode: Redeem already performed. Waiting for execution and Stellar balance arrival."); - await this.waitForOutputTokensToArriveOnStellar( - outputAmountUnits, - stellarEphemeralAccountId, - stellarTarget.stellarTokenDetails.stellarAsset.code.string - ); - return this.transitionToNextPhase(state, "stellarPayment"); - } - - // 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"); - } - } - - private async waitForOutputTokensToArriveOnStellar( - outputAmountUnits: string, - targetAccount: string, - stellarAssetCode: string - ): Promise { - // We wait for up to 10 minutes - - const amountUnitsBig = new Big(outputAmountUnits); - const stellarPollingTimeMs = 1000; - - try { - await checkBalancePeriodically(targetAccount, stellarAssetCode, amountUnitsBig, stellarPollingTimeMs, maxWaitingTimeMs); - logger.info("Balance check completed successfully."); - } catch (_balanceCheckError) { - throw new Error("Stellar balance did not arrive on time"); - } - } -} - -export default new SpacewalkRedeemPhaseHandler(); diff --git a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts index 5119bbaf1..aea6c136c 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts @@ -1,12 +1,15 @@ import { + ALFREDPAY_EVM_TOKEN, checkEvmBalanceForToken, EvmClientManager, EvmNetworks, + EvmTokenDetails, + evmTokenConfig, FiatToken, - getEvmTokenDetailsByAddress, - getNetworkFromDestination, - getNetworkId, + getEvmBalance, + getOnChainTokenDetails, isAlfredpayToken, + isEvmTokenDetails, Networks, RampDirection, RampPhase @@ -15,22 +18,15 @@ import { PublicClient } from "viem"; import logger from "../../../../config/logger"; import QuoteTicket from "../../../../models/quoteTicket.model"; import RampState from "../../../../models/rampState.model"; +import { isBrlToBrlaBaseDirect, isEurToEurcBaseDirect } from "../../quote/utils"; import { BasePhaseHandler } from "../base-phase-handler"; /** * Handler for the squidRouter phase */ export class SquidRouterPhaseHandler extends BasePhaseHandler { - private moonbeamClient: PublicClient; - private polygonClient: PublicClient; - private baseClient: PublicClient; - - constructor() { - super(); - const evmClientManager = EvmClientManager.getInstance(); - this.moonbeamClient = evmClientManager.getClient(Networks.Moonbeam); - this.polygonClient = evmClientManager.getClient(Networks.Polygon); - this.baseClient = evmClientManager.getClient(Networks.Base); + private getClient(network: EvmNetworks): PublicClient { + return EvmClientManager.getInstance().getClient(network); } /** @@ -48,23 +44,37 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { protected async executePhase(state: RampState): Promise { logger.info(`Executing squidRouter phase for ramp ${state.id}`); + if (state.state.isDirectTransfer === true) { + logger.info(`SquidRouterPhaseHandler: Skipping squidRouter for direct-transfer ramp ${state.id}`); + return this.transitionToNextPhase(state, "destinationTransfer"); + } + const quote = await QuoteTicket.findByPk(state.quoteId); if (!quote) { throw new Error("Quote not found for the given state"); } + if ( + isEurToEurcBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network) || + isBrlToBrlaBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network) + ) { + logger.info(`SquidRouterPhaseHandler: Skipping squidRouter for Base direct-transfer route (ramp ${state.id})`); + return this.transitionToNextPhase(state, "destinationTransfer"); + } + if (state.type === RampDirection.SELL) { logger.info("SquidRouter phase is not supported for off-ramp"); return state; } - // Alfredpay onramps mint directly to Polygon in the alfredpay token (e.g. USDT), - // so no squidRouter swap is needed — skip straight to destination transfer. + // Alfredpay mints USDT directly on Polygon. Skip the swap ONLY when the requested + // output is that direct token; metadata.to is the destination network, not the output + // token, so other Polygon outputs (e.g. USDC) still need a real USDT→output swap. const isAlfredpayOnramp = state.type === RampDirection.BUY && isAlfredpayToken(quote.inputCurrency as FiatToken) && !!quote.metadata.alfredpayMint; - if (isAlfredpayOnramp && quote.metadata.to === Networks.Polygon) { - logger.info(`SquidRouterPhaseHandler: Skipping squidRouter for Alfredpay onramp (ramp ${state.id})`); + if (isAlfredpayOnramp && quote.metadata.request.to === Networks.Polygon && quote.outputCurrency === ALFREDPAY_EVM_TOKEN) { + logger.info(`SquidRouterPhaseHandler: Skipping squidRouter for Alfredpay direct-token onramp (ramp ${state.id})`); return this.transitionToNextPhase(state, "finalSettlementSubsidy"); } @@ -93,7 +103,9 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { } const sourceNetwork = bridgeMeta.fromNetwork as EvmNetworks; - const sourceTokenDetails = getEvmTokenDetailsByAddress(sourceNetwork, bridgeMeta.fromToken); + const sourceTokenDetails = Object.values(evmTokenConfig[sourceNetwork] || {}).find( + token => token.erc20AddressSourceChain.toLowerCase() === bridgeMeta.fromToken.toLowerCase() + ) as EvmTokenDetails | undefined; if (!sourceTokenDetails) { throw new Error( @@ -128,21 +140,15 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { let approveHash = state.state.squidRouterApproveHash; // Check if the approve transaction has already been sent if (!approveHash) { - const accountNonce = await this.getNonce(state, approveTransaction.signer as `0x${string}`); + const accountNonce = await this.getNonce(sourceNetwork, approveTransaction.signer as `0x${string}`); if (approveTransaction.nonce && approveTransaction.nonce !== accountNonce) { logger.warn( `Nonce mismatch for approve transaction of account ${approveTransaction.signer}: expected ${accountNonce}, got ${approveTransaction.nonce}` ); } - const destinationNetwork = getNetworkFromDestination(state.to); - const chainId = destinationNetwork ? getNetworkId(destinationNetwork) : null; - if (!chainId) { - throw new Error("Invalid destination network"); - } - // Execute the approve transaction - approveHash = await this.executeTransaction(state, approveTransaction.txData as string); + approveHash = await this.executeTransaction(sourceNetwork, approveTransaction.txData as string); logger.info(`Approve transaction executed with hash: ${approveHash}`); // Update the state with the approve hash immediately after sending the transaction @@ -155,15 +161,15 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { } // Wait for the approve transaction to be confirmed - await this.waitForTransactionConfirmation(state, approveHash); + await this.waitForTransactionConfirmation(sourceNetwork, approveHash); logger.info(`Approve transaction confirmed: ${approveHash}`); // Execute the swap transaction - const swapHash = await this.executeTransaction(state, swapTransaction.txData as string); + const swapHash = await this.executeTransaction(sourceNetwork, swapTransaction.txData as string); logger.info(`Swap transaction executed with hash: ${swapHash}`); // Update the state with the transaction hashes - const updatedState = await state.update({ + let updatedState = await state.update({ state: { ...state.state, squidRouterSwapHash: swapHash @@ -171,55 +177,49 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { }); // Wait for the swap transaction to be confirmed - await this.waitForTransactionConfirmation(state, swapHash); + await this.waitForTransactionConfirmation(sourceNetwork, swapHash); logger.info(`Swap transaction confirmed: ${swapHash}`); - // Transition to the next phase - return this.transitionToNextPhase(updatedState, "squidRouterPay"); - } catch (error) { - logger.error(`Error in squidRouter phase for ramp ${state.id}:`, error); - throw error; - } - } + let preSettlementBalance = "0"; + try { + const destinationNetwork = quote.network as EvmNetworks; + const outTokenDetails = getOnChainTokenDetails(quote.network, quote.outputCurrency); - /** - * Get the appropriate public client based on the input token - * Monerium's EUR uses polygon, BRL uses Base - * @param state The current ramp state - * @returns The appropriate public client - */ - private async getPublicClient(state: RampState): Promise { - try { - const quote = await QuoteTicket.findByPk(state.quoteId); - if (!quote) { - throw new Error(`Quote not found for ramp ${state.id}`); - } + if (!outTokenDetails || !isEvmTokenDetails(outTokenDetails)) { + throw new Error(`Could not resolve destination token details for ${quote.outputCurrency} on ${destinationNetwork}`); + } - if (quote.inputCurrency === FiatToken.EURC || isAlfredpayToken(quote.inputCurrency as FiatToken)) { - return this.polygonClient; - } else if (quote.inputCurrency === FiatToken.BRL) { - return this.baseClient; - } else { - logger.info( - `SquidRouterPhaseHandler: Using Moonbeam client as default for input currency: ${quote.inputCurrency}. This is a bug.` + preSettlementBalance = ( + await getEvmBalance({ + chain: destinationNetwork, + ownerAddress: state.state.evmEphemeralAddress as `0x${string}`, + tokenDetails: outTokenDetails + }) + ).toString(); + } catch (error) { + logger.warn( + `SquidRouterPhaseHandler: Failed to snapshot pre-settlement balance for ramp ${state.id}; storing 0. Error: ${error}` ); - return this.moonbeamClient; } + + updatedState = await updatedState.update({ + state: { + ...updatedState.state, + preSettlementBalance + } + }); + + // Transition to the next phase + return this.transitionToNextPhase(updatedState, "squidRouterPay"); } catch (error) { - logger.error("SquidRouterPhaseHandler: Error determining public client, defaulting to moonbeam", error); - return this.moonbeamClient; + logger.error(`Error in squidRouter phase for ramp ${state.id}:`, error); + throw error; } } - /** - * Execute a transaction - * @param state The current ramp state - * @param txData The transaction data - * @returns The transaction hash - */ - private async executeTransaction(state: RampState, txData: string): Promise { + private async executeTransaction(network: EvmNetworks, txData: string): Promise { try { - const publicClient = await this.getPublicClient(state); + const publicClient = this.getClient(network); const txHash = await publicClient.sendRawTransaction({ serializedTransaction: txData as `0x${string}` }); @@ -230,19 +230,14 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { } } - /** - * Wait for a transaction to be confirmed with exponential backoff - * @param state The current ramp state - * @param txHash The transaction hash - */ - private async waitForTransactionConfirmation(state: RampState, txHash: string): Promise { + private async waitForTransactionConfirmation(network: EvmNetworks, txHash: string): Promise { const maxRetries = 3; const baseDelay = 5000; // 5 seconds const maxDelay = 30000; // 30 seconds for (let attempt = 0; attempt <= maxRetries; attempt++) { try { - const publicClient = await this.getPublicClient(state); + const publicClient = this.getClient(network); const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash as `0x${string}` }); @@ -282,10 +277,9 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { } } - private async getNonce(state: RampState, address: `0x${string}`): Promise { + private async getNonce(network: EvmNetworks, address: `0x${string}`): Promise { try { - const publicClient = await this.getPublicClient(state); - // List all transactions for the address to get the nonce + const publicClient = this.getClient(network); return await publicClient.getTransactionCount({ address }); } catch (error) { logger.error("Error getting nonce", error); diff --git a/apps/api/src/api/services/phases/handlers/stellar-payment-handler.ts b/apps/api/src/api/services/phases/handlers/stellar-payment-handler.ts deleted file mode 100644 index 838ec9924..000000000 --- a/apps/api/src/api/services/phases/handlers/stellar-payment-handler.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { HORIZON_URL, RampPhase } from "@vortexfi/shared"; -import { Horizon, NetworkError, Networks, Transaction } from "stellar-sdk"; -import logger from "../../../../config/logger"; -import { config } from "../../../../config/vars"; -import RampState from "../../../../models/rampState.model"; -import { BasePhaseHandler } from "../base-phase-handler"; -import { verifyStellarPaymentSuccess } from "../helpers/stellar-payment-verifier"; -import { StateMetadata } from "../meta-state-types"; -import { isStellarNetworkError } from "./fund-ephemeral-handler"; - -const NETWORK_PASSPHRASE = config.sandboxEnabled ? Networks.TESTNET : Networks.PUBLIC; - -const horizonServer = new Horizon.Server(HORIZON_URL); - -export class StellarPaymentPhaseHandler extends BasePhaseHandler { - public getPhaseName(): RampPhase { - return "stellarPayment"; - } - - protected async executePhase(state: RampState): Promise { - const { stellarPaymentTxHash } = state.state as StateMetadata; - - if (stellarPaymentTxHash) { - logger.info(`StellarPaymentPhaseHandler: Transaction already submitted (${stellarPaymentTxHash}), skipping to complete`); - return this.transitionToNextPhase(state, "complete"); - } - - 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); - const submissionResult = await horizonServer.submitTransaction(offrampingTransaction); - - state.state = { - ...state.state, - stellarPaymentTxHash: submissionResult.hash - }; - await state.update({ state: state.state }); - - return this.transitionToNextPhase(state, "complete"); - } catch (e) { - const horizonError = e as NetworkError; - - if (isStellarNetworkError(horizonError) && 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("tx_bad_seq error detected. Verifying if payment was actually successful..."); - - try { - const paymentSuccessful = await verifyStellarPaymentSuccess(state); - - if (paymentSuccessful) { - logger.info( - "Payment verification confirmed: all tokens transferred from ephemeral account. Proceeding to complete." - ); - return this.transitionToNextPhase(state, "complete"); - } else { - logger.error( - "Payment verification failed: tokens are still present in ephemeral account. Payment did not succeed." - ); - throw new Error("Stellar payment failed - tokens still present in ephemeral account despite tx_bad_seq error"); - } - } catch (verificationError) { - logger.error(`Failed to verify payment success: ${verificationError}`); - throw new Error(`Could not verify stellar payment success: ${verificationError}`); - } - } - - logger.error(horizonError.response.data.extras); - throw new Error("Could not submit the offramping transaction"); - } else { - logger.error("Error while submitting the offramp transaction", e); - throw new Error("Could not submit the offramping transaction"); - } - } - } -} - -export default new StellarPaymentPhaseHandler(); diff --git a/apps/api/src/api/services/phases/handlers/subsidize-post-swap-handler.ts b/apps/api/src/api/services/phases/handlers/subsidize-post-swap-handler.ts index e6b352530..08928a49d 100644 --- a/apps/api/src/api/services/phases/handlers/subsidize-post-swap-handler.ts +++ b/apps/api/src/api/services/phases/handlers/subsidize-post-swap-handler.ts @@ -44,7 +44,7 @@ export class SubsidizePostSwapPhaseHandler extends BasePhaseHandler { throw new Error("Quote not found for the given state"); } - if (quote.inputCurrency === FiatToken.BRL || quote.outputCurrency === FiatToken.BRL) { + if (quote.metadata.nablaSwapEvm) { return this.executeEvmSubsidize(state, quote); } @@ -99,8 +99,6 @@ export class SubsidizePostSwapPhaseHandler extends BasePhaseHandler { } else { if (quote.metadata.pendulumToMoonbeamXcm) { expectedSwapOutputAmountRaw = Big(quote.metadata.pendulumToMoonbeamXcm.inputAmountRaw); - } else if (quote.metadata.pendulumToStellar) { - expectedSwapOutputAmountRaw = Big(quote.metadata.pendulumToStellar.inputAmountRaw); } } @@ -290,7 +288,7 @@ export class SubsidizePostSwapPhaseHandler extends BasePhaseHandler { } } - return this.transitionToNextPhase(state, this.evmNextPhaseSelector(state)); + return this.transitionToNextPhase(state, this.evmNextPhaseSelector(state, quote)); } catch (e) { logger.error("Error in subsidizePostSwap (EVM):", e); if (e instanceof PhaseError) { @@ -321,7 +319,7 @@ export class SubsidizePostSwapPhaseHandler extends BasePhaseHandler { } if (state.type === RampDirection.SELL) { - return "spacewalkRedeem"; + throw new Error("SubsidizePostSwapPhaseHandler: Unsupported non-BRL offramp route after Stellar deprecation"); } throw new Error( @@ -329,12 +327,14 @@ export class SubsidizePostSwapPhaseHandler extends BasePhaseHandler { ); } - protected evmNextPhaseSelector(state: RampState): RampPhase { + protected evmNextPhaseSelector(state: RampState, quote: QuoteTicket): RampPhase { if (state.type === RampDirection.BUY) { return "squidRouterSwap"; - } else { - return "brlaPayoutOnBase"; } + if (quote.outputCurrency === FiatToken.EURC) { + return "mykoboPayoutOnBase"; + } + return "brlaPayoutOnBase"; } } diff --git a/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts b/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts index e60b74b2d..3d7ff6875 100644 --- a/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts +++ b/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts @@ -47,7 +47,7 @@ export class SubsidizePreSwapPhaseHandler extends BasePhaseHandler { throw new Error("Quote not found for the given state"); } - if (quote.inputCurrency === FiatToken.BRL || quote.outputCurrency === FiatToken.BRL) { + if (quote.metadata.nablaSwapEvm) { return this.executeEvmSubsidize(state, quote); } diff --git a/apps/api/src/api/services/phases/helpers/stellar-payment-verifier.ts b/apps/api/src/api/services/phases/helpers/stellar-payment-verifier.ts deleted file mode 100644 index ae267aea2..000000000 --- a/apps/api/src/api/services/phases/helpers/stellar-payment-verifier.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { HORIZON_URL } from "@vortexfi/shared"; -import Big from "big.js"; -import { Horizon } from "stellar-sdk"; -import logger from "../../../../config/logger"; -import QuoteTicket from "../../../../models/quoteTicket.model"; -import RampState from "../../../../models/rampState.model"; -import { StateMetadata } from "../meta-state-types"; - -/** - * Verifies if a stellar payment was successful by checking if the tokens - * are still present in the ephemeral account - */ -export async function verifyStellarPaymentSuccess(state: RampState): Promise { - const stateMetadata = state.state as StateMetadata; - const quote = await QuoteTicket.findByPk(state.quoteId); - - const { stellarEphemeralAccountId, stellarTarget } = stateMetadata; - - if (!quote) { - throw new Error("Quote not found for the given state"); - } - - if (!stellarEphemeralAccountId) { - throw new Error("Stellar ephemeral account ID not found in state metadata"); - } - - if (!stellarTarget) { - throw new Error("Stellar target information not found in state metadata"); - } - - if (!quote.metadata.nablaSwap?.outputAmountDecimal) { - throw new Error("Missing output amount in quote metadata for Nabla Swap"); - } - - const { stellarTokenDetails } = stellarTarget; - const expectedPaymentAmount = new Big(quote.metadata.nablaSwap.outputAmountDecimal); - - try { - logger.info( - `Verifying stellar payment success for account ${stellarEphemeralAccountId}, ` + - `asset ${stellarTokenDetails.stellarAsset.code.string}, ` + - `expected payment amount: ${expectedPaymentAmount.toString()}` - ); - - const currentBalance = await getStellarTokenBalance( - stellarEphemeralAccountId, - stellarTokenDetails.stellarAsset.code.string, - stellarTokenDetails.stellarAsset.issuer.stellarEncoding - ); - - logger.info(`Current balance: ${currentBalance.toString()}, expected payment: ${expectedPaymentAmount.toString()}`); - - // For Stellar payment operations, the transaction either succeeds completely or fails completely. - // If the payment succeeded, ALL tokens should have been transferred, leaving exactly 0 balance. - // Any remaining balance indicates the payment did not complete successfully. - if (currentBalance.eq(0)) { - logger.info( - `Payment succeeded: current balance is exactly 0, all ${expectedPaymentAmount.toString()} tokens were transferred` - ); - return true; // Payment succeeded - no tokens left - } else { - logger.warn( - `Payment failed: ${currentBalance.toString()} tokens still remain on account ` + - `(expected all ${expectedPaymentAmount.toString()} tokens to be transferred)` - ); - return false; // Payment failed - tokens still present - } - } catch (error) { - logger.error(`Error verifying stellar payment success: ${error}`); - throw new Error(`Failed to verify stellar payment success: ${error}`); - } -} - -/** - * Gets the balance of a specific token for a Stellar account - */ -async function getStellarTokenBalance(accountId: string, assetCode: string, assetIssuer: string): Promise { - try { - const server = new Horizon.Server(HORIZON_URL); - const account = await server.loadAccount(accountId); - - const assetBalance = account.balances.find(balance => { - if (balance.asset_type === "credit_alphanum4" || balance.asset_type === "credit_alphanum12") { - return balance.asset_code === assetCode && balance.asset_issuer === assetIssuer; - } - return false; - }); - - if (!assetBalance) { - logger.warn(`Asset ${assetCode} not found in account ${accountId} balances`); - return new Big(0); - } - - return new Big(assetBalance.balance); - } catch (error) { - logger.error(`Error getting stellar token balance for account ${accountId}: ${error}`); - throw error; - } -} diff --git a/apps/api/src/api/services/phases/helpers/stellar-sequence-validator.ts b/apps/api/src/api/services/phases/helpers/stellar-sequence-validator.ts deleted file mode 100644 index 4a127cb38..000000000 --- a/apps/api/src/api/services/phases/helpers/stellar-sequence-validator.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Horizon } from "stellar-sdk"; -import logger from "../../../../config/logger"; -import RampState from "../../../../models/rampState.model"; -import { horizonServer } from "../handlers/helpers"; - -/** - * Validates that the stellarPayment transaction can execute by comparing - * the expected sequence number with the current ephemeral account sequence number - */ -export async function validateStellarPaymentSequenceNumber(state: RampState, stellarEphemeralAccountId: string): Promise { - try { - const stellarPaymentTx = state.presignedTxs?.find(tx => tx.phase === "stellarPayment"); - - if (!stellarPaymentTx?.meta?.expectedSequenceNumber) { - throw new Error("Expected sequence number not found in stellarPayment transaction metadata"); - } - - const expectedSequenceNumber = stellarPaymentTx.meta.expectedSequenceNumber as string; - - let currentAccount: Horizon.AccountResponse; - try { - currentAccount = await horizonServer.loadAccount(stellarEphemeralAccountId); - } catch (error) { - throw new Error(`Failed to load Stellar ephemeral account ${stellarEphemeralAccountId}: ${error}`); - } - - const currentSequenceNumber = currentAccount.sequenceNumber(); - - logger.info( - `Validating sequence numbers for ephemeral account ${stellarEphemeralAccountId}: ` + - `expected=${expectedSequenceNumber}, current=${currentSequenceNumber}` - ); - - const expectedBigInt = BigInt(expectedSequenceNumber); - const currentBigInt = BigInt(currentSequenceNumber); - - if (expectedBigInt <= currentBigInt) { - throw new Error( - `Stellar payment transaction sequence validation failed: expected sequence number ${expectedSequenceNumber} is not greater than the current account sequence number ${currentSequenceNumber}. The stellarPayment transaction may not be able to execute.` - ); - } - } catch (error) { - logger.error(`Stellar payment sequence number validation failed: ${error}`); - throw error; - } -} diff --git a/apps/api/src/api/services/phases/meta-state-types.ts b/apps/api/src/api/services/phases/meta-state-types.ts index 6d6c2037b..c7509915a 100644 --- a/apps/api/src/api/services/phases/meta-state-types.ts +++ b/apps/api/src/api/services/phases/meta-state-types.ts @@ -1,30 +1,16 @@ -import { - AlfredpayFiatPaymentInstructions, - ExtrinsicOptions, - IbanPaymentData, - PermitSignature, - StellarTokenDetails -} from "@vortexfi/shared"; +import { AlfredpayFiatPaymentInstructions, ExtrinsicOptions, IbanPaymentData } from "@vortexfi/shared"; export interface StateMetadata { nablaSoftMinimumOutputRaw: string; // Only used in offramp squidRouterReceiverId: string; squidRouterReceiverHash: string; - // Only used in offramp - eurc & ars route - stellarEphemeralAccountId: string; - stellarTarget: { - stellarTargetAccountId: string; - stellarTokenDetails: StellarTokenDetails; - }; - executeSpacewalkNonce: number; distributeFeeHash: string; // Only used in onramp - brla aveniaTicketId: string; taxId: string; pixDestination: string; brlaEvmAddress: string; - moneriumWalletAddress: string | undefined; walletAddress: string | undefined; destinationAddress: string; receiverTaxId: string; @@ -54,8 +40,6 @@ export interface StateMetadata { presignChecksPass?: boolean; payOutTicketId: string | undefined; brlaPayoutTxHash?: `0x${string}`; - // Only used in onramp, offramp - monerium - moneriumOnrampPermit?: PermitSignature; permitTxHash?: string; moneriumOnrampSelfTransferHash?: string; ibanPaymentData: IbanPaymentData; @@ -76,9 +60,10 @@ export interface StateMetadata { alfredpayOfframpTransferTxHash?: string; squidRouterPermitExecutionHash?: string; squidRouterPermitExecutionValue?: string; - stellarPaymentTxHash?: string; nablaSwapTxHash?: string; isDirectTransfer?: boolean; + // Snapshot of destination-token raw balance on the ephemeral, recorded immediately before squidRouterPay so finalSettlementSubsidy can compute actual bridge delivery rather than total balance (which may include leftover dust from prior phases). + preSettlementBalance?: string; // Fallback path used when input ERC20 does not support EIP-2612 permit. // The user submits the substituting transaction(s) from their own wallet and // reports back the resulting tx hashes via UpdateRampRequest.additionalData. @@ -86,4 +71,10 @@ export interface StateMetadata { squidRouterNoPermitTransferHash?: string; squidRouterNoPermitApproveHash?: string; squidRouterNoPermitSwapHash?: string; + // Mykobo - EUR offramp on Base + mykoboEmail?: string; + mykoboTransactionId?: string; + mykoboReceivablesAddress?: string; + mykoboPayoutTxHash?: `0x${string}`; + mykoboTransactionReference?: string; } diff --git a/apps/api/src/api/services/phases/mykobo-eur-offramp.integration.test.ts b/apps/api/src/api/services/phases/mykobo-eur-offramp.integration.test.ts new file mode 100644 index 000000000..ad97c5de7 --- /dev/null +++ b/apps/api/src/api/services/phases/mykobo-eur-offramp.integration.test.ts @@ -0,0 +1,331 @@ +import { describe, expect, it, mock } from "bun:test"; +import fs from "node:fs"; +import path from "node:path"; +import Big from "big.js"; +import { Keyring } from "@polkadot/api"; +import { mnemonicGenerate } from "@polkadot/util-crypto"; + +// Mock the EVM Nabla swap quote function before importing QuoteService so the +// quote engine does not hit Base RPC for the (currently illiquid) USDC<->EURC pool. +mock.module("../quote/core/nabla", () => { + return { + calculateNablaSwapOutputEvm: async (request: { + inputAmountForSwap: string; + inputTokenDetails: { decimals: number }; + outputTokenDetails: { decimals: number }; + }) => { + console.log("[MOCK] calculateNablaSwapOutputEvm called with", request.inputAmountForSwap); + const decimalOut = new Big(request.inputAmountForSwap).times("0.92"); + const rawOut = decimalOut.times(new Big(10).pow(request.outputTokenDetails.decimals)).toFixed(0, 0); + return { + effectiveExchangeRate: "0.92", + nablaOutputAmountDecimal: decimalOut, + nablaOutputAmountRaw: rawOut + }; + }, + calculateNablaSwapOutput: async () => { + throw new Error("calculateNablaSwapOutput should not be called in EVM-only test"); + } + }; +}); +import { + AccountMeta, + BrlaApiService, + DestinationType, + EPaymentMethod, + EphemeralAccount, + EphemeralAccountType, + EvmToken, + FiatToken, + MYKOBO_ACCESS_KEY, + MYKOBO_BASE_URL, + MYKOBO_SECRET_KEY, + MykoboApiService, + MykoboCurrency, + MykoboFeeKind, + MykoboTransactionStatus, + MykoboTransactionType, + Networks, + RampDirection, + RegisterRampRequest +} from "@vortexfi/shared"; +import { UpdateOptions } from "sequelize"; +import QuoteTicket, { QuoteTicketAttributes, QuoteTicketCreationAttributes } from "../../../models/quoteTicket.model"; +import RampState, { RampStateAttributes, RampStateCreationAttributes } from "../../../models/rampState.model"; +import RampRecoveryWorker from "../../workers/ramp-recovery.worker"; +import { QuoteService } from "../quote"; +import { RampService } from "../ramp/ramp.service"; +import registerPhaseHandlers from "./register-handlers"; +import { StateMetadata } from "./meta-state-types"; + +const EVM_TESTING_ADDRESS = "0x30a300612ab372CC73e53ffE87fB73d62Ed68Da3"; +const EVM_DESTINATION_ADDRESS = "0x7ba99e99bc669b3508aff9cc0a898e869459f877"; +const TEST_INPUT_AMOUNT = "35"; +const TEST_EMAIL = "mail@test.com"; +const TEST_IP_ADDRESS = "203.0.113.42"; + +const filePath = path.join(__dirname, "lastRampStateMykoboEur.json"); +const EVM_ADDRESS_REGEX = /^0x[a-fA-F0-9]{40}$/; + +interface TestSigningAccounts { + EVM: EphemeralAccount; + Substrate: EphemeralAccount; +} + +async function createSubstrateEphemeral(): Promise { + const seedPhrase = mnemonicGenerate(); + const keyring = new Keyring({ type: "sr25519" }); + await new Promise(resolve => setTimeout(resolve, 1000)); + const kp = keyring.addFromUri(seedPhrase); + return { address: kp.address, secret: seedPhrase }; +} + +async function createMoonbeamEphemeralSeed(): Promise { + const seedPhrase = mnemonicGenerate(); + const keyring = new Keyring({ type: "ethereum" }); + const kp = keyring.addFromUri(`${seedPhrase}/m/44'/60'/0'/0/0`); + return { address: kp.address, secret: seedPhrase }; +} + +const testSigningAccounts: TestSigningAccounts = { + EVM: await createMoonbeamEphemeralSeed(), + Substrate: await createSubstrateEphemeral() +}; + +const testSigningAccountsMeta: AccountMeta[] = Object.keys(testSigningAccounts).map(networkKey => ({ + address: testSigningAccounts[networkKey as keyof TestSigningAccounts].address, + type: networkKey as EphemeralAccountType +})); + +let rampState: RampState; +let quoteTicket: QuoteTicket; + +type RampStateUpdateData = Partial; +type QuoteTicketUpdateData = Partial; + +RampState.update = mock(async function (updateData: RampStateUpdateData) { + rampState = { ...rampState, ...updateData, updatedAt: new Date() } as RampState; + fs.writeFileSync(filePath, JSON.stringify(rampState, null, 2)); + return rampState; +}) as unknown as typeof RampState.update; + +RampState.findByPk = mock(async (_id: string): Promise => rampState) as typeof RampState.findByPk; + +RampState.create = mock(async (data: RampStateCreationAttributes): Promise => { + rampState = { + ...data, + createdAt: new Date(), + id: data.id || "test-mykobo-ramp-id", + reload: async function (_options?: UpdateOptions): Promise { + return rampState; + }, + update: async function ( + updateData: RampStateUpdateData, + _options?: UpdateOptions + ): Promise { + rampState = { ...rampState, ...updateData, updatedAt: new Date() } as RampState; + fs.writeFileSync(filePath, JSON.stringify(rampState, null, 2)); + return rampState; + }, + updatedAt: new Date() + } as RampState; + fs.writeFileSync(filePath, JSON.stringify(rampState, null, 2)); + return rampState; +}) as typeof RampState.create; + +QuoteTicket.findByPk = mock(async (_id: string): Promise => quoteTicket) as typeof QuoteTicket.findByPk; + +QuoteTicket.update = mock(async (data: QuoteTicketUpdateData) => { + quoteTicket = { ...quoteTicket, ...data } as QuoteTicket; + return [1, [quoteTicket]]; +}) as unknown as typeof QuoteTicket.update; + +QuoteTicket.create = mock(async (data: QuoteTicketCreationAttributes): Promise => { + quoteTicket = { + ...data, + createdAt: new Date(), + id: data.id || "test-mykobo-quote-id", + update: async function ( + updateData: QuoteTicketUpdateData, + _options?: UpdateOptions + ): Promise { + quoteTicket = { ...quoteTicket, ...updateData } as QuoteTicket; + return quoteTicket; + }, + updatedAt: new Date() + } as QuoteTicket; + return quoteTicket; +}) as typeof QuoteTicket.create; + +const mockBrlaApiService = { + acknowledgeEvents: mock(async (): Promise => Promise.resolve()), + createFastQuote: mock(async () => ({ basePrice: "100" })), + createSubaccount: mock(async () => ({ id: "subaccount123" })), + generateBrCode: mock(async () => ({ brCode: "brcode123" })), + getAllEventsByUser: mock(async () => []), + getOnChainHistoryOut: mock(async () => []), + getPayInHistory: mock(async () => []), + getSubaccount: mock(async () => ({ brCode: "brcode123", wallets: { evm: EVM_DESTINATION_ADDRESS } })), + login: mock(async (): Promise => Promise.resolve()), + sendRequest: mock(async () => ({})), + swapRequest: mock(async () => ({ id: "swap123" })), + triggerOfframp: mock(async () => ({ id: "offramp123" })), + validatePixKey: mock(async () => ({ bankName: "Test Bank", name: "Test", taxId: "x" })) +}; + +BrlaApiService.getInstance = mock(() => mockBrlaApiService as unknown as BrlaApiService); + +RampRecoveryWorker.prototype.start = mock(async (): Promise => { + // worker disabled in test +}); + +describe("Mykobo EUR offramp contract test (real sandbox, no on-chain submission)", () => { + it("requires Mykobo sandbox credentials in the environment", () => { + if (!MYKOBO_ACCESS_KEY || !MYKOBO_SECRET_KEY) { + throw new Error("MYKOBO_ACCESS_KEY and MYKOBO_SECRET_KEY must be set to run this test"); + } + expect(MYKOBO_BASE_URL).toMatch(/mykobo/); + }); + + it("hits Mykobo /fees and returns a numeric total for WITHDRAW", async () => { + const mykobo = MykoboApiService.getInstance(); + const fees = await mykobo.lookupFees({ kind: MykoboFeeKind.WITHDRAW, value: TEST_INPUT_AMOUNT }); + console.log("Mykobo /fees response:", fees); + expect(fees).toBeDefined(); + expect(fees.total).toBeDefined(); + expect(Number.isFinite(Number(fees.total))).toBe(true); + }); + + it("creates a Mykobo WITHDRAW intent and returns withdraw instructions with an EVM address", async () => { + const mykobo = MykoboApiService.getInstance(); + let intent; + try { + intent = await mykobo.createTransactionIntent({ + currency: MykoboCurrency.EURC, + email_address: TEST_EMAIL, + ip_address: TEST_IP_ADDRESS, + transaction_type: MykoboTransactionType.WITHDRAW, + value: "3.000000", + wallet_address: testSigningAccounts.EVM.address + }); + } catch (e) { + const err = e as { body?: unknown; status?: number }; + console.log("Mykobo intent error status:", err.status); + console.log("Mykobo intent error body:", JSON.stringify(err.body, null, 2)); + throw e; + } + console.log("Mykobo intent response:", JSON.stringify(intent, null, 2)); + + expect(intent.transaction).toBeDefined(); + expect(intent.transaction.id).toBeTruthy(); + expect(intent.transaction.reference).toBeTruthy(); + expect(intent.transaction.status).toBeDefined(); + expect(intent.transaction.value).toBeDefined(); + expect(intent.transaction.wallet_address).toBe(testSigningAccounts.EVM.address); + expect(intent.instructions).toBeDefined(); + if (!intent.instructions || !("address" in intent.instructions)) { + throw new Error("Expected withdraw instructions with `address` field"); + } + expect(intent.instructions.address).toMatch(EVM_ADDRESS_REGEX); + + const fetched = await mykobo.getTransaction(intent.transaction.id); + console.log("Mykobo getTransaction response:", fetched); + expect(fetched.transaction.id).toBe(intent.transaction.id); + expect(Object.values(MykoboTransactionStatus)).toContain(fetched.transaction.status as MykoboTransactionStatus); + }); + + it("creates a EUR offramp quote on Base via QuoteService and populates nablaSwapEvm metadata", async () => { + const quoteService = new QuoteService(); + + const quote = await quoteService.createQuote({ + from: Networks.Base as DestinationType, + inputAmount: TEST_INPUT_AMOUNT, + inputCurrency: EvmToken.USDC, + network: Networks.Base, + outputCurrency: FiatToken.EURC, + rampType: RampDirection.SELL, + to: EPaymentMethod.SEPA as DestinationType + }); + + console.log("Quote created:", { + id: quote.id, + inputAmount: quote.inputAmount, + outputAmount: quote.outputAmount, + totalFeeFiat: quote.totalFeeFiat + }); + console.log("nablaSwapEvm metadata:", quoteTicket.metadata.nablaSwapEvm); + + expect(quote.inputCurrency).toBe(EvmToken.USDC); + expect(quote.outputCurrency).toBe(FiatToken.EURC); + expect(Number(quote.outputAmount)).toBeGreaterThan(0); + expect(Number(quote.totalFeeFiat)).toBeGreaterThan(0); + expect(quoteTicket.metadata.nablaSwapEvm).toBeDefined(); + expect(quoteTicket.metadata.nablaSwapEvm?.outputAmountDecimal).toBeDefined(); + expect(quoteTicket.metadata.nablaSwapEvm?.outputAmountRaw).toBeDefined(); + expect(Number(quoteTicket.metadata.nablaSwapEvm?.outputAmountDecimal)).toBeGreaterThan(0); + }); + + it("registers a Base+USDC ramp and prepares the Mykobo phase set (no squid, no broadcast)", async () => { + const rampService = new RampService(); + const quoteService = new QuoteService(); + + registerPhaseHandlers(); + + const quote = await quoteService.createQuote({ + from: Networks.Base as DestinationType, + inputAmount: TEST_INPUT_AMOUNT, + inputCurrency: EvmToken.USDC, + network: Networks.Base, + outputCurrency: FiatToken.EURC, + rampType: RampDirection.SELL, + to: EPaymentMethod.SEPA as DestinationType + }); + + const additionalData: RegisterRampRequest["additionalData"] = { + destinationAddress: EVM_DESTINATION_ADDRESS, + email: TEST_EMAIL, + ipAddress: TEST_IP_ADDRESS, + walletAddress: EVM_TESTING_ADDRESS + }; + + const registered = await rampService.registerRamp({ + additionalData, + quoteId: quote.id, + signingAccounts: testSigningAccountsMeta + }); + + if (!registered.unsignedTxs) { + throw new Error("Expected registerRamp to return unsigned transactions"); + } + + const phases = registered.unsignedTxs.map(tx => tx.phase); + console.log("Prepared phases:", phases); + + expect(phases).not.toContain("squidRouterApprove"); + expect(phases).not.toContain("squidRouterSwap"); + expect(phases).toContain("nablaApprove"); + expect(phases).toContain("nablaSwap"); + expect(phases).toContain("mykoboPayoutOnBase"); + expect(phases).toContain("baseCleanupUsdc"); + expect(phases).toContain("baseCleanupEurc"); + expect(phases).toContain("baseCleanupAxlUsdc"); + + const state = rampState.state as StateMetadata; + expect(state.mykoboEmail).toBe(TEST_EMAIL); + expect(state.mykoboTransactionId).toBeTruthy(); + expect(state.mykoboReceivablesAddress).toMatch(EVM_ADDRESS_REGEX); + expect(state.mykoboTransactionReference).toBeTruthy(); + expect(state.evmEphemeralAddress).toBe(testSigningAccounts.EVM.address); + console.log("StateMeta (Mykobo fields):", { + mykoboEmail: state.mykoboEmail, + mykoboReceivablesAddress: state.mykoboReceivablesAddress, + mykoboTransactionId: state.mykoboTransactionId, + mykoboTransactionReference: state.mykoboTransactionReference + }); + + const payoutTx = registered.unsignedTxs.find(tx => tx.phase === "mykoboPayoutOnBase"); + expect(payoutTx).toBeDefined(); + expect(payoutTx?.signer).toBe(testSigningAccounts.EVM.address); + expect(payoutTx?.network).toBe(Networks.Base); + }); +}); diff --git a/apps/api/src/api/services/phases/mykobo-eur-onramp.integration.test.ts b/apps/api/src/api/services/phases/mykobo-eur-onramp.integration.test.ts new file mode 100644 index 000000000..c8aa982b8 --- /dev/null +++ b/apps/api/src/api/services/phases/mykobo-eur-onramp.integration.test.ts @@ -0,0 +1,338 @@ +import { describe, expect, it, mock } from "bun:test"; +import fs from "node:fs"; +import path from "node:path"; +import Big from "big.js"; +import { Keyring } from "@polkadot/api"; +import { mnemonicGenerate } from "@polkadot/util-crypto"; + +// Mock the EVM Nabla swap quote function before importing QuoteService so the +// quote engine does not hit Base RPC for the (currently illiquid) EURC<->USDC pool. +mock.module("../quote/core/nabla", () => { + return { + calculateNablaSwapOutputEvm: async (request: { + inputAmountForSwap: string; + inputTokenDetails: { decimals: number }; + outputTokenDetails: { decimals: number }; + }) => { + console.log("[MOCK] calculateNablaSwapOutputEvm called with", request.inputAmountForSwap); + const decimalOut = new Big(request.inputAmountForSwap).times("1.05"); + const rawOut = decimalOut.times(new Big(10).pow(request.outputTokenDetails.decimals)).toFixed(0, 0); + return { + effectiveExchangeRate: "1.05", + nablaOutputAmountDecimal: decimalOut, + nablaOutputAmountRaw: rawOut + }; + }, + calculateNablaSwapOutput: async () => { + throw new Error("calculateNablaSwapOutput should not be called in EVM-only test"); + } + }; +}); +import { + AccountMeta, + BrlaApiService, + DestinationType, + EPaymentMethod, + EphemeralAccount, + EphemeralAccountType, + EvmToken, + FiatToken, + IbanPaymentData, + MYKOBO_ACCESS_KEY, + MYKOBO_BASE_URL, + MYKOBO_SECRET_KEY, + MykoboApiService, + MykoboCurrency, + MykoboFeeKind, + MykoboTransactionStatus, + MykoboTransactionType, + Networks, + RampDirection, + RegisterRampRequest +} from "@vortexfi/shared"; +import { UpdateOptions } from "sequelize"; +import QuoteTicket, { QuoteTicketAttributes, QuoteTicketCreationAttributes } from "../../../models/quoteTicket.model"; +import RampState, { RampStateAttributes, RampStateCreationAttributes } from "../../../models/rampState.model"; +import RampRecoveryWorker from "../../workers/ramp-recovery.worker"; +import { QuoteService } from "../quote"; +import { RampService } from "../ramp/ramp.service"; +import registerPhaseHandlers from "./register-handlers"; +import { StateMetadata } from "./meta-state-types"; + +const EVM_TESTING_ADDRESS = "0x30a300612ab372CC73e53ffE87fB73d62Ed68Da3"; +const EVM_DESTINATION_ADDRESS = "0x7ba99e99bc669b3508aff9cc0a898e869459f877"; +const TEST_INPUT_AMOUNT = "35"; +const TEST_EMAIL = "mail@test.com"; +const TEST_IP_ADDRESS = "203.0.113.42"; + +const filePath = path.join(__dirname, "lastRampStateMykoboEurOnramp.json"); +const EVM_ADDRESS_REGEX = /^0x[a-fA-F0-9]{40}$/; +const IBAN_REGEX = /^[A-Z]{2}[0-9A-Z]{2}[0-9A-Z]{4,30}$/; + +interface TestSigningAccounts { + EVM: EphemeralAccount; + Substrate: EphemeralAccount; +} + +async function createSubstrateEphemeral(): Promise { + const seedPhrase = mnemonicGenerate(); + const keyring = new Keyring({ type: "sr25519" }); + await new Promise(resolve => setTimeout(resolve, 1000)); + const kp = keyring.addFromUri(seedPhrase); + return { address: kp.address, secret: seedPhrase }; +} + +async function createMoonbeamEphemeralSeed(): Promise { + const seedPhrase = mnemonicGenerate(); + const keyring = new Keyring({ type: "ethereum" }); + const kp = keyring.addFromUri(`${seedPhrase}/m/44'/60'/0'/0/0`); + return { address: kp.address, secret: seedPhrase }; +} + +const testSigningAccounts: TestSigningAccounts = { + EVM: await createMoonbeamEphemeralSeed(), + Substrate: await createSubstrateEphemeral() +}; + +const testSigningAccountsMeta: AccountMeta[] = Object.keys(testSigningAccounts).map(networkKey => ({ + address: testSigningAccounts[networkKey as keyof TestSigningAccounts].address, + type: networkKey as EphemeralAccountType +})); + +let rampState: RampState; +let quoteTicket: QuoteTicket; + +type RampStateUpdateData = Partial; +type QuoteTicketUpdateData = Partial; + +RampState.update = mock(async function (updateData: RampStateUpdateData) { + rampState = { ...rampState, ...updateData, updatedAt: new Date() } as RampState; + fs.writeFileSync(filePath, JSON.stringify(rampState, null, 2)); + return rampState; +}) as unknown as typeof RampState.update; + +RampState.findByPk = mock(async (_id: string): Promise => rampState) as typeof RampState.findByPk; + +RampState.create = mock(async (data: RampStateCreationAttributes): Promise => { + rampState = { + ...data, + createdAt: new Date(), + id: data.id || "test-mykobo-onramp-ramp-id", + reload: async function (_options?: UpdateOptions): Promise { + return rampState; + }, + update: async function ( + updateData: RampStateUpdateData, + _options?: UpdateOptions + ): Promise { + rampState = { ...rampState, ...updateData, updatedAt: new Date() } as RampState; + fs.writeFileSync(filePath, JSON.stringify(rampState, null, 2)); + return rampState; + }, + updatedAt: new Date() + } as RampState; + fs.writeFileSync(filePath, JSON.stringify(rampState, null, 2)); + return rampState; +}) as typeof RampState.create; + +QuoteTicket.findByPk = mock(async (_id: string): Promise => quoteTicket) as typeof QuoteTicket.findByPk; + +QuoteTicket.update = mock(async (data: QuoteTicketUpdateData) => { + quoteTicket = { ...quoteTicket, ...data } as QuoteTicket; + return [1, [quoteTicket]]; +}) as unknown as typeof QuoteTicket.update; + +QuoteTicket.create = mock(async (data: QuoteTicketCreationAttributes): Promise => { + quoteTicket = { + ...data, + createdAt: new Date(), + id: data.id || "test-mykobo-onramp-quote-id", + update: async function ( + updateData: QuoteTicketUpdateData, + _options?: UpdateOptions + ): Promise { + quoteTicket = { ...quoteTicket, ...updateData } as QuoteTicket; + return quoteTicket; + }, + updatedAt: new Date() + } as QuoteTicket; + return quoteTicket; +}) as typeof QuoteTicket.create; + +const mockBrlaApiService = { + acknowledgeEvents: mock(async (): Promise => Promise.resolve()), + createFastQuote: mock(async () => ({ basePrice: "100" })), + createSubaccount: mock(async () => ({ id: "subaccount123" })), + generateBrCode: mock(async () => ({ brCode: "brcode123" })), + getAllEventsByUser: mock(async () => []), + getOnChainHistoryOut: mock(async () => []), + getPayInHistory: mock(async () => []), + getSubaccount: mock(async () => ({ brCode: "brcode123", wallets: { evm: EVM_DESTINATION_ADDRESS } })), + login: mock(async (): Promise => Promise.resolve()), + sendRequest: mock(async () => ({})), + swapRequest: mock(async () => ({ id: "swap123" })), + triggerOfframp: mock(async () => ({ id: "offramp123" })), + validatePixKey: mock(async () => ({ bankName: "Test Bank", name: "Test", taxId: "x" })) +}; + +BrlaApiService.getInstance = mock(() => mockBrlaApiService as unknown as BrlaApiService); + +RampRecoveryWorker.prototype.start = mock(async (): Promise => { + // worker disabled in test +}); + +describe("Mykobo EUR onramp contract test (real sandbox, no on-chain submission)", () => { + it("requires Mykobo sandbox credentials in the environment", () => { + if (!MYKOBO_ACCESS_KEY || !MYKOBO_SECRET_KEY) { + throw new Error("MYKOBO_ACCESS_KEY and MYKOBO_SECRET_KEY must be set to run this test"); + } + expect(MYKOBO_BASE_URL).toMatch(/mykobo/); + }); + + it("hits Mykobo /fees and returns a numeric total for DEPOSIT", async () => { + const mykobo = MykoboApiService.getInstance(); + const fees = await mykobo.lookupFees({ kind: MykoboFeeKind.DEPOSIT, value: TEST_INPUT_AMOUNT }); + console.log("Mykobo /fees response:", fees); + expect(fees).toBeDefined(); + expect(fees.total).toBeDefined(); + expect(Number.isFinite(Number(fees.total))).toBe(true); + }); + + it("creates a Mykobo DEPOSIT intent and returns IBAN instructions", async () => { + const mykobo = MykoboApiService.getInstance(); + let intent; + try { + intent = await mykobo.createTransactionIntent({ + currency: MykoboCurrency.EURC, + email_address: TEST_EMAIL, + ip_address: TEST_IP_ADDRESS, + transaction_type: MykoboTransactionType.DEPOSIT, + value: "3.000000", + wallet_address: testSigningAccounts.EVM.address + }); + } catch (e) { + const err = e as { body?: unknown; status?: number }; + console.log("Mykobo intent error status:", err.status); + console.log("Mykobo intent error body:", JSON.stringify(err.body, null, 2)); + throw e; + } + console.log("Mykobo intent response:", JSON.stringify(intent, null, 2)); + + expect(intent.transaction).toBeDefined(); + expect(intent.transaction.id).toBeTruthy(); + expect(intent.transaction.reference).toBeTruthy(); + expect(intent.transaction.status).toBeDefined(); + expect(intent.transaction.value).toBeDefined(); + expect(intent.transaction.wallet_address).toBe(testSigningAccounts.EVM.address); + expect(intent.instructions).toBeDefined(); + if (!intent.instructions || !("iban" in intent.instructions)) { + throw new Error("Expected deposit instructions with `iban` field"); + } + expect(intent.instructions.iban).toMatch(IBAN_REGEX); + expect(intent.instructions.bank_account_name).toBeTruthy(); + + const fetched = await mykobo.getTransaction(intent.transaction.id); + console.log("Mykobo getTransaction response:", fetched); + expect(fetched.transaction.id).toBe(intent.transaction.id); + expect(Object.values(MykoboTransactionStatus)).toContain(fetched.transaction.status as MykoboTransactionStatus); + }); + + it("creates a EUR onramp quote on Base via QuoteService and populates mykoboMint metadata", async () => { + const quoteService = new QuoteService(); + + const quote = await quoteService.createQuote({ + from: EPaymentMethod.SEPA as DestinationType, + inputAmount: TEST_INPUT_AMOUNT, + inputCurrency: FiatToken.EURC, + network: Networks.Base, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.Base as DestinationType + }); + + console.log("Quote created:", { + id: quote.id, + inputAmount: quote.inputAmount, + outputAmount: quote.outputAmount, + totalFeeFiat: quote.totalFeeFiat + }); + console.log("mykoboMint metadata:", quoteTicket.metadata.mykoboMint); + + expect(quote.inputCurrency).toBe(FiatToken.EURC); + expect(quote.outputCurrency).toBe(EvmToken.USDC); + expect(Number(quote.outputAmount)).toBeGreaterThan(0); + expect(Number(quote.totalFeeFiat)).toBeGreaterThanOrEqual(0); + expect(quoteTicket.metadata.mykoboMint).toBeDefined(); + expect(quoteTicket.metadata.mykoboMint?.outputAmountRaw).toBeDefined(); + expect(Number(quoteTicket.metadata.mykoboMint?.outputAmountRaw)).toBeGreaterThan(0); + }); + + it("registers a EUR->Base USDC onramp and prepares the Mykobo phase set (no squid, no broadcast)", async () => { + const rampService = new RampService(); + const quoteService = new QuoteService(); + + registerPhaseHandlers(); + + const quote = await quoteService.createQuote({ + from: EPaymentMethod.SEPA as DestinationType, + inputAmount: TEST_INPUT_AMOUNT, + inputCurrency: FiatToken.EURC, + network: Networks.Base, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.Base as DestinationType + }); + + const additionalData: RegisterRampRequest["additionalData"] = { + destinationAddress: EVM_DESTINATION_ADDRESS, + email: TEST_EMAIL, + ipAddress: TEST_IP_ADDRESS, + walletAddress: EVM_TESTING_ADDRESS + }; + + const registered = await rampService.registerRamp({ + additionalData, + quoteId: quote.id, + signingAccounts: testSigningAccountsMeta + }); + + if (!registered.unsignedTxs) { + throw new Error("Expected registerRamp to return unsigned transactions"); + } + + const phases = registered.unsignedTxs.map(tx => tx.phase); + console.log("Prepared phases:", phases); + + expect(phases).not.toContain("squidRouterApprove"); + expect(phases).not.toContain("squidRouterSwap"); + expect(phases).toContain("nablaApprove"); + expect(phases).toContain("nablaSwap"); + expect(phases).toContain("destinationTransfer"); + expect(phases).toContain("baseCleanupEurc"); + expect(phases).toContain("baseCleanupUsdc"); + + const state = rampState.state as StateMetadata; + expect(state.mykoboEmail).toBe(TEST_EMAIL); + expect(state.mykoboTransactionId).toBeTruthy(); + expect(state.mykoboTransactionReference).toBeTruthy(); + expect(state.evmEphemeralAddress).toBe(testSigningAccounts.EVM.address); + + const ibanPaymentData = (rampState.state as StateMetadata & { ibanPaymentData?: IbanPaymentData }).ibanPaymentData; + expect(ibanPaymentData).toBeDefined(); + expect(ibanPaymentData?.iban).toMatch(IBAN_REGEX); + expect(ibanPaymentData?.receiverName).toBeTruthy(); + expect(ibanPaymentData?.reference).toBe(state.mykoboTransactionReference); + + console.log("StateMeta (Mykobo fields):", { + ibanPaymentData, + mykoboEmail: state.mykoboEmail, + mykoboTransactionId: state.mykoboTransactionId, + mykoboTransactionReference: state.mykoboTransactionReference + }); + + const destinationTx = registered.unsignedTxs.find(tx => tx.phase === "destinationTransfer"); + expect(destinationTx).toBeDefined(); + expect(destinationTx?.signer).toBe(testSigningAccounts.EVM.address); + expect(destinationTx?.network).toBe(Networks.Base); + }); +}); diff --git a/apps/api/src/api/services/phases/phase-processor.integration.test.ts b/apps/api/src/api/services/phases/phase-processor.integration.test.ts deleted file mode 100644 index 464cd9db4..000000000 --- a/apps/api/src/api/services/phases/phase-processor.integration.test.ts +++ /dev/null @@ -1,282 +0,0 @@ -import {describe, it, mock} from "bun:test"; -import fs from "node:fs"; -import path from "node:path"; -import { - AccountMeta, - API, - ApiManager, - BrlaApiService, - DestinationType, - EPaymentMethod, - EphemeralAccount, - EphemeralAccountType, - EvmToken, - FiatToken, - Networks, - RampDirection, - RegisterRampRequest, - signUnsignedTransactions, -} from "@vortexfi/shared"; -import {Keyring} from "@polkadot/api"; -import {mnemonicGenerate} from "@polkadot/util-crypto"; -import Big from "big.js"; -import {UpdateOptions} from "sequelize"; -import {Keypair} from "stellar-sdk"; -import QuoteTicket, {QuoteTicketAttributes, QuoteTicketCreationAttributes} from "../../../models/quoteTicket.model"; -import RampState, {RampStateAttributes, RampStateCreationAttributes} from "../../../models/rampState.model"; -import RampRecoveryWorker from "../../workers/ramp-recovery.worker"; -import {QuoteService} from "../quote"; -import {RampService} from "../ramp/ramp.service"; -import registerPhaseHandlers from "./register-handlers"; - -const EVM_TESTING_ADDRESS = "0x30a300612ab372CC73e53ffE87fB73d62Ed68Da3"; -const EVM_DESTINATION_ADDRESS = "0x7ba99e99bc669b3508aff9cc0a898e869459f877"; -const STELLAR_MOCK_ANCHOR_ACCOUNT = "GAXW7RTC4LA3MGNEA3LO626ABUCZBW3FDQPYBTH6VQA5BFHXXYZUQWY7"; -const TEST_INPUT_AMOUNT = "1"; -const TEST_INPUT_CURRENCY = EvmToken.USDC; -const TEST_OUTPUT_CURRENCY = FiatToken.ARS; - -const QUOTE_TO = EPaymentMethod.SEPA; -const QUOTE_FROM = "evm"; - -const filePath = path.join(__dirname, "lastRampState.json"); - -interface TestSigningAccounts { - EVM: EphemeralAccount; - Substrate: EphemeralAccount; - Stellar: EphemeralAccount; -} - -async function getPendulumNode(): Promise { - const apiManager = ApiManager.getInstance(); - const networkName = "pendulum"; - return await apiManager.getApi(networkName); -} - -async function getMoonbeamNode(): Promise { - const apiManager = ApiManager.getInstance(); - const networkName = "moonbeam"; - return await apiManager.getApi(networkName); -} - -async function getHydrationNode(): Promise { - const apiManager = ApiManager.getInstance(); - const networkName = "hydration"; - return await apiManager.getApi(networkName); -} - -export async function createSubstrateEphemeral(): Promise { - const seedPhrase = mnemonicGenerate(); - - const keyring = new Keyring({ type: "sr25519" }); - await new Promise(resolve => setTimeout(resolve, 1000)); - const ephemeralAccountKeypair = keyring.addFromUri(seedPhrase); - - return { address: ephemeralAccountKeypair.address, secret: seedPhrase }; -} - -export function createStellarEphemeral(): EphemeralAccount { - const ephemeralKeys = Keypair.random(); - const address = ephemeralKeys.publicKey(); - - return { address, secret: ephemeralKeys.secret() }; -} - -export async function createMoonbeamEphemeralSeed(): Promise { - const seedPhrase = mnemonicGenerate(); - const keyring = new Keyring({ type: "ethereum" }); - - const ephemeralAccountKeypair = keyring.addFromUri(`${seedPhrase}/m/44'/60'/${0}'/${0}/${0}`); - - return { address: ephemeralAccountKeypair.address, secret: seedPhrase }; -} - -const testSigningAccounts: TestSigningAccounts = { - EVM: await createMoonbeamEphemeralSeed(), - Substrate: await createSubstrateEphemeral(), - Stellar: createStellarEphemeral() -}; - -const testSigningAccountsMeta: AccountMeta[] = Object.keys(testSigningAccounts).map(networkKey => { - const address = testSigningAccounts[networkKey as keyof typeof testSigningAccounts].address; - const network = networkKey as EphemeralAccountType; - return { address, type: network }; -}); - -console.log("Test Signing Accounts:", testSigningAccountsMeta); - -let rampState: RampState; -let quoteTicket: QuoteTicket; - -// Proper Sequelize types -type RampStateUpdateData = Partial; -type QuoteTicketUpdateData = Partial; - -// Mock RampState.update - static method returns [affectedCount, affectedRows] -RampState.update = mock(async function (updateData: RampStateUpdateData) { - rampState = { ...rampState, ...updateData, updatedAt: new Date() } as RampState; - - fs.writeFileSync(filePath, JSON.stringify(rampState, null, 2)); - return rampState; -}) as unknown as typeof RampState.update; - -RampState.findByPk = mock(async (_id: string): Promise => { - return rampState; -}) as typeof RampState.findByPk; - -RampState.create = mock(async (data: RampStateCreationAttributes): Promise => { - rampState = { - ...data, - createdAt: new Date(), - id: data.id || "test-id", - reload: async function (_options?: UpdateOptions): Promise { - return rampState; - }, - update: async function ( - updateData: RampStateUpdateData, - _options?: UpdateOptions - ): Promise { - rampState = { ...rampState, ...updateData, updatedAt: new Date() } as RampState; - - fs.writeFileSync(filePath, JSON.stringify(rampState, null, 2)); - return rampState; - }, - updatedAt: new Date() - } as RampState; - - fs.writeFileSync(filePath, JSON.stringify(rampState, null, 2)); - return rampState; -}) as typeof RampState.create; - -QuoteTicket.findByPk = mock(async (_id: string): Promise => { - return quoteTicket; -}) as typeof QuoteTicket.findByPk; - -QuoteTicket.create = mock(async (data: QuoteTicketCreationAttributes): Promise => { - quoteTicket = { - ...data, - createdAt: new Date(), - id: data.id || "test-quote-id", - update: async function ( - updateData: QuoteTicketUpdateData, - _options?: UpdateOptions - ): Promise { - quoteTicket = { ...quoteTicket, ...updateData } as QuoteTicket; - return quoteTicket; - }, - updatedAt: new Date() - } as QuoteTicket; - - console.log("Created QuoteTicket:", quoteTicket); - return quoteTicket; -}) as typeof QuoteTicket.create; - -const mockSubaccountData: { wallets: { evm: string }; brCode: string } = { - brCode: "brcode123", - wallets: { - evm: EVM_DESTINATION_ADDRESS, - } -}; - -const mockBrlaApiService = { - acknowledgeEvents: mock(async (): Promise => Promise.resolve()), - createFastQuote: mock(async () => ({ basePrice: "100" })), - createSubaccount: mock(async () => ({ id: "subaccount123" })), - generateBrCode: mock(async () => ({ brCode: "brcode123" })), - getAllEventsByUser: mock(async () => []), - getOnChainHistoryOut: mock(async () => []), - getPayInHistory: mock(async () => []), - getSubaccount: mock(async (): Promise<{ wallets: { evm: string }; brCode: string }> => mockSubaccountData), - login: mock(async (): Promise => Promise.resolve()), - sendRequest: mock(async () => ({})), - swapRequest: mock(async () => ({ id: "swap123" })), - triggerOfframp: mock(async () => ({ id: "offramp123" })), - validatePixKey: mock(async () => ({ - bankName: "Test Bank", - name: "Test Receiver", - taxId: "758.444.017-77" - })) -}; - -const mockVerifyReferenceLabel = mock(async (reference: string, receiverAddress: string): Promise => { - console.log("Verifying reference label:", reference, receiverAddress); - return true; -}); - -mock.module("../brla/helpers", () => { - return { - verifyReferenceLabel: mockVerifyReferenceLabel - }; -}); - -BrlaApiService.getInstance = mock(() => mockBrlaApiService as unknown as BrlaApiService); - -RampService.prototype.validateBrlaOfframpRequest = mock(async (): Promise<{ wallets: { evm: string }; brCode: string }> => mockSubaccountData); - -RampRecoveryWorker.prototype.start = mock(async (): Promise => { - // do nothing -}); - -describe("PhaseProcessor Integration Test", () => { - it("should process an offramp (evm -> sepa) through multiple phases until completion", async () => { - try { - const rampService = new RampService(); - const quoteService = new QuoteService(); - - registerPhaseHandlers(); - - const quoteTicket = await quoteService.createQuote({ - from: QUOTE_FROM as DestinationType, - inputAmount: TEST_INPUT_AMOUNT, - inputCurrency: TEST_INPUT_CURRENCY, - network: Networks.Ethereum, // Offramp from EVM network - outputCurrency: TEST_OUTPUT_CURRENCY, - rampType: RampDirection.SELL, - to: QUOTE_TO as DestinationType - }); - - const additionalData: RegisterRampRequest["additionalData"] = { - paymentData: { - amount: new Big(quoteTicket.outputAmount).add(new Big(quoteTicket.totalFeeFiat)).toString(), - anchorTargetAccount: STELLAR_MOCK_ANCHOR_ACCOUNT, - memo: "1204asjfnaksf10982e4", - memoType: "text" as const - }, - pixDestination: "758.444.017-77", - receiverTaxId: "758.444.017-77", - taxId: "758.444.017-77", - walletAddress: EVM_TESTING_ADDRESS - }; - - const registeredRamp = await rampService.registerRamp({ - additionalData, - quoteId: quoteTicket.id, - signingAccounts: testSigningAccountsMeta - }); - - console.log("register ramp:", registeredRamp); - - const pendulumNode = await getPendulumNode(); - const moonbeamNode = await getMoonbeamNode(); - const hydrationNode = await getHydrationNode(); - - const presignedTxs = await signUnsignedTransactions( - registeredRamp?.unsignedTxs || [], - { - evmEphemeral: testSigningAccounts.EVM, - substrateEphemeral: testSigningAccounts.Substrate, - stellarEphemeral: testSigningAccounts.Stellar - }, - pendulumNode.api, - moonbeamNode.api, - hydrationNode.api - ); - console.log("Presigned transactions:", presignedTxs); - } catch (error: unknown) { - console.error("Error during test execution:", error); - - fs.writeFileSync(filePath, JSON.stringify(rampState, null, 2)); - throw error; - } - }); -}); diff --git a/apps/api/src/api/services/phases/phase-processor.onramp.integration.test.ts b/apps/api/src/api/services/phases/phase-processor.onramp.integration.test.ts index fc956cbe3..ed24757d1 100644 --- a/apps/api/src/api/services/phases/phase-processor.onramp.integration.test.ts +++ b/apps/api/src/api/services/phases/phase-processor.onramp.integration.test.ts @@ -18,7 +18,6 @@ import { } from "@vortexfi/shared"; import {Keyring} from "@polkadot/api"; import {mnemonicGenerate} from "@polkadot/util-crypto"; -import {Keypair} from "stellar-sdk"; import QuoteTicket from "../../../models/quoteTicket.model"; import RampState from "../../../models/rampState.model"; import {QuoteService} from "../quote"; @@ -70,13 +69,6 @@ export async function createSubstrateEphemeral(): Promise { return { address: ephemeralAccountKeypair.address, secret: seedPhrase }; } -export function createStellarEphemeral(): EphemeralAccount { - const ephemeralKeys = Keypair.random(); - const address = ephemeralKeys.publicKey(); - - return { address, secret: ephemeralKeys.secret() }; -} - // only for onramp.... export async function createMoonbeamEphemeralSeed() { const seedPhrase = mnemonicGenerate(); @@ -90,8 +82,7 @@ export async function createMoonbeamEphemeralSeed() { const testSigningAccounts = { moonbeam: await createMoonbeamEphemeralSeed(), - pendulum: await createSubstrateEphemeral(), - stellar: createStellarEphemeral() + pendulum: await createSubstrateEphemeral() }; // convert into AccountMeta @@ -221,8 +212,7 @@ describe("Onramp PhaseProcessor Integration Test", () => { registeredRamp?.unsignedTxs || [], { evmEphemeral: testSigningAccounts.moonbeam, - substrateEphemeral: testSigningAccounts.pendulum, - stellarEphemeral: testSigningAccounts.stellar + substrateEphemeral: testSigningAccounts.pendulum }, pendulumNode.api, moonbeamNode.api, diff --git a/apps/api/src/api/services/phases/post-process/base-chain-post-process-handler.ts b/apps/api/src/api/services/phases/post-process/base-chain-post-process-handler.ts index ab1edc4b4..a7f1b2548 100644 --- a/apps/api/src/api/services/phases/post-process/base-chain-post-process-handler.ts +++ b/apps/api/src/api/services/phases/post-process/base-chain-post-process-handler.ts @@ -6,7 +6,7 @@ import RampState from "../../../../models/rampState.model"; import { getEvmFundingAccount } from "../evm-funding"; import { BasePostProcessHandler } from "./base-post-process-handler"; -const BASE_CLEANUP_PHASES: CleanupPhase[] = ["baseCleanupBrla", "baseCleanupUsdc", "baseCleanupAxlUsdc"]; +const BASE_CLEANUP_PHASES: CleanupPhase[] = ["baseCleanupBrla", "baseCleanupUsdc", "baseCleanupEurc", "baseCleanupAxlUsdc"]; export class BaseChainPostProcessHandler extends BasePostProcessHandler { public getCleanupName(): CleanupPhase { diff --git a/apps/api/src/api/services/phases/post-process/index.ts b/apps/api/src/api/services/phases/post-process/index.ts index 95ecbd07b..997212013 100644 --- a/apps/api/src/api/services/phases/post-process/index.ts +++ b/apps/api/src/api/services/phases/post-process/index.ts @@ -5,13 +5,11 @@ import hydrationPostProcessHandler from "./hydration-post-process-handler"; import moonbeamPostProcessHandler from "./moonbeam-post-process-handler"; import pendulumPostProcessHandler from "./pendulum-post-process-handler"; import polygonPostProcessHandler from "./polygon-post-process-handler"; -import stellarPostProcessHandler from "./stellar-post-process-handler"; /** * All available post-process handlers */ const postProcessHandlers: BasePostProcessHandler[] = [ - stellarPostProcessHandler, pendulumPostProcessHandler, moonbeamPostProcessHandler, polygonPostProcessHandler, @@ -28,4 +26,3 @@ export { HydrationPostProcessHandler } from "./hydration-post-process-handler"; export { MoonbeamPostProcessHandler } from "./moonbeam-post-process-handler"; export { PendulumPostProcessHandler } from "./pendulum-post-process-handler"; export { PolygonPostProcessHandler } from "./polygon-post-process-handler"; -export { StellarPostProcessHandler } from "./stellar-post-process-handler"; diff --git a/apps/api/src/api/services/phases/post-process/stellar-post-process-handler.ts b/apps/api/src/api/services/phases/post-process/stellar-post-process-handler.ts deleted file mode 100644 index cb0701cf2..000000000 --- a/apps/api/src/api/services/phases/post-process/stellar-post-process-handler.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { CleanupPhase, FiatToken, HORIZON_URL, RampDirection } from "@vortexfi/shared"; -import { Horizon, NetworkError, Networks as StellarNetworks, Transaction } from "stellar-sdk"; -import logger from "../../../../config/logger"; -import { config } from "../../../../config/vars"; -import { SEQUENCE_TIME_WINDOWS } from "../../../../constants/constants"; -import QuoteTicket from "../../../../models/quoteTicket.model"; -import RampState from "../../../../models/rampState.model"; -import { isStellarNetworkError } from "../handlers/fund-ephemeral-handler"; -import { BasePostProcessHandler } from "./base-post-process-handler"; - -const NETWORK_PASSPHRASE = config.sandboxEnabled ? StellarNetworks.TESTNET : StellarNetworks.PUBLIC; - -const horizonServer = new Horizon.Server(HORIZON_URL); - -/** - * Post process handler for Stellar cleanup operations - */ -export class StellarPostProcessHandler extends BasePostProcessHandler { - /** - * Check if this handler should process the given state - */ - public shouldProcess(state: RampState): boolean { - if (state.currentPhase !== "complete") { - return false; - } - - if (state.type !== RampDirection.SELL || this.getPresignedTransaction(state, "stellarCleanup") === undefined) { - return false; - } - - return true; - } - - /** - * Get the name of the cleanup handler - */ - public getCleanupName(): CleanupPhase { - return "stellarCleanup"; - } - - /** - * Process the Stellar cleanup for the given state - * @returns A tuple with [success, error] where success is true if the process completed successfully, - * and error is null if successful or an Error if it failed - */ - public async process(state: RampState): Promise<[boolean, Error | null]> { - const quote = await QuoteTicket.findByPk(state.quoteId); - if (!quote) { - return [false, this.createErrorObject("Quote not found for the given state")]; - } - - if (quote.outputCurrency === FiatToken.BRL) { - return [false, this.createErrorObject("Stellar cleanup not needed for BRL offramps")]; - } - - try { - const expectedLedgerTimeMs = state.createdAt.getTime() + SEQUENCE_TIME_WINDOWS.FIRST_TX * 1.1 * 1000; // Add some safety margin in case ledger production was slower. - if (expectedLedgerTimeMs > Date.now()) { - return [false, this.createErrorObject(`Stellar cleanup for ramp state ${state.id} cannot be processed yet.`)]; - } - const { txData: stellarCleanupTransactionXDR } = this.getPresignedTransaction(state, "stellarCleanup"); - - const stellarCleanupTransactionTransaction = new Transaction(stellarCleanupTransactionXDR as string, NETWORK_PASSPHRASE); - await horizonServer.submitTransaction(stellarCleanupTransactionTransaction); - - logger.info(`Successfully processed Stellar cleanup for ramp state ${state.id}`); - return [true, null]; - } catch (e) { - try { - const horizonError = e as NetworkError; - if (isStellarNetworkError(horizonError) && 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]; - } - - 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))]; - } - } - } -} - -export default new StellarPostProcessHandler(); diff --git a/apps/api/src/api/services/phases/register-handlers.ts b/apps/api/src/api/services/phases/register-handlers.ts index 50ff490bf..eb48061c1 100644 --- a/apps/api/src/api/services/phases/register-handlers.ts +++ b/apps/api/src/api/services/phases/register-handlers.ts @@ -10,20 +10,18 @@ import fundEphemeralHandler from "./handlers/fund-ephemeral-handler"; import hydrationSwapHandler from "./handlers/hydration-swap-handler"; import hydrationToAssethubXcmPhaseHandler from "./handlers/hydration-to-assethub-xcm-phase-handler"; import initialPhaseHandler from "./handlers/initial-phase-handler"; -import moneriumOnrampMintPhaseHandler from "./handlers/monerium-onramp-mint-handler"; -import moneriumOnrampSelfTransferHandler from "./handlers/monerium-onramp-self-transfer-handler"; import moonbeamToPendulumPhaseHandler from "./handlers/moonbeam-to-pendulum-handler"; import moonbeamToPendulumXcmHandler from "./handlers/moonbeam-to-pendulum-xcm-handler"; +import mykoboOnrampDepositHandler from "./handlers/mykobo-onramp-deposit-handler"; +import mykoboPayoutHandler from "./handlers/mykobo-payout-handler"; import nablaApproveHandler from "./handlers/nabla-approve-handler"; import nablaSwapHandler from "./handlers/nabla-swap-handler"; import pendulumToAssethubPhaseHandler from "./handlers/pendulum-to-assethub-phase-handler"; import pendulumToHydrationXcmPhaseHandler from "./handlers/pendulum-to-hydration-xcm-phase-handler"; import pendulumToMoonbeamXcmHandler from "./handlers/pendulum-to-moonbeam-xcm-handler"; -import spacewalkRedeemHandler from "./handlers/spacewalk-redeem-handler"; import squidRouterPayPhaseHandler from "./handlers/squid-router-pay-phase-handler"; import squidRouterPhaseHandler from "./handlers/squid-router-phase-handler"; import squidRouterPermitExecutionHandler from "./handlers/squidrouter-permit-execution-handler"; -import stellarPaymentHandler from "./handlers/stellar-payment-handler"; import subsidizePostSwapPhaseHandler from "./handlers/subsidize-post-swap-handler"; import subsidizePreSwapPhaseHandler from "./handlers/subsidize-pre-swap-handler"; import phaseRegistry from "./phase-registry"; @@ -39,12 +37,12 @@ export function registerPhaseHandlers(): void { phaseRegistry.registerHandler(squidRouterPhaseHandler); phaseRegistry.registerHandler(nablaApproveHandler); phaseRegistry.registerHandler(nablaSwapHandler); - phaseRegistry.registerHandler(stellarPaymentHandler); - phaseRegistry.registerHandler(spacewalkRedeemHandler); phaseRegistry.registerHandler(subsidizePostSwapPhaseHandler); phaseRegistry.registerHandler(subsidizePreSwapPhaseHandler); phaseRegistry.registerHandler(moonbeamToPendulumPhaseHandler); phaseRegistry.registerHandler(brlaPayoutBaseHandler); + phaseRegistry.registerHandler(mykoboPayoutHandler); + phaseRegistry.registerHandler(mykoboOnrampDepositHandler); phaseRegistry.registerHandler(fundEphemeralHandler); phaseRegistry.registerHandler(alfredpayOnrampMintHandler); phaseRegistry.registerHandler(alfredpayOfframpTransferHandler); @@ -52,8 +50,6 @@ export function registerPhaseHandlers(): void { phaseRegistry.registerHandler(pendulumToAssethubPhaseHandler); phaseRegistry.registerHandler(squidRouterPayPhaseHandler); phaseRegistry.registerHandler(distributeFeesHandler); - phaseRegistry.registerHandler(moneriumOnrampSelfTransferHandler); - phaseRegistry.registerHandler(moneriumOnrampMintPhaseHandler); phaseRegistry.registerHandler(moonbeamToPendulumXcmHandler); phaseRegistry.registerHandler(pendulumToMoonbeamXcmHandler); phaseRegistry.registerHandler(pendulumToHydrationXcmPhaseHandler); diff --git a/apps/api/src/api/services/quote/core/nabla.ts b/apps/api/src/api/services/quote/core/nabla.ts index bc7c3edec..e96b9dd97 100644 --- a/apps/api/src/api/services/quote/core/nabla.ts +++ b/apps/api/src/api/services/quote/core/nabla.ts @@ -2,10 +2,9 @@ import { ApiManager, EvmClientManager, EvmTokenDetails, + getNablaBasePool, getTokenOutAmount, multiplyByPowerOfTen, - NABLA_QUOTER_BASE, - NABLA_ROUTER_BASE, Networks, PendulumTokenDetails, parseContractBalanceResponse, @@ -78,16 +77,14 @@ export async function calculateNablaSwapOutput(request: NablaSwapRequest): Promi } ]; + const inputAddr = inputTokenPendulumDetails.erc20WrapperAddress as `0x${string}`; + const outputAddr = outputTokenPendulumDetails.erc20WrapperAddress as `0x${string}`; + const { router } = getNablaBasePool(inputAddr, outputAddr); + const result = await evmClientManager.readContractWithRetry<[bigint, bigint]>(Networks.Base, { abi: swapAbi, - address: NABLA_ROUTER_BASE, - args: [ - BigInt(amountIn), - [ - inputTokenPendulumDetails.erc20WrapperAddress as `0x${string}`, - outputTokenPendulumDetails.erc20WrapperAddress as `0x${string}` - ] - ], + address: router, + args: [BigInt(amountIn), [inputAddr, outputAddr]], functionName: "getAmountOut" }); @@ -167,17 +164,14 @@ export async function calculateNablaSwapOutputEvm(request: NablaSwapEvmRequest): } ]; + const inputAddr = inputTokenDetails.erc20AddressSourceChain as `0x${string}`; + const outputAddr = outputTokenDetails.erc20AddressSourceChain as `0x${string}`; + const { router, quoter } = getNablaBasePool(inputAddr, outputAddr); + const result = await evmClientManager.readContractWithRetry(Networks.Base, { abi: swapAbi, - address: NABLA_QUOTER_BASE, - args: [ - BigInt(amountIn), - [ - inputTokenDetails.erc20AddressSourceChain as `0x${string}`, - outputTokenDetails.erc20AddressSourceChain as `0x${string}` - ], - [NABLA_ROUTER_BASE] - ], + address: quoter, + args: [BigInt(amountIn), [inputAddr, outputAddr], [router]], functionName: "quoteSwapExactTokensForTokens" }); diff --git a/apps/api/src/api/services/quote/core/quote-fees.ts b/apps/api/src/api/services/quote/core/quote-fees.ts index b68be0486..473caf5be 100644 --- a/apps/api/src/api/services/quote/core/quote-fees.ts +++ b/apps/api/src/api/services/quote/core/quote-fees.ts @@ -221,10 +221,6 @@ async function calculateAnchorFee( anchorIdentifier = "moonbeam_brla"; } else if (rampType === RampDirection.SELL && to === "pix") { anchorIdentifier = "moonbeam_brla"; - } else if (rampType === RampDirection.SELL && to === "sepa") { - anchorIdentifier = "stellar_eurc"; - } else if (rampType === RampDirection.SELL && to === "cbu") { - anchorIdentifier = "stellar_ars"; } const anchorFeeConfigs = await Anchor.findAll({ diff --git a/apps/api/src/api/services/quote/core/squidrouter.ts b/apps/api/src/api/services/quote/core/squidrouter.ts index 7089d3146..b23dfad46 100644 --- a/apps/api/src/api/services/quote/core/squidrouter.ts +++ b/apps/api/src/api/services/quote/core/squidrouter.ts @@ -114,26 +114,51 @@ function prepareSquidrouterRouteParams(params: { }); } -/** - * Helper to calculate Squidrouter network fee including GLMR price fetching and fallback. - * Works with both full routes and cached routes (which include the value field needed for calculation). - */ -async function calculateSquidrouterNetworkFee(route: SquidrouterRoute | SquidrouterCachedRoute): Promise { +// Squid swap gas is paid in the source chain's native token. The CoinGecko ID +// depends on the source network — using GLMR for everything would severely +// under-report the fee for non-Moonbeam routes (e.g. ETH on Base is ~30,000x +// more expensive than GLMR). +function getNativeTokenCoingeckoId(network: Networks): string { + switch (network) { + case Networks.Base: + case Networks.Arbitrum: + case Networks.Ethereum: + case Networks.BaseSepolia: + return "ethereum"; + case Networks.Polygon: + case Networks.PolygonAmoy: + return "polygon-ecosystem-token"; + case Networks.Avalanche: + return "avalanche-2"; + case Networks.BSC: + return "binancecoin"; + case Networks.Moonbeam: + return "moonbeam"; + default: + return "moonbeam"; + } +} + +async function calculateSquidrouterNetworkFee( + route: SquidrouterRoute | SquidrouterCachedRoute, + fromNetwork: Networks +): Promise { const squidRouterSwapValue = multiplyByPowerOfTen(Big(route.transactionRequest.value), -18); + const nativeTokenId = getNativeTokenCoingeckoId(fromNetwork); try { - // Get current GLMR price in USD from price feed service - const glmrPriceUSD = await priceFeedService.getCryptoPrice("moonbeam", "usd"); - const squidFeeUSD = squidRouterSwapValue.mul(glmrPriceUSD).toFixed(6); - logger.debug(`Network fee calculated using GLMR price: $${glmrPriceUSD}, fee: $${squidFeeUSD}`); + const nativePriceUSD = await priceFeedService.getCryptoPrice(nativeTokenId, "usd"); + const squidFeeUSD = squidRouterSwapValue.mul(nativePriceUSD).toFixed(6); + logger.debug(`Network fee calculated using ${nativeTokenId} price: $${nativePriceUSD}, fee: $${squidFeeUSD}`); return squidFeeUSD; } catch (error) { - // If price feed fails, log the error and use a fallback price - logger.error(`Failed to get GLMR price, using fallback: ${error instanceof Error ? error.message : "Unknown error"}`); - // Fallback to previous hardcoded value as safety measure - const fallbackGlmrPrice = 0.08; - const squidFeeUSD = squidRouterSwapValue.mul(fallbackGlmrPrice).toFixed(6); - logger.warn(`Using fallback GLMR price: $${fallbackGlmrPrice}, fee: $${squidFeeUSD}`); + logger.error( + `Failed to get ${nativeTokenId} price, using fallback: ${error instanceof Error ? error.message : "Unknown error"}` + ); + // Conservative per-chain fallback so we never silently report ~$0 for ETH-priced chains. + const fallbackPriceUSD = nativeTokenId === "ethereum" ? 2500 : nativeTokenId === "polygon-ecosystem-token" ? 0.5 : 0.08; + const squidFeeUSD = squidRouterSwapValue.mul(fallbackPriceUSD).toFixed(6); + logger.warn(`Using fallback ${nativeTokenId} price: $${fallbackPriceUSD}, fee: $${squidFeeUSD}`); return squidFeeUSD; } } @@ -170,7 +195,7 @@ function buildRouteRequest(request: EvmBridgeQuoteRequest) { }); } -async function getSquidrouterRouteData(routeParams: RouteParams) { +async function getSquidrouterRouteData(routeParams: RouteParams, fromNetwork: Networks) { const routeResult = await getRoute(routeParams, { useCache: true }); if (!routeResult?.data?.route?.estimate) { @@ -184,7 +209,7 @@ async function getSquidrouterRouteData(routeParams: RouteParams) { const outputTokenDecimals = routeData.route.estimate.toToken.decimals; const outputAmountRaw = routeData.route.estimate.toAmount; const outputAmountDecimal = parseContractBalanceResponse(outputTokenDecimals, BigInt(outputAmountRaw)).preciseBigDecimal; - const networkFeeUSD = await calculateSquidrouterNetworkFee(routeData.route); + const networkFeeUSD = await calculateSquidrouterNetworkFee(routeData.route, fromNetwork); return { fromToken: routeParams.fromToken, @@ -216,7 +241,7 @@ export async function calculateEvmBridgeAndNetworkFee(request: EvmBridgeRequest) }); // Execute Squidrouter route and validate response - const { networkFeeUSD, routeData, outputTokenDecimals } = await getSquidrouterRouteData(routeParams); + const { networkFeeUSD, routeData, outputTokenDecimals } = await getSquidrouterRouteData(routeParams, fromNetwork); // Calculate network fee (Squidrouter fee) // Parse final gross output amount @@ -261,5 +286,5 @@ export async function calculateEvmBridgeAndNetworkFee(request: EvmBridgeRequest) export async function getEvmBridgeQuote(request: EvmBridgeQuoteRequest) { const routeParams = buildRouteRequest(request); - return getSquidrouterRouteData(routeParams); + return getSquidrouterRouteData(routeParams, request.fromNetwork); } diff --git a/apps/api/src/api/services/quote/core/types.ts b/apps/api/src/api/services/quote/core/types.ts index a0b98a0e4..523ed37d4 100644 --- a/apps/api/src/api/services/quote/core/types.ts +++ b/apps/api/src/api/services/quote/core/types.ts @@ -63,15 +63,6 @@ export interface XcmMeta { xcmFees: XcmFees; } -export interface StellarMeta { - inputAmountDecimal: Big; - inputAmountRaw: string; - outputAmountDecimal: Big; - outputAmountRaw: string; - fee: Big; - currency: RampCurrency; -} - // Partner info shared type export interface PartnerInfo { id: string | null; @@ -159,16 +150,18 @@ export interface QuoteContext { slippagePercent: number; }; - moneriumMint?: { + alfredpayMint?: { inputAmountDecimal: Big; inputAmountRaw: string; outputAmountDecimal: Big; outputAmountRaw: string; fee: Big; currency: RampCurrency; + quoteId: string; + expirationDate: Date; }; - alfredpayMint?: { + alfredpayOfframp?: { inputAmountDecimal: Big; inputAmountRaw: string; outputAmountDecimal: Big; @@ -179,18 +172,16 @@ export interface QuoteContext { expirationDate: Date; }; - alfredpayOfframp?: { + aveniaMint?: { inputAmountDecimal: Big; inputAmountRaw: string; outputAmountDecimal: Big; outputAmountRaw: string; fee: Big; currency: RampCurrency; - quoteId: string; - expirationDate: Date; }; - aveniaMint?: { + aveniaTransfer?: { inputAmountDecimal: Big; inputAmountRaw: string; outputAmountDecimal: Big; @@ -199,7 +190,7 @@ export interface QuoteContext { currency: RampCurrency; }; - aveniaTransfer?: { + mykoboMint?: { inputAmountDecimal: Big; inputAmountRaw: string; outputAmountDecimal: Big; @@ -228,8 +219,6 @@ export interface QuoteContext { pendulumToMoonbeamXcm?: XcmMeta; - pendulumToStellar?: StellarMeta; - // Fees in baseline and display currency fees?: { // Baseline normalization currency: USD diff --git a/apps/api/src/api/services/quote/engines/discount/helpers.test.ts b/apps/api/src/api/services/quote/engines/discount/helpers.test.ts new file mode 100644 index 000000000..549ac5b9d --- /dev/null +++ b/apps/api/src/api/services/quote/engines/discount/helpers.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "bun:test"; +import Big from "big.js"; +import { calculateSubsidyAmount } from "./helpers"; + +describe("calculateSubsidyAmount", () => { + it("returns 0 when actual output meets expected output", () => { + const result = calculateSubsidyAmount(new Big(100), new Big(100), 0); + expect(result.toString()).toBe("0"); + }); + + it("returns 0 when actual output exceeds expected output", () => { + const result = calculateSubsidyAmount(new Big(100), new Big(110), 0); + expect(result.toString()).toBe("0"); + }); + + it("returns full shortfall when no maxSubsidy cap", () => { + const result = calculateSubsidyAmount(new Big(100), new Big(90), 0); + expect(result.toString()).toBe("10"); + }); + + it("caps subsidy at maxSubsidy fraction of expected output", () => { + const result = calculateSubsidyAmount(new Big(100), new Big(80), 0.05); + // shortfall=20, maxAllowed=100*0.05=5 + expect(result.toString()).toBe("5"); + }); + + it("returns full shortfall when it is less than maxSubsidy cap", () => { + const result = calculateSubsidyAmount(new Big(100), new Big(95), 0.1); + // shortfall=5, maxAllowed=100*0.1=10 + expect(result.toString()).toBe("5"); + }); + + describe("negative targetDiscount scenarios (rate floor)", () => { + it("subsidizes when actual is below negative-target expected output", () => { + const result = calculateSubsidyAmount(new Big(99.9), new Big(99.5), 0); + expect(result.toString()).toBe("0.4"); + }); + + it("returns 0 when actual already meets negative-target expected output", () => { + const result = calculateSubsidyAmount(new Big(99.9), new Big(99.9), 0); + expect(result.toString()).toBe("0"); + }); + + it("returns 0 when actual exceeds negative-target expected output", () => { + const result = calculateSubsidyAmount(new Big(99.9), new Big(100.5), 0); + expect(result.toString()).toBe("0"); + }); + + it("caps subsidy at maxSubsidy for negative target", () => { + const result = calculateSubsidyAmount(new Big(99.9), new Big(98.0), 0.01); + // shortfall=1.9, maxAllowed=99.9*0.01=0.999 + expect(result.toString()).toBe("0.999"); + }); + }); +}); diff --git a/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts b/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts index cff901312..b1ac53a0e 100644 --- a/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts @@ -68,7 +68,7 @@ export class OffRampAlfredpayDiscountEngine extends BaseDiscountEngine { const idealSubsidyDecimal = expectedOutputDecimal.gt(finalOutput) ? expectedOutputDecimal.minus(finalOutput) : new Big(0); const actualSubsidyDecimal = - targetDiscount > 0 ? calculateSubsidyAmount(expectedOutputDecimal, finalOutput, maxSubsidy) : new Big(0); + targetDiscount !== 0 ? calculateSubsidyAmount(expectedOutputDecimal, finalOutput, maxSubsidy) : new Big(0); const targetOutputDecimal = finalOutput.plus(actualSubsidyDecimal); diff --git a/apps/api/src/api/services/quote/engines/discount/offramp.ts b/apps/api/src/api/services/quote/engines/discount/offramp.ts index 5f3eedb4f..aa6e7b547 100644 --- a/apps/api/src/api/services/quote/engines/discount/offramp.ts +++ b/apps/api/src/api/services/quote/engines/discount/offramp.ts @@ -74,7 +74,7 @@ export class OffRampDiscountEngine extends BaseDiscountEngine { // Calculate actual subsidy (capped by maxSubsidy) const actualSubsidyAmountDecimal = - targetDiscount > 0 + targetDiscount !== 0 ? calculateSubsidyAmount(adjustedExpectedOutputDecimal, actualOutputAmountDecimal, maxSubsidy) : Big(0); const actualSubsidyAmountRaw = multiplyByPowerOfTen(actualSubsidyAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0); diff --git a/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts b/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts index 1e4d20c7f..54962943c 100644 --- a/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts @@ -51,7 +51,7 @@ export class OnRampAlfredpayDiscountEngine extends BaseDiscountEngine { const idealSubsidyDecimal = expectedOutputDecimal.gt(finalOutput) ? expectedOutputDecimal.minus(finalOutput) : new Big(0); const actualSubsidyDecimal = - targetDiscount > 0 ? calculateSubsidyAmount(expectedOutputDecimal, finalOutput, maxSubsidy) : new Big(0); + targetDiscount !== 0 ? calculateSubsidyAmount(expectedOutputDecimal, finalOutput, maxSubsidy) : new Big(0); const targetOutputDecimal = finalOutput.plus(actualSubsidyDecimal); diff --git a/apps/api/src/api/services/quote/engines/discount/onramp.ts b/apps/api/src/api/services/quote/engines/discount/onramp.ts index 3be9689aa..8b0d5b8a3 100644 --- a/apps/api/src/api/services/quote/engines/discount/onramp.ts +++ b/apps/api/src/api/services/quote/engines/discount/onramp.ts @@ -10,6 +10,7 @@ import Big from "big.js"; import logger from "../../../../../config/logger"; import { getEvmBridgeQuote } from "../../core/squidrouter"; import { QuoteContext } from "../../core/types"; +import { isBrlToBrlaBaseDirect, isEurToEurcBaseDirect } from "../../utils"; import { BaseDiscountEngine, DiscountComputation } from "."; import { calculateExpectedOutput, calculateSubsidyAmount, resolveDiscountPartner } from "./helpers"; @@ -26,6 +27,12 @@ export class OnRampDiscountEngine extends BaseDiscountEngine { throw new Error("OnRampDiscountEngine requires either nablaSwap or nablaSwapEvm to be defined"); } + // Direct fiat->own-stablecoin passthrough (EUR->EURC, BRL->BRLA on Base) is 1:1: the nabla + // engine intentionally skips the oracle, so don't require oraclePrice here. + if (isFiatToOwnStablecoinDirect(ctx)) { + return; + } + const nablaSwap = ctx.nablaSwap || ctx.nablaSwapEvm; if (!nablaSwap?.oraclePrice) { throw new Error("OnRampDiscountEngine requires nablaSwap.oraclePrice to be defined"); @@ -154,6 +161,10 @@ export class OnRampDiscountEngine extends BaseDiscountEngine { } protected async compute(ctx: QuoteContext): Promise { + if (isFiatToOwnStablecoinDirect(ctx)) { + return buildPassthroughDiscountComputation(ctx); + } + // Determine which nabla swap we're using (Base EVM or Pendulum) const isBaseFlow = !!ctx.nablaSwapEvm; const nablaSwap = ctx.nablaSwapEvm || ctx.nablaSwap!; @@ -207,7 +218,7 @@ export class OnRampDiscountEngine extends BaseDiscountEngine { // Calculate actual subsidy (capped by maxSubsidy) const actualSubsidyAmountDecimal = - targetDiscount > 0 + targetDiscount !== 0 ? calculateSubsidyAmount(adjustedExpectedOutputDecimal, actualOutputAmountDecimal, maxSubsidy) : Big(0); const actualSubsidyAmountRaw = multiplyByPowerOfTen(actualSubsidyAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0); @@ -237,3 +248,33 @@ export class OnRampDiscountEngine extends BaseDiscountEngine { }; } } + +function isFiatToOwnStablecoinDirect(ctx: QuoteContext): boolean { + const { inputCurrency, outputCurrency, to } = ctx.request; + return isEurToEurcBaseDirect(inputCurrency, outputCurrency, to) || isBrlToBrlaBaseDirect(inputCurrency, outputCurrency, to); +} + +function buildPassthroughDiscountComputation(ctx: QuoteContext): DiscountComputation { + // biome-ignore lint/style/noNonNullAssertion: validate() guarantees one nabla swap is set + const nablaSwap = ctx.nablaSwapEvm || ctx.nablaSwap!; + const outputAmountDecimal = nablaSwap.outputAmountDecimal; + const outputAmountRaw = multiplyByPowerOfTen(outputAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0); + const zero = new Big(0); + + return { + actualOutputAmountDecimal: outputAmountDecimal, + actualOutputAmountRaw: outputAmountRaw, + adjustedDifference: zero, + adjustedTargetDiscount: zero, + expectedOutputAmountDecimal: outputAmountDecimal, + expectedOutputAmountRaw: outputAmountRaw, + idealSubsidyAmountInOutputTokenDecimal: zero, + idealSubsidyAmountInOutputTokenRaw: "0", + partnerId: null, + subsidyAmountInOutputTokenDecimal: zero, + subsidyAmountInOutputTokenRaw: "0", + subsidyRate: zero, + targetOutputAmountDecimal: outputAmountDecimal, + targetOutputAmountRaw: outputAmountRaw + }; +} diff --git a/apps/api/src/api/services/quote/engines/fee/offramp-mykobo.ts b/apps/api/src/api/services/quote/engines/fee/offramp-mykobo.ts new file mode 100644 index 000000000..73f5aa709 --- /dev/null +++ b/apps/api/src/api/services/quote/engines/fee/offramp-mykobo.ts @@ -0,0 +1,29 @@ +import { EvmToken, FiatToken, MykoboApiService, RampCurrency, RampDirection } from "@vortexfi/shared"; +import { QuoteContext } from "../../core/types"; +import { BaseFeeEngine, FeeComputation, FeeConfig } from "./index"; + +export class OffRampFeeMykoboEngine extends BaseFeeEngine { + readonly config: FeeConfig = { + direction: RampDirection.SELL, + skipNote: "Skipped for on-ramp request" + }; + + protected validate(ctx: QuoteContext): void { + if (!ctx.nablaSwapEvm) { + throw new Error("OffRampFeeMykoboEngine requires nablaSwapEvm in context"); + } + } + + protected async compute(ctx: QuoteContext, _anchorFee: string, _feeCurrency: RampCurrency): Promise { + // biome-ignore lint/style/noNonNullAssertion: validated above + const swapOutputEurc = ctx.nablaSwapEvm!.outputAmountDecimal.toFixed(2, 0); + + const mykoboFee = await MykoboApiService.getInstance().defaultWithdrawFee(swapOutputEurc); + const anchorFeeCurrency = FiatToken.EURC as RampCurrency; + + return { + anchor: { amount: mykoboFee.total, currency: anchorFeeCurrency }, + network: { amount: "0", currency: EvmToken.USDC as RampCurrency } + }; + } +} diff --git a/apps/api/src/api/services/quote/engines/fee/offramp-stellar.ts b/apps/api/src/api/services/quote/engines/fee/offramp-stellar.ts deleted file mode 100644 index 1431550ff..000000000 --- a/apps/api/src/api/services/quote/engines/fee/offramp-stellar.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { EvmToken, RampCurrency, RampDirection } from "@vortexfi/shared"; -import { QuoteContext } from "../../core/types"; -import { BaseFeeEngine, FeeComputation, FeeConfig } from "./index"; - -export class OffRampFeeStellarEngine extends BaseFeeEngine { - readonly config: FeeConfig = { - direction: RampDirection.SELL, - skipNote: "Skipped for on-ramp request" - }; - - protected validate(ctx: QuoteContext): void { - // No specific validation needed - } - - protected async compute(ctx: QuoteContext, anchorFee: string, feeCurrency: RampCurrency): Promise { - return { - anchor: { amount: anchorFee, currency: feeCurrency }, - network: { amount: "0", currency: EvmToken.USDC as RampCurrency } - }; - } -} diff --git a/apps/api/src/api/services/quote/engines/fee/onramp-monerium-to-assethub.ts b/apps/api/src/api/services/quote/engines/fee/onramp-monerium-to-assethub.ts deleted file mode 100644 index 96947b153..000000000 --- a/apps/api/src/api/services/quote/engines/fee/onramp-monerium-to-assethub.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { EvmToken, RampCurrency, RampDirection } from "@vortexfi/shared"; -import { QuoteContext } from "../../core/types"; -import { BaseFeeEngine, FeeComputation, FeeConfig } from "./index"; - -export class OnRampMoneriumToAssethubFeeEngine extends BaseFeeEngine { - readonly config: FeeConfig = { - direction: RampDirection.BUY, - skipNote: "Skipped for off-ramp request" - }; - - protected validate(ctx: QuoteContext): void { - if (!ctx.evmToMoonbeam) { - throw new Error("OnRampMoneriumToAssethubFeeEngine: evmToMoonbeam quote data is required"); - } - } - - protected async compute(ctx: QuoteContext, anchorFee: string, feeCurrency: RampCurrency): Promise { - return { - anchor: { amount: anchorFee, currency: feeCurrency }, - // biome-ignore lint/style/noNonNullAssertion: Context is validated in `validate` - network: { amount: ctx.evmToMoonbeam!.networkFeeUSD, currency: EvmToken.USDC as RampCurrency } - }; - } -} diff --git a/apps/api/src/api/services/quote/engines/fee/onramp-monerium-to-evm.ts b/apps/api/src/api/services/quote/engines/fee/onramp-monerium-to-evm.ts deleted file mode 100644 index d3fe2f467..000000000 --- a/apps/api/src/api/services/quote/engines/fee/onramp-monerium-to-evm.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { EvmToken, FiatToken, RampCurrency, RampDirection } from "@vortexfi/shared"; -import { QuoteContext } from "../../core/types"; -import { BaseFeeEngine, FeeComputation, FeeConfig } from "./index"; - -export class OnRampMoneriumToEvmFeeEngine extends BaseFeeEngine { - readonly config: FeeConfig = { - direction: RampDirection.BUY, - skipNote: "Skipped for off-ramp request" - }; - - protected validate(ctx: QuoteContext): void { - // No specific validation needed - } - - protected async compute(ctx: QuoteContext, anchorFee: string, feeCurrency: RampCurrency): Promise { - // For this specific engine, no fees are applied, so we return zero amounts for all fee components - return { - anchor: { amount: "0", currency: FiatToken.EURC as RampCurrency }, - forcedPartnerMarkupFee: { amount: "0", currency: feeCurrency }, - forcedVortexFee: { amount: "0", currency: feeCurrency }, - network: { amount: "0", currency: EvmToken.USDC as RampCurrency } - }; - } -} diff --git a/apps/api/src/api/services/quote/engines/fee/onramp-mykobo-to-evm.ts b/apps/api/src/api/services/quote/engines/fee/onramp-mykobo-to-evm.ts new file mode 100644 index 000000000..f0cc3d81a --- /dev/null +++ b/apps/api/src/api/services/quote/engines/fee/onramp-mykobo-to-evm.ts @@ -0,0 +1,84 @@ +import { + EvmNetworks, + EvmToken, + evmTokenConfig, + getNetworkFromDestination, + isNetworkEVM, + multiplyByPowerOfTen, + Networks, + OnChainToken, + RampCurrency, + RampDirection +} from "@vortexfi/shared"; +import { calculateEvmBridgeAndNetworkFee, getTokenDetailsForEvmDestination } from "../../core/squidrouter"; +import { QuoteContext } from "../../core/types"; +import { BaseFeeEngine, FeeComputation, FeeConfig } from "./index"; + +export class OnRampMykoboToEvmFeeEngine extends BaseFeeEngine { + readonly config: FeeConfig = { + direction: RampDirection.BUY, + skipNote: "Skipped for off-ramp request" + }; + + constructor( + private readonly fromNetwork: Networks, + private readonly fromToken: EvmToken + ) { + super(); + if (!isNetworkEVM(fromNetwork)) { + throw new Error(`OnRampMykoboToEvmFeeEngine: ${fromNetwork} is not an EVM network`); + } + } + + protected validate(ctx: QuoteContext): void { + if (!ctx.mykoboMint) { + throw new Error("OnRampMykoboToEvmFeeEngine requires mykoboMint in context"); + } + } + + protected async compute(ctx: QuoteContext, _anchorFee: string, _feeCurrency: RampCurrency): Promise { + const { request } = ctx; + + // biome-ignore lint/style/noNonNullAssertion: Context is validated in `validate` + const computedAnchorFee = ctx.mykoboMint!.fee.toString(); + // biome-ignore lint/style/noNonNullAssertion: Context is validated in `validate` + const anchorFeeCurrency = ctx.mykoboMint!.currency as RampCurrency; + + const toNetwork = getNetworkFromDestination(request.to); + if (!toNetwork) { + throw new Error(`OnRampMykoboToEvmFeeEngine: invalid network for destination: ${request.to}`); + } + + const toToken = getTokenDetailsForEvmDestination(request.outputCurrency as OnChainToken, toNetwork).erc20AddressSourceChain; + + const swapNetwork = this.fromNetwork as EvmNetworks; + const fromTokenDetails = evmTokenConfig[swapNetwork]?.[this.fromToken]; + if (!fromTokenDetails) { + throw new Error(`OnRampMykoboToEvmFeeEngine: invalid token configuration for ${this.fromToken} on ${swapNetwork}`); + } + + if (swapNetwork === toNetwork && fromTokenDetails.erc20AddressSourceChain.toLowerCase() === toToken.toLowerCase()) { + return { + anchor: { amount: computedAnchorFee, currency: anchorFeeCurrency }, + network: { amount: "0", currency: "USD" as RampCurrency } + }; + } + + const amountRaw = multiplyByPowerOfTen(request.inputAmount, fromTokenDetails.decimals).toFixed(0, 0); + + const bridgeResult = await calculateEvmBridgeAndNetworkFee({ + amountRaw, + fromNetwork: swapNetwork, + fromToken: fromTokenDetails.erc20AddressSourceChain, + originalInputAmountForRateCalc: request.inputAmount, + rampType: request.rampType, + toNetwork, + toToken + }); + + return { + anchor: { amount: computedAnchorFee, currency: anchorFeeCurrency }, + network: { amount: bridgeResult.networkFeeUSD, currency: "USD" as RampCurrency } + }; + } +} diff --git a/apps/api/src/api/services/quote/engines/finalize/offramp.ts b/apps/api/src/api/services/quote/engines/finalize/offramp.ts index d881bc31e..c3adfea42 100644 --- a/apps/api/src/api/services/quote/engines/finalize/offramp.ts +++ b/apps/api/src/api/services/quote/engines/finalize/offramp.ts @@ -17,20 +17,21 @@ export class OffRampFinalizeEngine extends BaseFinalizeEngine { const offrampAmount = ctx.request.to === "pix" ? (ctx.nablaSwapEvm?.outputAmountDecimal ?? ctx.pendulumToMoonbeamXcm?.outputAmountDecimal) - : ctx.alfredpayOfframp - ? ctx.alfredpayOfframp.outputAmountDecimal - : ctx.pendulumToStellar?.outputAmountDecimal; + : ctx.request.to === "sepa" + ? ctx.nablaSwapEvm?.outputAmountDecimal + : ctx.alfredpayOfframp + ? ctx.alfredpayOfframp.outputAmountDecimal + : undefined; if (!offrampAmount) { throw new APIError({ - message: - "OffRampFinalizeEngine requires nablaSwapEvm, pendulumToMoonbeamXcm, alfredpayOfframp or pendulumToStellar output", + message: "OffRampFinalizeEngine requires nablaSwapEvm, pendulumToMoonbeamXcm or alfredpayOfframp output", status: httpStatus.INTERNAL_SERVER_ERROR }); } // AlfredPay's toAmount is already net-of-fees, so no fee subtraction needed. - // For other providers (Stellar, BRLA), the anchor fee must still be subtracted. + // For other providers (e.g. BRLA), the anchor fee must still be subtracted. const isAlfredpay = !!ctx.alfredpayOfframp; let amount: Big; diff --git a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts index 0e1fe432e..436ae79ed 100644 --- a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts @@ -4,17 +4,11 @@ import { EvmBridgeQuoteRequest, getEvmBridgeQuote } from "../../core/squidrouter import { QuoteContext } from "../../core/types"; import { assignPreNablaContext, BaseInitializeEngine } from "./index"; -export class OffRampFromEvmInitializeEngine extends BaseInitializeEngine { - private readonly network: Networks; - - constructor(network: Networks) { - super(); - this.network = network; - } - +export class OffRampFromEvmInitializeAlfredpayEngine extends BaseInitializeEngine { readonly config = { direction: RampDirection.SELL, - skipNote: "OffRampFromEvmInitializeEngine: Skipped because rampType is BUY, this engine handles SELL operations only" + skipNote: + "OffRampFromEvmInitializeAlfredpayEngine: Skipped because rampType is BUY, this engine handles SELL operations only" }; protected async executeInternal(ctx: QuoteContext): Promise { @@ -28,7 +22,7 @@ export class OffRampFromEvmInitializeEngine extends BaseInitializeEngine { inputCurrency: req.inputCurrency as OnChainToken, outputCurrency: ALFREDPAY_EVM_TOKEN, rampType: req.rampType, - toNetwork: this.network + toNetwork: Networks.Polygon }; const bridgeQuote = await getEvmBridgeQuote(quoteRequest); diff --git a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-avenia.ts b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-avenia.ts new file mode 100644 index 000000000..450eac9b2 --- /dev/null +++ b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-avenia.ts @@ -0,0 +1,44 @@ +import { EvmToken, Networks, OnChainToken, RampDirection } from "@vortexfi/shared"; +import Big from "big.js"; +import { EvmBridgeQuoteRequest, getEvmBridgeQuote } from "../../core/squidrouter"; +import { QuoteContext } from "../../core/types"; +import { assignPreNablaContext, BaseInitializeEngine } from "./index"; + +export class OffRampFromEvmInitializeAveniaEngine extends BaseInitializeEngine { + readonly config = { + direction: RampDirection.SELL, + skipNote: "OffRampFromEvmInitializeAveniaEngine: Skipped because rampType is BUY, this engine handles SELL operations only" + }; + + protected async executeInternal(ctx: QuoteContext): Promise { + const req = ctx.request; + + await assignPreNablaContext(ctx); + + const quoteRequest: EvmBridgeQuoteRequest = { + amountDecimal: req.inputAmount, + fromNetwork: req.from as Networks, + inputCurrency: req.inputCurrency as OnChainToken, + outputCurrency: EvmToken.USDC, + rampType: req.rampType, + toNetwork: Networks.Base + }; + + const bridgeQuote = await getEvmBridgeQuote(quoteRequest); + + ctx.evmToEvm = { + ...quoteRequest, + fromToken: bridgeQuote.fromToken, + inputAmountDecimal: Big(quoteRequest.amountDecimal), + inputAmountRaw: bridgeQuote.inputAmountRaw, + networkFeeUSD: bridgeQuote.networkFeeUSD, + outputAmountDecimal: bridgeQuote.outputAmountDecimal, + outputAmountRaw: bridgeQuote.outputAmountRaw, + toToken: bridgeQuote.toToken + }; + + ctx.addNote?.( + `Initialized: input=${req.inputAmount} ${req.inputCurrency}, raw=${ctx.evmToEvm?.inputAmountRaw}, output=${ctx.evmToEvm?.outputAmountDecimal.toString()} ${ctx.evmToEvm?.toToken}, raw=${ctx.evmToEvm?.outputAmountRaw}` + ); + } +} diff --git a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-mykobo.ts b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-mykobo.ts new file mode 100644 index 000000000..6f9fb8117 --- /dev/null +++ b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-mykobo.ts @@ -0,0 +1,44 @@ +import { EvmToken, Networks, OnChainToken, RampDirection } from "@vortexfi/shared"; +import Big from "big.js"; +import { EvmBridgeQuoteRequest, getEvmBridgeQuote } from "../../core/squidrouter"; +import { QuoteContext } from "../../core/types"; +import { assignPreNablaContext, BaseInitializeEngine } from "./index"; + +export class OffRampFromEvmInitializeMykoboEngine extends BaseInitializeEngine { + readonly config = { + direction: RampDirection.SELL, + skipNote: "OffRampFromEvmInitializeMykoboEngine: Skipped because rampType is BUY, this engine handles SELL operations only" + }; + + protected async executeInternal(ctx: QuoteContext): Promise { + const req = ctx.request; + + await assignPreNablaContext(ctx); + + const quoteRequest: EvmBridgeQuoteRequest = { + amountDecimal: req.inputAmount, + fromNetwork: req.from as Networks, + inputCurrency: req.inputCurrency as OnChainToken, + outputCurrency: EvmToken.USDC, + rampType: req.rampType, + toNetwork: Networks.Base + }; + + const bridgeQuote = await getEvmBridgeQuote(quoteRequest); + + ctx.evmToEvm = { + ...quoteRequest, + fromToken: bridgeQuote.fromToken, + inputAmountDecimal: Big(quoteRequest.amountDecimal), + inputAmountRaw: bridgeQuote.inputAmountRaw, + networkFeeUSD: bridgeQuote.networkFeeUSD, + outputAmountDecimal: bridgeQuote.outputAmountDecimal, + outputAmountRaw: bridgeQuote.outputAmountRaw, + toToken: bridgeQuote.toToken + }; + + ctx.addNote?.( + `Initialized: input=${req.inputAmount} ${req.inputCurrency}, raw=${ctx.evmToEvm?.inputAmountRaw}, output=${ctx.evmToEvm?.outputAmountDecimal.toString()} ${ctx.evmToEvm?.toToken}, raw=${ctx.evmToEvm?.outputAmountRaw}` + ); + } +} diff --git a/apps/api/src/api/services/quote/engines/initialize/onramp-monerium.ts b/apps/api/src/api/services/quote/engines/initialize/onramp-monerium.ts deleted file mode 100644 index 3f1617050..000000000 --- a/apps/api/src/api/services/quote/engines/initialize/onramp-monerium.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { ERC20_EURE_POLYGON_DECIMALS, multiplyByPowerOfTen, RampDirection } from "@vortexfi/shared"; -import Big from "big.js"; -import { QuoteContext } from "../../core/types"; -import { BaseInitializeEngine } from "./index"; - -export class OnRampInitializeMoneriumEngine extends BaseInitializeEngine { - readonly config = { - direction: RampDirection.BUY, - skipNote: "OnRampInitializeMoneriumEngine: Skipped because rampType is SELL, this engine handles BUY operations only" - }; - - protected async executeInternal(ctx: QuoteContext): Promise { - const req = ctx.request; - - // Calculate Monerium input and output (of minting/deposit) - const eurTokenDecimals = ERC20_EURE_POLYGON_DECIMALS; - const inputAmountDecimal = new Big(req.inputAmount); - const inputAmountRaw = multiplyByPowerOfTen(inputAmountDecimal, eurTokenDecimals).toFixed(0, 0); - const moneriumFee = Big(0); - const outputAmountDecimal = inputAmountDecimal.minus(moneriumFee); - const outputAmountRaw = multiplyByPowerOfTen(outputAmountDecimal, eurTokenDecimals).toFixed(0, 0); - - ctx.moneriumMint = { - currency: ctx.request.inputCurrency, - fee: moneriumFee, - inputAmountDecimal: inputAmountDecimal, - inputAmountRaw: inputAmountRaw, - outputAmountDecimal, - outputAmountRaw - }; - - ctx.addNote?.( - `Initialized: ${inputAmountDecimal.toString()} ${req.inputCurrency} -> ${outputAmountDecimal.toString()} ${req.outputCurrency} (fee: ${moneriumFee.toString()})` - ); - } -} diff --git a/apps/api/src/api/services/quote/engines/initialize/onramp-mykobo.ts b/apps/api/src/api/services/quote/engines/initialize/onramp-mykobo.ts new file mode 100644 index 000000000..e19f692ef --- /dev/null +++ b/apps/api/src/api/services/quote/engines/initialize/onramp-mykobo.ts @@ -0,0 +1,55 @@ +import { + EvmToken, + FiatToken, + getOnChainTokenDetails, + MykoboApiService, + multiplyByPowerOfTen, + Networks, + RampDirection +} from "@vortexfi/shared"; +import Big from "big.js"; +import { QuoteContext } from "../../core/types"; +import { BaseInitializeEngine } from "./index"; + +export class OnRampInitializeMykoboEngine extends BaseInitializeEngine { + readonly config = { + direction: RampDirection.BUY, + skipNote: "OnRampInitializeMykoboEngine: Skipped because rampType is SELL, this engine handles BUY operations only" + }; + + protected async executeInternal(ctx: QuoteContext): Promise { + const req = ctx.request; + + const eurcBaseDetails = getOnChainTokenDetails(Networks.Base, EvmToken.EURC); + if (!eurcBaseDetails) { + throw new Error("OnRampInitializeMykoboEngine: EURC token details not found for Base"); + } + + const inputAmountDecimal = new Big(req.inputAmount); + const inputAmountRaw = multiplyByPowerOfTen(inputAmountDecimal, eurcBaseDetails.decimals).toFixed(0, 0); + + const mykoboFeeResponse = await MykoboApiService.getInstance().defaultDepositFee(inputAmountDecimal.toFixed(2, 0)); + const mykoboFeeDecimal = new Big(mykoboFeeResponse.total); + + const deliveredEurcDecimal = inputAmountDecimal.minus(mykoboFeeDecimal); + if (deliveredEurcDecimal.lte(0)) { + throw new Error( + `OnRampInitializeMykoboEngine: Mykobo deposit fee ${mykoboFeeDecimal.toFixed()} EUR is greater than or equal to input amount ${inputAmountDecimal.toFixed()} EUR` + ); + } + const deliveredEurcRaw = multiplyByPowerOfTen(deliveredEurcDecimal, eurcBaseDetails.decimals).toFixed(0, 0); + + ctx.mykoboMint = { + currency: FiatToken.EURC, + fee: mykoboFeeDecimal, + inputAmountDecimal, + inputAmountRaw, + outputAmountDecimal: deliveredEurcDecimal, + outputAmountRaw: deliveredEurcRaw + }; + + ctx.addNote?.( + `Assuming ${deliveredEurcDecimal.toFixed()} EURC delivered on Base ephemeral after ${mykoboFeeDecimal.toFixed()} EUR Mykobo deposit fee` + ); + } +} diff --git a/apps/api/src/api/services/quote/engines/nabla-swap/onramp-evm.ts b/apps/api/src/api/services/quote/engines/nabla-swap/onramp-evm.ts index 77e7a7825..4fdec5a91 100644 --- a/apps/api/src/api/services/quote/engines/nabla-swap/onramp-evm.ts +++ b/apps/api/src/api/services/quote/engines/nabla-swap/onramp-evm.ts @@ -1,5 +1,7 @@ -import { EvmToken, RampDirection } from "@vortexfi/shared"; +import { EvmToken, getOnChainTokenDetails, Networks, RampDirection } from "@vortexfi/shared"; +import { Big } from "big.js"; import { QuoteContext } from "../../core/types"; +import { isBrlToBrlaBaseDirect } from "../../utils"; import { BaseNablaSwapEngineEvm, NablaSwapEvmComputation } from "./base-evm"; export class OnRampSwapEngineEvm extends BaseNablaSwapEngineEvm { @@ -14,6 +16,47 @@ export class OnRampSwapEngineEvm extends BaseNablaSwapEngineEvm { } } + async execute(ctx: QuoteContext): Promise { + if (ctx.request.rampType !== RampDirection.BUY) { + ctx.addNote?.(this.config.skipNote); + return; + } + + this.validate(ctx); + + if (isBrlToBrlaBaseDirect(ctx.request.inputCurrency, ctx.request.outputCurrency, ctx.request.to)) { + if (!ctx.aveniaTransfer) { + throw new Error( + "OnRampSwapEngineEvm: Missing aveniaTransfer quote data from previous stage - ensure initialize stage ran successfully" + ); + } + const inputAmountPreFees = ctx.aveniaTransfer.outputAmountDecimal; + const brlaTokenDetails = getOnChainTokenDetails(Networks.Base, EvmToken.BRLA); + if (!brlaTokenDetails || brlaTokenDetails.type !== "evm") { + throw new Error("OnRampSwapEngineEvm: BRLA token details not found for Base"); + } + + const inputAmountForSwapRaw = inputAmountPreFees.times(new Big(10).pow(brlaTokenDetails.decimals)).toFixed(0, 0); + ctx.nablaSwapEvm = { + effectiveExchangeRate: "1", + inputAmountForSwapDecimal: inputAmountPreFees.toString(), + inputAmountForSwapRaw, + inputCurrency: EvmToken.BRLA, + inputDecimals: brlaTokenDetails.decimals, + inputToken: brlaTokenDetails.erc20AddressSourceChain, + outputAmountDecimal: inputAmountPreFees, + outputAmountRaw: inputAmountForSwapRaw, + outputCurrency: EvmToken.BRLA, + outputDecimals: brlaTokenDetails.decimals, + outputToken: brlaTokenDetails.erc20AddressSourceChain + }; + ctx.addNote?.(`Nabla swap bypassed for BRL→BRLA on Base, passthrough amount ${inputAmountPreFees.toFixed()} BRLA (1:1)`); + return; + } + + await super.execute(ctx); + } + protected compute(ctx: QuoteContext): NablaSwapEvmComputation { if (!ctx.aveniaTransfer) { throw new Error( diff --git a/apps/api/src/api/services/quote/engines/nabla-swap/onramp-mykobo-evm.ts b/apps/api/src/api/services/quote/engines/nabla-swap/onramp-mykobo-evm.ts new file mode 100644 index 000000000..7f6ee0b35 --- /dev/null +++ b/apps/api/src/api/services/quote/engines/nabla-swap/onramp-mykobo-evm.ts @@ -0,0 +1,71 @@ +import { EvmToken, getOnChainTokenDetails, Networks, RampDirection } from "@vortexfi/shared"; +import { Big } from "big.js"; +import { QuoteContext } from "../../core/types"; +import { isEurToEurcBaseDirect } from "../../utils"; +import { BaseNablaSwapEngineEvm, NablaSwapEvmComputation } from "./base-evm"; + +export class OnRampSwapEngineMykoboEvm extends BaseNablaSwapEngineEvm { + readonly config = { + direction: RampDirection.BUY, + skipNote: "OnRampSwapEngineMykoboEvm: Skipped because rampType is SELL, this engine handles BUY operations only" + } as const; + + protected validate(ctx: QuoteContext): void { + if (!ctx.fees?.usd) { + throw new Error("OnRampSwapEngineMykoboEvm: Fees in USD must be calculated first - ensure fee stage ran successfully"); + } + if (!ctx.mykoboMint) { + throw new Error( + "OnRampSwapEngineMykoboEvm: Missing mykoboMint quote data from previous stage - ensure initialize stage ran successfully" + ); + } + } + + async execute(ctx: QuoteContext): Promise { + if (ctx.request.rampType !== RampDirection.BUY) { + ctx.addNote?.(this.config.skipNote); + return; + } + + this.validate(ctx); + + if (isEurToEurcBaseDirect(ctx.request.inputCurrency, ctx.request.outputCurrency, ctx.request.to)) { + // biome-ignore lint/style/noNonNullAssertion: validated above + const inputAmountPreFees = ctx.mykoboMint!.outputAmountDecimal; + const eurcTokenDetails = getOnChainTokenDetails(Networks.Base, EvmToken.EURC); + if (!eurcTokenDetails || eurcTokenDetails.type !== "evm") { + throw new Error("OnRampSwapEngineMykoboEvm: EURC token details not found for Base"); + } + + const inputAmountForSwapRaw = inputAmountPreFees.times(new Big(10).pow(eurcTokenDetails.decimals)).toFixed(0, 0); + ctx.nablaSwapEvm = { + effectiveExchangeRate: "1", + inputAmountForSwapDecimal: inputAmountPreFees.toString(), + inputAmountForSwapRaw, + inputCurrency: EvmToken.EURC, + inputDecimals: eurcTokenDetails.decimals, + inputToken: eurcTokenDetails.erc20AddressSourceChain, + outputAmountDecimal: inputAmountPreFees, + outputAmountRaw: inputAmountForSwapRaw, + outputCurrency: EvmToken.EURC, + outputDecimals: eurcTokenDetails.decimals, + outputToken: eurcTokenDetails.erc20AddressSourceChain + }; + ctx.addNote?.(`Nabla swap bypassed for EUR→EURC on Base, passthrough amount ${inputAmountPreFees.toFixed()} EURC (1:1)`); + return; + } + + await super.execute(ctx); + } + + protected compute(ctx: QuoteContext): NablaSwapEvmComputation { + // biome-ignore lint/style/noNonNullAssertion: validated above + const inputAmountPreFees = ctx.mykoboMint!.outputAmountDecimal; + + return { + inputAmountPreFees, + inputToken: EvmToken.EURC, + outputToken: EvmToken.USDC + }; + } +} diff --git a/apps/api/src/api/services/quote/engines/pendulum-transfers/index.ts b/apps/api/src/api/services/quote/engines/pendulum-transfers/index.ts index de6c4a965..1e1723f26 100644 --- a/apps/api/src/api/services/quote/engines/pendulum-transfers/index.ts +++ b/apps/api/src/api/services/quote/engines/pendulum-transfers/index.ts @@ -1,6 +1,6 @@ import { RampDirection } from "@vortexfi/shared"; import Big from "big.js"; -import { QuoteContext, Stage, StageKey, StellarMeta, XcmMeta } from "../../core/types"; +import { QuoteContext, Stage, StageKey, XcmMeta } from "../../core/types"; export interface PendulumTransferConfig { direction: RampDirection; @@ -8,8 +8,8 @@ export interface PendulumTransferConfig { } export interface PendulumTransferComputation { - type: "xcm" | "stellar"; - data: XcmMeta | StellarMeta; + type: "xcm"; + data: XcmMeta; } export abstract class BasePendulumTransferEngine implements Stage { @@ -71,16 +71,9 @@ export abstract class BasePendulumTransferEngine implements Stage { } private addNote(ctx: QuoteContext, computation: PendulumTransferComputation): void { - if (computation.type === "xcm") { - const xcmData = computation.data as XcmMeta; - ctx.addNote?.( - `Calculated XCM transfer with ${xcmData.xcmFees.origin.amount} ${xcmData.xcmFees.origin.currency} origin fee and ${xcmData.xcmFees.destination.amount} ${xcmData.xcmFees.destination.currency} destination fee` - ); - } else { - const stellarData = computation.data as StellarMeta; - ctx.addNote?.( - `Calculated Stellar transfer with amount ${stellarData.inputAmountDecimal.toString()} ${stellarData.currency}` - ); - } + const xcmData = computation.data; + ctx.addNote?.( + `Calculated XCM transfer with ${xcmData.xcmFees.origin.amount} ${xcmData.xcmFees.origin.currency} origin fee and ${xcmData.xcmFees.destination.amount} ${xcmData.xcmFees.destination.currency} destination fee` + ); } } diff --git a/apps/api/src/api/services/quote/engines/pendulum-transfers/offramp-stellar.ts b/apps/api/src/api/services/quote/engines/pendulum-transfers/offramp-stellar.ts deleted file mode 100644 index 4682f2f97..000000000 --- a/apps/api/src/api/services/quote/engines/pendulum-transfers/offramp-stellar.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { RampDirection } from "@vortexfi/shared"; -import Big from "big.js"; -import { QuoteContext, StellarMeta } from "../../core/types"; -import { BasePendulumTransferEngine, PendulumTransferComputation, PendulumTransferConfig } from "./index"; - -export class OffRampToStellarPendulumTransferEngine extends BasePendulumTransferEngine { - readonly config: PendulumTransferConfig = { - direction: RampDirection.SELL, - skipNote: - "OffRampToStellarPendulumTransferEngine: Skipped because rampType is BUY, this engine handles SELL operations only" - }; - - protected validate(ctx: QuoteContext): void { - if (!ctx.nablaSwap) { - throw new Error( - "OffRampToStellarPendulumTransferEngine: Missing nablaSwap in context - ensure nabla-swap stage ran successfully" - ); - } - - if (!ctx.subsidy) { - throw new Error( - "OffRampToStellarPendulumTransferEngine: Missing subsidy in context - ensure subsidy calculation ran successfully" - ); - } - } - - protected async compute(ctx: QuoteContext): Promise { - // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - const nablaSwap = ctx.nablaSwap!; - - const fee = new Big(0); // The fee is not paid in the token being transferred - - // Trim the amounts to 2 decimals as higher precision is irrelevant for the fiat anchors - const inputAmountDecimal = this.mergeSubsidy(ctx, new Big(nablaSwap.outputAmountDecimal)).round(2, Big.roundDown); - const inputAmountRaw = inputAmountDecimal.mul(new Big(10).pow(nablaSwap.outputDecimals)).toFixed(0, 0); - - const stellarData: StellarMeta = { - currency: nablaSwap.outputCurrency, - fee, - inputAmountDecimal, - inputAmountRaw, - // The fees are not paid in the token being transferred, so amountOut = amountIn - outputAmountDecimal: inputAmountDecimal, - outputAmountRaw: inputAmountRaw - }; - - return { - data: stellarData, - type: "stellar" - }; - } - - protected assign(ctx: QuoteContext, computation: PendulumTransferComputation): void { - ctx.pendulumToStellar = computation.data as StellarMeta; - } -} diff --git a/apps/api/src/api/services/quote/engines/squidrouter/onramp-base-to-evm.ts b/apps/api/src/api/services/quote/engines/squidrouter/onramp-base-to-evm.ts index eb660dd61..cd5e2af8a 100644 --- a/apps/api/src/api/services/quote/engines/squidrouter/onramp-base-to-evm.ts +++ b/apps/api/src/api/services/quote/engines/squidrouter/onramp-base-to-evm.ts @@ -1,6 +1,7 @@ import { EvmToken, getNetworkFromDestination, + getOnChainTokenDetails, multiplyByPowerOfTen, Networks, OnChainToken, @@ -11,9 +12,10 @@ import httpStatus from "http-status"; import { APIError } from "../../../../errors/api-error"; import { getTokenDetailsForEvmDestination } from "../../core/squidrouter"; import { QuoteContext } from "../../core/types"; +import { isBrlToBrlaBaseDirect, isEurToEurcBaseDirect } from "../../utils"; import { BaseSquidRouterEngine, SquidRouterComputation, SquidRouterConfig } from "./index"; -export class OnRampSquidRouterBrlToEvmEngineBase extends BaseSquidRouterEngine { +export class OnRampSquidRouterToBaseEngine extends BaseSquidRouterEngine { readonly config: SquidRouterConfig = { direction: RampDirection.BUY, skipNote: "OnRampSquidRouterBrlToEvmEngine: Skipped because rampType is SELL, this engine handles BUY operations only" @@ -46,6 +48,56 @@ export class OnRampSquidRouterBrlToEvmEngineBase extends BaseSquidRouterEngine { // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate const nablaSwap = ctx.nablaSwapEvm!; + if (isEurToEurcBaseDirect(ctx.request.inputCurrency, ctx.request.outputCurrency, ctx.request.to)) { + const eurcBaseTokenDetails = getOnChainTokenDetails(Networks.Base, EvmToken.EURC); + if (!eurcBaseTokenDetails || eurcBaseTokenDetails.type !== "evm") { + throw new Error("OnRampSquidRouterToBaseEngine: EURC Base token details not found"); + } + + const inputAmountDecimal = this.mergeSubsidy(ctx, new Big(nablaSwap.outputAmountDecimal)); + const inputAmountRaw = this.mergeSubsidyRaw(ctx, new Big(nablaSwap.outputAmountRaw)).toFixed(0, 0); + + return { + data: { + amountRaw: inputAmountRaw, + fromNetwork: Networks.Base, + fromToken: eurcBaseTokenDetails.erc20AddressSourceChain, + inputAmountDecimal, + inputAmountRaw, + outputDecimals: eurcBaseTokenDetails.decimals, + skipRouteCalculation: true, + toNetwork: Networks.Base, + toToken: eurcBaseTokenDetails.erc20AddressSourceChain + }, + type: "evm-to-evm" + }; + } + + if (isBrlToBrlaBaseDirect(ctx.request.inputCurrency, ctx.request.outputCurrency, ctx.request.to)) { + const brlaBaseTokenDetails = getOnChainTokenDetails(Networks.Base, EvmToken.BRLA); + if (!brlaBaseTokenDetails || brlaBaseTokenDetails.type !== "evm") { + throw new Error("OnRampSquidRouterToBaseEngine: BRLA Base token details not found"); + } + + const inputAmountDecimal = this.mergeSubsidy(ctx, new Big(nablaSwap.outputAmountDecimal)); + const inputAmountRaw = this.mergeSubsidyRaw(ctx, new Big(nablaSwap.outputAmountRaw)).toFixed(0, 0); + + return { + data: { + amountRaw: inputAmountRaw, + fromNetwork: Networks.Base, + fromToken: brlaBaseTokenDetails.erc20AddressSourceChain, + inputAmountDecimal, + inputAmountRaw, + outputDecimals: brlaBaseTokenDetails.decimals, + skipRouteCalculation: true, + toNetwork: Networks.Base, + toToken: brlaBaseTokenDetails.erc20AddressSourceChain + }, + type: "evm-to-evm" + }; + } + const usdFeesDistributedDecimal = Big(usdFees.network).plus(usdFees.vortex).plus(usdFees.partnerMarkup); const usdFeesDistributedRaw = multiplyByPowerOfTen(usdFeesDistributedDecimal, nablaSwap.outputDecimals); diff --git a/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm.ts b/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm.ts deleted file mode 100644 index 8764edc9a..000000000 --- a/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { ERC20_EURE_POLYGON_V1, getNetworkFromDestination, Networks, OnChainToken, RampDirection } from "@vortexfi/shared"; -import httpStatus from "http-status"; -import { APIError } from "../../../../errors/api-error"; -import { getTokenDetailsForEvmDestination } from "../../core/squidrouter"; -import { QuoteContext } from "../../core/types"; -import { BaseSquidRouterEngine, SquidRouterComputation, SquidRouterConfig } from "./index"; - -export class OnRampSquidRouterEurToEvmEngine extends BaseSquidRouterEngine { - readonly config: SquidRouterConfig = { - direction: RampDirection.BUY, - skipNote: "OnRampSquidRouterEurToEvmEngine: Skipped because rampType is SELL, this engine handles BUY operations only" - }; - - protected validate(ctx: QuoteContext): void { - if (ctx.request.to === "assethub") { - throw new Error( - "OnRampSquidRouterEurToEvmEngine: Skipped because destination is assethub, this engine handles EVM destinations only" - ); - } - - if (!ctx.moneriumMint?.outputAmountDecimal) { - throw new Error( - "OnRampSquidRouterEurToEvmEngine: Missing moneriumMint.amountOut in context - ensure initialize stage ran successfully" - ); - } - } - - protected compute(ctx: QuoteContext): SquidRouterComputation { - const req = ctx.request; - const toNetwork = getNetworkFromDestination(req.to); - if (!toNetwork) { - throw new APIError({ - message: `Invalid network for destination: ${req.to} `, - status: httpStatus.BAD_REQUEST - }); - } - - const toToken = getTokenDetailsForEvmDestination(req.outputCurrency as OnChainToken, req.to); - const toTokenAddress = toToken.erc20AddressSourceChain; - // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - const moneriumMint = ctx.moneriumMint!; - - return { - data: { - amountRaw: moneriumMint.outputAmountRaw, - fromNetwork: Networks.Polygon, - fromToken: ERC20_EURE_POLYGON_V1, - inputAmountDecimal: moneriumMint.outputAmountDecimal, - inputAmountRaw: moneriumMint.outputAmountRaw, - outputDecimals: toToken.decimals, - toNetwork, - toToken: toTokenAddress - }, - type: "evm-to-evm" - }; - } -} diff --git a/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-moonbeam.ts b/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-moonbeam.ts deleted file mode 100644 index 3c87d24ea..000000000 --- a/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-moonbeam.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { AXL_USDC_MOONBEAM_DETAILS, ERC20_EURE_POLYGON_V1, Networks, RampDirection } from "@vortexfi/shared"; -import { QuoteContext } from "../../core/types"; -import { BaseSquidRouterEngine, SquidRouterComputation, SquidRouterConfig } from "./index"; - -export class OnRampSquidRouterEurToAssetHubEngine extends BaseSquidRouterEngine { - readonly config: SquidRouterConfig = { - direction: RampDirection.BUY, - skipNote: "OnRampSquidRouterEurToAssetHubEngine: Skipped because rampType is SELL, this engine handles BUY operations only" - }; - - protected validate(ctx: QuoteContext): void { - if (ctx.request.to !== "assethub") { - throw new Error( - "OnRampSquidRouterEurToAssetHubEngine: Skipped because destination is not assethub, this engine handles assethub destinations only" - ); - } - - if (!ctx.moneriumMint) { - throw new Error( - "OnRampSquidRouterEurToAssetHubEngine: Missing moneriumMint in context - ensure initialize stage ran successfully" - ); - } - } - - protected compute(ctx: QuoteContext): SquidRouterComputation { - // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - const moneriumMint = ctx.moneriumMint!; - - const toToken = AXL_USDC_MOONBEAM_DETAILS; - const toTokenAddress = toToken.erc20AddressSourceChain; - - return { - data: { - amountRaw: moneriumMint.outputAmountRaw, - fromNetwork: Networks.Polygon, - fromToken: ERC20_EURE_POLYGON_V1, - inputAmountDecimal: moneriumMint.outputAmountDecimal, - inputAmountRaw: moneriumMint.outputAmountRaw, - outputDecimals: toToken.decimals, - toNetwork: Networks.Moonbeam, - toToken: toTokenAddress - }, - type: "evm-to-moonbeam" - }; - } -} diff --git a/apps/api/src/api/services/quote/index.ts b/apps/api/src/api/services/quote/index.ts index b35562f93..402a6a950 100644 --- a/apps/api/src/api/services/quote/index.ts +++ b/apps/api/src/api/services/quote/index.ts @@ -217,13 +217,7 @@ export class QuoteService extends BaseRampService { // Detect Alfredpay trade limit error and surface it as a user-facing limit error if (error instanceof AlfredpayTradeLimitError) { - const isOnramp = ctx.request.rampType === RampDirection.BUY; - throw new APIError({ - message: isOnramp - ? `${QuoteError.BelowLowerLimitBuy} ${error.minQuantity} ${error.fromCurrency}` - : `${QuoteError.BelowLowerLimitSell} ${error.minQuantity} ${error.fromCurrency}`, - status: httpStatus.BAD_REQUEST - }); + throw mapAlfredpayLimitErrorToApiError(error, ctx.request.rampType === RampDirection.BUY); } // Wrap unexpected errors as generic failure @@ -265,6 +259,21 @@ export class QuoteService extends BaseRampService { } } +function mapAlfredpayLimitErrorToApiError(error: AlfredpayTradeLimitError, isOnramp: boolean): APIError { + const prefix = selectAlfredpayLimitPrefix(error.kind === "above", isOnramp); + return new APIError({ + message: `${prefix} ${error.quantity} ${error.fromCurrency}`, + status: httpStatus.BAD_REQUEST + }); +} + +function selectAlfredpayLimitPrefix(isAboveMax: boolean, isOnramp: boolean): QuoteError { + if (isAboveMax && isOnramp) return QuoteError.AboveUpperLimitBuy; + if (isAboveMax) return QuoteError.AboveUpperLimitSell; + if (isOnramp) return QuoteError.BelowLowerLimitBuy; + return QuoteError.BelowLowerLimitSell; +} + function requiresEvmPartnerPayout(request: CreateQuoteRequest): boolean { if (request.rampType === RampDirection.SELL && request.outputCurrency === FiatToken.BRL) { const fromNetwork = getNetworkFromDestination(request.from); diff --git a/apps/api/src/api/services/quote/routes/route-resolver.ts b/apps/api/src/api/services/quote/routes/route-resolver.ts index a581ee6ad..4d2d71b08 100644 --- a/apps/api/src/api/services/quote/routes/route-resolver.ts +++ b/apps/api/src/api/services/quote/routes/route-resolver.ts @@ -17,12 +17,11 @@ import { IRouteStrategy } from "../core/types"; import { offrampEvmToAlfredpayStrategy } from "./strategies/offramp-evm-to-alfredpay.strategy"; import { offrampToPixStrategy } from "./strategies/offramp-to-pix.strategy"; import { offrampToPixEvmStrategy } from "./strategies/offramp-to-pix-base.strategy"; -import { offrampToStellarStrategy } from "./strategies/offramp-to-stellar.strategy"; +import { offrampToSepaEvmStrategy } from "./strategies/offramp-to-sepa-evm.strategy"; import { onrampAlfredpayToEvmStrategy } from "./strategies/onramp-alfredpay-to-evm.strategy"; import { onrampAveniaToAssethubStrategy } from "./strategies/onramp-avenia-to-assethub.strategy"; import { onrampAveniaToEvmBaseStrategy } from "./strategies/onramp-avenia-to-evm.strategy-base"; -import { onrampMoneriumToAssethubStrategy } from "./strategies/onramp-monerium-to-assethub.strategy"; -import { onrampMoneriumToEvmStrategy } from "./strategies/onramp-monerium-to-evm.strategy"; +import { onrampMykoboToEvmStrategy } from "./strategies/onramp-mykobo-to-evm.strategy"; const ALFREDPAY_PAYMENT_METHODS: ReadonlySet = new Set([EPaymentMethod.ACH, EPaymentMethod.SPEI, EPaymentMethod.WIRE]); @@ -34,14 +33,16 @@ export class RouteResolver { if (isAlfredpayToken(ctx.request.inputCurrency as FiatToken)) { throw new APIError({ message: QuoteError.AssetHubNotSupportedForAlfredPay, status: httpStatus.BAD_REQUEST }); } - if (ctx.from === "pix") { - return onrampAveniaToAssethubStrategy; - } else { - return onrampMoneriumToAssethubStrategy; + if (ctx.request.inputCurrency === FiatToken.EURC) { + throw new APIError({ + message: "EUR onramp to AssetHub is not supported; please choose an EVM destination chain", + status: httpStatus.BAD_REQUEST + }); } + return onrampAveniaToAssethubStrategy; } else { if (ctx.request.inputCurrency === FiatToken.EURC) { - return onrampMoneriumToEvmStrategy; + return onrampMykoboToEvmStrategy; } else if (isAlfredpayToken(ctx.request.inputCurrency as FiatToken)) { return onrampAlfredpayToEvmStrategy; } else { @@ -72,9 +73,10 @@ export class RouteResolver { case "spei": return offrampEvmToAlfredpayStrategy; case "sepa": + return offrampToSepaEvmStrategy; case "cbu": default: - return offrampToStellarStrategy; + throw new APIError({ message: "ARS offramp temporarily unavailable", status: httpStatus.BAD_REQUEST }); } } } diff --git a/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts index ecce16594..6dccd0a8c 100644 --- a/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts +++ b/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts @@ -1,16 +1,15 @@ -import { Networks } from "@vortexfi/shared"; import { StageKey } from "../../core/types"; import { OffRampAlfredpayDiscountEngine } from "../../engines/discount/offramp-alfredpay"; import { OffRampEvmToAlfredpayFeeEngine } from "../../engines/fee/offramp-evm-to-alfredpay"; import { OffRampFinalizeEngine } from "../../engines/finalize/offramp"; -import { OffRampFromEvmInitializeEngine } from "../../engines/initialize/offramp-from-evm-alfredpay"; +import { OffRampFromEvmInitializeAlfredpayEngine } from "../../engines/initialize/offramp-from-evm-alfredpay"; import { OfframpTransactionAlfredpayEngine } from "../../engines/partners/offramp-alfredpay"; import { defineRouteStrategy } from "../route-definition"; export const offrampEvmToAlfredpayStrategy = defineRouteStrategy({ engines: () => ({ - [StageKey.Initialize]: new OffRampFromEvmInitializeEngine(Networks.Polygon), + [StageKey.Initialize]: new OffRampFromEvmInitializeAlfredpayEngine(), [StageKey.Fee]: new OffRampEvmToAlfredpayFeeEngine(), [StageKey.Discount]: new OffRampAlfredpayDiscountEngine(), [StageKey.PartnerOperation]: new OfframpTransactionAlfredpayEngine(), diff --git a/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix-base.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix-base.strategy.ts index c98b6d0fd..b6815d509 100644 --- a/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix-base.strategy.ts +++ b/apps/api/src/api/services/quote/routes/strategies/offramp-to-pix-base.strategy.ts @@ -1,16 +1,16 @@ -import { EvmToken, Networks } from "@vortexfi/shared"; +import { EvmToken } from "@vortexfi/shared"; import { StageKey } from "../../core/types"; import { OffRampDiscountEngine } from "../../engines/discount/offramp"; import { OffRampFeeAveniaEngine } from "../../engines/fee/offramp-avenia"; import { OffRampFinalizeEngine } from "../../engines/finalize/offramp"; -import { OffRampFromEvmInitializeEngine } from "../../engines/initialize/offramp-from-evm-alfredpay"; +import { OffRampFromEvmInitializeAveniaEngine } from "../../engines/initialize/offramp-from-evm-avenia"; import { OffRampMergeSubsidyEvmEngine } from "../../engines/merge-subsidy/offramp-evm"; import { OffRampSwapEngineEvm } from "../../engines/nabla-swap/offramp-evm"; import { defineRouteStrategy } from "../route-definition"; export const offrampToPixEvmStrategy = defineRouteStrategy({ engines: () => ({ - [StageKey.Initialize]: new OffRampFromEvmInitializeEngine(Networks.Base), + [StageKey.Initialize]: new OffRampFromEvmInitializeAveniaEngine(), [StageKey.NablaSwap]: new OffRampSwapEngineEvm(EvmToken.BRLA), [StageKey.Fee]: new OffRampFeeAveniaEngine(), [StageKey.Discount]: new OffRampDiscountEngine(), diff --git a/apps/api/src/api/services/quote/routes/strategies/offramp-to-sepa-evm.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-to-sepa-evm.strategy.ts new file mode 100644 index 000000000..97d2ddf6d --- /dev/null +++ b/apps/api/src/api/services/quote/routes/strategies/offramp-to-sepa-evm.strategy.ts @@ -0,0 +1,22 @@ +import { EvmToken } from "@vortexfi/shared"; +import { StageKey } from "../../core/types"; +import { OffRampDiscountEngine } from "../../engines/discount/offramp"; +import { OffRampFeeMykoboEngine } from "../../engines/fee/offramp-mykobo"; +import { OffRampFinalizeEngine } from "../../engines/finalize/offramp"; +import { OffRampFromEvmInitializeMykoboEngine } from "../../engines/initialize/offramp-from-evm-mykobo"; +import { OffRampMergeSubsidyEvmEngine } from "../../engines/merge-subsidy/offramp-evm"; +import { OffRampSwapEngineEvm } from "../../engines/nabla-swap/offramp-evm"; +import { defineRouteStrategy } from "../route-definition"; + +export const offrampToSepaEvmStrategy = defineRouteStrategy({ + engines: () => ({ + [StageKey.Initialize]: new OffRampFromEvmInitializeMykoboEngine(), + [StageKey.NablaSwap]: new OffRampSwapEngineEvm(EvmToken.EURC), + [StageKey.Fee]: new OffRampFeeMykoboEngine(), + [StageKey.Discount]: new OffRampDiscountEngine(), + [StageKey.MergeSubsidy]: new OffRampMergeSubsidyEvmEngine(), + [StageKey.Finalize]: new OffRampFinalizeEngine() + }), + name: "OfframpToSepaEvm", + stages: [StageKey.Initialize, StageKey.NablaSwap, StageKey.Fee, StageKey.Discount, StageKey.MergeSubsidy, StageKey.Finalize] +}); diff --git a/apps/api/src/api/services/quote/routes/strategies/offramp-to-stellar.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-to-stellar.strategy.ts deleted file mode 100644 index edb3e1f78..000000000 --- a/apps/api/src/api/services/quote/routes/strategies/offramp-to-stellar.strategy.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { StageKey } from "../../core/types"; -import { OffRampDiscountEngine } from "../../engines/discount/offramp"; -import { OffRampFeeStellarEngine } from "../../engines/fee/offramp-stellar"; -import { OffRampFinalizeEngine } from "../../engines/finalize/offramp"; -import { OffRampFromAssethubInitializeEngine } from "../../engines/initialize/offramp-from-assethub"; -import { OffRampFromEvmInitializeEngineMoonbeam } from "../../engines/initialize/offramp-from-evm"; -import { OffRampSwapEngine } from "../../engines/nabla-swap/offramp"; -import { OffRampToStellarPendulumTransferEngine } from "../../engines/pendulum-transfers/offramp-stellar"; -import { defineRouteStrategy } from "../route-definition"; - -export const offrampToStellarStrategy = defineRouteStrategy({ - engines: ctx => ({ - [StageKey.Initialize]: - ctx.request.from === "assethub" - ? new OffRampFromAssethubInitializeEngine() - : new OffRampFromEvmInitializeEngineMoonbeam(), - [StageKey.NablaSwap]: new OffRampSwapEngine(), - [StageKey.Fee]: new OffRampFeeStellarEngine(), - [StageKey.Discount]: new OffRampDiscountEngine(), - [StageKey.PendulumTransfer]: new OffRampToStellarPendulumTransferEngine(), - [StageKey.Finalize]: new OffRampFinalizeEngine() - }), - name: "OffRampStellar", - stages: [ - StageKey.Initialize, - StageKey.NablaSwap, - StageKey.Fee, - StageKey.Discount, - StageKey.PendulumTransfer, - StageKey.Finalize - ] -}); diff --git a/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy-base.ts b/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy-base.ts index 00f5b6c8a..48e564890 100644 --- a/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy-base.ts +++ b/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-evm.strategy-base.ts @@ -5,7 +5,7 @@ import { OnRampAveniaToEvmFeeEngine } from "../../engines/fee/onramp-brl-to-evm" import { OnRampFinalizeEngine } from "../../engines/finalize/onramp"; import { OnRampInitializeAveniaEngine } from "../../engines/initialize/onramp-avenia"; import { OnRampSwapEngineEvm } from "../../engines/nabla-swap/onramp-evm"; -import { OnRampSquidRouterBrlToEvmEngineBase } from "../../engines/squidrouter/onramp-base-to-evm"; +import { OnRampSquidRouterToBaseEngine } from "../../engines/squidrouter/onramp-base-to-evm"; import { defineRouteStrategy } from "../route-definition"; export const onrampAveniaToEvmBaseStrategy = defineRouteStrategy({ @@ -14,7 +14,7 @@ export const onrampAveniaToEvmBaseStrategy = defineRouteStrategy({ [StageKey.Fee]: new OnRampAveniaToEvmFeeEngine(Networks.Base, EvmToken.USDC), [StageKey.NablaSwap]: new OnRampSwapEngineEvm(), [StageKey.Discount]: new OnRampDiscountEngine(), - [StageKey.SquidRouter]: new OnRampSquidRouterBrlToEvmEngineBase(), + [StageKey.SquidRouter]: new OnRampSquidRouterToBaseEngine(), [StageKey.Finalize]: new OnRampFinalizeEngine() }), name: "OnRampAveniaToEvmBase", diff --git a/apps/api/src/api/services/quote/routes/strategies/onramp-monerium-to-assethub.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/onramp-monerium-to-assethub.strategy.ts deleted file mode 100644 index b478f9ef9..000000000 --- a/apps/api/src/api/services/quote/routes/strategies/onramp-monerium-to-assethub.strategy.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { StageKey } from "../../core/types"; -import { OnRampDiscountEngine } from "../../engines/discount/onramp"; -import { OnRampMoneriumToAssethubFeeEngine } from "../../engines/fee/onramp-monerium-to-assethub"; -import { OnRampFinalizeEngine } from "../../engines/finalize/onramp"; -import { OnRampHydrationEngine } from "../../engines/hydration/onramp"; -import { OnRampInitializeMoneriumEngine } from "../../engines/initialize/onramp-monerium"; -import { OnRampSwapEngine } from "../../engines/nabla-swap/onramp"; -import { OnRampPendulumTransferEngine } from "../../engines/pendulum-transfers/onramp"; -import { OnRampSquidRouterEurToAssetHubEngine } from "../../engines/squidrouter/onramp-polygon-to-moonbeam"; -import { defineRouteStrategy, withHydrationForNonUsdc } from "../route-definition"; - -export const onrampMoneriumToAssethubStrategy = defineRouteStrategy({ - engines: () => ({ - [StageKey.Initialize]: new OnRampInitializeMoneriumEngine(), - [StageKey.SquidRouter]: new OnRampSquidRouterEurToAssetHubEngine(), - [StageKey.Fee]: new OnRampMoneriumToAssethubFeeEngine(), - [StageKey.NablaSwap]: new OnRampSwapEngine(), - [StageKey.Discount]: new OnRampDiscountEngine(), - [StageKey.PendulumTransfer]: new OnRampPendulumTransferEngine(), - [StageKey.HydrationSwap]: new OnRampHydrationEngine(), - [StageKey.Finalize]: new OnRampFinalizeEngine() - }), - name: "OnRampMoneriumToAssetHub", - stages: withHydrationForNonUsdc([ - StageKey.Initialize, - StageKey.SquidRouter, - StageKey.Fee, - StageKey.NablaSwap, - StageKey.Discount, - StageKey.PendulumTransfer, - StageKey.Finalize - ]) -}); diff --git a/apps/api/src/api/services/quote/routes/strategies/onramp-monerium-to-evm.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/onramp-monerium-to-evm.strategy.ts deleted file mode 100644 index 993d65829..000000000 --- a/apps/api/src/api/services/quote/routes/strategies/onramp-monerium-to-evm.strategy.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { StageKey } from "../../core/types"; -import { OnRampMoneriumToEvmFeeEngine } from "../../engines/fee/onramp-monerium-to-evm"; -import { OnRampFinalizeEngine } from "../../engines/finalize/onramp"; -import { OnRampInitializeMoneriumEngine } from "../../engines/initialize/onramp-monerium"; -import { OnRampSquidRouterEurToEvmEngine } from "../../engines/squidrouter/onramp-polygon-to-evm"; -import { defineRouteStrategy } from "../route-definition"; - -export const onrampMoneriumToEvmStrategy = defineRouteStrategy({ - engines: () => ({ - [StageKey.Initialize]: new OnRampInitializeMoneriumEngine(), - [StageKey.Fee]: new OnRampMoneriumToEvmFeeEngine(), - [StageKey.SquidRouter]: new OnRampSquidRouterEurToEvmEngine(), - [StageKey.Finalize]: new OnRampFinalizeEngine() - }), - name: "OnRampMoneriumToEvm", - stages: [StageKey.Initialize, StageKey.Fee, StageKey.SquidRouter, StageKey.Finalize] -}); diff --git a/apps/api/src/api/services/quote/routes/strategies/onramp-mykobo-to-evm.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/onramp-mykobo-to-evm.strategy.ts new file mode 100644 index 000000000..232def9f7 --- /dev/null +++ b/apps/api/src/api/services/quote/routes/strategies/onramp-mykobo-to-evm.strategy.ts @@ -0,0 +1,22 @@ +import { EvmToken, Networks } from "@vortexfi/shared"; +import { StageKey } from "../../core/types"; +import { OnRampDiscountEngine } from "../../engines/discount/onramp"; +import { OnRampMykoboToEvmFeeEngine } from "../../engines/fee/onramp-mykobo-to-evm"; +import { OnRampFinalizeEngine } from "../../engines/finalize/onramp"; +import { OnRampInitializeMykoboEngine } from "../../engines/initialize/onramp-mykobo"; +import { OnRampSwapEngineMykoboEvm } from "../../engines/nabla-swap/onramp-mykobo-evm"; +import { OnRampSquidRouterToBaseEngine } from "../../engines/squidrouter/onramp-base-to-evm"; +import { defineRouteStrategy } from "../route-definition"; + +export const onrampMykoboToEvmStrategy = defineRouteStrategy({ + engines: () => ({ + [StageKey.Initialize]: new OnRampInitializeMykoboEngine(), + [StageKey.Fee]: new OnRampMykoboToEvmFeeEngine(Networks.Base, EvmToken.EURC), + [StageKey.NablaSwap]: new OnRampSwapEngineMykoboEvm(), + [StageKey.Discount]: new OnRampDiscountEngine(), + [StageKey.SquidRouter]: new OnRampSquidRouterToBaseEngine(), + [StageKey.Finalize]: new OnRampFinalizeEngine() + }), + name: "OnRampMykoboToEvm", + stages: [StageKey.Initialize, StageKey.Fee, StageKey.NablaSwap, StageKey.Discount, StageKey.SquidRouter, StageKey.Finalize] +}); diff --git a/apps/api/src/api/services/quote/utils.ts b/apps/api/src/api/services/quote/utils.ts new file mode 100644 index 000000000..729943941 --- /dev/null +++ b/apps/api/src/api/services/quote/utils.ts @@ -0,0 +1,9 @@ +import { EvmToken, FiatToken, Networks } from "@vortexfi/shared"; + +export function isEurToEurcBaseDirect(inputCurrency: string, outputCurrency: string, toNetwork: string): boolean { + return inputCurrency === FiatToken.EURC && outputCurrency === EvmToken.EURC && toNetwork === Networks.Base; +} + +export function isBrlToBrlaBaseDirect(inputCurrency: string, outputCurrency: string, toNetwork: string): boolean { + return inputCurrency === FiatToken.BRL && outputCurrency === EvmToken.BRLA && toNetwork === Networks.Base; +} diff --git a/apps/api/src/api/services/ramp/base.service.ts b/apps/api/src/api/services/ramp/base.service.ts index 86c6ce537..e76bda1ae 100644 --- a/apps/api/src/api/services/ramp/base.service.ts +++ b/apps/api/src/api/services/ramp/base.service.ts @@ -27,14 +27,14 @@ export class BaseRampService { } ); - // Delete quotes that have been expired for more than 60 days - const sixtyDaysAgo = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000); + // Delete quotes that have been expired for more than 90 days + const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); count += await QuoteTicket.destroy({ where: { expiresAt: { - [Op.lt]: sixtyDaysAgo + [Op.lt]: ninetyDaysAgo }, - status: "pending" + status: "expired" } }); diff --git a/apps/api/src/api/services/ramp/ephemeral-freshness.test.ts b/apps/api/src/api/services/ramp/ephemeral-freshness.test.ts index 41f73c9a1..0e6e1e424 100644 --- a/apps/api/src/api/services/ramp/ephemeral-freshness.test.ts +++ b/apps/api/src/api/services/ramp/ephemeral-freshness.test.ts @@ -2,14 +2,12 @@ import {beforeEach, describe, expect, it, mock} from "bun:test"; import {EphemeralAccountType} from "@vortexfi/shared"; import {APIError} from "../../errors/api-error"; -const STELLAR_ADDR = "GABCD"; const SUBSTRATE_ADDR = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty"; const EVM_ADDR = "0x1111111111111111111111111111111111111111"; let substrateNonce = 0; let substrateFree = "0"; let evmNonce = 0; -let stellarAccount: { sequence: string } | null = null; let evmGetClientShouldThrow = false; mock.module("@vortexfi/shared", () => { @@ -45,10 +43,6 @@ mock.module("@vortexfi/shared", () => { }; }); -mock.module("../stellar/loadAccount", () => ({ - loadAccountWithRetry: async (_address: string) => stellarAccount -})); - // Import AFTER mocks are registered so the module picks up the mocked deps. const { validateEphemeralAccountsFresh } = await import("./ephemeral-freshness"); @@ -57,7 +51,6 @@ describe("validateEphemeralAccountsFresh", () => { substrateNonce = 0; substrateFree = "0"; evmNonce = 0; - stellarAccount = null; evmGetClientShouldThrow = false; }); @@ -65,7 +58,6 @@ describe("validateEphemeralAccountsFresh", () => { await expect( validateEphemeralAccountsFresh({ [EphemeralAccountType.EVM]: EVM_ADDR, - [EphemeralAccountType.Stellar]: STELLAR_ADDR, [EphemeralAccountType.Substrate]: SUBSTRATE_ADDR }) ).resolves.toBeUndefined(); @@ -108,17 +100,6 @@ describe("validateEphemeralAccountsFresh", () => { } }); - it("rejects when Stellar account already exists on-chain", async () => { - stellarAccount = { sequence: "12345" }; - try { - await validateEphemeralAccountsFresh({ [EphemeralAccountType.Stellar]: STELLAR_ADDR }); - throw new Error("expected rejection"); - } catch (err) { - expect((err as APIError).status).toBe(400); - expect((err as APIError).message).toContain("already exists"); - } - }); - it("fails closed with SERVICE_UNAVAILABLE on RPC error", async () => { evmGetClientShouldThrow = true; try { diff --git a/apps/api/src/api/services/ramp/ephemeral-freshness.ts b/apps/api/src/api/services/ramp/ephemeral-freshness.ts index 103a01df0..ba8545001 100644 --- a/apps/api/src/api/services/ramp/ephemeral-freshness.ts +++ b/apps/api/src/api/services/ramp/ephemeral-freshness.ts @@ -9,7 +9,6 @@ import { import Big from "big.js"; import httpStatus from "http-status"; import { APIError } from "../../errors/api-error"; -import { loadAccountWithRetry } from "../stellar/loadAccount"; const SUPPORTED_SUBSTRATE_NETWORKS: SubstrateApiNetwork[] = ["pendulum", "hydration", "assethub"]; @@ -49,11 +48,6 @@ export async function validateEphemeralAccountsFresh( } } - const stellarAddress = ephemerals[EphemeralAccountType.Stellar]; - if (stellarAddress) { - checks.push(assertStellarAccountFresh(stellarAddress)); - } - await Promise.all(checks); } @@ -100,22 +94,3 @@ async function assertEvmAccountFresh(address: string, network: EvmNetworks): Pro }); } } - -async function assertStellarAccountFresh(address: string): Promise { - let account: Awaited>; - try { - account = await loadAccountWithRetry(address); - } catch (error) { - throw new APIError({ - message: `Could not verify freshness of Stellar ephemeral ${address}: ${(error as Error).message}`, - status: httpStatus.SERVICE_UNAVAILABLE - }); - } - - if (account !== null) { - throw new APIError({ - message: `Stellar ephemeral ${address} already exists on-chain (sequence=${account.sequence}). The server creates and funds this account during the ramp; the provided address must not exist yet.`, - status: httpStatus.BAD_REQUEST - }); - } -} diff --git a/apps/api/src/api/services/ramp/helpers.ts b/apps/api/src/api/services/ramp/helpers.ts index 12a633a4e..ab6b34c62 100644 --- a/apps/api/src/api/services/ramp/helpers.ts +++ b/apps/api/src/api/services/ramp/helpers.ts @@ -1,4 +1,3 @@ -import { FiatToken, Networks } from "@vortexfi/shared"; import logger from "../../../config/logger"; import { config } from "../../../config/vars"; import QuoteTicket from "../../../models/quoteTicket.model"; @@ -88,12 +87,7 @@ const EXPLORER_LINK_BUILDERS: Record = [TransactionHashKey.PendulumToAssethubXcmHash]: hash => `https://pendulum.subscan.io/block/${hash}`, - [TransactionHashKey.SquidRouterSwapHash]: (hash, rampState, quote) => { - const isMoneriumPolygonOnramp = - rampState.from === "sepa" && quote.inputCurrency === FiatToken.EURC && rampState.to === Networks.Polygon; - - return isMoneriumPolygonOnramp ? `https://polygonscan.com/tx/${hash}` : `https://axelarscan.io/gmp/${hash}`; - } + [TransactionHashKey.SquidRouterSwapHash]: hash => `https://axelarscan.io/gmp/${hash}` }; const TRANSACTION_HASH_PRIORITY: readonly TransactionHashKey[] = [ @@ -136,18 +130,7 @@ export async function getFinalTransactionHashForRamp( // For SquidRouter swaps, query the execution hash from AxelarScan if (hashKey === TransactionHashKey.SquidRouterSwapHash) { try { - const isMoneriumPolygonOnramp = - rampState.from === "sepa" && quote.inputCurrency === FiatToken.EURC && rampState.to === Networks.Polygon; - - if (isMoneriumPolygonOnramp) { - // For Monerium Polygon onramp, use the hash directly - return { - transactionExplorerLink: `https://polygonscan.com/tx/${hash}`, - transactionHash: hash - }; - } - - // For other cases, query AxelarScan for the execution hash and chain-specific explorer + // Query AxelarScan for the execution hash and chain-specific explorer const { explorerLink, executionHash } = await getAxelarScanExecutionLink(hash); return { diff --git a/apps/api/src/api/services/ramp/monerium-permit.test.ts b/apps/api/src/api/services/ramp/monerium-permit.test.ts deleted file mode 100644 index e7d3ea2fd..000000000 --- a/apps/api/src/api/services/ramp/monerium-permit.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { ERC20_EURE_POLYGON_TOKEN_NAME, ERC20_EURE_POLYGON_V2, Networks, PermitSignature } from "@vortexfi/shared"; -import { Signature as EthersSignature, Wallet } from "ethers"; -import { - analyzeMoneriumPermitPreflight, - validateMoneriumOnrampPermit -} from "./monerium-permit"; - -const OWNER = new Wallet("0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); -const SPENDER = "0x4e84e0b84054F078D4Adc785818663eF83c032E3"; -const VALUE_RAW = "1150000000000000000"; -const NONCE = "0"; -const DEADLINE = "1779978803"; - -async function signPermit(overrides: Partial = {}): Promise { - const context = { - chainId: 137, - deadline: DEADLINE, - nonce: NONCE, - owner: OWNER.address as `0x${string}`, - spender: SPENDER as `0x${string}`, - tokenAddress: ERC20_EURE_POLYGON_V2, - tokenName: ERC20_EURE_POLYGON_TOKEN_NAME, - tokenVersion: "1", - valueRaw: VALUE_RAW, - ...overrides - }; - const signature = EthersSignature.from( - await OWNER.signTypedData( - { - chainId: context.chainId, - name: context.tokenName, - verifyingContract: context.tokenAddress, - version: context.tokenVersion - }, - { - Permit: [ - { name: "owner", type: "address" }, - { name: "spender", type: "address" }, - { name: "value", type: "uint256" }, - { name: "nonce", type: "uint256" }, - { name: "deadline", type: "uint256" } - ] - }, - { - deadline: context.deadline, - nonce: context.nonce, - owner: context.owner, - spender: context.spender, - value: context.valueRaw - } - ) - ); - - return { - context, - deadline: Number(context.deadline), - r: signature.r as `0x${string}`, - s: signature.s as `0x${string}`, - v: signature.v - }; -} - -describe("validateMoneriumOnrampPermit", () => { - it("accepts a permit whose signed context matches the expected onramp transfer", async () => { - const permit = await signPermit(); - - expect(() => - validateMoneriumOnrampPermit(permit, { - expectedOwner: OWNER.address, - expectedSpender: SPENDER, - expectedTokenAddress: ERC20_EURE_POLYGON_V2, - expectedTokenName: ERC20_EURE_POLYGON_TOKEN_NAME, - expectedValueRaw: VALUE_RAW, - network: Networks.Polygon - }) - ).not.toThrow(); - }); - - it("rejects a permit signed for a different raw value before payment details are released", async () => { - const permit = await signPermit({ valueRaw: "1000000000000000000" }); - - expect(() => - validateMoneriumOnrampPermit(permit, { - expectedOwner: OWNER.address, - expectedSpender: SPENDER, - expectedTokenAddress: ERC20_EURE_POLYGON_V2, - expectedTokenName: ERC20_EURE_POLYGON_TOKEN_NAME, - expectedValueRaw: VALUE_RAW, - network: Networks.Polygon - }) - ).toThrow("valueRaw"); - }); - - it("rejects a permit whose signed context is missing entirely", () => { - const permit: PermitSignature = { - deadline: Number(DEADLINE), - r: `0x${"0".repeat(64)}`, - s: `0x${"0".repeat(64)}`, - v: 27 - }; - - expect(() => - validateMoneriumOnrampPermit(permit, { - expectedOwner: OWNER.address, - expectedSpender: SPENDER, - expectedTokenAddress: ERC20_EURE_POLYGON_V2, - expectedTokenName: ERC20_EURE_POLYGON_TOKEN_NAME, - expectedValueRaw: VALUE_RAW, - network: Networks.Polygon - }) - ).toThrow("missing signed context"); - }); - - it("rejects a permit signed with a different token version", async () => { - const permit = await signPermit({ tokenVersion: "2" }); - - expect(() => - validateMoneriumOnrampPermit(permit, { - expectedOwner: OWNER.address, - expectedSpender: SPENDER, - expectedTokenAddress: ERC20_EURE_POLYGON_V2, - expectedTokenName: ERC20_EURE_POLYGON_TOKEN_NAME, - expectedValueRaw: VALUE_RAW, - network: Networks.Polygon - }) - ).toThrow("tokenVersion"); - }); - - it("rejects an expired permit before payment details are released", async () => { - const permit = await signPermit({ deadline: "1700000000" }); - - expect(() => - validateMoneriumOnrampPermit( - permit, - { - expectedOwner: OWNER.address, - expectedSpender: SPENDER, - expectedTokenAddress: ERC20_EURE_POLYGON_V2, - expectedTokenName: ERC20_EURE_POLYGON_TOKEN_NAME, - expectedValueRaw: VALUE_RAW, - network: Networks.Polygon - }, - 1700000000 - ) - ).toThrow("has expired"); - }); -}); - -describe("analyzeMoneriumPermitPreflight", () => { - it("skips sending permit when allowance already covers the self-transfer amount", async () => { - const permit = await signPermit(); - - expect( - analyzeMoneriumPermitPreflight( - permit, - { - expectedOwner: OWNER.address, - expectedSpender: SPENDER, - expectedTokenAddress: ERC20_EURE_POLYGON_V2, - expectedTokenName: ERC20_EURE_POLYGON_TOKEN_NAME, - expectedValueRaw: VALUE_RAW, - network: Networks.Polygon - }, - { - allowanceRaw: 2n * BigInt(VALUE_RAW), - balanceRaw: 0n, - nonce: 5n, - tokenName: ERC20_EURE_POLYGON_TOKEN_NAME - }, - 1779970000 - ) - ).toEqual({ reason: "allowance-sufficient", shouldSendPermit: false }); - }); - - it("reports nonce drift before attempting a permit that would revert", async () => { - const permit = await signPermit(); - - expect(() => - analyzeMoneriumPermitPreflight( - permit, - { - expectedOwner: OWNER.address, - expectedSpender: SPENDER, - expectedTokenAddress: ERC20_EURE_POLYGON_V2, - expectedTokenName: ERC20_EURE_POLYGON_TOKEN_NAME, - expectedValueRaw: VALUE_RAW, - network: Networks.Polygon - }, - { - allowanceRaw: 0n, - balanceRaw: BigInt(VALUE_RAW), - nonce: 1n, - tokenName: ERC20_EURE_POLYGON_TOKEN_NAME - }, - 1779970000 - ) - ).toThrow("nonce"); - }); -}); diff --git a/apps/api/src/api/services/ramp/monerium-permit.ts b/apps/api/src/api/services/ramp/monerium-permit.ts deleted file mode 100644 index c3510478a..000000000 --- a/apps/api/src/api/services/ramp/monerium-permit.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { getNetworkId, Networks, PermitSignature } from "@vortexfi/shared"; -import { Signature as EvmSignature, verifyTypedData } from "ethers"; -import httpStatus from "http-status"; -import { APIError } from "../../errors/api-error"; - -const PERMIT_TYPES = { - Permit: [ - { name: "owner", type: "address" }, - { name: "spender", type: "address" }, - { name: "value", type: "uint256" }, - { name: "nonce", type: "uint256" }, - { name: "deadline", type: "uint256" } - ] -}; - -export interface MoneriumPermitExpectation { - expectedOwner: string; - expectedSpender: string; - expectedValueRaw: string; - expectedTokenAddress: `0x${string}`; - expectedTokenName: string; - expectedTokenVersion?: string; - network: Networks; -} - -export interface MoneriumPermitDiagnostics { - allowanceRaw: bigint; - balanceRaw: bigint; - nonce: bigint; - tokenName: string; -} - -function throwBadPermit(message: string): never { - throw new APIError({ - message, - status: httpStatus.BAD_REQUEST - }); -} - -function assertEqual(label: string, actual: string | number | undefined, expected: string | number): void { - if (actual === undefined) { - throwBadPermit(`Monerium permit ${label} is missing from signed context (expected ${String(expected)})`); - } - if (String(actual).toLowerCase() !== String(expected).toLowerCase()) { - throwBadPermit(`Monerium permit ${label} ${String(actual)} does not match expected ${String(expected)}`); - } -} - -function getPermitContext(permit: PermitSignature) { - if (!permit.context) { - throwBadPermit("Monerium permit is missing signed context; please sign again with the latest client"); - } - return permit.context; -} - -function validateMoneriumPermitSignature(permit: PermitSignature, expectation: MoneriumPermitExpectation) { - const context = getPermitContext(permit); - const expectedChainId = getNetworkId(expectation.network); - - assertEqual("owner", context.owner, expectation.expectedOwner); - assertEqual("spender", context.spender, expectation.expectedSpender); - assertEqual("valueRaw", context.valueRaw, expectation.expectedValueRaw); - assertEqual("tokenAddress", context.tokenAddress, expectation.expectedTokenAddress); - assertEqual("tokenName", context.tokenName, expectation.expectedTokenName); - assertEqual("tokenVersion", context.tokenVersion, expectation.expectedTokenVersion ?? "1"); - assertEqual("chainId", context.chainId, expectedChainId); - assertEqual("deadline", context.deadline, permit.deadline); - - const recoveredSigner = verifyTypedData( - { - chainId: context.chainId, - name: context.tokenName, - verifyingContract: context.tokenAddress, - version: context.tokenVersion - }, - PERMIT_TYPES, - { - deadline: context.deadline, - nonce: context.nonce, - owner: context.owner, - spender: context.spender, - value: context.valueRaw - }, - EvmSignature.from({ r: permit.r, s: permit.s, v: permit.v }).serialized - ); - - if (recoveredSigner.toLowerCase() !== expectation.expectedOwner.toLowerCase()) { - throwBadPermit(`Monerium permit signature was produced by ${recoveredSigner}, expected ${expectation.expectedOwner}`); - } - - return context; -} - -function assertPermitDeadlineInFuture(deadline: string, nowSeconds: number): void { - if (BigInt(deadline) <= BigInt(nowSeconds)) { - throwBadPermit(`Monerium permit deadline ${deadline} has expired`); - } -} - -export function validateMoneriumOnrampPermit( - permit: PermitSignature, - expectation: MoneriumPermitExpectation, - nowSeconds = Math.floor(Date.now() / 1000) -): void { - const context = validateMoneriumPermitSignature(permit, expectation); - assertPermitDeadlineInFuture(context.deadline, nowSeconds); -} - -export function analyzeMoneriumPermitPreflight( - permit: PermitSignature, - expectation: MoneriumPermitExpectation, - diagnostics: MoneriumPermitDiagnostics, - nowSeconds = Math.floor(Date.now() / 1000) -): { reason: "allowance-sufficient" | "permit-required"; shouldSendPermit: boolean } { - const context = validateMoneriumPermitSignature(permit, expectation); - - const expectedValueRaw = BigInt(expectation.expectedValueRaw); - - if (diagnostics.allowanceRaw >= expectedValueRaw) { - return { reason: "allowance-sufficient", shouldSendPermit: false }; - } - - if (diagnostics.tokenName !== context.tokenName) { - throwBadPermit( - `Monerium permit tokenName ${context.tokenName} does not match on-chain token name ${diagnostics.tokenName}` - ); - } - - if (BigInt(context.nonce) !== diagnostics.nonce) { - throwBadPermit( - `Monerium permit nonce ${context.nonce} does not match current on-chain nonce ${diagnostics.nonce.toString()}` - ); - } - - assertPermitDeadlineInFuture(context.deadline, nowSeconds); - - return { reason: "permit-required", shouldSendPermit: true }; -} diff --git a/apps/api/src/api/services/ramp/monerium-self-transfer.test.ts b/apps/api/src/api/services/ramp/monerium-self-transfer.test.ts deleted file mode 100644 index 465e22d25..000000000 --- a/apps/api/src/api/services/ramp/monerium-self-transfer.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { inspectMoneriumSelfTransferTransaction } from "./monerium-self-transfer"; - -const rawSelfTransferTx = - "0x02f8d381898085e64020937685e640209376830186a094e0aea583266584dafbb3f9c3211d5588c73fea8d80b86423b872dd000000000000000000000000976ff31a56daf5a0e09f411950311f5877ff00d50000000000000000000000007c4e657eeb8ba8bbf0882c817a7a9f2df55636ad0000000000000000000000000000000000000000000000000e27c49886e60000c001a029c840d52a6634e2ed642d50c306f08a379f8466a10c332e07f03bc85da1ae52a00ae865be836a16b25bbe9d647085930d4b0b1cedf3d3e84e127e14f7dddf660e"; - -const expectation = { - expectedAmountRaw: "1020000000000000000", - expectedOwner: "0x976fF31a56dAF5A0E09F411950311F5877ff00D5" as const, - expectedRecipient: "0x7c4E657EEb8bA8bBF0882C817A7A9f2Df55636AD" as const, - expectedSigner: "0x7c4E657EEb8bA8bBF0882C817A7A9f2Df55636AD" as const, - rampId: "ramp-1" -}; - -describe("inspectMoneriumSelfTransferTransaction", () => { - it("decodes and validates a signed Monerium self-transfer", async () => { - const inspection = await inspectMoneriumSelfTransferTransaction(rawSelfTransferTx, expectation); - - expect(inspection.amountRaw).toBe(1020000000000000000n); - expect(inspection.owner.toLowerCase()).toBe(expectation.expectedOwner.toLowerCase()); - expect(inspection.recipient.toLowerCase()).toBe(expectation.expectedRecipient.toLowerCase()); - expect(inspection.signer.toLowerCase()).toBe(expectation.expectedSigner.toLowerCase()); - expect(inspection.signedGas).toBe(100000n); - expect(inspection.signedNonce).toBe(0); - expect(inspection.tokenAddress.toLowerCase()).toBe("0xe0aea583266584dafbb3f9c3211d5588c73fea8d"); - }); - - it("rejects a signed transfer for the wrong amount", async () => { - await expect( - inspectMoneriumSelfTransferTransaction(rawSelfTransferTx, { - ...expectation, - expectedAmountRaw: "1020000000000000001" - }) - ).rejects.toThrow("Self-transfer amount 1020000000000000000 does not match expected 1020000000000000001"); - }); - - it("accepts a signed transfer when chainId matches the expected network", async () => { - const inspection = await inspectMoneriumSelfTransferTransaction(rawSelfTransferTx, { - ...expectation, - expectedChainId: 137 - }); - - expect(inspection.amountRaw).toBe(1020000000000000000n); - }); - - it("rejects a signed transfer when chainId does not match the expected network", async () => { - await expect( - inspectMoneriumSelfTransferTransaction(rawSelfTransferTx, { - ...expectation, - expectedChainId: 1 - }) - ).rejects.toThrow("Self-transfer chainId 137 does not match expected 1"); - }); -}); diff --git a/apps/api/src/api/services/ramp/monerium-self-transfer.ts b/apps/api/src/api/services/ramp/monerium-self-transfer.ts deleted file mode 100644 index 9a0652a8d..000000000 --- a/apps/api/src/api/services/ramp/monerium-self-transfer.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { ERC20_EURE_POLYGON_V2 } from "@vortexfi/shared"; -import { decodeFunctionData, isAddress, parseTransaction, recoverTransactionAddress } from "viem"; - -export const moneriumTransferFromAbi = [ - { - inputs: [ - { name: "from", type: "address" }, - { name: "to", type: "address" }, - { name: "value", type: "uint256" } - ], - name: "transferFrom", - outputs: [{ name: "", type: "bool" }], - stateMutability: "nonpayable", - type: "function" - } -] as const; - -type RecoverableSerializedTransaction = Parameters[0]["serializedTransaction"]; - -interface MoneriumSelfTransferExpectation { - expectedAmountRaw: string; - expectedChainId?: number; - expectedOwner: `0x${string}`; - expectedRecipient: `0x${string}`; - expectedSigner: `0x${string}`; - expectedTokenAddress?: `0x${string}`; - rampId: string; -} - -export interface MoneriumSelfTransferInspection { - amountRaw: bigint; - owner: `0x${string}`; - recipient: `0x${string}`; - serializedTransaction: RecoverableSerializedTransaction; - signedGas: bigint; - signedNonce: number; - signer: `0x${string}`; - tokenAddress: `0x${string}`; -} - -function requireAddress(value: string | null | undefined, label: string, rampId: string): `0x${string}` { - if (!value || !isAddress(value)) { - throw new Error(`[${rampId}] ${label} ${value ?? ""} is not a valid EVM address`); - } - - return value as `0x${string}`; -} - -export async function inspectMoneriumSelfTransferTransaction( - txData: string, - expectation: MoneriumSelfTransferExpectation -): Promise { - const serializedTransaction = txData as RecoverableSerializedTransaction; - const parsedTx = parseTransaction(serializedTransaction); - const signer = requireAddress( - await recoverTransactionAddress({ serializedTransaction }), - "Self-transfer signer", - expectation.rampId - ); - const expectedTokenAddress = expectation.expectedTokenAddress ?? ERC20_EURE_POLYGON_V2; - const tokenAddress = requireAddress(parsedTx.to, "Self-transfer token", expectation.rampId); - const signedNonce = parsedTx.nonce; - - if (signedNonce === undefined) { - throw new Error(`[${expectation.rampId}] Self-transfer signed transaction is missing a nonce`); - } - - if (signer.toLowerCase() !== expectation.expectedSigner.toLowerCase()) { - throw new Error( - `[${expectation.rampId}] Self-transfer signer ${signer} does not match expected EVM ephemeral ${expectation.expectedSigner}` - ); - } - - if (tokenAddress.toLowerCase() !== expectedTokenAddress.toLowerCase()) { - throw new Error( - `[${expectation.rampId}] Self-transfer token ${tokenAddress} does not match expected ${expectedTokenAddress}` - ); - } - - if (expectation.expectedChainId !== undefined && parsedTx.chainId !== expectation.expectedChainId) { - throw new Error( - `[${expectation.rampId}] Self-transfer chainId ${parsedTx.chainId} does not match expected ${expectation.expectedChainId}` - ); - } - - const decodedTransfer = decodeFunctionData({ - abi: moneriumTransferFromAbi, - data: parsedTx.data ?? "0x" - }); - const [decodedOwner, decodedRecipient, amountRaw] = decodedTransfer.args; - const owner = requireAddress(decodedOwner, "Self-transfer owner", expectation.rampId); - const recipient = requireAddress(decodedRecipient, "Self-transfer recipient", expectation.rampId); - const expectedAmount = BigInt(expectation.expectedAmountRaw); - - if (owner.toLowerCase() !== expectation.expectedOwner.toLowerCase()) { - throw new Error( - `[${expectation.rampId}] Self-transfer owner ${owner} does not match expected ${expectation.expectedOwner}` - ); - } - if (recipient.toLowerCase() !== expectation.expectedRecipient.toLowerCase()) { - throw new Error( - `[${expectation.rampId}] Self-transfer recipient ${recipient} does not match expected ${expectation.expectedRecipient}` - ); - } - if (amountRaw !== expectedAmount) { - throw new Error( - `[${expectation.rampId}] Self-transfer amount ${amountRaw.toString()} does not match expected ${expectation.expectedAmountRaw}` - ); - } - - return { - amountRaw, - owner, - recipient, - serializedTransaction, - signedGas: parsedTx.gas ?? 0n, - signedNonce, - signer, - tokenAddress - }; -} diff --git a/apps/api/src/api/services/ramp/ramp-transaction-preparation.test.ts b/apps/api/src/api/services/ramp/ramp-transaction-preparation.test.ts index 12fec56aa..491d5cfe0 100644 --- a/apps/api/src/api/services/ramp/ramp-transaction-preparation.test.ts +++ b/apps/api/src/api/services/ramp/ramp-transaction-preparation.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { FiatToken, RampDirection } from "@vortexfi/shared"; -import { - RampTransactionPreparationKind, - selectRampTransactionPreparationKind -} from "./ramp-transaction-preparation"; +import { FiatToken, Networks, RampDirection } from "@vortexfi/shared"; +import { RampTransactionPreparationKind, selectRampTransactionPreparationKind } from "./ramp-transaction-preparation"; describe("selectRampTransactionPreparationKind", () => { it("selects the BRL offramp preparer for sell quotes that output BRL", () => { @@ -16,7 +13,7 @@ describe("selectRampTransactionPreparationKind", () => { ).toBe(RampTransactionPreparationKind.OfframpBrl); }); - it("uses the Monerium offramp preparer only when the Monerium auth token is present", () => { + it("selects the non-BRL offramp preparer for EUR sell quotes (Mykobo offramp handled downstream)", () => { expect( selectRampTransactionPreparationKind({ inputCurrency: FiatToken.EURC, @@ -24,28 +21,28 @@ describe("selectRampTransactionPreparationKind", () => { rampType: RampDirection.SELL }) ).toBe(RampTransactionPreparationKind.OfframpNonBrl); + }); + it("routes EURC onramps to Mykobo on every supported destination", () => { expect( - selectRampTransactionPreparationKind( - { - inputCurrency: FiatToken.EURC, - outputCurrency: FiatToken.EURC, - rampType: RampDirection.SELL - }, - { moneriumAuthToken: "token" } - ) - ).toBe(RampTransactionPreparationKind.OfframpMonerium); - }); + selectRampTransactionPreparationKind({ + inputCurrency: FiatToken.EURC, + outputCurrency: FiatToken.EURC, + rampType: RampDirection.BUY, + to: Networks.Base + }) + ).toBe(RampTransactionPreparationKind.OnrampMykobo); - it("selects onramp preparers from the fiat input token", () => { expect( selectRampTransactionPreparationKind({ inputCurrency: FiatToken.EURC, outputCurrency: FiatToken.EURC, rampType: RampDirection.BUY }) - ).toBe(RampTransactionPreparationKind.OnrampMonerium); + ).toBe(RampTransactionPreparationKind.OnrampMykobo); + }); + it("selects non-EURC onramp preparers from the fiat input token", () => { expect( selectRampTransactionPreparationKind({ inputCurrency: FiatToken.USD, diff --git a/apps/api/src/api/services/ramp/ramp-transaction-preparation.ts b/apps/api/src/api/services/ramp/ramp-transaction-preparation.ts index 1af5cf67a..3c763efed 100644 --- a/apps/api/src/api/services/ramp/ramp-transaction-preparation.ts +++ b/apps/api/src/api/services/ramp/ramp-transaction-preparation.ts @@ -2,35 +2,33 @@ import { FiatToken, isAlfredpayToken, RampDirection, RegisterRampRequest } from export enum RampTransactionPreparationKind { OfframpBrl = "offramp-brl", - OfframpMonerium = "offramp-monerium", OfframpNonBrl = "offramp-non-brl", OnrampAlfredpay = "onramp-alfredpay", OnrampAvenia = "onramp-avenia", - OnrampMonerium = "onramp-monerium" + OnrampMykobo = "onramp-mykobo" } export interface RampTransactionPreparationQuote { inputCurrency: string; outputCurrency: string; rampType: RampDirection; + to?: string; } export function selectRampTransactionPreparationKind( quote: RampTransactionPreparationQuote, - additionalData?: RegisterRampRequest["additionalData"] + _additionalData?: RegisterRampRequest["additionalData"] ): RampTransactionPreparationKind { if (quote.rampType === RampDirection.SELL) { if (quote.outputCurrency === FiatToken.BRL) { return RampTransactionPreparationKind.OfframpBrl; } - return additionalData?.moneriumAuthToken - ? RampTransactionPreparationKind.OfframpMonerium - : RampTransactionPreparationKind.OfframpNonBrl; + return RampTransactionPreparationKind.OfframpNonBrl; } if (quote.inputCurrency === FiatToken.EURC) { - return RampTransactionPreparationKind.OnrampMonerium; + return RampTransactionPreparationKind.OnrampMykobo; } if (isAlfredpayToken(quote.inputCurrency as FiatToken)) { diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 9d6520724..d1da0ade9 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -12,9 +12,6 @@ import { BrlaCurrency, CreateAlfredpayOnrampRequest, EphemeralAccountType, - ERC20_EURE_POLYGON_TOKEN_NAME, - ERC20_EURE_POLYGON_V2, - EvmNetworks, FiatToken, GetRampHistoryResponse, GetRampStatusResponse, @@ -22,7 +19,9 @@ import { IbanPaymentData, isAlfredpayToken, Limit, - MoneriumErrors, + MykoboApiService, + MykoboCurrency, + MykoboTransactionType, Networks, normalizeTaxId, QuoteError, @@ -43,41 +42,39 @@ import { import Big from "big.js"; import httpStatus from "http-status"; import { Op, Transaction, WhereOptions } from "sequelize"; -import { StrKey } from "stellar-sdk"; import { isAddress } from "viem"; import logger from "../../../config/logger"; import { config } from "../../../config/vars"; -import { SEQUENCE_TIME_WINDOW_IN_SECONDS } from "../../../constants/constants"; import Partner from "../../../models/partner.model"; import QuoteTicket from "../../../models/quoteTicket.model"; import RampState, { RampStateAttributes } from "../../../models/rampState.model"; import TaxId from "../../../models/taxId.model"; import { APIError } from "../../errors/api-error"; import { ActivePartner, handleQuoteConsumptionForDiscountState } from "../../services/quote/engines/discount/helpers"; -import { createEpcQrCodeData, getIbanForAddress } from "../monerium"; +import { syncMykoboCustomerKyc } from "../mykobo/mykobo-customer.service"; import { StateMetadata } from "../phases/meta-state-types"; import phaseProcessor from "../phases/phase-processor"; import { PriceFeedService } from "../priceFeed.service"; import { prepareOfframpTransactions } from "../transactions/offramp"; import { prepareOnrampTransactions } from "../transactions/onramp"; -import { AveniaOnrampTransactionParams, MoneriumOnrampTransactionParams } from "../transactions/onramp/common/types"; +import { AveniaOnrampTransactionParams } from "../transactions/onramp/common/types"; +import { prepareMykoboToEvmOnrampTransactions } from "../transactions/onramp/routes/mykobo-to-evm"; import { validatePresignedTxs } from "../transactions/validation"; import webhookDeliveryService from "../webhook/webhook-delivery.service"; import { BaseRampService } from "./base.service"; import { validateEphemeralAccountsFresh } from "./ephemeral-freshness"; import { getFinalTransactionHashForRamp } from "./helpers"; -import { validateMoneriumOnrampPermit } from "./monerium-permit"; import { RampTransactionPreparationKind, selectRampTransactionPreparationKind } from "./ramp-transaction-preparation"; -const RAMP_START_EXPIRATION_TIME_SECONDS = SEQUENCE_TIME_WINDOW_IN_SECONDS * 0.8; +const RAMP_START_EXPIRATION_TIME_SECONDS = 480; // Classifies unsigned txs by signer: ephemeral-signed (backend pre-signs) vs user-wallet-signed. function partitionUnsignedTxs( unsignedTxs: UnsignedTx[], - ephemerals: { evm?: string; substrate?: string; stellar?: string } + ephemerals: { evm?: string; substrate?: string } ): { ephemeralTxs: UnsignedTx[]; userWalletTxs: UnsignedTx[] } { const ephemeralSigners = new Set( - [ephemerals.evm, ephemerals.substrate, ephemerals.stellar].filter((v): v is string => Boolean(v)).map(s => s.toLowerCase()) + [ephemerals.evm, ephemerals.substrate].filter((v): v is string => Boolean(v)).map(s => s.toLowerCase()) ); const ephemeralTxs: UnsignedTx[] = []; @@ -101,7 +98,6 @@ function filterUnsignedTxsForResponse(rampState: RampState, ephemeralPresignChec const { ephemeralTxs } = partitionUnsignedTxs(rampState.unsignedTxs, { evm: rampState.state.evmEphemeralAddress, - stellar: rampState.state.stellarEphemeralAccountId, substrate: rampState.state.substrateEphemeralAddress }); return ephemeralTxs; @@ -117,12 +113,6 @@ function validateAddressFormat(address: string, type: EphemeralAccountType): voi } switch (type) { - case EphemeralAccountType.Stellar: - if (!StrKey.isValidEd25519PublicKey(address)) { - throw new Error(`Invalid Stellar address format: "${address}". Expected a valid Ed25519 public key.`); - } - break; - case EphemeralAccountType.Substrate: try { decodeAddress(address); @@ -259,7 +249,6 @@ export class RampService extends BaseRampService { depositQrCode, evmEphemeralAddress: ephemerals.EVM, ibanPaymentData, - stellarEphemeralAccountId: ephemerals.Stellar, substrateEphemeralAddress: ephemerals.Substrate, ...request.additionalData, ...stateMeta @@ -335,7 +324,6 @@ export class RampService extends BaseRampService { // Validate presigned transactions, if some were supplied const ephemerals: { [key in EphemeralAccountType]: string } = { EVM: rampState.state.evmEphemeralAddress, - Stellar: rampState.state.stellarEphemeralAccountId, Substrate: rampState.state.substrateEphemeralAddress }; if (presignedTxs && presignedTxs.length > 0) { @@ -455,7 +443,6 @@ export class RampService extends BaseRampService { // Validate presigned transactions const ephemerals: { [key in EphemeralAccountType]: string } = { EVM: rampState.state.evmEphemeralAddress, - Stellar: rampState.state.stellarEphemeralAccountId, Substrate: rampState.state.substrateEphemeralAddress }; await validatePresignedTxs(rampState.type, rampState.presignedTxs, ephemerals, rampState.unsignedTxs); @@ -1036,7 +1023,6 @@ export class RampService extends BaseRampService { quote, receiverTaxId: additionalData.receiverTaxId, signingAccounts: normalizedSigningAccounts, - stellarPaymentData: additionalData.paymentData, taxId: additionalData.taxId, userAddress: additionalData.walletAddress }); @@ -1051,10 +1037,12 @@ export class RampService extends BaseRampService { userId?: string ): Promise<{ unsignedTxs: UnsignedTx[]; stateMeta: Partial }> { const { unsignedTxs, stateMeta } = await prepareOfframpTransactions({ + destinationAddress: additionalData?.destinationAddress, + email: additionalData?.email, fiatAccountId: additionalData?.fiatAccountId as string | undefined, + ipAddress: additionalData?.ipAddress, quote, signingAccounts: normalizedSigningAccounts, - stellarPaymentData: additionalData?.paymentData, userAddress: additionalData?.walletAddress, userId }); @@ -1123,87 +1111,71 @@ export class RampService extends BaseRampService { return { stateMeta: stateMeta as Partial, unsignedTxs }; } - private async prepareMoneriumOnrampTransactions( + private async prepareMykoboOnrampTransactions( quote: QuoteTicket, normalizedSigningAccounts: AccountMeta[], - additionalData: RegisterRampRequest["additionalData"] + additionalData: RegisterRampRequest["additionalData"], + userId?: string ): Promise<{ unsignedTxs: UnsignedTx[]; stateMeta: Partial; - depositQrCode: string; ibanPaymentData?: IbanPaymentData; }> { - if ( - !additionalData || - !additionalData.moneriumAuthToken || - !additionalData.destinationAddress || - !additionalData.moneriumWalletAddress - ) { + if (!additionalData?.destinationAddress || !additionalData?.email || !additionalData?.ipAddress) { throw new APIError({ - message: "Parameters moneriumAuthToken, destinationAddress and moneriumWalletAddress are required for Monerium onramp", + message: "Parameters destinationAddress, email and ipAddress are required for Mykobo EUR onramp", status: httpStatus.BAD_REQUEST }); } - try { - // Validate the user mint address - const ibanData = await getIbanForAddress( - additionalData.moneriumWalletAddress, - additionalData.moneriumAuthToken, - quote.to as EvmNetworks // Fixme: assethub network type issue. - ); - - const params: MoneriumOnrampTransactionParams = { - destinationAddress: additionalData.destinationAddress, - moneriumWalletAddress: additionalData.moneriumWalletAddress, - quote, - signingAccounts: normalizedSigningAccounts - }; - - const { unsignedTxs, stateMeta } = await prepareOnrampTransactions(params); - - const ibanPaymentData = { - bic: ibanData.bic, - iban: ibanData.iban, - receiverName: ibanData.name - }; - - const ibanCode = createEpcQrCodeData({ - amount: quote.inputAmount, - bic: ibanData.bic, - iban: ibanData.iban, - name: ibanData.name + const evmEphemeralEntry = normalizedSigningAccounts.find(account => account.type === "EVM"); + if (!evmEphemeralEntry) { + throw new APIError({ + message: "EVM ephemeral account is required for Mykobo EUR onramp", + status: httpStatus.BAD_REQUEST }); - return { depositQrCode: ibanCode, ibanPaymentData, stateMeta: stateMeta as Partial, unsignedTxs }; - } catch (error) { - if (error instanceof Error && error.message.includes(MoneriumErrors.USER_MINT_ADDRESS_NOT_FOUND)) { - throw new APIError({ - message: MoneriumErrors.USER_MINT_ADDRESS_NOT_FOUND, - status: httpStatus.BAD_REQUEST - }); - } - throw error; } - } - private async prepareMoneriumOfframpTransactions( - quote: QuoteTicket, - normalizedSigningAccounts: AccountMeta[], - additionalData: RegisterRampRequest["additionalData"] - ): Promise<{ unsignedTxs: UnsignedTx[]; stateMeta: Partial }> { - if (!additionalData || additionalData.walletAddress === undefined || !additionalData.moneriumAuthToken) { + const mykobo = MykoboApiService.getInstance(); + const intent = await mykobo.createTransactionIntent({ + currency: MykoboCurrency.EURC, + email_address: additionalData.email, + ip_address: additionalData.ipAddress, + transaction_type: MykoboTransactionType.DEPOSIT, + value: new Big(quote.inputAmount).toFixed(2, 0), + wallet_address: evmEphemeralEntry.address + }); + + const instructions = intent.instructions; + if (!instructions || !("iban" in instructions)) { throw new APIError({ - message: "Parameters walletAddress and moneriumAuthToken is required for Monerium onramp", - status: httpStatus.BAD_REQUEST + message: "Mykobo deposit intent did not return IBAN instructions", + status: httpStatus.BAD_GATEWAY }); } - const { unsignedTxs, stateMeta } = await prepareOfframpTransactions({ - moneriumAuthToken: additionalData.moneriumAuthToken, + + const { unsignedTxs, stateMeta } = await prepareMykoboToEvmOnrampTransactions({ + destinationAddress: additionalData.destinationAddress, + ipAddress: additionalData.ipAddress, + mykoboEmail: additionalData.email, + mykoboTransactionId: intent.transaction.id, + mykoboTransactionReference: intent.transaction.reference, quote, - signingAccounts: [], - userAddress: additionalData.walletAddress + signingAccounts: normalizedSigningAccounts }); - return { stateMeta: stateMeta as Partial, unsignedTxs }; + + const ibanPaymentData: IbanPaymentData = { + bic: "", + iban: instructions.iban, + receiverName: instructions.bank_account_name, + reference: intent.transaction.reference + }; + + if (userId) { + await syncMykoboCustomerKyc(userId, additionalData.email); + } + + return { ibanPaymentData, stateMeta: stateMeta as Partial, unsignedTxs }; } private async prepareRampTransactions( @@ -1223,14 +1195,11 @@ export class RampService extends BaseRampService { case RampTransactionPreparationKind.OfframpBrl: return this.prepareOfframpBrlTransactions(quote, normalizedSigningAccounts, additionalData); - case RampTransactionPreparationKind.OfframpMonerium: - return this.prepareMoneriumOfframpTransactions(quote, normalizedSigningAccounts, additionalData); - case RampTransactionPreparationKind.OfframpNonBrl: return this.prepareOfframpNonBrlTransactions(quote, normalizedSigningAccounts, additionalData, userId); - case RampTransactionPreparationKind.OnrampMonerium: - return this.prepareMoneriumOnrampTransactions(quote, normalizedSigningAccounts, additionalData); + case RampTransactionPreparationKind.OnrampMykobo: + return this.prepareMykoboOnrampTransactions(quote, normalizedSigningAccounts, additionalData, userId); case RampTransactionPreparationKind.OnrampAlfredpay: return this.prepareAlfredpayOnrampTransactions(quote, normalizedSigningAccounts, additionalData, userId); @@ -1243,7 +1212,6 @@ export class RampService extends BaseRampService { private async ephemeralPresignChecksPass(rampState: RampState): Promise { const ephemerals: { [key in EphemeralAccountType]: string } = { EVM: rampState.state.evmEphemeralAddress, - Stellar: rampState.state.stellarEphemeralAccountId, Substrate: rampState.state.substrateEphemeralAddress }; @@ -1260,7 +1228,6 @@ export class RampService extends BaseRampService { const ephemerals: { [key in EphemeralAccountType]: string } = { EVM: rampState.state.evmEphemeralAddress, - Stellar: rampState.state.stellarEphemeralAccountId, Substrate: rampState.state.substrateEphemeralAddress }; @@ -1291,42 +1258,15 @@ export class RampService extends BaseRampService { message: `Missing required additional data 'assethubToPendulumHash' for ${rampState.type} ramp. Cannot proceed.`, status: httpStatus.BAD_REQUEST }); - } else if (rampState.from !== Networks.AssetHub && !rampState.state.squidRouterSwapHash) { - throw new APIError({ - message: `Missing required additional data 'squidRouterSwapHash' for ${rampState.type} ramp. Cannot proceed.`, - status: httpStatus.BAD_REQUEST - }); - } - } - - if (rampState.type === RampDirection.BUY && quote.inputCurrency === FiatToken.EURC) { - if (!rampState.state.moneriumOnrampPermit) { - throw new APIError({ - message: "Missing moneriumOnrampPermit in state. Cannot proceed.", - status: httpStatus.BAD_REQUEST - }); - } - if (!quote.metadata.moneriumMint?.outputAmountRaw) { - throw new APIError({ - message: "Missing moneriumMint.outputAmountRaw in quote metadata. Cannot validate Monerium onramp permit.", - status: httpStatus.BAD_REQUEST - }); - } - if (!rampState.state.moneriumWalletAddress || !rampState.state.evmEphemeralAddress) { - throw new APIError({ - message: "Missing Monerium wallet or EVM ephemeral address in state. Cannot validate Monerium onramp permit.", - status: httpStatus.BAD_REQUEST - }); + } else if (rampState.from !== Networks.AssetHub) { + const requiresSquidSwapHash = rampState.unsignedTxs.some(tx => tx.phase === "squidRouterSwap"); + if (requiresSquidSwapHash && !rampState.state.squidRouterSwapHash) { + throw new APIError({ + message: `Missing required additional data 'squidRouterSwapHash' for ${rampState.type} ramp. Cannot proceed.`, + status: httpStatus.BAD_REQUEST + }); + } } - - validateMoneriumOnrampPermit(rampState.state.moneriumOnrampPermit, { - expectedOwner: rampState.state.moneriumWalletAddress, - expectedSpender: rampState.state.evmEphemeralAddress, - expectedTokenAddress: ERC20_EURE_POLYGON_V2, - expectedTokenName: ERC20_EURE_POLYGON_TOKEN_NAME, - expectedValueRaw: quote.metadata.moneriumMint.outputAmountRaw, - network: Networks.Polygon - }); } } diff --git a/apps/api/src/api/services/sep10/helpers.ts b/apps/api/src/api/services/sep10/helpers.ts deleted file mode 100644 index a64a71d07..000000000 --- a/apps/api/src/api/services/sep10/helpers.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { TOKEN_CONFIG } from "@vortexfi/shared"; -import { Operation, Transaction } from "stellar-sdk"; - -interface TokenConfig { - tomlFileUrl: string; - homeDomain: string; - clientDomainEnabled: boolean; - memoEnabled: boolean; -} - -export const getOutToken = (outToken: keyof typeof TOKEN_CONFIG): TokenConfig => TOKEN_CONFIG[outToken] as TokenConfig; - -export const validateTransaction = (transaction: Transaction, anchorSigningKey: string, memo: string | null) => { - if (transaction.source !== anchorSigningKey) { - throw new Error(`Invalid source account: ${transaction.source}`); - } - if (transaction.sequence !== "0") { - throw new Error(`Invalid sequence number: ${transaction.sequence}`); - } - if (transaction.memo.value !== memo) { - throw new Error("Memo does not match with specified user signature or address. Could not validate."); - } -}; - -export const validateFirstOperation = ( - operation: Operation, - clientPublicKey: string, - homeDomain: string, - memo: string | null, - memoEnabled: boolean, - masterPublicKey: string -) => { - if (operation.type !== "manageData") { - throw new Error("The first operation should be manageData"); - } - - if (operation.source !== clientPublicKey) { - throw new Error("First manageData operation must have the client account as the source"); - } - - if (memo !== null && memoEnabled) { - if (operation.source !== masterPublicKey) { - throw new Error("First manageData operation must have the master signing key as the source when memo is being used."); - } - } - - if (operation.name !== `${homeDomain} auth`) { - throw new Error(`First manageData operation should have key '${homeDomain} auth'`); - } - if (!operation.value || operation.value.length !== 64) { - throw new Error("First manageData operation should have a 64-byte random nonce as value"); - } -}; - -export const validateRemainingOperations = ( - operations: Operation[], - anchorSigningKey: string, - clientDomainPublicKey: string, - clientDomainEnabled: boolean -) => { - let hasWebAuthDomain = false; - let hasClientDomain = false; - - for (let i = 1; i < operations.length; i++) { - const op = operations[i]; - - if (op.type !== "manageData") { - throw new Error("All operations should be manage_data operations"); - } - - if (op.name === "web_auth_domain") { - hasWebAuthDomain = true; - if (op.source !== anchorSigningKey) { - throw new Error("web_auth_domain manage_data operation must have the server account as the source"); - } - } - - if (op.name === "client_domain") { - hasClientDomain = true; - if (op.source !== clientDomainPublicKey) { - throw new Error("client_domain manage_data operation must have the client domain account as the source"); - } - } - } - - if (!hasWebAuthDomain) { - throw new Error("Transaction must contain a web_auth_domain manageData operation"); - } - if (!hasClientDomain && clientDomainEnabled) { - throw new Error("Transaction must contain a client_domain manageData operation"); - } -}; diff --git a/apps/api/src/api/services/sep10/sep10.service.ts b/apps/api/src/api/services/sep10/sep10.service.ts deleted file mode 100644 index b51b0c17e..000000000 --- a/apps/api/src/api/services/sep10/sep10.service.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { FiatToken, TOKEN_CONFIG } from "@vortexfi/shared"; -import { Keypair, Networks, Transaction, TransactionBuilder } from "stellar-sdk"; -import { config, SEP10_MASTER_SECRET } from "../../../config/vars"; -import { fetchTomlValues } from "../../helpers/anchors"; -import { getOutToken, validateFirstOperation, validateRemainingOperations, validateTransaction } from "./helpers"; - -const NETWORK_PASSPHRASE = config.sandboxEnabled ? Networks.TESTNET : Networks.PUBLIC; - -interface TomlValues { - signingKey: string; -} - -interface Sep10Response { - clientSignature?: string; - clientPublic: string; - masterClientSignature?: string; - masterClientPublic: string; -} - -export const signSep10Challenge = async ( - challengeXDR: string, - fiatToken: FiatToken, - clientPublicKey: string, - memo: string | null -): Promise => { - if (!SEP10_MASTER_SECRET || !config.secrets.clientDomainSecret) { - throw new Error("Missing required secrets"); - } - const masterStellarKeypair = Keypair.fromSecret(SEP10_MASTER_SECRET); - const clientDomainStellarKeypair = Keypair.fromSecret(config.secrets.clientDomainSecret); - - // Map FiatToken enum values to TOKEN_CONFIG keys - const tokenMapping: Record = { - [FiatToken.EURC]: "EURC", - [FiatToken.ARS]: "ARS", - [FiatToken.BRL]: "BRL", - [FiatToken.USD]: "USDC", - [FiatToken.MXN]: "USDC", - [FiatToken.COP]: "USDC" - }; - - const outToken = tokenMapping[fiatToken]; - - const outTokenConfig = getOutToken(outToken); - const { signingKey: anchorSigningKey } = (await fetchTomlValues(outTokenConfig.tomlFileUrl)) as TomlValues; - const { homeDomain, clientDomainEnabled, memoEnabled } = outTokenConfig; - const transactionSigned = TransactionBuilder.fromXDR(challengeXDR, NETWORK_PASSPHRASE); - - if (!(transactionSigned instanceof Transaction)) { - throw new Error("Expected a Transaction, got a FeeBumpTransaction"); - } - - validateTransaction(transactionSigned, anchorSigningKey, memo); - - const { operations } = transactionSigned; - validateFirstOperation(operations[0], clientPublicKey, homeDomain, memo, memoEnabled, masterStellarKeypair.publicKey()); - - validateRemainingOperations(operations, anchorSigningKey, clientDomainStellarKeypair.publicKey(), clientDomainEnabled); - - const clientDomainSignature = clientDomainEnabled - ? transactionSigned.getKeypairSignature(clientDomainStellarKeypair) - : undefined; - - const masterClientSignature = - memo !== null && memoEnabled ? transactionSigned.getKeypairSignature(masterStellarKeypair) : undefined; - - return { - clientPublic: clientDomainStellarKeypair.publicKey(), - clientSignature: clientDomainSignature, - masterClientPublic: masterStellarKeypair.publicKey(), - masterClientSignature - }; -}; diff --git a/apps/api/src/api/services/stellar.service.ts b/apps/api/src/api/services/stellar.service.ts deleted file mode 100644 index 8ad2b4436..000000000 --- a/apps/api/src/api/services/stellar.service.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { getTokenConfigByAssetCode, HORIZON_URL, StellarTokenConfig, TOKEN_CONFIG } from "@vortexfi/shared"; -import { Asset, Horizon, Keypair, Networks, Operation, TransactionBuilder } from "stellar-sdk"; -import { config } from "../../config/vars"; -import { STELLAR_EPHEMERAL_STARTING_BALANCE_UNITS } from "../../constants/constants"; - -interface CreationTxResult { - signature: string; - sequence: string; -} - -// Constants -export const horizonServer = new Horizon.Server(HORIZON_URL); -const NETWORK_PASSPHRASE = config.sandboxEnabled ? Networks.TESTNET : Networks.PUBLIC; - -async function buildCreationStellarTx( - fundingSecret: string, - ephemeralAccountId: string, - maxTime: number, - assetCode: string, - baseFee: string -): Promise { - const tokenConfig = getTokenConfigByAssetCode(TOKEN_CONFIG, assetCode) as StellarTokenConfig; - if (!tokenConfig) { - throw new Error("Invalid asset id or configuration not found"); - } - - const fundingAccountKeypair = Keypair.fromSecret(fundingSecret); - const fundingAccountId = fundingAccountKeypair.publicKey(); - const fundingAccount = await horizonServer.loadAccount(fundingAccountId); - const fundingSequence = fundingAccount.sequence; - // add a setOption oeration in order to make this a 2-of-2 multisig account where the - // funding account is a cosigner - const createAccountTransaction = new TransactionBuilder(fundingAccount, { - fee: baseFee, - networkPassphrase: NETWORK_PASSPHRASE - }) - .addOperation( - Operation.createAccount({ - destination: ephemeralAccountId, - startingBalance: STELLAR_EPHEMERAL_STARTING_BALANCE_UNITS - }) - ) - .addOperation( - Operation.setOptions({ - highThreshold: 2, - lowThreshold: 2, - medThreshold: 2, - signer: { ed25519PublicKey: fundingAccountId, weight: 1 }, - source: ephemeralAccountId - }) - ) - .addOperation( - Operation.changeTrust({ - asset: new Asset(tokenConfig.assetCode, tokenConfig.assetIssuer), - source: ephemeralAccountId - }) - ) - .setTimebounds(0, maxTime) - .build(); - - return { - sequence: fundingSequence, - signature: createAccountTransaction.getKeypairSignature(fundingAccountKeypair) - }; -} - -export { buildCreationStellarTx }; diff --git a/apps/api/src/api/services/stellar/checkBalance.ts b/apps/api/src/api/services/stellar/checkBalance.ts deleted file mode 100644 index 70f975263..000000000 --- a/apps/api/src/api/services/stellar/checkBalance.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { HORIZON_URL } from "@vortexfi/shared"; -import Big from "big.js"; -import { Horizon } from "stellar-sdk"; - -import logger from "../../../config/logger"; - -export function checkBalancePeriodically( - stellarTargetAccountId: string, - stellarAssetCode: string, - amountDesiredUnitsBig: Big, - intervalMs: number, - timeoutMs: number -) { - return new Promise((resolve, reject) => { - const startTime = Date.now(); - const intervalId = setInterval(async () => { - try { - const someBalanceUnits = await getStellarBalanceUnits(stellarTargetAccountId, stellarAssetCode); - logger.info(`Balance check: ${someBalanceUnits.toString()} / ${amountDesiredUnitsBig.toString()}`); - - if (someBalanceUnits.gte(amountDesiredUnitsBig)) { - clearInterval(intervalId); - resolve(someBalanceUnits); - } else if (Date.now() - startTime > timeoutMs) { - clearInterval(intervalId); - reject(new Error(`Balance did not meet the limit within the specified time (${timeoutMs} ms)`)); - } - } catch (error) { - logger.error("Error checking balance:", error); - // Don't clear the interval here, allow it to continue checking - } - }, intervalMs); - - // Set a timeout to reject the promise if the total time exceeds timeoutMs - setTimeout(() => { - clearInterval(intervalId); - reject(new Error(`Balance did not meet the limit within the specified time (${timeoutMs} ms)`)); - }, timeoutMs); - }); -} - -const getStellarBalanceUnits = async (publicKey: string, assetCode: string): Promise => { - try { - const server = new Horizon.Server(HORIZON_URL); - const account = await server.loadAccount(publicKey); - let balanceUnits = "0"; - account.balances.forEach(balance => { - if (balance.asset_type === "credit_alphanum4" && balance.asset_code === assetCode) { - balanceUnits = balance.balance; - } - }); - - return new Big(balanceUnits); - } catch (error) { - logger.error(error); - throw new Error("Error Reading Stellar Balance"); - } -}; diff --git a/apps/api/src/api/services/stellar/getVaults.ts b/apps/api/src/api/services/stellar/getVaults.ts deleted file mode 100644 index 8b6bd866f..000000000 --- a/apps/api/src/api/services/stellar/getVaults.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { SpacewalkPrimitivesVaultId } from "@pendulum-chain/types/interfaces"; -import { ApiPromise } from "@polkadot/api"; -import { Option, Struct } from "@polkadot/types-codec"; -import Big from "big.js"; - -import logger from "../../../config/logger"; - -interface VaultRegistryVault extends Struct { - readonly id: SpacewalkPrimitivesVaultId; - readonly issuedTokens: number; // u128 - readonly toBeRedeemedTokens: number; // u128 -} - -function vaultHasEnoughRedeemable(vault: VaultRegistryVault, redeemableAmount: string): boolean { - const redeemableTokens = new Big(vault.issuedTokens).sub(new Big(vault.toBeRedeemedTokens)); - if (redeemableTokens.gt(new Big(redeemableAmount))) { - return true; - } - return false; -} - -export async function getVaultsForCurrency( - api: ApiPromise, - assetCodeHex: string, - assetIssuerHex: string, - redeemableAmountRaw: string -) { - const vaultEntries = await api.query.vaultRegistry.vaults.entries(); - const vaults = vaultEntries.map(([_, value]) => (value as Option).unwrap()); - - const vaultsForCurrency = vaults.filter( - vault => - // toString returns the hex string - // toHuman returns the hex string if the string has length < 4, otherwise the readable string - vault.id.currencies.wrapped.isStellar && - vault.id.currencies.wrapped.asStellar.isAlphaNum4 && - vault.id.currencies.wrapped.asStellar.asAlphaNum4.code.toString() === assetCodeHex && - vault.id.currencies.wrapped.asStellar.asAlphaNum4.issuer.toString() === assetIssuerHex && - vaultHasEnoughRedeemable(vault, redeemableAmountRaw) - ); - - if (vaultsForCurrency.length === 0) { - const errorMessage = `No vaults found for currency ${assetCodeHex} and amount ${redeemableAmountRaw}`; - logger.error(errorMessage); - throw new Error(errorMessage); - } - - return vaultsForCurrency; -} diff --git a/apps/api/src/api/services/stellar/loadAccount.ts b/apps/api/src/api/services/stellar/loadAccount.ts deleted file mode 100644 index 76901bc0f..000000000 --- a/apps/api/src/api/services/stellar/loadAccount.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { HORIZON_URL } from "@vortexfi/shared"; -import { Horizon } from "stellar-sdk"; -import logger from "../../../config/logger"; - -const horizonServer = new Horizon.Server(HORIZON_URL); - -export async function loadAccountWithRetry( - ephemeralAccountId: string, - retries = 3, - timeout = 15000 -): Promise { - let lastError: Error | null = null; - - const loadAccountWithTimeout = (accountId: string, timeout: number): Promise => - Promise.race([ - horizonServer.loadAccount(accountId), - new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeout)) - ]); - - for (let i = 0; i < retries; i++) { - try { - return await loadAccountWithTimeout(ephemeralAccountId, timeout); - } catch (err: unknown) { - if (err instanceof Error && err.toString().includes("NotFoundError")) { - // The account does not exist - return null; - } - logger.info(`Attempt ${i + 1} to load account ${ephemeralAccountId} failed: ${err}`); - lastError = err as Error; - } - } - - throw new Error(`Failed to load account ${ephemeralAccountId} after ${retries} attempts: ${lastError?.toString()}`); -} diff --git a/apps/api/src/api/services/stellar/vaultService.ts b/apps/api/src/api/services/stellar/vaultService.ts deleted file mode 100644 index 577438e72..000000000 --- a/apps/api/src/api/services/stellar/vaultService.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { SpacewalkPrimitivesVaultId } from "@pendulum-chain/types/interfaces"; -import { SubmittableExtrinsic } from "@polkadot/api/promise/types"; -import { DispatchError, EventRecord } from "@polkadot/types/interfaces"; -import { ISubmittableResult } from "@polkadot/types/types"; -import { API, getAddressForFormat, parseEventRedeemRequest, SpacewalkRedeemRequestEvent } from "@vortexfi/shared"; -import logger from "../../../config/logger"; -import { getVaultsForCurrency } from "./getVaults"; - -export async function createVaultService( - apiComponents: API, - assetCodeHex: string, - assetIssuerHex: string, - redeemAmountRaw: string -) { - const { api, ss58Format, decimals } = apiComponents; - // we expect the list to have at least one vault, otherwise getVaultsForCurrency would throw - const vaultsForCurrency = await getVaultsForCurrency(api, assetCodeHex, assetIssuerHex, redeemAmountRaw); - const targetVaultId = vaultsForCurrency[0].id; - return new VaultService(targetVaultId, { api, decimals, ss58Format }); -} - -export class VaultService { - vaultId: SpacewalkPrimitivesVaultId; - - apiComponents: API; - - constructor(vaultId: SpacewalkPrimitivesVaultId, apiComponents: API) { - this.vaultId = vaultId; - this.apiComponents = apiComponents; - } - - async createRequestRedeemExtrinsic(amountRaw: string, stellarPkBytesBuffer: Buffer) { - const stellarPkBytes = Uint8Array.from(stellarPkBytesBuffer); - return this.apiComponents.api.tx.redeem.requestRedeem(amountRaw, stellarPkBytes, this.vaultId); - } - - async submitRedeem(senderAddress: string, extrinsic: SubmittableExtrinsic): Promise { - return new Promise((resolve, reject) => { - extrinsic - .send((submissionResult: ISubmittableResult) => { - const { status, events, dispatchError } = submissionResult; - - if (status.isFinalized) { - logger.info(`Requested redeem for vault ${this.vaultId} with status ${status.type}`); - - // Try to find a 'system.ExtrinsicFailed' event - 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 => event.event.section.toLowerCase() === "redeem" && event.event.method.toLowerCase() === "requestredeem" - ); - - const event = redeemEvents - .map(event => parseEventRedeemRequest(event)) - .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 - if (event.length !== 1) { - reject(new Error("Inconsistent amount of redeem request events for account")); - } - resolve(event[0]); - } - }) - .catch(error => { - reject(new Error(`Failed to request redeem: ${error}`)); - }); - }); - } - - // We first check if dispatchError is of type "module", - // If not we either return ExtrinsicFailedError or Unknown dispatch error - handleDispatchError( - dispatchError: DispatchError, - systemExtrinsicFailedEvent: EventRecord | undefined, - extrinsicCalled: unknown - ) { - if (dispatchError?.isModule) { - const decoded = this.apiComponents?.api.registry.findMetaError(dispatchError.asModule); - const { name, section, method } = decoded; - - return new Error(`Dispatch error: ${section}.${method}:: ${name}`); - } - if (systemExtrinsicFailedEvent) { - const eventName = - systemExtrinsicFailedEvent?.event.data && systemExtrinsicFailedEvent?.event.data.length > 0 - ? systemExtrinsicFailedEvent?.event.data[0].toString() - : "Unknown"; - - const { - phase, - event: { method, section } - } = systemExtrinsicFailedEvent; - logger.error(`Extrinsic failed in phase ${phase.toString()} with ${section}.${method}:: ${eventName}`); - - return new Error(`Failed to dispatch ${extrinsicCalled}`); - } - - logger.error("Encountered some other error: ", dispatchError?.toString(), JSON.stringify(dispatchError)); - return new Error(`Unknown error during ${extrinsicCalled}`); - } -} diff --git a/apps/api/src/api/services/transactions/offramp/common/transactions.ts b/apps/api/src/api/services/transactions/offramp/common/transactions.ts index 01935ebc8..03328188d 100644 --- a/apps/api/src/api/services/transactions/offramp/common/transactions.ts +++ b/apps/api/src/api/services/transactions/offramp/common/transactions.ts @@ -11,21 +11,16 @@ import { EvmTransactionData, encodeSubmittableExtrinsic, Networks, - PaymentData, PendulumTokenDetails, - StellarTokenDetails, UnsignedTx } from "@vortexfi/shared"; import Big from "big.js"; -import { Keypair } from "stellar-sdk"; import { encodeFunctionData } from "viem"; import { config } from "../../../../../config/vars"; import erc20ABI from "../../../../../contracts/ERC20"; import { QuoteTicketAttributes } from "../../../../../models/quoteTicket.model"; import { StateMetadata } from "../../../phases/meta-state-types"; import { encodeEvmTransactionData } from "../../index"; -import { prepareSpacewalkRedeemTransaction } from "../../spacewalk/redeem"; -import { buildPaymentAndMergeTx } from "../../stellar/offrampTransaction"; /** * Creates transactions for EVM source networks using Squidrouter or mock transactions in sandbox @@ -251,161 +246,6 @@ export async function createBRLTransactions( }; } -/** - * Creates Stellar-specific transactions for Spacewalk redeem - * @param params Transaction parameters - * @param unsignedTxs Array to add transactions to - * @param pendulumCleanupTx Cleanup transaction template - * @param nextNonce Next available nonce - * @returns Updated nonce and state metadata - */ -export async function createSpacewalkTransactions( - params: { - outputAmountRaw: string; - stellarEphemeralEntry: AccountMeta; - outputTokenDetails: StellarTokenDetails; - account: AccountMeta; - stellarPaymentData: PaymentData; - }, - unsignedTxs: UnsignedTx[], - pendulumCleanupTx: Omit, - nextNonce: number -): Promise<{ nextNonce: number; stateMeta: Partial }> { - const { outputAmountRaw, stellarEphemeralEntry, outputTokenDetails, account, stellarPaymentData } = params; - - const stellarEphemeralAccountRaw = Keypair.fromPublicKey(stellarEphemeralEntry.address).rawPublicKey(); - const spacewalkRedeemTransaction = await prepareSpacewalkRedeemTransaction({ - executeSpacewalkNonce: nextNonce, - outputAmountRaw: outputAmountRaw, - outputTokenDetails, - stellarEphemeralAccountRaw - }); - - unsignedTxs.push({ - meta: {}, - network: Networks.Pendulum, - nonce: nextNonce, - phase: "spacewalkRedeem", - signer: account.address, - txData: encodeSubmittableExtrinsic(spacewalkRedeemTransaction) - }); - const executeSpacewalkNonce = nextNonce; - nextNonce++; - - // Add the cleanup transaction with the next nonce - unsignedTxs.push({ - ...pendulumCleanupTx, - nonce: nextNonce - }); - nextNonce++; - - return { - nextNonce, - stateMeta: { - executeSpacewalkNonce, - stellarEphemeralAccountId: stellarEphemeralEntry.address, - stellarTarget: { - stellarTargetAccountId: stellarPaymentData.anchorTargetAccount, - stellarTokenDetails: outputTokenDetails - } - } - }; -} - -/** - * Creates Stellar payment and merge transactions - * @param params Transaction parameters - * @param unsignedTxs Array to add transactions to - */ -export async function createStellarPaymentTransactions( - params: { - ephemeralAddress: string; - outputAmountUnits: Big; - outputTokenDetails: StellarTokenDetails; - stellarPaymentData: PaymentData; - }, - unsignedTxs: UnsignedTx[] -): Promise { - const { ephemeralAddress, outputAmountUnits, outputTokenDetails, stellarPaymentData } = params; - - const { paymentTransactions, mergeAccountTransactions, createAccountTransactions, expectedSequenceNumbers } = - await buildPaymentAndMergeTx({ - amountToAnchorUnits: outputAmountUnits.toFixed(), - ephemeralAccountId: ephemeralAddress, - paymentData: stellarPaymentData, - tokenConfigStellar: outputTokenDetails - }); - - const createAccountPrimaryTx: UnsignedTx = { - meta: { - expectedSequenceNumber: expectedSequenceNumbers[0] - }, - network: Networks.Stellar, - nonce: 0, - phase: "stellarCreateAccount", - signer: ephemeralAddress, - txData: createAccountTransactions[0].tx - }; - - const paymentTransactionPrimary: UnsignedTx = { - meta: { - expectedSequenceNumber: expectedSequenceNumbers[0] - }, - network: Networks.Stellar, - nonce: 1, - phase: "stellarPayment", - signer: ephemeralAddress, - txData: paymentTransactions[0].tx - }; - - const mergeAccountTransactionPrimary: UnsignedTx = { - meta: { - expectedSequenceNumber: expectedSequenceNumbers[0] - }, - network: Networks.Stellar, - nonce: 2, - phase: "stellarCleanup", - signer: ephemeralAddress, - txData: mergeAccountTransactions[0].tx - }; - - const createAccountMultiSignedTxs = createAccountTransactions.map((tx, index) => ({ - ...createAccountPrimaryTx, - meta: { - expectedSequenceNumber: expectedSequenceNumbers[index] - }, - nonce: createAccountPrimaryTx.nonce + index, - txData: tx.tx - })); - - const createAccountTx = addAdditionalTransactionsToMeta(createAccountPrimaryTx, createAccountMultiSignedTxs); - unsignedTxs.push(createAccountTx); - - const paymentTransactionMultiSignedTxs = paymentTransactions.map((tx, index) => ({ - ...paymentTransactionPrimary, - meta: { - expectedSequenceNumber: expectedSequenceNumbers[index] - }, - nonce: paymentTransactionPrimary.nonce + index, - txData: tx.tx - })); - - const paymentTransaction = addAdditionalTransactionsToMeta(paymentTransactionPrimary, paymentTransactionMultiSignedTxs); - unsignedTxs.push(paymentTransaction); - - const mergeAccountTransactionMultiSignedTxs = mergeAccountTransactions.map((tx, index) => ({ - ...mergeAccountTransactionPrimary, - meta: { - expectedSequenceNumber: expectedSequenceNumbers[index] - }, - nonce: mergeAccountTransactionPrimary.nonce + index, - txData: tx.tx - })); - - const mergeAccountTx = addAdditionalTransactionsToMeta(mergeAccountTransactionPrimary, mergeAccountTransactionMultiSignedTxs); - unsignedTxs.push(mergeAccountTx); -} - /** * Creates mock approve and swap transactions for sandbox mode * @param inputAmountRaw The raw input amount to approve diff --git a/apps/api/src/api/services/transactions/offramp/common/types.ts b/apps/api/src/api/services/transactions/offramp/common/types.ts index f1c1a9f57..6b51bfdbe 100644 --- a/apps/api/src/api/services/transactions/offramp/common/types.ts +++ b/apps/api/src/api/services/transactions/offramp/common/types.ts @@ -1,18 +1,19 @@ -import { AccountMeta, PaymentData, UnsignedTx } from "@vortexfi/shared"; +import { AccountMeta, UnsignedTx } from "@vortexfi/shared"; import { QuoteTicketAttributes } from "../../../../../models/quoteTicket.model"; export interface OfframpTransactionParams { quote: QuoteTicketAttributes; signingAccounts: AccountMeta[]; - stellarPaymentData?: PaymentData; userAddress?: string; pixDestination?: string; taxId?: string; receiverTaxId?: string; brlaEvmAddress?: string; - moneriumAuthToken?: string; userId?: string; fiatAccountId?: string; + email?: string; + destinationAddress?: string; + ipAddress?: string; } export interface OfframpTransactionsWithMeta { diff --git a/apps/api/src/api/services/transactions/offramp/common/validation.ts b/apps/api/src/api/services/transactions/offramp/common/validation.ts index c661a6f41..ebc9aeb8d 100644 --- a/apps/api/src/api/services/transactions/offramp/common/validation.ts +++ b/apps/api/src/api/services/transactions/offramp/common/validation.ts @@ -6,21 +6,23 @@ import { getOnChainTokenDetails, isFiatToken, isOnChainToken, - isStellarTokenDetails, - normalizeTaxId, - PaymentData, - StellarTokenDetails + normalizeTaxId } from "@vortexfi/shared"; -import Big from "big.js"; import { QuoteTicketAttributes } from "../../../../../models/quoteTicket.model"; /** * Validates offramp quote and returns required data * @param quote The quote ticket * @param signingAccounts The signing accounts + * @param options Set `requireSubstrateEphemeral: false` for EVM-only offramp routes (e.g. Mykobo on Base) * @returns Validation result with required data */ -export function validateOfframpQuote(quote: QuoteTicketAttributes, signingAccounts: AccountMeta[]) { +export function validateOfframpQuote( + quote: QuoteTicketAttributes, + signingAccounts: AccountMeta[], + options: { requireSubstrateEphemeral?: boolean } = {} +) { + const { requireSubstrateEphemeral = true } = options; const fromNetwork = getNetworkFromDestination(quote.from); if (!fromNetwork) { throw new Error(`Invalid network for destination ${quote.from}`); @@ -41,13 +43,8 @@ export function validateOfframpQuote(quote: QuoteTicketAttributes, signingAccoun const outputTokenDetails = getAnyFiatTokenDetails(quote.outputCurrency); - const stellarEphemeralEntry = signingAccounts.find(ephemeral => ephemeral.type === "Stellar"); - if (!stellarEphemeralEntry) { - throw new Error("Stellar ephemeral not found"); - } - const substrateEphemeralEntry = signingAccounts.find(ephemeral => ephemeral.type === "Substrate"); - if (!substrateEphemeralEntry) { + if (requireSubstrateEphemeral && !substrateEphemeralEntry) { throw new Error("Pendulum ephemeral not found"); } @@ -55,7 +52,6 @@ export function validateOfframpQuote(quote: QuoteTicketAttributes, signingAccoun fromNetwork, inputTokenDetails, outputTokenDetails, - stellarEphemeralEntry, substrateEphemeralEntry }; } @@ -109,49 +105,3 @@ export function validateBRLOfframpMetadata(quote: QuoteTicketAttributes): { offrampAmountBeforeAnchorFeesRaw: quote.metadata.pendulumToMoonbeamXcm.outputAmountRaw }; } - -/** - * Validates Stellar offramp requirements - * @param outputTokenDetails Output token details - * @param stellarPaymentData Stellar payment data - * @returns Validated Stellar token details and payment data - */ -export function validateStellarOfframp( - outputTokenDetails: FiatTokenDetails, - stellarPaymentData?: PaymentData -): { - stellarTokenDetails: StellarTokenDetails; - stellarPaymentData: PaymentData; -} { - if (!isStellarTokenDetails(outputTokenDetails)) { - throw new Error("Output currency must be Stellar token for offramp, got output token details type"); - } - - if (!stellarPaymentData?.anchorTargetAccount) { - throw new Error("Stellar payment data must be provided for offramp"); - } - - return { - stellarPaymentData, - stellarTokenDetails: outputTokenDetails as StellarTokenDetails - }; -} - -/** - * Validates Stellar offramp metadata - * @param quote The quote ticket - * @returns Validated Stellar metadata - */ -export function validateStellarOfframpMetadata(quote: QuoteTicketAttributes): { - offrampAmountBeforeAnchorFeesUnits: Big; - offrampAmountBeforeAnchorFeesRaw: string; -} { - if (!quote.metadata.pendulumToStellar?.outputAmountDecimal || !quote.metadata.pendulumToStellar?.outputAmountRaw) { - throw new Error("Quote metadata is missing pendulumToStellar information"); - } - - return { - offrampAmountBeforeAnchorFeesRaw: quote.metadata.pendulumToStellar.outputAmountRaw, - offrampAmountBeforeAnchorFeesUnits: new Big(quote.metadata.pendulumToStellar.outputAmountDecimal) - }; -} diff --git a/apps/api/src/api/services/transactions/offramp/index.ts b/apps/api/src/api/services/transactions/offramp/index.ts index e8af3125d..1e48a6878 100644 --- a/apps/api/src/api/services/transactions/offramp/index.ts +++ b/apps/api/src/api/services/transactions/offramp/index.ts @@ -8,11 +8,9 @@ import { } from "@vortexfi/shared"; import { OfframpTransactionParams, OfframpTransactionsWithMeta } from "./common/types"; import { prepareAssethubToBRLOfframpTransactions } from "./routes/assethub-to-brl"; -import { prepareAssethubToStellarOfframpTransactions } from "./routes/assethub-to-stellar"; import { prepareEvmToAlfredpayOfframpTransactions } from "./routes/evm-to-alfredpay"; import { prepareEvmToBRLOfframpBaseTransactions } from "./routes/evm-to-brl-base"; -import { prepareEvmToMoneriumEvmOfframpTransactions } from "./routes/evm-to-monerium-evm"; -import { prepareEvmToStellarOfframpTransactions } from "./routes/evm-to-stellar"; +import { prepareEvmToMykoboOfframpTransactions } from "./routes/evm-to-mykobo"; export async function prepareOfframpTransactions(params: OfframpTransactionParams): Promise { const { quote } = params; @@ -30,19 +28,17 @@ export async function prepareOfframpTransactions(params: OfframpTransactionParam } else { return prepareAssethubToBRLOfframpTransactions(params); } - } else if (quote.outputCurrency === FiatToken.EURC && params.moneriumAuthToken) { - // Monerium EVM offramp - return prepareEvmToMoneriumEvmOfframpTransactions(params); + } else if (quote.outputCurrency === FiatToken.EURC) { + // Mykobo EUR offramp on Base (EVM-only path) + const inputTokenDetails = getOnChainTokenDetails(fromNetwork, quote.inputCurrency as OnChainToken); + if (!inputTokenDetails || !isEvmTokenDetails(inputTokenDetails)) { + throw new Error("Mykobo EUR offramp requires an EVM source chain"); + } + return prepareEvmToMykoboOfframpTransactions(params); } else if (isAlfredpayToken(quote.outputCurrency as FiatToken)) { // Alfredpay offramp (USD, MXN, COP) return prepareEvmToAlfredpayOfframpTransactions(params); - } else { - // Stellar offramp - const inputTokenDetails = getOnChainTokenDetails(fromNetwork, quote.inputCurrency as OnChainToken); - if (inputTokenDetails && isEvmTokenDetails(inputTokenDetails)) { - return prepareEvmToStellarOfframpTransactions(params); - } else { - return prepareAssethubToStellarOfframpTransactions(params); - } } + + throw new Error(`Unsupported offramp output currency: ${quote.outputCurrency}`); } diff --git a/apps/api/src/api/services/transactions/offramp/routes/assethub-to-brl.ts b/apps/api/src/api/services/transactions/offramp/routes/assethub-to-brl.ts index f0141d94a..8cda2faf5 100644 --- a/apps/api/src/api/services/transactions/offramp/routes/assethub-to-brl.ts +++ b/apps/api/src/api/services/transactions/offramp/routes/assethub-to-brl.ts @@ -29,6 +29,9 @@ export async function prepareAssethubToBRLOfframpTransactions({ quote, signingAccounts ); + if (!substrateEphemeralEntry) { + throw new Error("Pendulum ephemeral not found"); + } const { brlaEvmAddress: validatedBrlaEvmAddress, diff --git a/apps/api/src/api/services/transactions/offramp/routes/assethub-to-stellar.ts b/apps/api/src/api/services/transactions/offramp/routes/assethub-to-stellar.ts deleted file mode 100644 index 357cac1e0..000000000 --- a/apps/api/src/api/services/transactions/offramp/routes/assethub-to-stellar.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { - encodeSubmittableExtrinsic, - getPendulumDetails, - isStellarOutputTokenDetails, - Networks, - UnsignedTx -} from "@vortexfi/shared"; -import Big from "big.js"; -import { multiplyByPowerOfTen } from "../../../pendulum/helpers"; -import { StateMetadata } from "../../../phases/meta-state-types"; -import { addFeeDistributionTransaction } from "../../common/feeDistribution"; -import { preparePendulumCleanupTransaction } from "../../pendulum/cleanup"; -import { - createAssetHubSourceTransactions, - createNablaSwapTransactions, - createSpacewalkTransactions, - createStellarPaymentTransactions -} from "../common/transactions"; -import { OfframpTransactionParams, OfframpTransactionsWithMeta } from "../common/types"; -import { validateOfframpQuote, validateStellarOfframp, validateStellarOfframpMetadata } from "../common/validation"; - -/** - * Prepares all transactions for an AssetHub to Stellar offramp. - * This route handles: AssetHub → Pendulum (swap) → Spacewalk → Stellar - */ -export async function prepareAssethubToStellarOfframpTransactions({ - quote, - signingAccounts, - stellarPaymentData, - userAddress -}: OfframpTransactionParams): Promise { - const unsignedTxs: UnsignedTx[] = []; - let stateMeta: Partial = {}; - - // Validate inputs and extract required data - const { fromNetwork, inputTokenDetails, outputTokenDetails, stellarEphemeralEntry, substrateEphemeralEntry } = - validateOfframpQuote(quote, signingAccounts); - - const { stellarTokenDetails, stellarPaymentData: validatedStellarPaymentData } = validateStellarOfframp( - outputTokenDetails, - stellarPaymentData - ); - const { offrampAmountBeforeAnchorFeesUnits, offrampAmountBeforeAnchorFeesRaw } = validateStellarOfframpMetadata(quote); - - const inputAmountRaw = multiplyByPowerOfTen(new Big(quote.inputAmount), inputTokenDetails.decimals).toFixed(0, 0); - - if (validatedStellarPaymentData.amount) { - const stellarAmount = new Big(validatedStellarPaymentData.amount); - if (!stellarAmount.eq(offrampAmountBeforeAnchorFeesUnits)) { - throw new Error( - `Stellar amount ${stellarAmount.toString()} not equal to expected payment ${offrampAmountBeforeAnchorFeesUnits.toString()}` - ); - } - } - - // Initialize state metadata - stateMeta = { - stellarEphemeralAccountId: stellarEphemeralEntry.address, - substrateEphemeralAddress: substrateEphemeralEntry.address - }; - - if (!userAddress) { - throw new Error("User address must be provided for offramping."); - } - - // Create AssetHub source transactions - await createAssetHubSourceTransactions( - { - inputAmountRaw, - pendulumEphemeralAddress: substrateEphemeralEntry.address, - userAddress - }, - unsignedTxs, - fromNetwork - ); - - // Process Pendulum account - const substrateAccount = signingAccounts.find(account => account.type === "Substrate"); - if (!substrateAccount) { - throw new Error("Substrate account not found"); - } - - const inputTokenPendulumDetails = getPendulumDetails(quote.inputCurrency, fromNetwork); - const outputTokenPendulumDetails = getPendulumDetails(quote.outputCurrency); - - let pendulumNonce = 0; - - // Add fee distribution transaction - pendulumNonce = await addFeeDistributionTransaction(quote, substrateAccount, unsignedTxs, pendulumNonce); - - // Create Nabla swap transactions - const nablaResult = await createNablaSwapTransactions( - { - account: substrateAccount, - inputTokenPendulumDetails, - outputTokenPendulumDetails, - quote - }, - unsignedTxs, - pendulumNonce - ); - - pendulumNonce = nablaResult.nextNonce; - stateMeta = { - ...stateMeta, - ...nablaResult.stateMeta - }; - - // Prepare cleanup transaction - const pendulumCleanupTransaction = await preparePendulumCleanupTransaction( - inputTokenPendulumDetails.currencyId, - outputTokenPendulumDetails.currencyId - ); - - const pendulumCleanupTx: Omit = { - meta: {}, - network: Networks.Pendulum, - phase: "pendulumCleanup", - signer: substrateAccount.address, - txData: encodeSubmittableExtrinsic(pendulumCleanupTransaction) - }; - - // Create Spacewalk transactions - const stellarResult = await createSpacewalkTransactions( - { - account: substrateAccount, - outputAmountRaw: offrampAmountBeforeAnchorFeesRaw, - outputTokenDetails: stellarTokenDetails, - stellarEphemeralEntry, - stellarPaymentData: validatedStellarPaymentData - }, - unsignedTxs, - pendulumCleanupTx, - pendulumNonce - ); - - stateMeta = { - ...stateMeta, - ...stellarResult.stateMeta - }; - - if (!isStellarOutputTokenDetails(outputTokenDetails)) { - throw new Error(`Output currency must be Stellar token for offramp, got ${quote.outputCurrency}`); - } - - if (!stellarPaymentData) { - throw new Error("Stellar payment data must be provided for offramp"); - } - - await createStellarPaymentTransactions( - { - ephemeralAddress: stellarEphemeralEntry.address, - outputAmountUnits: offrampAmountBeforeAnchorFeesUnits, - outputTokenDetails: outputTokenDetails as any, - stellarPaymentData - }, - unsignedTxs - ); - - return { stateMeta, unsignedTxs }; -} diff --git a/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts index 116730533..bc6ba3bbe 100644 --- a/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts +++ b/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts @@ -37,6 +37,7 @@ import { } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { config } from "../../../../../config/vars"; +import erc20ABI from "../../../../../contracts/ERC20"; import AlfredPayCustomer from "../../../../../models/alfredPayCustomer.model"; import { getEvmFundingAccount } from "../../../phases/evm-funding"; import { StateMetadata } from "../../../phases/meta-state-types"; @@ -155,19 +156,6 @@ const erc20Abi = [ { inputs: [], name: "name", outputs: [{ name: "", type: "string" }], stateMutability: "view", type: "function" } ]; -const transferAbi = [ - { - inputs: [ - { name: "to", type: "address" }, - { name: "value", type: "uint256" } - ], - name: "transfer", - outputs: [{ name: "", type: "bool" }], - stateMutability: "nonpayable", - type: "function" - } -] as const; - /** * Prepares all transactions for an EVM to Alfredpay (USD) offramp. * This route handles: EVM → Polygon (USDC) → Alfredpay (Fiat) @@ -434,7 +422,7 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ // No permit available, but user already holds USDT on Polygon: user signs a single // transfer(ephemeral, amount) in their wallet. Funds land directly on the ephemeral. const transferData = encodeFunctionData({ - abi: transferAbi, + abi: erc20ABI, args: [evmEphemeralEntry.address as `0x${string}`, BigInt(inputAmountRaw)], functionName: "transfer" }); diff --git a/apps/api/src/api/services/transactions/offramp/routes/evm-to-brl-base.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-brl-base.ts index aeab4cc0d..6bfa4e181 100644 --- a/apps/api/src/api/services/transactions/offramp/routes/evm-to-brl-base.ts +++ b/apps/api/src/api/services/transactions/offramp/routes/evm-to-brl-base.ts @@ -9,6 +9,8 @@ import { UnsignedTx } from "@vortexfi/shared"; import Big from "big.js"; +import { encodeFunctionData } from "viem"; +import erc20ABI from "../../../../../contracts/ERC20"; import { getEvmFundingAccount } from "../../../phases/evm-funding"; import { StateMetadata } from "../../../phases/meta-state-types"; import { encodeEvmTransactionData } from "../.."; @@ -69,8 +71,30 @@ export async function prepareEvmToBRLOfframpBaseTransactions({ throw new Error("Invalid BRLA configuration for Base in evmTokenConfig"); } - // Special case: if user is already on Base with USDC, skip squidrouter transactions - if (!(fromNetwork === Networks.Base && inputTokenDetails.erc20AddressSourceChain === baseUsdcAddress)) { + // Special case: if user already holds USDC on Base, skip squidrouter and have the user sign a + // direct ERC20 transfer to the ephemeral. Without this leg the ephemeral never receives USDC and + // downstream phases (distributeFees, nablaSwap) would revert from insufficient balance. + if (fromNetwork === Networks.Base && inputTokenDetails.erc20AddressSourceChain === baseUsdcAddress) { + const transferData = encodeFunctionData({ + abi: erc20ABI, + args: [evmEphemeralEntry.address as `0x${string}`, BigInt(inputAmountRaw)], + functionName: "transfer" + }); + + unsignedTxs.push({ + meta: {}, + network: fromNetwork, + nonce: 0, + phase: "squidRouterNoPermitTransfer", + signer: userAddress, + txData: { + data: transferData, + gas: "0", + to: inputTokenDetails.erc20AddressSourceChain, + value: "0" + } + }); + } else { // TODO Maybe, move to contract-base squid swap. // Otherwise use the same approach as previously const { approveData, swapData } = await createOfframpSquidrouterTransactionsToEvm({ @@ -102,8 +126,6 @@ export async function prepareEvmToBRLOfframpBaseTransactions({ }); } - // TODO isn't this missing the rest of the edge case handling? - let baseNonce = 0; const baseUSDCTokenAddress = evmTokenConfig[Networks.Base][EvmToken.USDC]?.erc20AddressSourceChain; diff --git a/apps/api/src/api/services/transactions/offramp/routes/evm-to-monerium-evm.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-monerium-evm.ts deleted file mode 100644 index d73bcf885..000000000 --- a/apps/api/src/api/services/transactions/offramp/routes/evm-to-monerium-evm.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { - createOfframpSquidrouterTransactionsToEvm, - ERC20_EURE_POLYGON_V1, - EvmTransactionData, - getNetworkFromDestination, - getOnChainTokenDetails, - isEvmTokenDetails, - isFiatToken, - isOnChainToken, - Networks, - UnsignedTx -} from "@vortexfi/shared"; -import { getFirstMoneriumLinkedAddress } from "../../../monerium"; -import { StateMetadata } from "../../../phases/meta-state-types"; -import { encodeEvmTransactionData } from "../../index"; -import { OfframpTransactionParams, OfframpTransactionsWithMeta } from "../common/types"; - -/** - * Prepares all transactions for an EVM to Monerium EVM offramp. - * This route handles: EVM → Polygon (EURE) → Monerium EVM - */ -export async function prepareEvmToMoneriumEvmOfframpTransactions({ - quote, - signingAccounts, - userAddress, - moneriumAuthToken -}: OfframpTransactionParams): Promise { - const unsignedTxs: UnsignedTx[] = []; - const stateMeta: Partial = {}; - - const fromNetwork = getNetworkFromDestination(quote.from); - if (!fromNetwork) { - throw new Error(`Invalid network for destination ${quote.from}`); - } - - if (!isOnChainToken(quote.inputCurrency)) { - throw new Error(`Input currency must be on-chain token for offramp, got ${quote.inputCurrency}`); - } - - const inputTokenDetails = getOnChainTokenDetails(fromNetwork, quote.inputCurrency); - if (!inputTokenDetails) { - throw new Error(`Input token details not found for ${quote.inputCurrency} on network ${fromNetwork}`); - } - - if (!isFiatToken(quote.outputCurrency)) { - throw new Error(`Output currency must be fiat token for offramp, got ${quote.outputCurrency}`); - } - - if (!quote.metadata.moneriumMint?.outputAmountRaw) { - throw new Error("Monerium Offramp requires moneriumMint metadata with amountOutRaw."); - } - - const inputAmountRaw = quote.metadata.moneriumMint.outputAmountRaw; - - if (!userAddress) { - throw new Error("User address must be provided for offramping."); - } - - if (!isEvmTokenDetails(inputTokenDetails)) { - throw new Error("Offramp from Assethub not supported for Monerium"); - } - - if (!moneriumAuthToken) { - throw new Error("Monerium Offramp requires a valid authorization token"); - } - - const moneriumEvmAddress = await getFirstMoneriumLinkedAddress(moneriumAuthToken); - - if (!moneriumEvmAddress) { - throw new Error("No Address linked for Monerium."); - } - - const { approveData, swapData } = await createOfframpSquidrouterTransactionsToEvm({ - destinationAddress: moneriumEvmAddress, - fromAddress: userAddress, - fromNetwork, // By design, EUR.e offramp starts from Polygon. - fromToken: inputTokenDetails.erc20AddressSourceChain, - rawAmount: inputAmountRaw, // By design, EURe is the only supported offramp currency. - toNetwork: Networks.Polygon, - toToken: ERC20_EURE_POLYGON_V1 - }); - - unsignedTxs.push({ - meta: {}, - network: fromNetwork, - nonce: 0, - phase: "squidRouterApprove", - signer: userAddress, - txData: encodeEvmTransactionData(approveData) as EvmTransactionData - }); - - unsignedTxs.push({ - meta: {}, - network: fromNetwork, - nonce: 1, - phase: "squidRouterSwap", - signer: userAddress, - txData: encodeEvmTransactionData(swapData) as EvmTransactionData - }); - - return { stateMeta, unsignedTxs }; -} diff --git a/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo.ts new file mode 100644 index 000000000..f47e2c3a8 --- /dev/null +++ b/apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo.ts @@ -0,0 +1,254 @@ +import { + createOfframpSquidrouterTransactionsToEvm, + EvmToken, + EvmTransactionData, + evmTokenConfig, + isEvmTokenDetails, + isWithdrawInstructions, + MykoboApiService, + MykoboCurrency, + MykoboTransactionType, + multiplyByPowerOfTen, + Networks, + UnsignedTx +} from "@vortexfi/shared"; +import Big from "big.js"; +import httpStatus from "http-status"; +import { encodeFunctionData } from "viem"; +import erc20ABI from "../../../../../contracts/ERC20"; +import { APIError } from "../../../../errors/api-error"; +import { syncMykoboCustomerKyc } from "../../../mykobo/mykobo-customer.service"; +import { getEvmFundingAccount } from "../../../phases/evm-funding"; +import { StateMetadata } from "../../../phases/meta-state-types"; +import { encodeEvmTransactionData } from "../.."; +import { prepareBaseCleanupApproval } from "../../base/cleanup"; +import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; +import { addNablaSwapTransactionsOnBase, addOnrampDestinationChainTransactions } from "../../onramp/common/transactions"; +import { OfframpTransactionParams, OfframpTransactionsWithMeta } from "../common/types"; +import { validateOfframpQuote } from "../common/validation"; + +export async function prepareEvmToMykoboOfframpTransactions({ + quote, + signingAccounts, + userAddress, + email, + destinationAddress, + ipAddress, + userId +}: OfframpTransactionParams): Promise { + const unsignedTxs: UnsignedTx[] = []; + let stateMeta: Partial = {}; + + const { fromNetwork, inputTokenDetails } = validateOfframpQuote(quote, signingAccounts, { requireSubstrateEphemeral: false }); + + const evmEphemeralEntry = signingAccounts.find(account => account.type === "EVM"); + if (!evmEphemeralEntry) { + throw new Error("EVM ephemeral account not found for EVM to Mykobo offramp"); + } + + if (!email) { + throw new APIError({ + isPublic: true, + message: "email must be provided for Mykobo (EUR) offramp", + status: httpStatus.BAD_REQUEST + }); + } + + if (!ipAddress) { + throw new APIError({ + isPublic: true, + message: "ipAddress must be provided for Mykobo (EUR) offramp", + status: httpStatus.BAD_REQUEST + }); + } + + if (!destinationAddress) { + throw new APIError({ + isPublic: true, + message: "destinationAddress (user receiving wallet) must be provided for Mykobo offramp", + status: httpStatus.BAD_REQUEST + }); + } + + if (!userAddress) { + throw new Error("User address must be provided for offramping."); + } + + if (!isEvmTokenDetails(inputTokenDetails)) { + throw new Error("EVM to Mykobo route requires EVM input token"); + } + + const baseUsdcAddress = evmTokenConfig[Networks.Base][EvmToken.USDC]?.erc20AddressSourceChain; + if (!baseUsdcAddress) { + throw new Error("Invalid USDC configuration for Base in evmTokenConfig"); + } + + const baseEurcAddress = evmTokenConfig[Networks.Base][EvmToken.EURC]?.erc20AddressSourceChain; + if (!baseEurcAddress) { + throw new Error("Invalid EURC configuration for Base in evmTokenConfig"); + } + + const baseAxlUsdcAddress = evmTokenConfig[Networks.Base][EvmToken.AXLUSDC]?.erc20AddressSourceChain; + if (!baseAxlUsdcAddress) { + throw new Error("Invalid AXLUSDC configuration for Base in evmTokenConfig"); + } + + const inputAmountRaw = multiplyByPowerOfTen(new Big(quote.inputAmount), inputTokenDetails.decimals).toFixed(0, 0); + const inputTokenAddress = inputTokenDetails.erc20AddressSourceChain; + const isDirectBaseTransfer = + fromNetwork === Networks.Base && inputTokenAddress.toLowerCase() === baseUsdcAddress.toLowerCase(); + + // Resolve the Mykobo intent before building any user-signed transactions so that an API failure aborts early. + const mykoboIntentValue = quote.metadata.nablaSwapEvm?.outputAmountDecimal; + if (!mykoboIntentValue) { + throw new Error("Missing nablaSwapEvm.outputAmountDecimal in quote metadata for Mykobo intent value"); + } + + // Mykobo silently truncates the intent value to 2 decimals. We floor here so the on-chain + // EURC transfer below matches the amount Mykobo actually credits to the withdraw intent. + const mykoboFlooredValue = new Big(mykoboIntentValue).toFixed(2, 0); + const eurcDecimals = evmTokenConfig[Networks.Base][EvmToken.EURC]?.decimals; + if (eurcDecimals === undefined) { + throw new Error("Invalid EURC decimals configuration for Base in evmTokenConfig"); + } + const eurcTransferAmountRaw = multiplyByPowerOfTen(new Big(mykoboFlooredValue), eurcDecimals).toFixed(0, 0); + + const mykobo = MykoboApiService.getInstance(); + const intent = await mykobo.createTransactionIntent({ + currency: MykoboCurrency.EURC, + email_address: email, + ip_address: ipAddress, + transaction_type: MykoboTransactionType.WITHDRAW, + value: mykoboFlooredValue, + wallet_address: evmEphemeralEntry.address + }); + + if (!isWithdrawInstructions(intent.instructions)) { + throw new Error("Mykobo intent did not return withdraw instructions; cannot derive receivables address"); + } + const mykoboReceivablesAddress = intent.instructions.address; + const mykoboTransactionId = intent.transaction.id; + const mykoboTransactionReference = intent.transaction.reference; + + if (isDirectBaseTransfer) { + // User already holds USDC on Base — they sign a single ERC-20 transfer to the ephemeral. + // Mirrors the isDirectPolygonTransfer branch in evm-to-alfredpay.ts. + const transferData = encodeFunctionData({ + abi: erc20ABI, + args: [evmEphemeralEntry.address as `0x${string}`, BigInt(inputAmountRaw)], + functionName: "transfer" + }); + + unsignedTxs.push({ + meta: {}, + network: fromNetwork, + nonce: 0, + phase: "squidRouterNoPermitTransfer", + signer: userAddress, + txData: { + data: transferData, + gas: "0", + to: inputTokenAddress, + value: "0" + } + }); + } else { + const { approveData, swapData } = await createOfframpSquidrouterTransactionsToEvm({ + destinationAddress: evmEphemeralEntry.address, + fromAddress: userAddress, + fromNetwork, + fromToken: inputTokenAddress, + rawAmount: inputAmountRaw, + toNetwork: Networks.Base, + toToken: baseUsdcAddress + }); + + unsignedTxs.push({ + meta: {}, + network: fromNetwork, + nonce: 0, + phase: "squidRouterApprove", + signer: userAddress, + txData: encodeEvmTransactionData(approveData) as EvmTransactionData + }); + + unsignedTxs.push({ + meta: {}, + network: fromNetwork, + nonce: 1, + phase: "squidRouterSwap", + signer: userAddress, + txData: encodeEvmTransactionData(swapData) as EvmTransactionData + }); + } + + let baseNonce = await addEvmFeeDistributionTransaction(quote, evmEphemeralEntry, unsignedTxs, 0); + + const { nextNonce: nonceAfterNabla, stateMeta: nablaStateMeta } = await addNablaSwapTransactionsOnBase( + { + account: evmEphemeralEntry, + inputTokenAddress: baseUsdcAddress, + outputTokenAddress: baseEurcAddress, + quote + }, + unsignedTxs, + baseNonce + ); + stateMeta = nablaStateMeta; + baseNonce = nonceAfterNabla; + + const payoutTransfer = await addOnrampDestinationChainTransactions({ + amountRaw: eurcTransferAmountRaw, + destinationNetwork: Networks.Base, + isNativeToken: false, + toAddress: mykoboReceivablesAddress as `0x${string}`, + toToken: baseEurcAddress as `0x${string}` + }); + + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce, + phase: "mykoboPayoutOnBase", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(payoutTransfer) as EvmTransactionData + }); + baseNonce++; + + const baseFundingAccount = getEvmFundingAccount(Networks.Base); + + const cleanupTokens = [ + { address: baseUsdcAddress, phase: "baseCleanupUsdc" as const }, + { address: baseEurcAddress, phase: "baseCleanupEurc" as const }, + { address: baseAxlUsdcAddress, phase: "baseCleanupAxlUsdc" as const } + ]; + + for (const { address, phase } of cleanupTokens) { + const approval = await prepareBaseCleanupApproval(address as `0x${string}`, baseFundingAccount.address, Networks.Base); + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase, + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(approval) as EvmTransactionData + }); + } + + stateMeta = { + ...stateMeta, + destinationAddress, + evmEphemeralAddress: evmEphemeralEntry.address, + mykoboEmail: email, + mykoboReceivablesAddress, + mykoboTransactionId, + mykoboTransactionReference, + walletAddress: userAddress + }; + + if (userId) { + await syncMykoboCustomerKyc(userId, email); + } + + return { stateMeta, unsignedTxs }; +} diff --git a/apps/api/src/api/services/transactions/offramp/routes/evm-to-stellar.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-stellar.ts deleted file mode 100644 index e2da5a424..000000000 --- a/apps/api/src/api/services/transactions/offramp/routes/evm-to-stellar.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { encodeSubmittableExtrinsic, getPendulumDetails, isEvmTokenDetails, Networks, UnsignedTx } from "@vortexfi/shared"; -import Big from "big.js"; -import { multiplyByPowerOfTen } from "../../../pendulum/helpers"; -import { StateMetadata } from "../../../phases/meta-state-types"; -import { addFeeDistributionTransaction } from "../../common/feeDistribution"; -import { preparePendulumCleanupTransaction } from "../../pendulum/cleanup"; -import { - createEvmSourceTransactions, - createNablaSwapTransactions, - createSpacewalkTransactions, - createStellarPaymentTransactions -} from "../common/transactions"; -import { OfframpTransactionParams, OfframpTransactionsWithMeta } from "../common/types"; -import { validateOfframpQuote, validateStellarOfframp, validateStellarOfframpMetadata } from "../common/validation"; - -/** - * Prepares all transactions for an EVM to Stellar offramp. - * This route handles: EVM → Pendulum (swap) → Spacewalk → Stellar - */ -export async function prepareEvmToStellarOfframpTransactions({ - quote, - signingAccounts, - stellarPaymentData, - userAddress -}: OfframpTransactionParams): Promise { - const unsignedTxs: UnsignedTx[] = []; - let stateMeta: Partial = {}; - - // Validate inputs and extract required data - const { fromNetwork, inputTokenDetails, outputTokenDetails, stellarEphemeralEntry, substrateEphemeralEntry } = - validateOfframpQuote(quote, signingAccounts); - - const { stellarTokenDetails, stellarPaymentData: validatedStellarPaymentData } = validateStellarOfframp( - outputTokenDetails, - stellarPaymentData - ); - const { offrampAmountBeforeAnchorFeesUnits, offrampAmountBeforeAnchorFeesRaw } = validateStellarOfframpMetadata(quote); - - const inputAmountRaw = multiplyByPowerOfTen(new Big(quote.inputAmount), inputTokenDetails.decimals).toFixed(0, 0); - - if (validatedStellarPaymentData.amount) { - const stellarAmount = new Big(validatedStellarPaymentData.amount); - if (!stellarAmount.eq(offrampAmountBeforeAnchorFeesUnits)) { - throw new Error( - `Stellar amount ${stellarAmount.toString()} not equal to expected payment ${offrampAmountBeforeAnchorFeesUnits.toString()}` - ); - } - } - - // Initialize state metadata - stateMeta = { - stellarEphemeralAccountId: stellarEphemeralEntry.address, - substrateEphemeralAddress: substrateEphemeralEntry.address - }; - - if (!userAddress) { - throw new Error("User address must be provided for offramping."); - } - - if (!isEvmTokenDetails(inputTokenDetails)) { - throw new Error("EVM to Stellar route requires EVM input token"); - } - - // Create EVM source transactions - const evmSourceMetadata = await createEvmSourceTransactions( - { - fromNetwork, - fromToken: inputTokenDetails.erc20AddressSourceChain, - inputAmountRaw, - pendulumEphemeralAddress: substrateEphemeralEntry.address, - toToken: "0xA0b86a33E6441e88C5F2712C3E9b74F5F4e3E3D6", // AXL USDC on Moonbeam - userAddress - }, - unsignedTxs - ); - - stateMeta = { - ...stateMeta, - ...evmSourceMetadata - }; - - // Process Pendulum account - const substrateAccount = signingAccounts.find(account => account.type === "Substrate"); - if (!substrateAccount) { - throw new Error("Substrate account not found"); - } - - const inputTokenPendulumDetails = getPendulumDetails(quote.inputCurrency, fromNetwork); - const outputTokenPendulumDetails = getPendulumDetails(quote.outputCurrency); - - let pendulumNonce = 0; - - // Add fee distribution transaction - pendulumNonce = await addFeeDistributionTransaction(quote, substrateAccount, unsignedTxs, pendulumNonce); - - // Create Nabla swap transactions - const nablaResult = await createNablaSwapTransactions( - { - account: substrateAccount, - inputTokenPendulumDetails, - outputTokenPendulumDetails, - quote - }, - unsignedTxs, - pendulumNonce - ); - - pendulumNonce = nablaResult.nextNonce; - stateMeta = { - ...stateMeta, - ...nablaResult.stateMeta - }; - - // Prepare cleanup transaction - const pendulumCleanupTransaction = await preparePendulumCleanupTransaction( - inputTokenPendulumDetails.currencyId, - outputTokenPendulumDetails.currencyId - ); - - const pendulumCleanupTx: Omit = { - meta: {}, - network: Networks.Pendulum, - phase: "pendulumCleanup", - signer: substrateAccount.address, - txData: encodeSubmittableExtrinsic(pendulumCleanupTransaction) - }; - - // Create Spacewalk transactions - const stellarResult = await createSpacewalkTransactions( - { - account: substrateAccount, - outputAmountRaw: offrampAmountBeforeAnchorFeesRaw, - outputTokenDetails: stellarTokenDetails, - stellarEphemeralEntry, - stellarPaymentData: validatedStellarPaymentData - }, - unsignedTxs, - pendulumCleanupTx, - pendulumNonce - ); - - pendulumNonce = stellarResult.nextNonce; - stateMeta = { - ...stateMeta, - ...stellarResult.stateMeta - }; - - if (!stellarPaymentData) { - throw new Error("Stellar payment data must be provided for offramp"); - } - - await createStellarPaymentTransactions( - { - ephemeralAddress: stellarEphemeralEntry.address, - outputAmountUnits: offrampAmountBeforeAnchorFeesUnits, - outputTokenDetails: stellarTokenDetails, - stellarPaymentData - }, - unsignedTxs - ); - - return { stateMeta, unsignedTxs }; -} diff --git a/apps/api/src/api/services/transactions/onramp/common/monerium.test.ts b/apps/api/src/api/services/transactions/onramp/common/monerium.test.ts deleted file mode 100644 index ab20e2107..000000000 --- a/apps/api/src/api/services/transactions/onramp/common/monerium.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { MONERIUM_SELF_TRANSFER_GAS_LIMIT } from "./monerium"; - -describe("MONERIUM_SELF_TRANSFER_GAS_LIMIT", () => { - it("keeps enough room for EURe v2 transferFrom compliance checks", () => { - expect(BigInt(MONERIUM_SELF_TRANSFER_GAS_LIMIT)).toBeGreaterThan(100000n); - }); -}); diff --git a/apps/api/src/api/services/transactions/onramp/common/monerium.ts b/apps/api/src/api/services/transactions/onramp/common/monerium.ts deleted file mode 100644 index e17b0a6ff..000000000 --- a/apps/api/src/api/services/transactions/onramp/common/monerium.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { ERC20_EURE_POLYGON_V2, EvmClientManager, EvmTransactionData, Networks } from "@vortexfi/shared"; -import { encodeFunctionData } from "viem"; -import { config } from "../../../../../config/vars"; -import erc20ABI from "../../../../../contracts/ERC20"; - -export const MONERIUM_SELF_TRANSFER_GAS_LIMIT = "300000"; - -export async function createOnrampEphemeralSelfTransfer( - amountRaw: string, - fromAddress: string, - toAddress: string -): Promise { - const evmClientManager = EvmClientManager.getInstance(); - const network = config.sandboxEnabled ? Networks.PolygonAmoy : Networks.Polygon; - const polygonClient = evmClientManager.getClient(network); - - const transferCallData = encodeFunctionData({ - abi: erc20ABI, - args: [fromAddress, toAddress, amountRaw], - functionName: "transferFrom" - }); - - const { maxFeePerGas } = await polygonClient.estimateFeesPerGas(); - - const txData: EvmTransactionData = { - data: transferCallData as `0x${string}`, - gas: MONERIUM_SELF_TRANSFER_GAS_LIMIT, - maxFeePerGas: String(maxFeePerGas), - maxPriorityFeePerGas: String(maxFeePerGas), - to: ERC20_EURE_POLYGON_V2, - value: "0" - }; - - return txData; -} diff --git a/apps/api/src/api/services/transactions/onramp/common/transactions.ts b/apps/api/src/api/services/transactions/onramp/common/transactions.ts index 713c6182e..b3cfec69e 100644 --- a/apps/api/src/api/services/transactions/onramp/common/transactions.ts +++ b/apps/api/src/api/services/transactions/onramp/common/transactions.ts @@ -9,6 +9,7 @@ import { EvmNetworks, EvmTransactionData, encodeSubmittableExtrinsic, + getNablaBasePool, getNetworkId, Networks, PendulumTokenDetails, @@ -271,7 +272,8 @@ export async function addNablaSwapTransactionsOnBase( inputTokenAddress, outputTokenAddress, nablaHardMinimumOutputRaw, - config.swap.deadlineMinutes + config.swap.deadlineMinutes, + getNablaBasePool(inputTokenAddress, outputTokenAddress).router ); unsignedTxs.push({ diff --git a/apps/api/src/api/services/transactions/onramp/common/types.ts b/apps/api/src/api/services/transactions/onramp/common/types.ts index 7bb618d7e..005b325f2 100644 --- a/apps/api/src/api/services/transactions/onramp/common/types.ts +++ b/apps/api/src/api/services/transactions/onramp/common/types.ts @@ -11,7 +11,10 @@ export type AveniaOnrampTransactionParams = OnrampTransactionParams & { taxId: s export type AlfredpayOnrampTransactionParams = OnrampTransactionParams & { userId: string }; -export type MoneriumOnrampTransactionParams = OnrampTransactionParams & { moneriumWalletAddress: string }; +export type MykoboOnrampTransactionParams = OnrampTransactionParams & { + mykoboEmail: string; + ipAddress: string; +}; export interface OnrampTransactionsWithMeta { unsignedTxs: UnsignedTx[]; diff --git a/apps/api/src/api/services/transactions/onramp/common/validation.ts b/apps/api/src/api/services/transactions/onramp/common/validation.ts index 3cbc29381..2e1bb2a38 100644 --- a/apps/api/src/api/services/transactions/onramp/common/validation.ts +++ b/apps/api/src/api/services/transactions/onramp/common/validation.ts @@ -105,45 +105,42 @@ export function validateAveniaOnrampOnBase( return { evmEphemeralEntry, inputTokenDetails, outputTokenDetails, toNetwork }; } -export function validateMoneriumOnramp( +export function validateMykoboOnramp( quote: QuoteTicketAttributes, signingAccounts: AccountMeta[] ): { toNetwork: Networks; outputTokenDetails: OnChainTokenDetails; - substrateEphemeralEntry: AccountMeta; evmEphemeralEntry: AccountMeta; + inputTokenDetails: OnChainTokenDetails; } { const toNetwork = getNetworkFromDestination(quote.to); if (!toNetwork) { throw new Error(`Invalid network for destination ${quote.to}`); } + const evmEphemeralEntry = signingAccounts.find(ephemeral => ephemeral.type === "EVM"); + if (!evmEphemeralEntry) { + throw new Error("Base ephemeral not found"); + } + if (quote.inputCurrency !== FiatToken.EURC) { - throw new Error(`Input currency must be EURC for onramp, got ${quote.inputCurrency}`); + throw new Error(`Input currency must be EURC for Mykobo onramp, got ${quote.inputCurrency}`); + } + + const inputTokenDetails = getEvmTokenConfig().base[EvmToken.EURC]; + if (!inputTokenDetails) { + throw new Error("EURC token details not found for Base"); } if (!isOnChainToken(quote.outputCurrency)) { throw new Error(`Output currency cannot be fiat token ${quote.outputCurrency} for onramp.`); } const outputTokenDetails = getOnChainTokenDetails(toNetwork, quote.outputCurrency); - if (!outputTokenDetails) { - throw new Error(`Output token details not found for ${quote.outputCurrency} on network ${toNetwork}`); - } - if (!isOnChainTokenDetails(outputTokenDetails)) { + if (!outputTokenDetails || !isOnChainTokenDetails(outputTokenDetails)) { throw new Error(`Output token must be on-chain token for onramp, got ${quote.outputCurrency}`); } - const evmEphemeralEntry = signingAccounts.find(ephemeral => ephemeral.type === "EVM"); - if (!evmEphemeralEntry) { - throw new Error("Polygon ephemeral not found"); - } - - const substrateEphemeralEntry = signingAccounts.find(ephemeral => ephemeral.type === "Substrate"); - if (!substrateEphemeralEntry) { - throw new Error("Pendulum ephemeral not found"); - } - - return { evmEphemeralEntry, outputTokenDetails, substrateEphemeralEntry, toNetwork }; + return { evmEphemeralEntry, inputTokenDetails, outputTokenDetails, toNetwork }; } diff --git a/apps/api/src/api/services/transactions/onramp/index.ts b/apps/api/src/api/services/transactions/onramp/index.ts index ec8a490d4..8c66c4f0e 100644 --- a/apps/api/src/api/services/transactions/onramp/index.ts +++ b/apps/api/src/api/services/transactions/onramp/index.ts @@ -2,26 +2,18 @@ import { FiatToken, isAlfredpayToken, Networks } from "@vortexfi/shared"; import { AlfredpayOnrampTransactionParams, AveniaOnrampTransactionParams, - MoneriumOnrampTransactionParams, OnrampTransactionParams, OnrampTransactionsWithMeta } from "./common/types"; import { prepareAlfredpayToEvmOnrampTransactions } from "./routes/alfredpay-to-evm"; import { prepareAveniaToAssethubOnrampTransactions } from "./routes/avenia-to-assethub"; import { prepareAveniaToEvmOnrampTransactionsOnBase } from "./routes/avenia-to-evm-base"; -import { prepareMoneriumToAssethubOnrampTransactions } from "./routes/monerium-to-assethub"; -import { prepareMoneriumToEvmOnrampTransactions } from "./routes/monerium-to-evm"; export async function prepareOnrampTransactions( - params: - | AveniaOnrampTransactionParams - | MoneriumOnrampTransactionParams - | AlfredpayOnrampTransactionParams - | OnrampTransactionParams + params: AveniaOnrampTransactionParams | AlfredpayOnrampTransactionParams | OnrampTransactionParams ): Promise { const { quote } = params; - // Route based on input currency and destination network if (quote.inputCurrency === FiatToken.BRL) { if (!("taxId" in params)) { throw new Error("taxId is required for Avenia onramp"); @@ -35,15 +27,9 @@ export async function prepareOnrampTransactions( return prepareAveniaToEvmOnrampTransactionsOnBase(aveniaParams); } } else if (quote.inputCurrency === FiatToken.EURC) { - if (!("moneriumWalletAddress" in params)) { - throw new Error("moneriumWalletAddress is required for Monerium onramp"); - } - - if (quote.to === Networks.AssetHub) { - return prepareMoneriumToAssethubOnrampTransactions(params); - } else { - return prepareMoneriumToEvmOnrampTransactions(params); - } + throw new Error( + "EURC onramp must be prepared via prepareMykoboToEvmOnrampTransactions, not through prepareOnrampTransactions" + ); } else if (isAlfredpayToken(quote.inputCurrency as FiatToken)) { if (!("userId" in params)) { throw new Error("Alfredpay onramps requires logged in user"); diff --git a/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts index b86a476eb..e2f298d60 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts @@ -167,6 +167,49 @@ export async function prepareAlfredpayToEvmOnrampTransactions({ txData: encodeEvmTransactionData(swapData) as EvmTransactionData }); + // Same-chain Polygon: destinationTransfer must be the next executable nonce after the swap. The cleanup + // approval runs post-complete, so it follows the transfer. Backup re-swap txs are omitted here (no handler + // executes them, and on a shared nonce sequence they would push destinationTransfer beyond the live nonce). + if (toNetwork === Networks.Polygon) { + const sameChainTransferTxData = await addOnrampDestinationChainTransactions({ + amountRaw: quote.metadata.alfredpayMint.outputAmountRaw, + destinationNetwork: Networks.Polygon, + toAddress: destinationAddress, + toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain + }); + unsignedTxs.push({ + meta: {}, + network: Networks.Polygon, + nonce: polygonAccountNonce++, + phase: "destinationTransfer", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(sameChainTransferTxData) as EvmTransactionData + }); + + const sameChainCleanupApproval = await preparePolygonCleanupApproval( + ERC20_USDC_POLYGON, + fundingAccount.address, + Networks.Polygon + ); + unsignedTxs.push({ + meta: {}, + network: Networks.Polygon, + nonce: polygonAccountNonce++, + phase: "polygonCleanup", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(sameChainCleanupApproval) as EvmTransactionData + }); + + stateMeta = { + ...stateMeta, + squidRouterQuoteId, + squidRouterReceiverHash, + squidRouterReceiverId + }; + + return { stateMeta, unsignedTxs }; + } + const polygonCleanupApproval = await preparePolygonCleanupApproval( ERC20_USDC_POLYGON, fundingAccount.address, @@ -188,7 +231,8 @@ export async function prepareAlfredpayToEvmOnrampTransactions({ toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain }); - let destinationNonce = toNetwork === Networks.Polygon ? polygonAccountNonce++ : 0; // If the destination is Polygon, we need to use the same nonce sequence. Otherwise, we start fresh on the new chain. + let destinationNonce = 0; + const destinationStartingNonce = destinationNonce; unsignedTxs.push({ meta: {}, @@ -244,8 +288,8 @@ export async function prepareAlfredpayToEvmOnrampTransactions({ tokenAddress: bridgedTokenForFallback }); - // We set this to 0 on purpose because we don't want to risk that the required nonce is never reached - const backupApproveNonce = 0; + // We set this to the destinationTransfer nonce on purpose because we don't want to risk that the required nonce is never reached + const backupApproveNonce = destinationStartingNonce; unsignedTxs.push({ meta: {}, network: toNetwork, diff --git a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.ts b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.ts index 5452e6de3..466ca0d89 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm-base.ts @@ -18,6 +18,7 @@ import { isAddress } from "viem"; import logger from "../../../../../config/logger"; import { getEvmFundingAccount } from "../../../phases/evm-funding"; import { StateMetadata } from "../../../phases/meta-state-types"; +import { isBrlToBrlaBaseDirect } from "../../../quote/utils"; import { prepareBaseCleanupApproval } from "../../base/cleanup"; import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; import { encodeEvmTransactionData } from "../../index"; @@ -53,15 +54,45 @@ export async function prepareAveniaToEvmOnrampTransactionsOnBase({ signingAccounts ); logger.debug(`Starting prepareAveniaToEvmOnrampTransactionsOnBase with destinationAddress: ${destinationAddress}`); + const isDirectTransfer = isBrlToBrlaBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network); // Setup state metadata stateMeta = { destinationAddress, evmEphemeralAddress: evmEphemeralEntry.address, + isDirectTransfer, taxId }; let baseNonce = 0; + if (!isEvmTokenDetails(outputTokenDetails)) { + throw new Error(`Output token must be an EVM token for onramp to any EVM chain, got ${outputTokenDetails.assetSymbol}`); + } + + // BRL→BRLA on Base: Avenia already minted the requested BRLA, so no Nabla swap, fee-token + // conversion, or SquidRouter step is needed — transfer the minted BRLA straight to the user. + if (isDirectTransfer) { + const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals); + const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ + amountRaw: finalAmountRaw.toString(), + destinationNetwork: Networks.Base, + isNativeToken: isNativeEvmToken(outputTokenDetails), + toAddress: destinationAddress, + toToken: outputTokenDetails.erc20AddressSourceChain + }); + + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce, + phase: "destinationTransfer", + signer: evmEphemeralEntry.address, + txData: finalDestinationTransfer + }); + + return { stateMeta, unsignedTxs }; + } + if (!quote.metadata.aveniaTransfer?.outputAmountRaw) { throw new Error("Missing aveniaTransfer amountOutRaw in quote metadata"); } @@ -70,10 +101,6 @@ export async function prepareAveniaToEvmOnrampTransactionsOnBase({ throw new Error("Missing evmToEvm inputAmountRaw in quote metadata"); } - if (!isEvmTokenDetails(outputTokenDetails)) { - throw new Error(`Output token must be an EVM token for onramp to any EVM chain, got ${outputTokenDetails.assetSymbol}`); - } - // Output for BRLA onramp will always go through USDC. // TODO. Unless the actual BRLA token wants to be onramped. const nablaSwapOutputTokenAddress = evmTokenConfig[Networks.Base][EvmToken.USDC]?.erc20AddressSourceChain; @@ -174,6 +201,63 @@ export async function prepareAveniaToEvmOnrampTransactionsOnBase({ txData: encodeEvmTransactionData(swapData) as EvmTransactionData }); + // Same-chain Base: destinationTransfer must be the next executable nonce after the swap. Cleanups run + // post-complete, so they follow the transfer. Backup re-swap txs are omitted here (no handler executes + // them, and on a shared nonce sequence they would push destinationTransfer beyond the live nonce). + if (toNetwork === Networks.Base) { + const sameChainDestinationTransfer = await addOnrampDestinationChainTransactions({ + amountRaw: finalAmountRaw.toString(), + destinationNetwork: Networks.Base, + isNativeToken: isNativeEvmToken(outputTokenDetails), + toAddress: destinationAddress, + toToken: outputTokenDetails.erc20AddressSourceChain + }); + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase: "destinationTransfer", + signer: evmEphemeralEntry.address, + txData: sameChainDestinationTransfer + }); + + const sameChainFundingAddress = getEvmFundingAccount(Networks.Base).address; + const sameChainBrlaAddress = (inputTokenDetails as EvmTokenDetails).erc20AddressSourceChain as `0x${string}`; + + const brlaCleanup = await prepareBaseCleanupApproval(sameChainBrlaAddress, sameChainFundingAddress, Networks.Base); + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase: "baseCleanupBrla", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(brlaCleanup) as EvmTransactionData + }); + + const usdcCleanup = await prepareBaseCleanupApproval( + nablaSwapOutputTokenAddress as `0x${string}`, + sameChainFundingAddress, + Networks.Base + ); + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase: "baseCleanupUsdc", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(usdcCleanup) as EvmTransactionData + }); + + stateMeta = { + ...stateMeta, + squidRouterQuoteId, + squidRouterReceiverHash, + squidRouterReceiverId + }; + + return { stateMeta, unsignedTxs }; + } + const baseFundingAccountAddress = getEvmFundingAccount(Networks.Base).address; const brlaTokenAddress = (inputTokenDetails as EvmTokenDetails).erc20AddressSourceChain as `0x${string}`; @@ -202,6 +286,7 @@ export async function prepareAveniaToEvmOnrampTransactionsOnBase({ }); let destinationNonce = 0; + const destinationStartingNonce = destinationNonce; const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ amountRaw: finalAmountRaw.toString(), @@ -277,8 +362,8 @@ export async function prepareAveniaToEvmOnrampTransactionsOnBase({ tokenAddress: bridgedTokenForFallback }); - // We set this to 0 on purpose because we don't want to risk that the required nonce is never reached - const backupApproveNonce = 0; + // We set this to the destinationTransfer nonce on purpose because we don't want to risk that the required nonce is never reached + const backupApproveNonce = destinationStartingNonce; unsignedTxs.push({ meta: {}, network: toNetwork, diff --git a/apps/api/src/api/services/transactions/onramp/routes/monerium-to-assethub.ts b/apps/api/src/api/services/transactions/onramp/routes/monerium-to-assethub.ts deleted file mode 100644 index a5b2daddd..000000000 --- a/apps/api/src/api/services/transactions/onramp/routes/monerium-to-assethub.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { - AXL_USDC_MOONBEAM, - createOnrampSquidrouterTransactionsFromPolygonToMoonbeamWithPendulumPosthook, - createPendulumToAssethubTransfer, - createPendulumToHydrationTransfer, - ERC20_EURE_POLYGON_V1, - EvmTransactionData, - encodeSubmittableExtrinsic, - getNetworkId, - getPendulumDetails, - isAssetHubTokenDetails, - Networks, - PENDULUM_USDC_AXL, - UnsignedTx -} from "@vortexfi/shared"; -import Big from "big.js"; -import { config } from "../../../../../config/vars"; -import { getEvmFundingAccount } from "../../../phases/evm-funding"; -import { StateMetadata } from "../../../phases/meta-state-types"; -import { addFeeDistributionTransaction } from "../../common/feeDistribution"; -import { buildHydrationSwapTransaction, buildHydrationToAssetHubTransfer } from "../../hydration"; -import { prepareHydrationCleanupTransaction } from "../../hydration/cleanup"; -import { encodeEvmTransactionData } from "../../index"; -import { preparePolygonCleanupApproval } from "../../polygon/cleanup"; -import { createOnrampEphemeralSelfTransfer } from "../common/monerium"; -import { addMoonbeamTransactions, addNablaSwapTransactions, addPendulumCleanupTx } from "../common/transactions"; -import { MoneriumOnrampTransactionParams, OnrampTransactionsWithMeta } from "../common/types"; -import { validateMoneriumOnramp } from "../common/validation"; - -/** - * Prepares all transactions for a Monerium (EUR) onramp to AssetHub. - * This route handles: EUR → Polygon (EURe) → Moonbeam (axlUSDC) → Pendulum (swap) → AssetHub (final transfer) - */ -export async function prepareMoneriumToAssethubOnrampTransactions({ - quote, - signingAccounts, - destinationAddress, - moneriumWalletAddress -}: MoneriumOnrampTransactionParams): Promise { - let stateMeta: Partial = {}; - const unsignedTxs: UnsignedTx[] = []; - - // Validate inputs and extract required data - const { toNetwork, outputTokenDetails, evmEphemeralEntry, substrateEphemeralEntry } = validateMoneriumOnramp( - quote, - signingAccounts - ); - const toNetworkId = getNetworkId(toNetwork); - - // Setup state metadata - stateMeta = { - destinationAddress, - evmEphemeralAddress: evmEphemeralEntry.address, - moneriumWalletAddress, - substrateEphemeralAddress: substrateEphemeralEntry.address, - walletAddress: destinationAddress - }; - - if (!quote.metadata.moneriumMint?.outputAmountRaw) { - throw new Error("Missing moonbeamToEvm output amount in quote metadata"); - } - - const inputAmountPostAnchorFeeRaw = new Big(quote.metadata.moneriumMint.outputAmountRaw).toFixed(0, 0); - - let polygonAccountNonce = 0; - - const polygonSelfTransferTxData = await createOnrampEphemeralSelfTransfer( - inputAmountPostAnchorFeeRaw, - moneriumWalletAddress, - evmEphemeralEntry.address - ); - const moneriumMintNetwork = config.sandboxEnabled ? Networks.PolygonAmoy : Networks.Polygon; - - unsignedTxs.push({ - meta: {}, - network: moneriumMintNetwork, - nonce: polygonAccountNonce++, - phase: "moneriumOnrampSelfTransfer", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(polygonSelfTransferTxData) as EvmTransactionData - }); - - const { approveData, swapData, squidRouterReceiverId, squidRouterReceiverHash, squidRouterQuoteId } = - await createOnrampSquidrouterTransactionsFromPolygonToMoonbeamWithPendulumPosthook({ - destinationAddress: substrateEphemeralEntry.address, - fromAddress: evmEphemeralEntry.address, - fromToken: ERC20_EURE_POLYGON_V1, - rawAmount: inputAmountPostAnchorFeeRaw, - toToken: AXL_USDC_MOONBEAM - }); - - unsignedTxs.push({ - meta: {}, - network: moneriumMintNetwork, - nonce: polygonAccountNonce++, - phase: "squidRouterApprove", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(approveData) as EvmTransactionData - }); - - unsignedTxs.push({ - meta: {}, - network: moneriumMintNetwork, - nonce: polygonAccountNonce++, - phase: "squidRouterSwap", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(swapData) as EvmTransactionData - }); - - const fundingAccount = getEvmFundingAccount(moneriumMintNetwork); - const polygonCleanupApproval = await preparePolygonCleanupApproval( - ERC20_EURE_POLYGON_V1, - fundingAccount.address, - moneriumMintNetwork - ); - unsignedTxs.push({ - meta: {}, - network: moneriumMintNetwork, - nonce: polygonAccountNonce++, - phase: "polygonCleanup", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(polygonCleanupApproval) as EvmTransactionData - }); - - stateMeta = { - ...stateMeta, - squidRouterQuoteId, - squidRouterReceiverHash, - squidRouterReceiverId - }; - - if (!quote.metadata.evmToMoonbeam?.outputAmountRaw) { - throw new Error("Missing evmToMoonbeam in quote metadata"); - } - const receivedTokensOnMoonbeam = quote.metadata.evmToMoonbeam.outputAmountRaw; - - await addMoonbeamTransactions( - { - account: evmEphemeralEntry, - fromToken: AXL_USDC_MOONBEAM, - inputAmountRaw: receivedTokensOnMoonbeam, - pendulumEphemeralAddress: substrateEphemeralEntry.address, - toNetworkId - }, - unsignedTxs, - 0 // start nonce - ); - - // Pendulum: Nabla swap and transfer to AssetHub - const inputTokenPendulumDetails = PENDULUM_USDC_AXL; - const outputTokenPendulumDetails = getPendulumDetails(quote.outputCurrency, toNetwork); - - let pendulumNonce = 0; - - // Add Nabla swap transactions - const { nextNonce: nonceAfterNabla, stateMeta: nablaStateMeta } = await addNablaSwapTransactions( - { - account: substrateEphemeralEntry, - inputTokenPendulumDetails, - outputTokenPendulumDetails, - quote - }, - unsignedTxs, - pendulumNonce - ); - stateMeta = { ...stateMeta, ...nablaStateMeta }; - pendulumNonce = nonceAfterNabla; - - // Add fee distribution - pendulumNonce = await addFeeDistributionTransaction(quote, substrateEphemeralEntry, unsignedTxs, pendulumNonce); - - // Finalization: Transfer to AssetHub - const pendulumCleanupTx = await addPendulumCleanupTx({ - account: substrateEphemeralEntry, - inputTokenPendulumDetails, - outputTokenPendulumDetails - }); - - if (quote.outputCurrency === "USDC") { - if (!quote.metadata.pendulumToAssethubXcm?.inputAmountRaw) { - throw new Error("Missing input amount for Pendulum to Assethub transfer"); - } - const transferAmountRaw = quote.metadata.pendulumToAssethubXcm.inputAmountRaw; - - const pendulumToAssethubXcmTransaction = await createPendulumToAssethubTransfer( - destinationAddress, - outputTokenDetails.pendulumRepresentative.currencyId, - transferAmountRaw - ); - - unsignedTxs.push({ - meta: {}, - network: Networks.Pendulum, - nonce: pendulumNonce, - phase: "pendulumToAssethubXcm", - signer: substrateEphemeralEntry.address, - txData: encodeSubmittableExtrinsic(pendulumToAssethubXcmTransaction) - }); - pendulumNonce++; - } else { - if (!quote.metadata.pendulumToHydrationXcm?.inputAmountRaw) { - throw new Error("Missing input amount for Pendulum to Hydration transfer"); - } - const transferAmountRaw = quote.metadata.pendulumToHydrationXcm.inputAmountRaw; - - const pendulumToHydrationXcmTransaction = await createPendulumToHydrationTransfer( - substrateEphemeralEntry.address, - outputTokenDetails.pendulumRepresentative.currencyId, - transferAmountRaw - ); - - unsignedTxs.push({ - meta: {}, - network: Networks.Pendulum, - nonce: pendulumNonce, - phase: "pendulumToHydrationXcm", - signer: substrateEphemeralEntry.address, - txData: encodeSubmittableExtrinsic(pendulumToHydrationXcmTransaction) - }); - pendulumNonce++; - - if (!quote.metadata.hydrationSwap) { - throw new Error("Missing hydration swap details for Hydration finalization"); - } - - // Keep the hydration nonce at 0. It doesn't increase on the network for some reason - const hydrationNonce = 0; - const { inputAsset, outputAsset, inputAmountDecimal, minOutputAmountRaw } = quote.metadata.hydrationSwap; - const hydrationSwap = await buildHydrationSwapTransaction( - inputAsset, - outputAsset, - inputAmountDecimal, - substrateEphemeralEntry.address, - quote.metadata.hydrationSwap.slippagePercent - ); - - unsignedTxs.push({ - meta: {}, - network: Networks.Hydration, - nonce: hydrationNonce, - phase: "hydrationSwap", - signer: substrateEphemeralEntry.address, - txData: encodeSubmittableExtrinsic(hydrationSwap) - }); - - // Transfer from Hydration to AssetHub - if (!isAssetHubTokenDetails(outputTokenDetails)) { - throw new Error( - `Output token must be an AssetHub token for finalization to AssetHub, got ${outputTokenDetails.assetSymbol}` - ); - } - const hydrationAssetId = outputTokenDetails.hydrationId; - // biome-ignore lint/style/noNonNullAssertion: Checked by isAssetHubTokenDetails - const assethubAssetId = outputTokenDetails.isNative ? "native" : outputTokenDetails.foreignAssetId!; - - const hydrationToAssethubTransfer = await buildHydrationToAssetHubTransfer( - destinationAddress, - minOutputAmountRaw, - hydrationAssetId, - assethubAssetId - ); - - unsignedTxs.push({ - meta: {}, - network: Networks.Hydration, - nonce: hydrationNonce, - phase: "hydrationToAssethubXcm", - signer: substrateEphemeralEntry.address, - txData: encodeSubmittableExtrinsic(hydrationToAssethubTransfer) - }); - - const hydrationCleanupTx = await prepareHydrationCleanupTransaction(inputAsset, outputAsset); - unsignedTxs.push({ - meta: {}, - network: Networks.Hydration, - nonce: hydrationNonce, - phase: "hydrationCleanup", - signer: substrateEphemeralEntry.address, - txData: encodeSubmittableExtrinsic(hydrationCleanupTx) - }); - } - - unsignedTxs.push({ - ...pendulumCleanupTx, - nonce: pendulumNonce - }); - - return { stateMeta, unsignedTxs }; -} diff --git a/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts deleted file mode 100644 index c8b017120..000000000 --- a/apps/api/src/api/services/transactions/onramp/routes/monerium-to-evm.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { - createOnrampSquidrouterTransactionsFromPolygonToEvm, - createOnrampSquidrouterTransactionsOnDestinationChain, - ERC20_EURE_POLYGON_V1, - EvmNetworks, - EvmToken, - EvmTokenDetails, - EvmTransactionData, - evmTokenConfig, - getOnChainTokenDetailsOrDefault, - isAssetHubTokenDetails, - isNativeEvmToken, - multiplyByPowerOfTen, - Networks, - UnsignedTx -} from "@vortexfi/shared"; -import Big from "big.js"; -import { isAddress } from "viem"; -import { config } from "../../../../../config/vars"; -import { getEvmFundingAccount } from "../../../phases/evm-funding"; -import { StateMetadata } from "../../../phases/meta-state-types"; -import { priceFeedService } from "../../../priceFeed.service"; -import { encodeEvmTransactionData } from "../../index"; -import { preparePolygonCleanupApproval } from "../../polygon/cleanup"; -import { createOnrampEphemeralSelfTransfer } from "../common/monerium"; -import { addDestinationChainApprovalTransaction, addOnrampDestinationChainTransactions } from "../common/transactions"; -import { MoneriumOnrampTransactionParams, OnrampTransactionsWithMeta } from "../common/types"; -import { validateMoneriumOnramp } from "../common/validation"; - -/** - * Prepares all transactions for a Monerium (EUR) onramp to EVM chain. - * This route handles: EUR → Polygon (EURE) → EVM (final transfer) - */ -export async function prepareMoneriumToEvmOnrampTransactions({ - quote, - signingAccounts, - destinationAddress, - moneriumWalletAddress -}: MoneriumOnrampTransactionParams): Promise { - let stateMeta: Partial = {}; - const unsignedTxs: UnsignedTx[] = []; - - // Validate that destinationAddress is a valid EVM address for EVM routes - if (!isAddress(destinationAddress)) { - throw new Error(`Invalid destination address for EVM route: ${destinationAddress}. Must be a valid EVM address.`); - } - - // Validate inputs and extract required data - const { toNetwork, outputTokenDetails, evmEphemeralEntry } = validateMoneriumOnramp(quote, signingAccounts); - - if (isAssetHubTokenDetails(outputTokenDetails)) { - throw new Error(`AssetHub token ${quote.outputCurrency} is not supported for onramp.`); - } - - if (!quote.metadata.moneriumMint?.outputAmountRaw) { - throw new Error("Missing moonbeamToEvm output amount in quote metadata"); - } - - if (!quote.metadata.evmToEvm?.inputAmountDecimal) { - throw new Error("Missing evmToEvm input amount in quote metadata"); - } - - const inputAmountPostAnchorFeeRaw = new Big(quote.metadata.moneriumMint.outputAmountRaw).toFixed(0, 0); - - // Setup state metadata - stateMeta = { - destinationAddress, - evmEphemeralAddress: evmEphemeralEntry.address, - moneriumWalletAddress, - walletAddress: destinationAddress - }; - - const moneriumMintNetwork = config.sandboxEnabled ? Networks.PolygonAmoy : Networks.Polygon; - const fundingAccount = getEvmFundingAccount(moneriumMintNetwork); - - let polygonAccountNonce = 0; - - const polygonSelfTransferTxData = await createOnrampEphemeralSelfTransfer( - inputAmountPostAnchorFeeRaw, - moneriumWalletAddress, - evmEphemeralEntry.address - ); - - unsignedTxs.push({ - meta: {}, - network: moneriumMintNetwork, - nonce: polygonAccountNonce++, - phase: "moneriumOnrampSelfTransfer", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(polygonSelfTransferTxData) as EvmTransactionData - }); - - const { approveData, swapData, squidRouterQuoteId, squidRouterReceiverId, squidRouterReceiverHash } = - await createOnrampSquidrouterTransactionsFromPolygonToEvm({ - destinationAddress: evmEphemeralEntry.address, - fromAddress: evmEphemeralEntry.address, - fromToken: ERC20_EURE_POLYGON_V1, - rawAmount: inputAmountPostAnchorFeeRaw, - toNetwork, - toToken: outputTokenDetails.erc20AddressSourceChain - }); - - unsignedTxs.push({ - meta: {}, - network: moneriumMintNetwork, - nonce: polygonAccountNonce++, - phase: "squidRouterApprove", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(approveData) as EvmTransactionData - }); - - unsignedTxs.push({ - meta: {}, - network: moneriumMintNetwork, - nonce: polygonAccountNonce++, - phase: "squidRouterSwap", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(swapData) as EvmTransactionData - }); - - const polygonCleanupApproval = await preparePolygonCleanupApproval( - ERC20_EURE_POLYGON_V1, - fundingAccount.address, - moneriumMintNetwork - ); - - unsignedTxs.push({ - meta: {}, - network: moneriumMintNetwork, - nonce: polygonAccountNonce++, - phase: "polygonCleanup", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(polygonCleanupApproval) as EvmTransactionData - }); - - let destinationNonce = toNetwork === Networks.Polygon ? polygonAccountNonce : 0; - - const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals); - const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ - amountRaw: finalAmountRaw.toString(), - destinationNetwork: toNetwork as EvmNetworks, - isNativeToken: isNativeEvmToken(outputTokenDetails), - toAddress: destinationAddress, - toToken: outputTokenDetails.erc20AddressSourceChain - }); - - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: destinationNonce, - phase: "destinationTransfer", - signer: evmEphemeralEntry.address, - txData: finalDestinationTransfer - }); - - // Fallback swap depends on the EVM chain. For Ethereum, the bridged token is USDC. For the rest, it is axlUSDC. - const destinationAxlUsdcDetails = getOnChainTokenDetailsOrDefault(toNetwork as Networks, EvmToken.AXLUSDC) as EvmTokenDetails; - - const bridgedTokenForFallback = - toNetwork === Networks.Ethereum - ? evmTokenConfig.ethereum.USDC!.erc20AddressSourceChain - : destinationAxlUsdcDetails.erc20AddressSourceChain; - - const intermediateUsdAmountForFallback = await priceFeedService.convertCurrency( - Big(quote.metadata.evmToEvm?.inputAmountDecimal).toFixed(2, 0), - quote.inputCurrency, - EvmToken.USDC - ); - const intermediateUsdAmountForFallbackRaw = multiplyByPowerOfTen( - intermediateUsdAmountForFallback, - destinationAxlUsdcDetails.decimals - ).toFixed(0, 0); - - const { approveData: destApproveData, swapData: destSwapData } = await createOnrampSquidrouterTransactionsOnDestinationChain({ - destinationAddress: evmEphemeralEntry.address, - fromAddress: evmEphemeralEntry.address, - fromToken: bridgedTokenForFallback, - network: toNetwork as EvmNetworks, - rawAmount: intermediateUsdAmountForFallbackRaw, - toToken: outputTokenDetails.erc20AddressSourceChain - }); - - destinationNonce++; - - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: destinationNonce, - phase: "backupSquidRouterApprove", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(destApproveData) as EvmTransactionData - }); - destinationNonce++; - - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: destinationNonce, - phase: "backupSquidRouterSwap", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(destSwapData) as EvmTransactionData - }); - destinationNonce++; - - const maxUint256 = 2n ** 256n - 1n; - - const backupApproveTransaction = await addDestinationChainApprovalTransaction({ - amountRaw: maxUint256.toString(), - destinationNetwork: toNetwork as EvmNetworks, - spenderAddress: fundingAccount.address, - tokenAddress: bridgedTokenForFallback - }); - - // We set this to 0 for non-polygon networks on purpose because we don't want to risk that the required nonce - // is never reached - const backupApproveNonce = toNetwork === Networks.Polygon ? polygonAccountNonce : 0; - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: backupApproveNonce, - phase: "backupApprove", - signer: evmEphemeralEntry.address, - txData: backupApproveTransaction - }); - - stateMeta = { - ...stateMeta, - squidRouterQuoteId, - squidRouterReceiverHash, - squidRouterReceiverId - }; - - return { stateMeta, unsignedTxs }; -} diff --git a/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm.ts new file mode 100644 index 000000000..d5131e37a --- /dev/null +++ b/apps/api/src/api/services/transactions/onramp/routes/mykobo-to-evm.ts @@ -0,0 +1,404 @@ +import { + createOnrampSquidrouterTransactionsFromBaseToEvm, + createOnrampSquidrouterTransactionsOnDestinationChain, + EvmNetworks, + EvmToken, + EvmTokenDetails, + EvmTransactionData, + evmTokenConfig, + getOnChainTokenDetailsOrDefault, + isEvmTokenDetails, + isNativeEvmToken, + multiplyByPowerOfTen, + Networks, + UnsignedTx +} from "@vortexfi/shared"; +import Big from "big.js"; +import { isAddress } from "viem"; +import logger from "../../../../../config/logger"; +import { getEvmFundingAccount } from "../../../phases/evm-funding"; +import { StateMetadata } from "../../../phases/meta-state-types"; +import { isEurToEurcBaseDirect } from "../../../quote/utils"; +import { prepareBaseCleanupApproval } from "../../base/cleanup"; +import { addEvmFeeDistributionTransaction } from "../../common/feeDistribution"; +import { encodeEvmTransactionData } from "../../index"; +import { + addDestinationChainApprovalTransaction, + addNablaSwapTransactionsOnBase, + addOnrampDestinationChainTransactions +} from "../common/transactions"; +import { MykoboOnrampTransactionParams, OnrampTransactionsWithMeta } from "../common/types"; +import { validateMykoboOnramp } from "../common/validation"; + +/** + * Prepares all transactions for a Mykobo (EUR) onramp to an EVM chain via Base. + * + * Flow: user SEPA deposit → EURC on Base ephemeral → Nabla swap EURC→USDC → SquidRouter to destination chain. + * + * Unlike Avenia/BRLA, no on-chain mint step is required: Mykobo settles the SEPA deposit + * directly on the Base ephemeral as EURC. The Mykobo deposit intent is expected to have been + * created by the caller; its identifiers are threaded into stateMeta. + */ +export async function prepareMykoboToEvmOnrampTransactions({ + quote, + signingAccounts, + destinationAddress, + mykoboEmail, + mykoboTransactionId, + mykoboTransactionReference +}: MykoboOnrampTransactionParams & { + mykoboTransactionId: string; + mykoboTransactionReference: string; +}): Promise { + let stateMeta: Partial = {}; + const unsignedTxs: UnsignedTx[] = []; + + if (!isAddress(destinationAddress)) { + throw new Error(`Invalid destination address for EVM route: ${destinationAddress}. Must be a valid EVM address.`); + } + + const { toNetwork, outputTokenDetails, evmEphemeralEntry, inputTokenDetails } = validateMykoboOnramp(quote, signingAccounts); + logger.debug(`Starting prepareMykoboToEvmOnrampTransactions with destinationAddress: ${destinationAddress}`); + + if (!isEvmTokenDetails(outputTokenDetails)) { + throw new Error(`Output token must be an EVM token for onramp to any EVM chain, got ${outputTokenDetails.assetSymbol}`); + } + + const isDirectTransfer = isEurToEurcBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network); + if (!isDirectTransfer && !quote.metadata.nablaSwapEvm?.outputAmountRaw) { + throw new Error("Missing nablaSwapEvm.outputAmountRaw in quote metadata for Mykobo onramp"); + } + + if (!isDirectTransfer && !quote.metadata.evmToEvm?.inputAmountRaw) { + throw new Error("Missing evmToEvm.inputAmountRaw in quote metadata for Mykobo onramp"); + } + const bridgeInputAmountRaw = quote.metadata.evmToEvm?.inputAmountRaw; + + stateMeta = { + destinationAddress, + evmEphemeralAddress: evmEphemeralEntry.address, + isDirectTransfer, + mykoboEmail, + mykoboTransactionId, + mykoboTransactionReference, + walletAddress: destinationAddress + }; + + let baseNonce = 0; + + if (isDirectTransfer) { + const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals); + const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ + amountRaw: finalAmountRaw.toString(), + destinationNetwork: Networks.Base, + isNativeToken: isNativeEvmToken(outputTokenDetails), + toAddress: destinationAddress, + toToken: outputTokenDetails.erc20AddressSourceChain + }); + + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce, + phase: "destinationTransfer", + signer: evmEphemeralEntry.address, + txData: finalDestinationTransfer + }); + + return { stateMeta, unsignedTxs }; + } + + const nablaSwapOutputTokenAddress = evmTokenConfig[Networks.Base][EvmToken.USDC]?.erc20AddressSourceChain; + if (!nablaSwapOutputTokenAddress) { + throw new Error("Invalid USDC configuration for Base in evmTokenConfig"); + } + const eurcInputTokenAddress = (inputTokenDetails as EvmTokenDetails).erc20AddressSourceChain; + + const { nextNonce: nonceAfterNabla, stateMeta: nablaStateMeta } = await addNablaSwapTransactionsOnBase( + { + account: evmEphemeralEntry, + inputTokenAddress: eurcInputTokenAddress, + outputTokenAddress: nablaSwapOutputTokenAddress, + quote + }, + unsignedTxs, + baseNonce + ); + stateMeta = { ...stateMeta, ...nablaStateMeta }; + baseNonce = nonceAfterNabla; + + baseNonce = await addEvmFeeDistributionTransaction(quote, evmEphemeralEntry, unsignedTxs, baseNonce); + + const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals); + + // Special case: onramping USDC on Base. Skip SquidRouter and transfer directly to destination. + if (toNetwork === Networks.Base && outputTokenDetails.erc20AddressSourceChain === nablaSwapOutputTokenAddress) { + const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ + amountRaw: finalAmountRaw.toString(), + destinationNetwork: Networks.Base, + isNativeToken: isNativeEvmToken(outputTokenDetails), + toAddress: destinationAddress, + toToken: outputTokenDetails.erc20AddressSourceChain + }); + + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase: "destinationTransfer", + signer: evmEphemeralEntry.address, + txData: finalDestinationTransfer + }); + + const baseFundingAccountAddress = getEvmFundingAccount(Networks.Base).address; + + const eurcCleanupApproval = await prepareBaseCleanupApproval( + eurcInputTokenAddress as `0x${string}`, + baseFundingAccountAddress, + Networks.Base + ); + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase: "baseCleanupEurc", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(eurcCleanupApproval) as EvmTransactionData + }); + + const usdcCleanupApproval = await prepareBaseCleanupApproval( + nablaSwapOutputTokenAddress as `0x${string}`, + baseFundingAccountAddress, + Networks.Base + ); + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase: "baseCleanupUsdc", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(usdcCleanupApproval) as EvmTransactionData + }); + + return { stateMeta, unsignedTxs }; + } + + const { approveData, swapData, squidRouterQuoteId, squidRouterReceiverId, squidRouterReceiverHash } = + await createOnrampSquidrouterTransactionsFromBaseToEvm({ + destinationAddress: evmEphemeralEntry.address, + fromAddress: evmEphemeralEntry.address, + fromToken: nablaSwapOutputTokenAddress, + rawAmount: bridgeInputAmountRaw as string, + toNetwork, + toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain + }); + + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase: "squidRouterApprove", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(approveData) as EvmTransactionData + }); + + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase: "squidRouterSwap", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(swapData) as EvmTransactionData + }); + + // Same-chain Base: destinationTransfer must be the next executable nonce after the swap. Cleanups run + // post-complete, so they follow the transfer. Backup re-swap txs are omitted here (no handler executes + // them, and on a shared nonce sequence they would push destinationTransfer beyond the live nonce). + if (toNetwork === Networks.Base) { + const sameChainDestinationTransfer = await addOnrampDestinationChainTransactions({ + amountRaw: finalAmountRaw.toString(), + destinationNetwork: Networks.Base, + isNativeToken: isNativeEvmToken(outputTokenDetails), + toAddress: destinationAddress, + toToken: outputTokenDetails.erc20AddressSourceChain + }); + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase: "destinationTransfer", + signer: evmEphemeralEntry.address, + txData: sameChainDestinationTransfer + }); + + const sameChainFundingAddress = getEvmFundingAccount(Networks.Base).address; + + const eurcCleanup = await prepareBaseCleanupApproval( + eurcInputTokenAddress as `0x${string}`, + sameChainFundingAddress, + Networks.Base + ); + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase: "baseCleanupEurc", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(eurcCleanup) as EvmTransactionData + }); + + const usdcCleanup = await prepareBaseCleanupApproval( + nablaSwapOutputTokenAddress as `0x${string}`, + sameChainFundingAddress, + Networks.Base + ); + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase: "baseCleanupUsdc", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(usdcCleanup) as EvmTransactionData + }); + + stateMeta = { + ...stateMeta, + squidRouterQuoteId, + squidRouterReceiverHash, + squidRouterReceiverId + }; + + return { stateMeta, unsignedTxs }; + } + + const baseFundingAccountAddress = getEvmFundingAccount(Networks.Base).address; + + const eurcCleanupApproval = await prepareBaseCleanupApproval( + eurcInputTokenAddress as `0x${string}`, + baseFundingAccountAddress, + Networks.Base + ); + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase: "baseCleanupEurc", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(eurcCleanupApproval) as EvmTransactionData + }); + + const usdcCleanupApproval = await prepareBaseCleanupApproval( + nablaSwapOutputTokenAddress as `0x${string}`, + baseFundingAccountAddress, + Networks.Base + ); + unsignedTxs.push({ + meta: {}, + network: Networks.Base, + nonce: baseNonce++, + phase: "baseCleanupUsdc", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(usdcCleanupApproval) as EvmTransactionData + }); + + let destinationNonce = 0; + const destinationStartingNonce = destinationNonce; + + const finalDestinationTransfer = await addOnrampDestinationChainTransactions({ + amountRaw: finalAmountRaw.toString(), + destinationNetwork: toNetwork as EvmNetworks, + isNativeToken: isNativeEvmToken(outputTokenDetails), + toAddress: destinationAddress, + toToken: outputTokenDetails.erc20AddressSourceChain + }); + + unsignedTxs.push({ + meta: {}, + network: toNetwork, + nonce: destinationNonce, + phase: "destinationTransfer", + signer: evmEphemeralEntry.address, + txData: finalDestinationTransfer + }); + + // Fallback bridged token: USDC for Ethereum, axlUSDC for all other EVM chains. Mirrors avenia-to-evm-base. + const destinationAxlUsdcDetails = getOnChainTokenDetailsOrDefault(toNetwork as Networks, EvmToken.AXLUSDC) as EvmTokenDetails; + let bridgedTokenForFallback: `0x${string}`; + if (toNetwork === Networks.Ethereum) { + const ethereumUsdc = evmTokenConfig.ethereum.USDC; + if (!ethereumUsdc) { + throw new Error("USDC config missing for Ethereum"); + } + bridgedTokenForFallback = ethereumUsdc.erc20AddressSourceChain as `0x${string}`; + } else { + bridgedTokenForFallback = destinationAxlUsdcDetails.erc20AddressSourceChain as `0x${string}`; + } + + const inputAmountRawFinalBridge = bridgeInputAmountRaw as string; + + const { approveData: finalApproveData, swapData: finalSwapData } = + await createOnrampSquidrouterTransactionsOnDestinationChain({ + destinationAddress: evmEphemeralEntry.address, + fromAddress: evmEphemeralEntry.address, + fromToken: bridgedTokenForFallback, + network: toNetwork as EvmNetworks, + rawAmount: inputAmountRawFinalBridge, + toToken: outputTokenDetails.erc20AddressSourceChain + }); + + destinationNonce++; + + unsignedTxs.push({ + meta: {}, + network: toNetwork, + nonce: destinationNonce, + phase: "backupSquidRouterApprove", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(finalApproveData) as EvmTransactionData + }); + destinationNonce++; + + unsignedTxs.push({ + meta: {}, + network: toNetwork, + nonce: destinationNonce, + phase: "backupSquidRouterSwap", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(finalSwapData) as EvmTransactionData + }); + destinationNonce++; + + const fundingAccount = getEvmFundingAccount(Networks.Base); + + // Bound approval to bridged amount + 5% slippage cushion (matches avenia-to-evm-base). + const backupApproveAmountRaw = new Big(inputAmountRawFinalBridge).mul("1.05").toFixed(0, 0); + + const backupApproveTransaction = await addDestinationChainApprovalTransaction({ + amountRaw: backupApproveAmountRaw, + destinationNetwork: toNetwork as EvmNetworks, + spenderAddress: fundingAccount.address, + tokenAddress: bridgedTokenForFallback + }); + + // Nonce 0 on purpose: ensures the approval can land even if other destination-chain txs are missed. + // When source chain == destination chain, the ephemeral has already consumed nonces 0..N-1, so we + // reuse destinationTransfer's nonce (the first destination-chain nonce) for the same effect. + const backupApproveNonce = destinationStartingNonce; + unsignedTxs.push({ + meta: {}, + network: toNetwork, + nonce: backupApproveNonce, + phase: "backupApprove", + signer: evmEphemeralEntry.address, + txData: backupApproveTransaction + }); + + stateMeta = { + ...stateMeta, + squidRouterQuoteId, + squidRouterReceiverHash, + squidRouterReceiverId + }; + + return { stateMeta, unsignedTxs }; +} diff --git a/apps/api/src/api/services/transactions/spacewalk/redeem.ts b/apps/api/src/api/services/transactions/spacewalk/redeem.ts deleted file mode 100644 index edb8bac3a..000000000 --- a/apps/api/src/api/services/transactions/spacewalk/redeem.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { SubmittableExtrinsic } from "@polkadot/api/types"; -import { ISubmittableResult } from "@polkadot/types/types"; -import { ApiManager, StellarTokenDetails } from "@vortexfi/shared"; -import { createVaultService } from "../../stellar/vaultService"; - -interface SpacewalkRedeemParams { - outputAmountRaw: string; - stellarEphemeralAccountRaw: Buffer; - outputTokenDetails: StellarTokenDetails; - executeSpacewalkNonce: number; -} - -export async function prepareSpacewalkRedeemTransaction({ - outputAmountRaw, - stellarEphemeralAccountRaw, - outputTokenDetails -}: SpacewalkRedeemParams): Promise> { - const apiManager = ApiManager.getInstance(); - const networkName = "pendulum"; - const pendulumNode = await apiManager.getApi(networkName); - - try { - const vaultService = await createVaultService( - pendulumNode, - outputTokenDetails.stellarAsset.code.hex, - outputTokenDetails.stellarAsset.issuer.hex, - outputAmountRaw - ); - - const redeemExtrinsic = await vaultService.createRequestRedeemExtrinsic(outputAmountRaw, stellarEphemeralAccountRaw); - - return redeemExtrinsic; - } catch (_e) { - throw Error("Couldn't create redeem extrinsic"); - } -} diff --git a/apps/api/src/api/services/transactions/stellar/offrampTransaction.ts b/apps/api/src/api/services/transactions/stellar/offrampTransaction.ts deleted file mode 100644 index 9efd4eb35..000000000 --- a/apps/api/src/api/services/transactions/stellar/offrampTransaction.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { - HORIZON_URL, - NUMBER_OF_PRESIGNED_TXS, - PaymentData, - STELLAR_EPHEMERAL_STARTING_BALANCE_UNITS, - StellarTokenDetails -} from "@vortexfi/shared"; -import Big from "big.js"; -import { Account, Asset, Horizon, Keypair, Memo, Networks, Operation, TransactionBuilder } from "stellar-sdk"; -import logger from "../../../../config/logger"; -import { config } from "../../../../config/vars"; -import { SEQUENCE_TIME_WINDOW_IN_SECONDS, SEQUENCE_TIME_WINDOWS, STELLAR_BASE_FEE } from "../../../../constants/constants"; - -// Define HorizonServer type -type HorizonServer = Horizon.Server; - -const FUNDING_PUBLIC_KEY = config.secrets.stellarFundingSecret - ? Keypair.fromSecret(config.secrets.stellarFundingSecret).publicKey() - : ""; -const NETWORK_PASSPHRASE = config.sandboxEnabled ? Networks.TESTNET : Networks.PUBLIC; - -const APPROXIMATE_STELLAR_LEDGER_CLOSE_TIME_SECONDS = 7; - -export const horizonServer = new Horizon.Server(HORIZON_URL); - -interface StellarBuildPaymentAndMergeTx { - ephemeralAccountId: string; - amountToAnchorUnits: string; - paymentData: PaymentData; - tokenConfigStellar: StellarTokenDetails; -} - -export async function buildPaymentAndMergeTx({ - ephemeralAccountId, - amountToAnchorUnits, - paymentData, - tokenConfigStellar -}: StellarBuildPaymentAndMergeTx): Promise<{ - expectedSequenceNumbers: string[]; - paymentTransactions: Array<{ sequence: string; tx: string }>; - mergeAccountTransactions: Array<{ sequence: string; tx: string }>; - createAccountTransactions: Array<{ sequence: string; tx: string }>; -}> { - const baseFee = STELLAR_BASE_FEE; - - if (!config.secrets.stellarFundingSecret) { - logger.error("Stellar funding secret not defined"); - throw new Error("Stellar funding secret not defined"); - } - - const fundingAccountKeypair = Keypair.fromSecret(config.secrets.stellarFundingSecret); - - const { memo, memoType, anchorTargetAccount } = paymentData; - const transactionMemo = - memoType === "text" ? Memo.text(memo) : memoType === "id" ? Memo.id(memo) : Memo.hash(Buffer.from(memo, "base64")); - - const fundingAccount = await horizonServer.loadAccount(fundingAccountKeypair.publicKey()); - - // Define timeframes for each presigned transaction - const timeWindows = [ - SEQUENCE_TIME_WINDOWS.FIRST_TX, - SEQUENCE_TIME_WINDOWS.SECOND_TX, - SEQUENCE_TIME_WINDOWS.THIRD_TX, - SEQUENCE_TIME_WINDOWS.FOURTH_TX, - SEQUENCE_TIME_WINDOWS.FIFTH_TX - ]; - - const sequenceNumbers: string[] = []; - for (let i = 0; i < NUMBER_OF_PRESIGNED_TXS; i++) { - const sequenceNumber = await getFutureShiftedLedgerSequence(horizonServer, 32, timeWindows[i]); - sequenceNumbers.push(sequenceNumber); - } - - const paymentTransactions: Array<{ sequence: string; tx: string }> = []; - const mergeAccountTransactions: Array<{ sequence: string; tx: string }> = []; - const createAccountTransactions: Array<{ sequence: string; tx: string }> = []; - - for (let i = 0; i < NUMBER_OF_PRESIGNED_TXS; i++) { - const currentFundingAccount = - i === 0 - ? fundingAccount - : new Account(fundingAccountKeypair.publicKey(), String(BigInt(fundingAccount.sequenceNumber()) + BigInt(i))); - - const currentCreateAccountTransaction = new TransactionBuilder(currentFundingAccount, { - fee: baseFee, - networkPassphrase: NETWORK_PASSPHRASE - }) - .addOperation( - Operation.createAccount({ - destination: ephemeralAccountId, - startingBalance: STELLAR_EPHEMERAL_STARTING_BALANCE_UNITS - }) - ) - .addOperation( - Operation.setOptions({ - highThreshold: 2, - lowThreshold: 2, - medThreshold: 2, - signer: { - ed25519PublicKey: fundingAccountKeypair.publicKey(), - weight: 1 - }, - source: ephemeralAccountId - }) - ) - .addOperation( - Operation.changeTrust({ - asset: new Asset(tokenConfigStellar.stellarAsset.code.string, tokenConfigStellar.stellarAsset.issuer.stellarEncoding), - source: ephemeralAccountId - }) - ) - .setTimebounds(0, 0) - .setMinAccountSequence(String(0)) - .build(); - - currentCreateAccountTransaction.sign(fundingAccountKeypair); - - createAccountTransactions.push({ - sequence: fundingAccount.sequenceNumber(), // TODO do we require this? - tx: currentCreateAccountTransaction.toEnvelope().toXDR().toString("base64") - }); - } - - for (let i = 0; i < NUMBER_OF_PRESIGNED_TXS; i++) { - const currentSequence = sequenceNumbers[i]; - const currentEphemeralAccount = new Account(ephemeralAccountId, currentSequence); - - const currentPaymentTransaction = new TransactionBuilder(currentEphemeralAccount, { - fee: STELLAR_BASE_FEE, - networkPassphrase: NETWORK_PASSPHRASE - }) - .addOperation( - Operation.payment({ - amount: amountToAnchorUnits, - asset: new Asset(tokenConfigStellar.stellarAsset.code.string, tokenConfigStellar.stellarAsset.issuer.stellarEncoding), - destination: anchorTargetAccount - }) - ) - .addMemo(transactionMemo) - .setTimebounds(0, 0) - .setMinAccountSequence(String(0)) - .build(); - - currentPaymentTransaction.sign(fundingAccountKeypair); - - const currentMergeAccountTransaction = new TransactionBuilder(currentEphemeralAccount, { - fee: STELLAR_BASE_FEE, - networkPassphrase: NETWORK_PASSPHRASE - }) - .addOperation( - Operation.changeTrust({ - asset: new Asset(tokenConfigStellar.stellarAsset.code.string, tokenConfigStellar.stellarAsset.issuer.stellarEncoding), - limit: "0" - }) - ) - .addOperation( - Operation.accountMerge({ - destination: FUNDING_PUBLIC_KEY - }) - ) - .setTimebounds(0, 0) - .setMinAccountSequence(String(1n)) - .build(); - - currentMergeAccountTransaction.sign(fundingAccountKeypair); - - paymentTransactions.push({ - sequence: currentSequence, - tx: currentPaymentTransaction.toEnvelope().toXDR().toString("base64") - }); - - mergeAccountTransactions.push({ - sequence: currentSequence, - tx: currentMergeAccountTransaction.toEnvelope().toXDR().toString("base64") - }); - } - - return { - createAccountTransactions, - expectedSequenceNumbers: sequenceNumbers, - mergeAccountTransactions, - paymentTransactions - }; -} - -async function getFutureShiftedLedgerSequence( - horizonServer: HorizonServer, - shiftAmount = 32, - timeWindowSeconds = SEQUENCE_TIME_WINDOW_IN_SECONDS -) { - try { - const latestLedger = await horizonServer.ledgers().order("desc").limit(1).call(); - - const currentLedgerSequence = latestLedger.records[0].sequence; - - const ledgersInTimeWindow = Math.ceil(timeWindowSeconds / APPROXIMATE_STELLAR_LEDGER_CLOSE_TIME_SECONDS); - - const futureLedgerSequence = currentLedgerSequence + ledgersInTimeWindow; - - const bigFutureLedger = new Big(futureLedgerSequence); - const bigShift = new Big(2).pow(shiftAmount); - const shiftedSequence = bigFutureLedger.times(bigShift).toFixed(); - - return shiftedSequence; - } catch (error) { - logger.error("Error fetching and calculating ledger sequence:", error); - throw error; - } -} diff --git a/apps/api/src/api/services/transactions/validation.test.ts b/apps/api/src/api/services/transactions/validation.test.ts index 1d2c6951c..b61a8d8ed 100644 --- a/apps/api/src/api/services/transactions/validation.test.ts +++ b/apps/api/src/api/services/transactions/validation.test.ts @@ -23,12 +23,6 @@ const MOCK_TX_DATA_SUBSTRATE_SIGNER_1 = "0x71038400ac1767af9bf4282c0f268b5ce1797 const MOCK_TX_DATA_SUBSTRATE_SIGNER_2 = "0x71038400b61dacc574163a9e8da2aca2b29090c610e61b21de9adbafb699156b6b6d9465019c7a2a23097caa54fcdd14432c731bd7c7c82a5648ee6d9a12378af3e241b435f2675ee515c50edfdf9098318c8a31abcc511d52a76133ad9e6b35cf5209bb8d000000003806005c1026460683b902672db0bbf65df0c021f5c9f844663e4dd1fcb13935ac6ba600072a494093029e820200001101095ea7b3e0a5f34199e165cbd3f9b0eba1f5e15d5018a7ffe8b76a3693ba5b317efb09d8e0ccb60000000000000000000000000000000000000000000000000000000000"; -// Stellar mock payloads — each phase has different operation-count requirements -const MOCK_TX_DATA_STELLAR_CREATE_ACCOUNT = "AAAAAgAAAADkOCw1GPsc4U0bNLBfqRtbB05ZcogqYJfDKZYB95sHRAAtxsADWqM3AAAArQAAAAIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAAAAAAAAAAAAAABb98/OGp4OrOMF58ADgwMHgBEvumr4FGRDfL2ZggooKAAAAAABfXhAAAAAAQAAAABb98/OGp4OrOMF58ADgwMHgBEvumr4FGRDfL2ZggooKAAAAAUAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAIAAAABAAAAAgAAAAEAAAACAAAAAAAAAAEAAAAA5DgsNRj7HOFNGzSwX6kbWwdOWXKIKmCXwymWAfebB0QAAAABAAAAAQAAAABb98/OGp4OrOMF58ADgwMHgBEvumr4FGRDfL2ZggooKAAAAAYAAAABRVVSQwAAAADPT1om4gkLs63PAsep1z2/5mWcxpBGFHW4ZDf6SccRNn//////////AAAAAAAAAAL3mwdEAAAAQCsExvxklazpsIDVJtyQU8Ou969v8j1NeM/MDMATo0UlUifWtbb218kd+ql6i21PQbD7ibxm6M4Zp1zflDIRMwOCCigoAAAAQF1MLyxdcdQ9lMYiR8iHye4TIKoP9zOimi4AKCL87rgDeXbEazuVR0GS0ILjnsc3NLFySKtAWcUFX20XXp7v5Aw="; - -const MOCK_TX_DATA_STELLAR_PAYMENT = "AAAAAgAAAABb98/OGp4OrOMF58ADgwMHgBEvumr4FGRDfL2ZggooKAAPQkADjNQFAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAA1NWUsxNzYxNDkyNzc2AAAAAAAAAQAAAAAAAAABAAAAAMwmH81TyAdqCkge7nLAJdasnz/JchoiBMyDM9Io97NEAAAAAUVVUkMAAAAAz09aJuIJC7OtzwLHqdc9v+ZlnMaQRhR1uGQ3+knHETYAAAAABh8T4AAAAAAAAAAC95sHRAAAAEB4aZkEhfZ98f+FbQSEj0wFNirD7fe2HiWLM9jIuvkoQ9ruzSxycCK+NMiIgppZnNSNnibw10BseXsG9kjK1u0KggooKAAAAED8tHWEfIKPzeuHVBnMy9x+ireQ6kepvWCLq/ZRyXWN8m+lcE0r60HwjD25xJovaY9hyVh9X50o/xm0dM6DlIsF"; - -const MOCK_TX_DATA_STELLAR_CLEANUP = "AAAAAgAAAABb98/OGp4OrOMF58ADgwMHgBEvumr4FGRDfL2ZggooKAAehIADjNQFAAAAAgAAAAIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAABgAAAAFFVVJDAAAAAM9PWibiCQuzrc8Cx6nXPb/mZZzGkEYUdbhkN/pJxxE2AAAAAAAAAAAAAAAAAAAACAAAAADkOCw1GPsc4U0bNLBfqRtbB05ZcogqYJfDKZYB95sHRAAAAAAAAAAC95sHRAAAAEB8+udS9KiWj8JjxxPB3HSMC0EkRvggU2hOP9IoHF8+T7VzqZiPzzwuothCSKwaOgaVvG/SSPUIKJQkpVYhjqwJggooKAAAAECSKoTeRu3ttJ9G3Cj6a79Yv6ZQTguCIlGo2klJltKvQex7SQys69T93BeoG+XALB8I8MvSiQoEXE7unZYpmL0A="; async function makeSignedEvmTx(overrides: { nonce: number; @@ -142,13 +136,13 @@ function withBackups(tx: PresignedTx): PresignedTx { } const VALID_EXAMPLE_PRESIGNED_TX_EUR_ONRAMP: PresignedTx[] = await Promise.all([ - makeSignedEvmTxWithBackups({ nonce: 0, phase: "moneriumOnrampSelfTransfer", network: Networks.Polygon }), + makeSignedEvmTxWithBackups({ nonce: 0, phase: "nablaApprove", network: Networks.Polygon }), makeSignedEvmTxWithBackups({ nonce: 1, phase: "squidRouterApprove", network: Networks.Polygon }), makeSignedEvmTxWithBackups({ nonce: 2, phase: "squidRouterSwap", network: Networks.Polygon }), ]); const VALID_EXAMPLE_UNSIGNED_TX_EUR_ONRAMP: PresignedTx[] = [ - { meta: {}, network: Networks.Polygon, nonce: 0, phase: "moneriumOnrampSelfTransfer", signer: EVM_SIGNER, txData: { data: "0x12345678", gas: "21000", maxFeePerGas: "1000000000", maxPriorityFeePerGas: "1000000000", to: "0x000000000000000000000000000000000000dEaD", value: "0" } }, + { meta: {}, network: Networks.Polygon, nonce: 0, phase: "nablaApprove", signer: EVM_SIGNER, txData: { data: "0x12345678", gas: "21000", maxFeePerGas: "1000000000", maxPriorityFeePerGas: "1000000000", to: "0x000000000000000000000000000000000000dEaD", value: "0" } }, { meta: {}, network: Networks.Polygon, nonce: 1, phase: "squidRouterApprove", signer: EVM_SIGNER, txData: { data: "0x12345678", gas: "21000", maxFeePerGas: "1000000000", maxPriorityFeePerGas: "1000000000", to: "0x000000000000000000000000000000000000dEaD", value: "0" } }, { meta: {}, network: Networks.Polygon, nonce: 2, phase: "squidRouterSwap", signer: EVM_SIGNER, txData: { data: "0x12345678", gas: "21000", maxFeePerGas: "1000000000", maxPriorityFeePerGas: "1000000000", to: "0x000000000000000000000000000000000000dEaD", value: "0" } }, ]; @@ -212,74 +206,6 @@ const VALID_EXAMPLE_UNSIGNED_TX_BRL_ONRAMP: PresignedTx[] = [ { meta: {}, network: Networks.Moonbeam, nonce: 3, phase: "squidRouterSwap", signer: EVM_SIGNER_2, txData: { data: "0x12345678", gas: "21000", maxFeePerGas: "1000000000", maxPriorityFeePerGas: "1000000000", to: "0x000000000000000000000000000000000000dEaD", value: "0" } }, ]; -const VALID_EXAMPLE_PRESIGNED_TX_EUR_OFFRAMP: PresignedTx[] = [ - withBackups({ - meta: {}, - nonce: 0, - phase: "stellarCreateAccount", - signer: "GBN7PT6ODKPA5LHDAXT4AA4DAMDYAEJPXJVPQFDEIN6L3GMCBIUCQSAJ", - txData: MOCK_TX_DATA_STELLAR_CREATE_ACCOUNT, - network: Networks.Stellar - }), - withBackups({ - meta: {}, - nonce: 1, - phase: "stellarPayment", - signer: "GBN7PT6ODKPA5LHDAXT4AA4DAMDYAEJPXJVPQFDEIN6L3GMCBIUCQSAJ", - txData: MOCK_TX_DATA_STELLAR_PAYMENT, - network: Networks.Stellar - }), - withBackups({ - meta: {}, - nonce: 2, - phase: "stellarCleanup", - signer: "GBN7PT6ODKPA5LHDAXT4AA4DAMDYAEJPXJVPQFDEIN6L3GMCBIUCQSAJ", - txData: MOCK_TX_DATA_STELLAR_CLEANUP, - network: Networks.Stellar - }), - withBackups({ - meta: {}, - nonce: 0, - phase: "nablaApprove", - signer: "5GBVPRfgZYjDMqQSACxzfrPeKxnsKGyinwwGRFpcacaAzDov", - txData: MOCK_TX_DATA_SUBSTRATE_SIGNER_2, - network: Networks.Pendulum - }), - withBackups({ - meta: {}, - nonce: 1, - phase: "nablaSwap", - signer: "5GBVPRfgZYjDMqQSACxzfrPeKxnsKGyinwwGRFpcacaAzDov", - txData: MOCK_TX_DATA_SUBSTRATE_SIGNER_2, - network: Networks.Pendulum - }), - withBackups({ - meta: {}, - nonce: 2, - phase: "spacewalkRedeem", - signer: "5GBVPRfgZYjDMqQSACxzfrPeKxnsKGyinwwGRFpcacaAzDov", - txData: MOCK_TX_DATA_SUBSTRATE_SIGNER_2, - network: Networks.Pendulum, - }), - withBackups({ - meta: {}, - nonce: 3, - phase: "pendulumCleanup", - signer: "5GBVPRfgZYjDMqQSACxzfrPeKxnsKGyinwwGRFpcacaAzDov", - txData: MOCK_TX_DATA_SUBSTRATE_SIGNER_2, - network: Networks.Pendulum - }) -]; - -const VALID_EXAMPLE_UNSIGNED_TX_EUR_OFFRAMP: PresignedTx[] = [ - { meta: {}, network: Networks.Stellar, nonce: 0, phase: "stellarCreateAccount", signer: "GBN7PT6ODKPA5LHDAXT4AA4DAMDYAEJPXJVPQFDEIN6L3GMCBIUCQSAJ", txData: MOCK_TX_DATA_STELLAR_CREATE_ACCOUNT }, - { meta: {}, network: Networks.Stellar, nonce: 1, phase: "stellarPayment", signer: "GBN7PT6ODKPA5LHDAXT4AA4DAMDYAEJPXJVPQFDEIN6L3GMCBIUCQSAJ", txData: MOCK_TX_DATA_STELLAR_PAYMENT }, - { meta: {}, network: Networks.Stellar, nonce: 2, phase: "stellarCleanup", signer: "GBN7PT6ODKPA5LHDAXT4AA4DAMDYAEJPXJVPQFDEIN6L3GMCBIUCQSAJ", txData: MOCK_TX_DATA_STELLAR_CLEANUP }, - { meta: {}, network: Networks.Pendulum, nonce: 0, phase: "nablaApprove", signer: "5GBVPRfgZYjDMqQSACxzfrPeKxnsKGyinwwGRFpcacaAzDov", txData: MOCK_TX_DATA_SUBSTRATE_SIGNER_2 }, - { meta: {}, network: Networks.Pendulum, nonce: 1, phase: "nablaSwap", signer: "5GBVPRfgZYjDMqQSACxzfrPeKxnsKGyinwwGRFpcacaAzDov", txData: MOCK_TX_DATA_SUBSTRATE_SIGNER_2 }, - { meta: {}, network: Networks.Pendulum, nonce: 2, phase: "spacewalkRedeem", signer: "5GBVPRfgZYjDMqQSACxzfrPeKxnsKGyinwwGRFpcacaAzDov", txData: MOCK_TX_DATA_SUBSTRATE_SIGNER_2 }, - { meta: {}, network: Networks.Pendulum, nonce: 3, phase: "pendulumCleanup", signer: "5GBVPRfgZYjDMqQSACxzfrPeKxnsKGyinwwGRFpcacaAzDov", txData: MOCK_TX_DATA_SUBSTRATE_SIGNER_2 }, -]; describe("Presigned Transaction validation", () => { it("matches a signed EVM transaction to the unsigned server-built transaction", async () => { @@ -410,7 +336,6 @@ describe("Presigned Transaction validation", () => { await expect( validatePresignedTxs(RampDirection.SELL, [presignedTx], { EVM: "0x0000000000000000000000000000000000000004", - Stellar: "", Substrate: "" }, [unsignedTx]) ).resolves.toBeUndefined(); @@ -451,7 +376,6 @@ describe("Presigned Transaction validation", () => { ], { EVM: expectedEvmSigner, - Stellar: "", Substrate: "5FxM3dFCnXJXEbMozuVbhEUQuQK1gmquFpUJ577HebqBc7pz" }, [unsignedTx] @@ -461,11 +385,7 @@ describe("Presigned Transaction validation", () => { }); it("should pass validation for valid presigned EVM transactions", async () => { - const ephemerals: { [key in EphemeralAccountType]: string } = { - Substrate: "", - EVM: EVM_SIGNER, - Stellar: "" - }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER }; await expect(validatePresignedTxs(RampDirection.BUY, VALID_EXAMPLE_PRESIGNED_TX_EUR_ONRAMP, ephemerals, VALID_EXAMPLE_UNSIGNED_TX_EUR_ONRAMP)).resolves.toBeUndefined(); }); @@ -474,34 +394,16 @@ describe("Presigned Transaction validation", () => { const singleTx: PresignedTx[] = [VALID_EXAMPLE_PRESIGNED_TX_EUR_ONRAMP[0]]; const singleUnsigned: PresignedTx[] = [VALID_EXAMPLE_UNSIGNED_TX_EUR_ONRAMP[0]]; - const ephemerals: { [key in EphemeralAccountType]: string } = { - Substrate: "", - EVM: EVM_SIGNER, - Stellar: "" - }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER }; await expect(validatePresignedTxs(RampDirection.BUY, singleTx, ephemerals, singleUnsigned)).resolves.toBeUndefined(); }); - it("should pass validation for valid presigned mixed transactions", async () => { - const ephemerals: { [key in EphemeralAccountType]: string } = { - Substrate: "5GBVPRfgZYjDMqQSACxzfrPeKxnsKGyinwwGRFpcacaAzDov", - EVM: EVM_SIGNER_2, - Stellar: "GBN7PT6ODKPA5LHDAXT4AA4DAMDYAEJPXJVPQFDEIN6L3GMCBIUCQSAJ" - }; - - await expect(validatePresignedTxs(RampDirection.SELL, VALID_EXAMPLE_PRESIGNED_TX_EUR_OFFRAMP, ephemerals, VALID_EXAMPLE_UNSIGNED_TX_EUR_OFFRAMP)).resolves.toBeUndefined(); - }, 30000); - it("should throw for transaction with mismatch of expected signer for Substrate tx", async () => { const invalidTxs: PresignedTx[] = JSON.parse(JSON.stringify(VALID_EXAMPLE_PRESIGNED_TX_BRL_ONRAMP)); const invalidSigner = "5CoKLhtjijsxVneDXeV3QhcdD4byxDK7cSmNCuWEfQ8NjebM"; invalidTxs[0].signer = invalidSigner; - const ephemerals: { [key in EphemeralAccountType]: string } = { - Substrate: "5FxM3dFCnXJXEbMozuVbhEUQuQK1gmquFpUJ577HebqBc7pz", - EVM: EVM_SIGNER_2, - Stellar: "GBN7PT6ODKPA5LHDAXT4AA4DAMDYAEJPXJVPQFDEIN6L3GMCBIUCQSAJ" - }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "5FxM3dFCnXJXEbMozuVbhEUQuQK1gmquFpUJ577HebqBc7pz", EVM: EVM_SIGNER_2 }; await expect(validatePresignedTxs(RampDirection.BUY, invalidTxs, ephemerals, VALID_EXAMPLE_UNSIGNED_TX_BRL_ONRAMP)).rejects.toThrow( `Substrate transaction signer ${invalidSigner} does not match the expected signer 5FxM3dFCnXJXEbMozuVbhEUQuQK1gmquFpUJ577HebqBc7pz for phase nablaApprove` ); @@ -524,48 +426,22 @@ describe("Presigned Transaction validation", () => { txData: { data: "0x12345678", gas: "21000", maxFeePerGas: "1000000000", maxPriorityFeePerGas: "1000000000", to: "0x000000000000000000000000000000000000dEaD", value: "0" } }; - const ephemerals: { [key in EphemeralAccountType]: string } = { - Substrate: "", - EVM: EVM_SIGNER, - Stellar: "" - }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER }; await expect(validatePresignedTxs(RampDirection.BUY, [presignedTx], ephemerals, [unsignedTx])).rejects.toThrow( `EVM transaction signer ${wrongSigner} does not match the expected signer ${EVM_SIGNER}` ); }); - it("should throw for transaction with mismatch of expected signer for Stellar tx", async () => { - const invalidTxs: PresignedTx[] = JSON.parse(JSON.stringify(VALID_EXAMPLE_PRESIGNED_TX_EUR_OFFRAMP)); - const invalidSigner = "GCFX5YV7Y5LF2XK3S5Y4L5XW4D5Z6A7B8C9D0E1F2G3H4I5J6K7L8M9N0O1P2Q3R4S5T6U7V8W9X0Y1Z2"; - invalidTxs[0].signer = invalidSigner; - const ephemerals: { [key in EphemeralAccountType]: string } = { - Substrate: "5FxM3dFCnXJXEbMozuVbhEUQuQK1gmquFpUJ577HebqBc7pz", - EVM: EVM_SIGNER_2, - Stellar: "GBN7PT6ODKPA5LHDAXT4AA4DAMDYAEJPXJVPQFDEIN6L3GMCBIUCQSAJ" - }; - await expect(validatePresignedTxs(RampDirection.SELL, invalidTxs, ephemerals, VALID_EXAMPLE_UNSIGNED_TX_EUR_OFFRAMP)).rejects.toThrow( - `Stellar transaction signer ${invalidSigner} does not match the expected signer GBN7PT6ODKPA5LHDAXT4AA4DAMDYAEJPXJVPQFDEIN6L3GMCBIUCQSAJ for phase stellarCreateAccount.` - ); - }); - it("should throw error for invalid presigned transactions array", async () => { const invalidTxs: any = "invalid data"; - const ephemerals: { [key in EphemeralAccountType]: string } = { - Substrate: "5FxM3dFCnXJXEbMozuVbhEUQuQK1gmquFpUJ577HebqBc7pz", - EVM: EVM_SIGNER_2, - Stellar: "GBN7PT6ODKPA5LHDAXT4AA4DAMDYAEJPXJVPQFDEIN6L3GMCBIUCQSAJ" - }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "5FxM3dFCnXJXEbMozuVbhEUQuQK1gmquFpUJ577HebqBc7pz", EVM: EVM_SIGNER_2 }; await expect(validatePresignedTxs(RampDirection.BUY, invalidTxs, ephemerals, [])).rejects.toThrow("presignedTxs must be an array with 1-100 elements"); }); it("should throw error for too many transactions", async () => { const invalidTxs: PresignedTx[] = new Array(101).fill(VALID_EXAMPLE_PRESIGNED_TX_EUR_ONRAMP[0]); - const ephemerals: { [key in EphemeralAccountType]: string } = { - Substrate: "5FxM3dFCnXJXEbMozuVbhEUQuQK1gmquFpUJ577HebqBc7pz", - EVM: EVM_SIGNER_2, - Stellar: "GBN7PT6ODKPA5LHDAXT4AA4DAMDYAEJPXJVPQFDEIN6L3GMCBIUCQSAJ" - }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "5FxM3dFCnXJXEbMozuVbhEUQuQK1gmquFpUJ577HebqBc7pz", EVM: EVM_SIGNER_2 }; await expect(validatePresignedTxs(RampDirection.BUY, invalidTxs, ephemerals, [])).rejects.toThrow("presignedTxs must be an array with 1-100 elements"); }); @@ -573,11 +449,7 @@ describe("Presigned Transaction validation", () => { const invalidTxs: PresignedTx[] = JSON.parse(JSON.stringify(VALID_EXAMPLE_PRESIGNED_TX_EUR_ONRAMP)); invalidTxs[2].meta = {}; - const ephemerals: { [key in EphemeralAccountType]: string } = { - Substrate: "", - EVM: EVM_SIGNER, - Stellar: "" - }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER }; await expect(validatePresignedTxs(RampDirection.BUY, invalidTxs, ephemerals, VALID_EXAMPLE_UNSIGNED_TX_EUR_ONRAMP)).rejects.toThrow( "Transaction for phase squidRouterSwap must include at least 4 backup transactions in meta.additionalTxs" @@ -592,11 +464,7 @@ describe("Presigned Transaction validation", () => { } backupTx.nonce = 9; - const ephemerals: { [key in EphemeralAccountType]: string } = { - Substrate: "", - EVM: EVM_SIGNER, - Stellar: "" - }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER }; await expect(validatePresignedTxs(RampDirection.BUY, invalidTxs, ephemerals, VALID_EXAMPLE_UNSIGNED_TX_EUR_ONRAMP)).rejects.toThrow( "Transaction for phase squidRouterSwap has invalid backup nonce sequence. Expected 4, got 5" @@ -618,11 +486,7 @@ describe("Presigned Transaction validation", () => { txData: { data: "0x12345678", gas: "21000", maxFeePerGas: "1000000000", maxPriorityFeePerGas: "1000000000", to: "0x000000000000000000000000000000000000dEaD", value: "0" } }; - const ephemerals: { [key in EphemeralAccountType]: string } = { - Substrate: "", - EVM: EVM_SIGNER, - Stellar: "" - }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER }; await expect(validatePresignedTxs(RampDirection.BUY, [presignedTx], ephemerals, [unsignedTx])).resolves.toBeUndefined(); }); @@ -644,11 +508,7 @@ describe("Presigned Transaction validation", () => { txData: { data: "0x12345678", gas: "21000", maxFeePerGas: "1000000000", maxPriorityFeePerGas: "1000000000", to: "0x000000000000000000000000000000000000dEaD", value: "0" } }; - const ephemerals: { [key in EphemeralAccountType]: string } = { - Substrate: "", - EVM: wrongSigner, - Stellar: "" - }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: wrongSigner }; await expect(validatePresignedTxs(RampDirection.BUY, [presignedTx], ephemerals, [unsignedTx])).rejects.toThrow( "Recovered signer" @@ -671,11 +531,7 @@ describe("Presigned Transaction validation", () => { txData: { data: "0x12345678", gas: "21000", maxFeePerGas: "1000000000", maxPriorityFeePerGas: "1000000000", to: "0x000000000000000000000000000000000000dEaD", value: "0" } }; - const ephemerals: { [key in EphemeralAccountType]: string } = { - Substrate: "", - EVM: EVM_SIGNER, - Stellar: "" - }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER }; await expect(validatePresignedTxs(RampDirection.BUY, [presignedTxWithWrongNonce], ephemerals, [unsignedTx])).rejects.toThrow( "does not match expected nonce" @@ -697,11 +553,7 @@ describe("Presigned Transaction validation", () => { txData: { data: "0x12345678", gas: "21000", maxFeePerGas: "1000000000", maxPriorityFeePerGas: "1000000000", to: "0x000000000000000000000000000000000000dEaD", value: "0" } }; - const ephemerals: { [key in EphemeralAccountType]: string } = { - Substrate: "", - EVM: EVM_SIGNER, - Stellar: "" - }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER }; await expect(validatePresignedTxs(RampDirection.BUY, [presignedTxWithWrongNonce], ephemerals, [unsignedTx])).rejects.toThrow( "does not match expected nonce" @@ -725,11 +577,7 @@ describe("Presigned Transaction validation", () => { txData: { data: "0x12345678", gas: "21000", maxFeePerGas: "1000000000", maxPriorityFeePerGas: "1000000000", to: "0x000000000000000000000000000000000000dEaD", value: "0" } }; - const ephemerals: { [key in EphemeralAccountType]: string } = { - Substrate: "", - EVM: EVM_SIGNER, - Stellar: "" - }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER }; await expect(validatePresignedTxs(RampDirection.BUY, [presignedTx], ephemerals, [unsignedTx])).rejects.toThrow( "does not match expected network ID" @@ -771,11 +619,7 @@ describe("Presigned Transaction validation", () => { txData: signedRawTx }; - const ephemerals: { [key in EphemeralAccountType]: string } = { - Substrate: "", - EVM: EVM_SIGNER, - Stellar: "" - }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER }; await expect(validatePresignedTxs(RampDirection.BUY, [presignedTx], ephemerals, [unsignedTx])).rejects.toThrow( "Signed EVM transaction value" @@ -817,11 +661,7 @@ describe("Presigned Transaction validation", () => { txData: signedRawTx }; - const ephemerals: { [key in EphemeralAccountType]: string } = { - Substrate: "", - EVM: EVM_SIGNER, - Stellar: "" - }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER }; await expect(validatePresignedTxs(RampDirection.BUY, [presignedTx], ephemerals, [unsignedTx])).rejects.toThrow( "Signed EVM transaction data" @@ -859,7 +699,7 @@ describe("Presigned Transaction validation", () => { const presignedTx: PresignedTx = { ...unsignedTx, txData: signedRawTx }; await expect( - validatePresignedTxs(RampDirection.BUY, [presignedTx], { Substrate: "", EVM: EVM_SIGNER, Stellar: "" }, [unsignedTx]) + validatePresignedTxs(RampDirection.BUY, [presignedTx], { Substrate: "", EVM: EVM_SIGNER }, [unsignedTx]) ).rejects.toThrow("Signed EVM transaction 'to'"); }); @@ -893,7 +733,7 @@ describe("Presigned Transaction validation", () => { const presignedTx: PresignedTx = { ...unsignedTx, txData: signedRawTx }; await expect( - validatePresignedTxs(RampDirection.BUY, [presignedTx], { Substrate: "", EVM: EVM_SIGNER, Stellar: "" }, [unsignedTx]) + validatePresignedTxs(RampDirection.BUY, [presignedTx], { Substrate: "", EVM: EVM_SIGNER }, [unsignedTx]) ).rejects.toThrow("contract creation not allowed"); }); @@ -922,7 +762,7 @@ describe("Presigned Transaction validation", () => { }); await expect( - validatePresignedTxs(RampDirection.BUY, [presignedTx], { Substrate: "", EVM: EVM_SIGNER, Stellar: "" }, [unsignedTx]) + validatePresignedTxs(RampDirection.BUY, [presignedTx], { Substrate: "", EVM: EVM_SIGNER }, [unsignedTx]) ).rejects.toThrow("gas limit"); }); @@ -952,7 +792,7 @@ describe("Presigned Transaction validation", () => { }); await expect( - validatePresignedTxs(RampDirection.BUY, [presignedTx], { Substrate: "", EVM: EVM_SIGNER, Stellar: "" }, [unsignedTx]) + validatePresignedTxs(RampDirection.BUY, [presignedTx], { Substrate: "", EVM: EVM_SIGNER }, [unsignedTx]) ).rejects.toThrow("maxFeePerGas"); }); @@ -982,7 +822,7 @@ describe("Presigned Transaction validation", () => { }); await expect( - validatePresignedTxs(RampDirection.BUY, [presignedTx], { Substrate: "", EVM: EVM_SIGNER, Stellar: "" }, [unsignedTx]) + validatePresignedTxs(RampDirection.BUY, [presignedTx], { Substrate: "", EVM: EVM_SIGNER }, [unsignedTx]) ).rejects.toThrow("maxPriorityFeePerGas"); }); @@ -1011,7 +851,7 @@ describe("Presigned Transaction validation", () => { }); await expect( - validatePresignedTxs(RampDirection.BUY, [presignedTx], { Substrate: "", EVM: EVM_SIGNER, Stellar: "" }, [unsignedTx]) + validatePresignedTxs(RampDirection.BUY, [presignedTx], { Substrate: "", EVM: EVM_SIGNER }, [unsignedTx]) ).resolves.toBeUndefined(); }); @@ -1042,28 +882,19 @@ describe("Presigned Transaction validation", () => { }); await expect( - validatePresignedTxs(RampDirection.BUY, [presignedTx], { Substrate: "", EVM: EVM_SIGNER, Stellar: "" }, [unsignedTx]) + validatePresignedTxs(RampDirection.BUY, [presignedTx], { Substrate: "", EVM: EVM_SIGNER }, [unsignedTx]) ).resolves.toBeUndefined(); }); it("should throw error when transaction is missing required properties", async () => { const invalidTx: any = { network: Networks.Polygon, nonce: 0, signer: EVM_SIGNER, txData: "0x" }; // missing phase - const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER, Stellar: "" }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER }; await expect(validatePresignedTxs(RampDirection.BUY, [invalidTx], ephemerals, [])).rejects.toThrow("Each transaction must have txData, phase, network, nonce and signer properties"); }); - it("rejects presignedTx submitted for moneriumOnrampMint (user-wallet phase)", async () => { - const tx: PresignedTx = { meta: {}, network: Networks.Polygon, nonce: 0, phase: "moneriumOnrampMint", signer: EVM_SIGNER, txData: "invalid data" }; - const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER_2, Stellar: "" }; - const unsignedTx = { ...tx }; - await expect(validatePresignedTxs(RampDirection.BUY, [tx], ephemerals, [unsignedTx])).rejects.toThrow( - "Phase moneriumOnrampMint is broadcast by the user wallet" - ); - }); - it("rejects presignedTx submitted for squidRouterNoPermitTransfer (user-wallet phase)", async () => { const tx: PresignedTx = { meta: {}, network: Networks.Polygon, nonce: 0, phase: "squidRouterNoPermitTransfer", signer: EVM_SIGNER, txData: "invalid data" }; - const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER_2, Stellar: "" }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER_2 }; const unsignedTx = { ...tx }; await expect(validatePresignedTxs(RampDirection.BUY, [tx], ephemerals, [unsignedTx])).rejects.toThrow( "Phase squidRouterNoPermitTransfer is broadcast by the user wallet" @@ -1071,7 +902,7 @@ describe("Presigned Transaction validation", () => { }); it("rejects presignedTx for squidRouterNoPermitApprove and squidRouterNoPermitSwap (user-wallet phases)", async () => { - const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER_2, Stellar: "" }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER_2 }; const approveTx: PresignedTx = { meta: {}, network: Networks.Polygon, nonce: 0, phase: "squidRouterNoPermitApprove", signer: EVM_SIGNER, txData: "data" }; await expect(validatePresignedTxs(RampDirection.BUY, [approveTx], ephemerals, [approveTx])).rejects.toThrow( "Phase squidRouterNoPermitApprove is broadcast by the user wallet" @@ -1084,7 +915,7 @@ describe("Presigned Transaction validation", () => { it("rejects presignedTx submitted for squidRouterSwap when direction is SELL (user-wallet phase)", async () => { const tx: PresignedTx = { meta: {}, network: Networks.Polygon, nonce: 0, phase: "squidRouterSwap", signer: EVM_SIGNER, txData: "invalid data" }; - const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER_2, Stellar: "" }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER_2 }; const unsignedTx = { ...tx }; await expect(validatePresignedTxs(RampDirection.SELL, [tx], ephemerals, [unsignedTx])).rejects.toThrow( "Phase squidRouterSwap is broadcast by the user wallet" @@ -1093,7 +924,7 @@ describe("Presigned Transaction validation", () => { it("rejects presignedTx submitted for squidRouterApprove when direction is SELL (user-wallet phase)", async () => { const tx: PresignedTx = { meta: {}, network: Networks.Polygon, nonce: 0, phase: "squidRouterApprove", signer: EVM_SIGNER, txData: "invalid data" }; - const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER_2, Stellar: "" }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER_2 }; const unsignedTx = { ...tx }; await expect(validatePresignedTxs(RampDirection.SELL, [tx], ephemerals, [unsignedTx])).rejects.toThrow( "Phase squidRouterApprove is broadcast by the user wallet" @@ -1102,13 +933,13 @@ describe("Presigned Transaction validation", () => { it("still validates squidRouterSwap on BUY direction (signed by EVM ephemeral, not user wallet)", async () => { const tx = await makeSignedEvmTxWithBackups({ nonce: 0, phase: "squidRouterSwap", network: Networks.Polygon }); - const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER, Stellar: "" }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER }; const unsignedTx: PresignedTx = { meta: {}, network: Networks.Polygon, nonce: 0, phase: "squidRouterSwap", signer: EVM_SIGNER, txData: { data: "0x12345678", gas: "21000", maxFeePerGas: "1000000000", maxPriorityFeePerGas: "1000000000", to: "0x000000000000000000000000000000000000dEaD", value: "0" } }; await expect(validatePresignedTxs(RampDirection.BUY, [tx], ephemerals, [unsignedTx])).resolves.toBeUndefined(); }); it("should throw when an ephemeral transaction is missing from presignedTxs", async () => { - const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER, Stellar: "" }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER }; const unsignedTx: PresignedTx = { meta: {}, network: Networks.Polygon, nonce: 0, phase: "fundEphemeral", signer: EVM_SIGNER, txData: { data: "0x12345678", gas: "21000", maxFeePerGas: "1000000000", maxPriorityFeePerGas: "1000000000", to: "0x000000000000000000000000000000000000dEaD", value: "0" } }; const ephemeralTx: PresignedTx = await makeSignedEvmTxWithBackups({ nonce: 0, phase: "fundEphemeral", network: Networks.Polygon }); const unsignedExtra: PresignedTx = { meta: {}, network: Networks.Polygon, nonce: 1, phase: "nablaApprove", signer: EVM_SIGNER, txData: { data: "0x12345678", gas: "21000", maxFeePerGas: "1000000000", maxPriorityFeePerGas: "1000000000", to: "0x000000000000000000000000000000000000dEaD", value: "0" } }; @@ -1116,14 +947,14 @@ describe("Presigned Transaction validation", () => { }); it("should throw when there is an extra presigned transaction not in unsignedTxs", async () => { - const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER, Stellar: "" }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER }; const tx: PresignedTx = await makeSignedEvmTxWithBackups({ nonce: 0, phase: "fundEphemeral", network: Networks.Polygon }); await expect(validatePresignedTxs(RampDirection.BUY, [tx], ephemerals, [])).rejects.toThrow("Some presigned transactions do not match any unsigned transaction"); }); it("should throw for an unknown phase", async () => { const tx: PresignedTx = { meta: {}, network: Networks.Polygon, nonce: 0, phase: "unknownPhase" as any, signer: EVM_SIGNER, txData: "0x" }; - const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER, Stellar: "" }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER }; await expect(validatePresignedTxs(RampDirection.BUY, [tx], ephemerals, [tx])).rejects.toThrow('Unknown phase "unknownPhase" — cannot determine transaction type'); }); @@ -1136,7 +967,7 @@ describe("Presigned Transaction validation", () => { signature: [] as any // Array signature }; const presignedTx: PresignedTx = { meta: {}, network: Networks.Polygon, nonce: 0, phase: "squidRouterPermitExecute", signer: EVM_WALLET.address, txData: [typedData] }; - const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER_2, Stellar: "" }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER_2 }; await expect(validatePresignedTxs(RampDirection.SELL, [presignedTx], ephemerals, [presignedTx])).rejects.toThrow("must include exactly one signature"); }); @@ -1181,11 +1012,7 @@ describe("Presigned Transaction validation", () => { presignedTx.meta!.additionalTxs!.backup2.txData = maliciousBackup; - const ephemerals: { [key in EphemeralAccountType]: string } = { - Substrate: "", - EVM: EVM_SIGNER, - Stellar: "" - }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER }; await expect(validatePresignedTxs(RampDirection.BUY, [presignedTx], ephemerals, [unsignedTx])).rejects.toThrow( "Signed EVM transaction data does not match expected data" @@ -1223,55 +1050,15 @@ describe("Presigned Transaction validation", () => { data: "0x99999999" }); - const ephemerals: { [key in EphemeralAccountType]: string } = { - Substrate: "", - EVM: EVM_SIGNER, - Stellar: "" - }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER }; await expect(validatePresignedTxs(RampDirection.BUY, [presignedTx], ephemerals, [unsignedTx])).rejects.toThrow( "must include exactly 4 backup transactions" ); }); - it("rejects when a Substrate backup encodes a different call than the primary", async () => { - const invalidTxs: PresignedTx[] = JSON.parse(JSON.stringify(VALID_EXAMPLE_PRESIGNED_TX_EUR_OFFRAMP)); - const substrateTx = invalidTxs.find(tx => tx.phase === "nablaApprove" && tx.network === Networks.Pendulum)!; - substrateTx.meta!.additionalTxs!.backup2.txData = MOCK_TX_DATA_SUBSTRATE_SIGNER_1; - - const ephemerals: { [key in EphemeralAccountType]: string } = { - Substrate: "5GBVPRfgZYjDMqQSACxzfrPeKxnsKGyinwwGRFpcacaAzDov", - EVM: EVM_SIGNER_2, - Stellar: "GBN7PT6ODKPA5LHDAXT4AA4DAMDYAEJPXJVPQFDEIN6L3GMCBIUCQSAJ" - }; - - await expect(validatePresignedTxs(RampDirection.SELL, invalidTxs, ephemerals, VALID_EXAMPLE_UNSIGNED_TX_EUR_OFFRAMP)).rejects.toThrow( - /does not (match|encode)/ - ); - }, 30000); - - it("rejects when a Stellar backup has the wrong shape for its phase", async () => { - const invalidTxs: PresignedTx[] = JSON.parse(JSON.stringify(VALID_EXAMPLE_PRESIGNED_TX_EUR_OFFRAMP)); - const stellarPayment = invalidTxs.find(tx => tx.phase === "stellarPayment")!; - stellarPayment.meta!.additionalTxs!.backup2.txData = MOCK_TX_DATA_STELLAR_CREATE_ACCOUNT; - - const ephemerals: { [key in EphemeralAccountType]: string } = { - Substrate: "5GBVPRfgZYjDMqQSACxzfrPeKxnsKGyinwwGRFpcacaAzDov", - EVM: EVM_SIGNER_2, - Stellar: "GBN7PT6ODKPA5LHDAXT4AA4DAMDYAEJPXJVPQFDEIN6L3GMCBIUCQSAJ" - }; - - await expect(validatePresignedTxs(RampDirection.SELL, invalidTxs, ephemerals, VALID_EXAMPLE_UNSIGNED_TX_EUR_OFFRAMP)).rejects.toThrow( - /Stellar Payment transaction must have exactly 1 operation/ - ); - }, 30000); - it("accepts a subset of presigned txs when requireComplete is false (updateRamp partial submission)", async () => { - const ephemerals: { [key in EphemeralAccountType]: string } = { - Substrate: "", - EVM: EVM_SIGNER, - Stellar: "" - }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER }; const subset = VALID_EXAMPLE_PRESIGNED_TX_EUR_ONRAMP.slice(0, 1); await expect( validatePresignedTxs(RampDirection.BUY, subset, ephemerals, VALID_EXAMPLE_UNSIGNED_TX_EUR_ONRAMP, { requireComplete: false }) @@ -1279,11 +1066,7 @@ describe("Presigned Transaction validation", () => { }); it("still rejects subset submissions by default (requireComplete defaults to true)", async () => { - const ephemerals: { [key in EphemeralAccountType]: string } = { - Substrate: "", - EVM: EVM_SIGNER, - Stellar: "" - }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER }; const subset = VALID_EXAMPLE_PRESIGNED_TX_EUR_ONRAMP.slice(0, 1); await expect( validatePresignedTxs(RampDirection.BUY, subset, ephemerals, VALID_EXAMPLE_UNSIGNED_TX_EUR_ONRAMP) @@ -1291,11 +1074,7 @@ describe("Presigned Transaction validation", () => { }); it("still rejects extra/unknown txs when requireComplete is false", async () => { - const ephemerals: { [key in EphemeralAccountType]: string } = { - Substrate: "", - EVM: EVM_SIGNER, - Stellar: "" - }; + const ephemerals: { [key in EphemeralAccountType]: string } = { Substrate: "", EVM: EVM_SIGNER }; const extra = await makeSignedEvmTxWithBackups({ nonce: 99, phase: "fundEphemeral", network: Networks.Polygon }); await expect( validatePresignedTxs(RampDirection.BUY, [extra], ephemerals, VALID_EXAMPLE_UNSIGNED_TX_EUR_ONRAMP, { requireComplete: false }) @@ -1322,7 +1101,7 @@ describe("Presigned Transaction validation", () => { }; await expect( - validatePresignedTxs(RampDirection.SELL, [presignedTx], { EVM: "0x0000000000000000000000000000000000000004", Stellar: "", Substrate: "" }, [unsignedTx]) + validatePresignedTxs(RampDirection.SELL, [presignedTx], { EVM: "0x0000000000000000000000000000000000000004", Substrate: "" }, [unsignedTx]) ).rejects.toThrow("does not match the server-issued unsigned typed data"); }); @@ -1346,7 +1125,7 @@ describe("Presigned Transaction validation", () => { }; await expect( - validatePresignedTxs(RampDirection.SELL, [presignedTx], { EVM: "0x0000000000000000000000000000000000000004", Stellar: "", Substrate: "" }, [unsignedTx]) + validatePresignedTxs(RampDirection.SELL, [presignedTx], { EVM: "0x0000000000000000000000000000000000000004", Substrate: "" }, [unsignedTx]) ).rejects.toThrow("does not match the server-issued unsigned typed data"); }); @@ -1372,7 +1151,7 @@ describe("Presigned Transaction validation", () => { }; await expect( - validatePresignedTxs(RampDirection.SELL, [presignedTx], { EVM: "0x0000000000000000000000000000000000000004", Stellar: "", Substrate: "" }, [unsignedTx]) + validatePresignedTxs(RampDirection.SELL, [presignedTx], { EVM: "0x0000000000000000000000000000000000000004", Substrate: "" }, [unsignedTx]) ).rejects.toThrow("does not match the server-issued unsigned typed data"); }); @@ -1395,7 +1174,7 @@ describe("Presigned Transaction validation", () => { }; await expect( - validatePresignedTxs(RampDirection.BUY, [presignedTx], { Substrate: "", EVM: EVM_SIGNER, Stellar: "" }, [unsignedTx]) + validatePresignedTxs(RampDirection.BUY, [presignedTx], { Substrate: "", EVM: EVM_SIGNER }, [unsignedTx]) ).rejects.toThrow("does not match expected network ID"); }); @@ -1406,7 +1185,7 @@ describe("Presigned Transaction validation", () => { }; const presignedAtWrongNonce = await makeSignedEvmTxWithBackups({ nonce: 7, phase: "fundEphemeral", network: Networks.Polygon }); await expect( - validatePresignedTxs(RampDirection.BUY, [presignedAtWrongNonce], { Substrate: "", EVM: EVM_SIGNER, Stellar: "" }, [fundEphemeralAt0]) + validatePresignedTxs(RampDirection.BUY, [presignedAtWrongNonce], { Substrate: "", EVM: EVM_SIGNER }, [fundEphemeralAt0]) ).rejects.toThrow("Some presigned transactions do not match any unsigned transaction"); }); }); diff --git a/apps/api/src/api/services/transactions/validation.ts b/apps/api/src/api/services/transactions/validation.ts index d432d97c8..338d00468 100644 --- a/apps/api/src/api/services/transactions/validation.ts +++ b/apps/api/src/api/services/transactions/validation.ts @@ -18,10 +18,9 @@ import { SubstrateApiNetwork, substrateAddressEqual } from "@vortexfi/shared"; -import { Signature as EvmSignature, verifyTypedData } from "ethers"; +import { Transaction as EthersTransaction, Signature as EvmSignature, verifyTypedData } from "ethers"; import httpStatus from "http-status"; -import { Networks as StellarNetworks, Transaction as StellarTransaction, TransactionBuilder } from "stellar-sdk"; -import { type Hex, keccak256, parseTransaction, recoverAddress, serializeTransaction, toBytes } from "viem"; +import { type Hex, parseTransaction } from "viem"; import logger from "../../../config/logger"; import { config } from "../../../config/vars"; import { APIError } from "../../errors/api-error"; @@ -85,26 +84,13 @@ async function verifySignedEvmTransaction( }); } - const unsignedTx = serializeTransaction({ - accessList: parsed.accessList, - chainId: parsed.chainId, - data: parsed.data, - gas: parsed.gas, - gasPrice: parsed.gasPrice, - maxFeePerGas: parsed.maxFeePerGas, - maxPriorityFeePerGas: parsed.maxPriorityFeePerGas, - nonce: parsed.nonce, - to: parsed.to, - type: parsed.type || "eip1559", - value: parsed.value ?? 0n - } as any); - - const hash = keccak256(toBytes(unsignedTx)); - - const yParity = parsed.yParity !== undefined ? Number(parsed.yParity) : parsed.v !== undefined ? Number(parsed.v) - 27 : 0; - const signature = (parsed.r + parsed.s.slice(2) + yParity.toString(16).padStart(2, "0")) as `0x${string}`; - - const recoveredSigner = await recoverAddress({ hash, signature }); + const recoveredSigner = EthersTransaction.from(signedTxHex).from; + if (!recoveredSigner) { + throw new APIError({ + message: "Signed EVM transaction must include a recoverable signer", + status: httpStatus.BAD_REQUEST + }); + } if (recoveredSigner.toLowerCase() !== expectedSigner.toLowerCase()) { throw new APIError({ @@ -137,7 +123,9 @@ async function verifySignedEvmTransaction( }); } - if (parsed.data?.toLowerCase() !== unsignedTxData.data.toLowerCase()) { + const normalizedActual = (parsed.data ?? "0x").toLowerCase(); + const normalizedExpected = (unsignedTxData.data || "0x").toLowerCase(); + if (normalizedActual !== normalizedExpected) { throw new APIError({ message: "Signed EVM transaction data does not match expected data", status: httpStatus.BAD_REQUEST @@ -215,21 +203,14 @@ function getTransactionTypeForPhase(phase: RampPhase | CleanupPhase, network: Ne case "pendulumToMoonbeamXcm": case "assethubToPendulum": case "hydrationSwap": - case "spacewalkRedeem": case "pendulumCleanup": case "moonbeamCleanup": case "hydrationCleanup": return EphemeralAccountType.Substrate; - case "stellarCreateAccount": - case "stellarPayment": - case "stellarCleanup": - return EphemeralAccountType.Stellar; case "squidRouterApprove": case "squidRouterSwap": case "squidRouterPermitExecute": case "squidRouterPay": - case "moneriumOnrampSelfTransfer": - case "moneriumOnrampMint": case "fundEphemeral": case "destinationTransfer": case "moonbeamToPendulum": @@ -237,6 +218,7 @@ function getTransactionTypeForPhase(phase: RampPhase | CleanupPhase, network: Ne case "alfredpayOfframpTransfer": case "brlaOnrampMint": case "brlaPayoutOnBase": + case "mykoboPayoutOnBase": case "finalSettlementSubsidy": case "backupSquidRouterApprove": case "backupSquidRouterSwap": @@ -245,6 +227,7 @@ function getTransactionTypeForPhase(phase: RampPhase | CleanupPhase, network: Ne case "polygonCleanupAxlUsdc": case "baseCleanupBrla": case "baseCleanupUsdc": + case "baseCleanupEurc": case "baseCleanupAxlUsdc": case "alfredOnrampMintFallback": case "alfredpayOfframpTransferFallback": @@ -319,8 +302,6 @@ async function validateBackupTransactions( } else if (txType === EphemeralAccountType.Substrate) { await validateSubstrateTransaction(backupTx, ephemerals.Substrate, ephemerals.EVM); await assertSubstrateBackupMatchesPrimary(tx, backup); - } else if (txType === EphemeralAccountType.Stellar) { - await validateStellarTransaction(backupTx, ephemerals.Stellar); } } } @@ -370,7 +351,6 @@ export async function validatePresignedTxs( // is then verified against the unsigned blueprint by user-tx-verifier at phase execution time. // Accepting a presignedTx here would create a fake authority surface that bypasses that check. const isUserWalletPhase = - tx.phase === "moneriumOnrampMint" || tx.phase === "squidRouterNoPermitTransfer" || tx.phase === "squidRouterNoPermitApprove" || tx.phase === "squidRouterNoPermitSwap" || @@ -405,7 +385,6 @@ export async function validatePresignedTxs( await validateEvmTransaction(tx, ephemerals.EVM, matchingUnsigned.txData); } if (txType === EphemeralAccountType.Substrate) await validateSubstrateTransaction(tx, ephemerals.Substrate, ephemerals.EVM); - if (txType === EphemeralAccountType.Stellar) await validateStellarTransaction(tx, ephemerals.Stellar); await validateBackupTransactions(tx, ephemerals, evmUnsignedTxData); } @@ -660,161 +639,3 @@ async function validateSubstrateTransaction(tx: PresignedTx, expectedSignerSubst } logger.debug(`Validated Substrate extrinsic for phase ${tx.phase}: ${method.section}.${method.method}`); } - -async function validateStellarTransaction(tx: PresignedTx, expectedSigner: string) { - const { txData, signer, phase } = tx; - - if (!expectedSigner) { - throw new APIError({ - message: "Expected signer for Stellar transaction is not provided", - status: httpStatus.BAD_REQUEST - }); - } - - if (signer.toLowerCase() !== expectedSigner.toLowerCase()) { - throw new APIError({ - message: `Stellar transaction signer ${signer} does not match the expected signer ${expectedSigner} for phase ${phase}.`, - status: httpStatus.BAD_REQUEST - }); - } - - let transaction: StellarTransaction; - try { - transaction = TransactionBuilder.fromXDR(txData as string, StellarNetworks.PUBLIC) as StellarTransaction; - } catch (error) { - throw new APIError({ - message: `Invalid Stellar transaction data: ${(error as Error).message}`, - status: httpStatus.BAD_REQUEST - }); - } - - logger.debug("Parsed Stellar transaction source:", transaction.source); - - if (phase === "stellarCreateAccount") { - if (transaction.operations.length !== 3) { - throw new APIError({ - message: `Stellar Create Account transaction must have exactly 3 operations, found ${transaction.operations.length}`, - status: httpStatus.BAD_REQUEST - }); - } - - const createAccountOp = transaction.operations[0]; - if (createAccountOp.type !== "createAccount") { - throw new APIError({ - message: `First operation in Stellar Create Account transaction must be 'createAccount', found '${createAccountOp.type}'`, - status: httpStatus.BAD_REQUEST - }); - } - if (createAccountOp.destination !== signer) { - throw new APIError({ - message: `Stellar Create Account operation destination ${createAccountOp.destination} does not match the signer ${signer}`, - status: httpStatus.BAD_REQUEST - }); - } - if (!createAccountOp.startingBalance || parseFloat(createAccountOp.startingBalance) <= 0) { - throw new APIError({ - message: "Stellar Create Account operation must have a positive startingBalance", - status: httpStatus.BAD_REQUEST - }); - } - - const setOptionsOp = transaction.operations[1]; - if (setOptionsOp.type !== "setOptions") { - throw new APIError({ - message: `Second operation in Stellar Create Account transaction must be 'setOptions', found '${setOptionsOp.type}'`, - status: httpStatus.BAD_REQUEST - }); - } - if (setOptionsOp.source !== signer) { - throw new APIError({ - message: `Stellar Set Options operation source ${setOptionsOp.source} does not match the signer ${signer}`, - status: httpStatus.BAD_REQUEST - }); - } - if (setOptionsOp.type === "setOptions" && !setOptionsOp.signer) { - throw new APIError({ - message: "Stellar SetOptions operation must include a signer (cosigner) key", - status: httpStatus.BAD_REQUEST - }); - } - - const changeTrustOp = transaction.operations[2]; - if (changeTrustOp.type !== "changeTrust") { - throw new APIError({ - message: `Second operation in Stellar Create Account transaction must be 'changeTrust', found '${changeTrustOp.type}'`, - status: httpStatus.BAD_REQUEST - }); - } - if (changeTrustOp.source !== signer) { - throw new APIError({ - message: `Stellar Change Trust operation source ${changeTrustOp.source} does not match the signer ${signer}`, - status: httpStatus.BAD_REQUEST - }); - } - if (changeTrustOp.type === "changeTrust" && !changeTrustOp.line) { - throw new APIError({ - message: "Stellar ChangeTrust operation must specify a trust line asset", - status: httpStatus.BAD_REQUEST - }); - } - } - - if (phase === "stellarPayment") { - if (transaction.operations.length !== 1) { - throw new APIError({ - message: `Stellar Payment transaction must have exactly 1 operation, found ${transaction.operations.length}`, - status: httpStatus.BAD_REQUEST - }); - } - - const paymentOp = transaction.operations[0]; - if (paymentOp.type !== "payment") { - throw new APIError({ - message: `Stellar Payment transaction must have a 'payment' operation, found '${paymentOp.type}'`, - status: httpStatus.BAD_REQUEST - }); - } - if (transaction.source !== signer) { - throw new APIError({ - message: `Stellar Payment transaction source ${transaction.source} does not match the signer ${signer}`, - status: httpStatus.BAD_REQUEST - }); - } - - if (paymentOp.type === "payment") { - if (!paymentOp.destination) { - throw new APIError({ - message: "Stellar Payment operation must have a destination address", - status: httpStatus.BAD_REQUEST - }); - } - if (!paymentOp.amount || parseFloat(paymentOp.amount) <= 0) { - throw new APIError({ - message: "Stellar Payment operation must have a positive amount", - status: httpStatus.BAD_REQUEST - }); - } - if (!paymentOp.asset) { - throw new APIError({ - message: "Stellar Payment operation must specify an asset", - status: httpStatus.BAD_REQUEST - }); - } - } - } - - if (phase === "stellarCleanup") { - if (transaction.source !== signer) { - throw new APIError({ - message: `Stellar Cleanup transaction source ${transaction.source} does not match the signer ${signer}`, - status: httpStatus.BAD_REQUEST - }); - } - if (transaction.operations.length === 0 || transaction.operations.length > 5) { - throw new APIError({ - message: `Stellar Cleanup transaction has unexpected operation count: ${transaction.operations.length} (expected 1-5)`, - status: httpStatus.BAD_REQUEST - }); - } - } -} diff --git a/apps/api/src/api/workers/cleanup.worker.test.ts b/apps/api/src/api/workers/cleanup.worker.test.ts index 4796549d7..4e9b8a810 100644 --- a/apps/api/src/api/workers/cleanup.worker.test.ts +++ b/apps/api/src/api/workers/cleanup.worker.test.ts @@ -31,14 +31,8 @@ const mockPendulumHandler = { shouldProcess: mock((_state: RampState) => true) }; -const mockStellarHandler = { - getCleanupName: () => "stellarCleanup" as CleanupPhase, - process: mock(async (): Promise => [true, null]), - shouldProcess: mock((_state: RampState) => true) -}; - mock.module("../services/phases/post-process", () => ({ - postProcessHandlers: [mockPendulumHandler, mockStellarHandler] + postProcessHandlers: [mockPendulumHandler] })); const updateMock = mock((_values: Partial, _options: { where: { id: string } }) => { @@ -69,24 +63,19 @@ describe("CleanupWorker - processCleanup", () => { mockPendulumHandler.shouldProcess.mockClear(); mockPendulumHandler.process.mockClear(); - mockStellarHandler.shouldProcess.mockClear(); - mockStellarHandler.process.mockClear(); updateMock.mockClear(); }); it("should process all applicable handlers successfully", async () => { mockPendulumHandler.process.mockImplementation(async () => [true, null] as ProcessResult); - mockStellarHandler.process.mockImplementation(async () => [true, null] as ProcessResult); await cleanupWorker.testProcessCleanup(testState); - // Verify both handlers were checked + // Verify handler was checked expect(mockPendulumHandler.shouldProcess).toHaveBeenCalledTimes(1); - expect(mockStellarHandler.shouldProcess).toHaveBeenCalledTimes(1); - // Verify both handlers were processed + // Verify handler was processed expect(mockPendulumHandler.process).toHaveBeenCalledTimes(1); - expect(mockStellarHandler.process).toHaveBeenCalledTimes(1); // Verify state was updated correctly expect(updateMock).toHaveBeenCalledTimes(1); @@ -104,12 +93,10 @@ describe("CleanupWorker - processCleanup", () => { // Setup one handler to fail const testError = new Error("Test failure"); mockPendulumHandler.process.mockImplementation(async () => [false, testError] as ProcessResult); - mockStellarHandler.process.mockImplementation(async () => [true, null] as ProcessResult); await cleanupWorker.testProcessCleanup(testState); expect(mockPendulumHandler.process).toHaveBeenCalledTimes(1); - expect(mockStellarHandler.process).toHaveBeenCalledTimes(1); expect(updateMock).toHaveBeenCalledTimes(1); @@ -123,18 +110,14 @@ describe("CleanupWorker - processCleanup", () => { it("should only retry failed handlers on subsequent attempts", async () => { testState.postCompleteState.cleanup.errors = [{ error: "Previous failure", name: "pendulumCleanup" }]; - // Setup both handlers to succeed this time + // Setup handler to succeed this time mockPendulumHandler.process.mockImplementation(async () => [true, null] as ProcessResult); - mockStellarHandler.process.mockImplementation(async () => [true, null] as ProcessResult); await cleanupWorker.testProcessCleanup(testState); // The pendulum handler should be processed again (because it failed before) expect(mockPendulumHandler.process).toHaveBeenCalledTimes(1); - // The stellar handler should NOT be processed again (since it didn't fail before) - expect(mockStellarHandler.process).toHaveBeenCalledTimes(0); - // Verify errors are cleared when handler succeeds expect(updateMock).toHaveBeenCalledTimes(1); @@ -145,9 +128,8 @@ describe("CleanupWorker - processCleanup", () => { expect(values.postCompleteState?.cleanup.errors).toBe(null); }); - it("should properly track errors for multiple failed handlers", async () => { + it("should properly track errors for failed handler", async () => { mockPendulumHandler.process.mockImplementation(async () => [false, new Error("Pendulum failure")] as ProcessResult); - mockStellarHandler.process.mockImplementation(async () => [false, new Error("Stellar failure")] as ProcessResult); await cleanupWorker.testProcessCleanup(testState); @@ -158,8 +140,7 @@ describe("CleanupWorker - processCleanup", () => { expect(values.postCompleteState?.cleanup.cleanupCompleted).toBe(false); const errors = values.postCompleteState?.cleanup.errors; - expect(errors).toHaveLength(2); + expect(errors).toHaveLength(1); expect(errors?.some(e => e.name === "pendulumCleanup" && e.error === "Pendulum failure")).toBe(true); - expect(errors?.some(e => e.name === "stellarCleanup" && e.error === "Stellar failure")).toBe(true); }); }); diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index 0c178af72..4e254e87c 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -103,17 +103,12 @@ interface Config { secrets: { pendulumFundingSeed: string | undefined; - stellarFundingSecret: string | undefined; moonbeamExecutorPrivateKey: string | undefined; clientDomainSecret: string | undefined; webhookPrivateKey: string | undefined; }; integrations: { - monerium: { - clientId: string | undefined; - clientSecret: string | undefined; - }; alchemy: { apiKey: string | undefined; }; @@ -155,10 +150,6 @@ export const config: Config = { alchemy: { apiKey: process.env.ALCHEMY_API_KEY }, - monerium: { - clientId: process.env.MONERIUM_CLIENT_ID_APP, - clientSecret: process.env.MONERIUM_CLIENT_SECRET - }, slack: { userId: process.env.SLACK_USER_ID, webhookToken: process.env.SLACK_WEB_HOOK_TOKEN @@ -203,7 +194,6 @@ export const config: Config = { clientDomainSecret: process.env.CLIENT_DOMAIN_SECRET, moonbeamExecutorPrivateKey: process.env.MOONBEAM_EXECUTOR_PRIVATE_KEY, pendulumFundingSeed: process.env.PENDULUM_FUNDING_SEED, - stellarFundingSecret: process.env.FUNDING_SECRET, webhookPrivateKey: process.env.WEBHOOK_PRIVATE_KEY }, spreadsheet: { @@ -228,8 +218,6 @@ export const config: Config = { vortexFeePenPercentage: parseFloat(process.env.VORTEX_FEE_PEN_PERCENTAGE || "0.0") }; -// Derived values — aliases kept for semantic clarity in consuming code -export const SEP10_MASTER_SECRET = config.secrets.stellarFundingSecret; export const EVM_FUNDING_PRIVATE_KEY = process.env.EVM_FUNDING_PRIVATE_KEY ?? config.secrets.moonbeamExecutorPrivateKey; if (config.sandboxEnabled && config.deploymentEnv !== "sandbox") { diff --git a/apps/api/src/constants/constants.ts b/apps/api/src/constants/constants.ts index 0d852b943..1e42e296c 100644 --- a/apps/api/src/constants/constants.ts +++ b/apps/api/src/constants/constants.ts @@ -6,7 +6,6 @@ const STELLAR_FUNDING_AMOUNT_UNITS = "10"; // 10 XLM. Minimum balance of fundin const MOONBEAM_FUNDING_AMOUNT_UNITS = "10"; // 10 GLMR. Minimum balance of funding account const SUBSIDY_MINIMUM_RATIO_FUND_UNITS = "5"; // 5 Subsidies considering maximum subsidy amount use on each (worst case scenario) const MOONBEAM_RECEIVER_CONTRACT_ADDRESS = "0x2AB52086e8edaB28193172209407FF9df1103CDc"; -const STELLAR_EPHEMERAL_STARTING_BALANCE_UNITS = "2.5"; // Amount to send to the new stellar ephemeral account created const PENDULUM_EPHEMERAL_STARTING_BALANCE_UNITS = "0.1"; // Amount to send to the new pendulum ephemeral account created const MOONBEAM_EPHEMERAL_STARTING_BALANCE_UNITS = "1"; // Amount to send to the new moonbeam ephemeral account created const POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS = "1.5"; // Amount to send to the new polygon ephemeral account created @@ -22,9 +21,6 @@ const WEBHOOKS_CACHE_URL = "https://webhooks-cache.pendulumchain.tech"; // EXAMP const STELLAR_BASE_FEE = "1000000"; -// Expiration and timeout values -const SEQUENCE_TIME_WINDOW_IN_SECONDS = 600; // 10 minutes. Marks the MAXIMUM window between creating the stellar ephemeral transactions and it's creation on chain. - const DEFAULT_LOGIN_EXPIRATION_TIME_HOURS = 7 * 24; const FIRST_TX_TIME_WINDOW_IN_SECONDS = 5 * 60; // 5 minutes @@ -44,7 +40,6 @@ const SEQUENCE_TIME_WINDOWS = { export { POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS, ASSETHUB_XCM_FEE_USDC_UNITS, - SEQUENCE_TIME_WINDOW_IN_SECONDS, SEQUENCE_TIME_WINDOWS, GLMR_FUNDING_AMOUNT_RAW, PENDULUM_GLMR_FUNDING_AMOUNT_UNITS, @@ -53,7 +48,6 @@ export { MOONBEAM_FUNDING_AMOUNT_UNITS, MOONBEAM_RECEIVER_CONTRACT_ADDRESS, SUBSIDY_MINIMUM_RATIO_FUND_UNITS, - STELLAR_EPHEMERAL_STARTING_BALANCE_UNITS, PENDULUM_EPHEMERAL_STARTING_BALANCE_UNITS, MOONBEAM_EPHEMERAL_STARTING_BALANCE_UNITS, DEFAULT_LOGIN_EXPIRATION_TIME_HOURS, diff --git a/apps/api/src/database/migrations/028-remove-stellar-anchors.ts b/apps/api/src/database/migrations/028-remove-stellar-anchors.ts new file mode 100644 index 000000000..fe601c9a8 --- /dev/null +++ b/apps/api/src/database/migrations/028-remove-stellar-anchors.ts @@ -0,0 +1,49 @@ +import { Op, QueryInterface } from "sequelize"; + +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.bulkDelete("anchors", { + identifier: { + [Op.in]: ["stellar_eurc", "stellar_ars"] + } + }); + + await queryInterface.sequelize.query("DELETE FROM subsidies WHERE token = 'XLM'"); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.bulkInsert("anchors", [ + { + created_at: new Date(), + currency: "EUR", + id: queryInterface.sequelize.literal("uuid_generate_v4()"), + identifier: "stellar_eurc", + is_active: true, + ramp_type: "off", + updated_at: new Date(), + value: 0.0025, + value_type: "relative" + }, + { + created_at: new Date(), + currency: "ARS", + id: queryInterface.sequelize.literal("uuid_generate_v4()"), + identifier: "stellar_ars", + is_active: true, + ramp_type: "off", + updated_at: new Date(), + value: 0.02, + value_type: "relative" + }, + { + created_at: new Date(), + currency: "ARS", + id: queryInterface.sequelize.literal("uuid_generate_v4()"), + identifier: "stellar_ars", + is_active: true, + ramp_type: "off", + updated_at: new Date(), + value: 10, + value_type: "absolute" + } + ]); +} diff --git a/apps/api/src/database/migrations/029-create-mykobo-customers-table.ts b/apps/api/src/database/migrations/029-create-mykobo-customers-table.ts new file mode 100644 index 000000000..415aa966d --- /dev/null +++ b/apps/api/src/database/migrations/029-create-mykobo-customers-table.ts @@ -0,0 +1,82 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.createTable("mykobo_customers", { + created_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + }, + email: { + allowNull: false, + type: DataTypes.STRING, + unique: true + }, + id: { + allowNull: false, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + last_failure_reasons: { + allowNull: true, + defaultValue: [], + type: DataTypes.ARRAY(DataTypes.STRING) + }, + status: { + allowNull: false, + defaultValue: "CONSULTED", + type: DataTypes.ENUM("CONSULTED", "PENDING", "APPROVED", "REJECTED") + }, + status_external: { + allowNull: true, + type: DataTypes.STRING + }, + type: { + allowNull: false, + defaultValue: "INDIVIDUAL", + type: DataTypes.ENUM("INDIVIDUAL", "BUSINESS") + }, + updated_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + }, + user_id: { + allowNull: false, + type: DataTypes.UUID, + unique: true + } + }); + + await queryInterface.addConstraint("mykobo_customers", { + fields: ["user_id"], + name: "fk_mykobo_customers_user_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + field: "id", + table: "profiles" + }, + type: "foreign key" + }); + + await queryInterface.addIndex("mykobo_customers", ["user_id"], { + name: "idx_mykobo_customers_user_id", + unique: true + }); + + await queryInterface.addIndex("mykobo_customers", ["email"], { + name: "idx_mykobo_customers_email", + unique: true + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeConstraint("mykobo_customers", "fk_mykobo_customers_user_id"); + + await queryInterface.dropTable("mykobo_customers"); + + await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_mykobo_customers_status";').catch(() => {}); + await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_mykobo_customers_type";').catch(() => {}); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 6e7dc180e..62118fa6d 100755 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -27,7 +27,6 @@ setLogger(logger); const validateRequiredEnvVars = () => { const requiredVars = { CLIENT_DOMAIN_SECRET: config.secrets.clientDomainSecret, - FUNDING_SECRET: config.secrets.stellarFundingSecret, MOONBEAM_EXECUTOR_PRIVATE_KEY: config.secrets.moonbeamExecutorPrivateKey, PENDULUM_FUNDING_SEED: config.secrets.pendulumFundingSeed }; diff --git a/apps/api/src/models/index.ts b/apps/api/src/models/index.ts index 6cbc45d87..73ea4998d 100644 --- a/apps/api/src/models/index.ts +++ b/apps/api/src/models/index.ts @@ -4,6 +4,7 @@ import Anchor from "./anchor.model"; import ApiKey from "./apiKey.model"; import KycLevel2 from "./kycLevel2.model"; import MaintenanceSchedule from "./maintenanceSchedule.model"; +import MykoboCustomer from "./mykoboCustomer.model"; import Partner from "./partner.model"; import QuoteTicket from "./quoteTicket.model"; import RampState from "./rampState.model"; @@ -36,6 +37,9 @@ TaxId.belongsTo(User, { as: "user", foreignKey: "userId" }); User.hasMany(AlfredPayCustomer, { as: "alfredPayCustomers", foreignKey: "userId" }); AlfredPayCustomer.belongsTo(User, { as: "user", foreignKey: "userId" }); +User.hasOne(MykoboCustomer, { as: "mykoboCustomer", foreignKey: "userId" }); +MykoboCustomer.belongsTo(User, { as: "user", foreignKey: "userId" }); + // Initialize models const models = { AlfredPayCustomer, @@ -43,6 +47,7 @@ const models = { ApiKey, KycLevel2, MaintenanceSchedule, + MykoboCustomer, Partner, QuoteTicket, RampState, diff --git a/apps/api/src/models/mykoboCustomer.model.ts b/apps/api/src/models/mykoboCustomer.model.ts new file mode 100644 index 000000000..55aee45fc --- /dev/null +++ b/apps/api/src/models/mykoboCustomer.model.ts @@ -0,0 +1,117 @@ +import { MykoboCustomerStatus, MykoboCustomerType } from "@vortexfi/shared"; +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +export interface MykoboCustomerAttributes { + id: string; + userId: string; + email: string; + status: MykoboCustomerStatus; + statusExternal: string | null; + lastFailureReasons: string[] | null; + type: MykoboCustomerType; + createdAt: Date; + updatedAt: Date; +} + +type MykoboCustomerCreationAttributes = Optional< + MykoboCustomerAttributes, + "id" | "createdAt" | "updatedAt" | "statusExternal" | "lastFailureReasons" | "status" | "type" +>; + +class MykoboCustomer + extends Model + implements MykoboCustomerAttributes +{ + declare id: string; + declare userId: string; + declare email: string; + declare status: MykoboCustomerStatus; + declare statusExternal: string | null; + declare lastFailureReasons: string[] | null; + declare type: MykoboCustomerType; + declare createdAt: Date; + declare updatedAt: Date; +} + +MykoboCustomer.init( + { + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + email: { + allowNull: false, + type: DataTypes.STRING, + unique: true + }, + id: { + allowNull: false, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + lastFailureReasons: { + allowNull: true, + defaultValue: [], + field: "last_failure_reasons", + type: DataTypes.ARRAY(DataTypes.STRING) + }, + status: { + allowNull: false, + defaultValue: MykoboCustomerStatus.CONSULTED, + type: DataTypes.ENUM(...Object.values(MykoboCustomerStatus)) + }, + statusExternal: { + allowNull: true, + comment: "Mykobo's raw kyc_status.review_status", + field: "status_external", + type: DataTypes.STRING + }, + type: { + allowNull: false, + defaultValue: MykoboCustomerType.INDIVIDUAL, + type: DataTypes.ENUM(...Object.values(MykoboCustomerType)) + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + }, + userId: { + allowNull: false, + field: "user_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + key: "id", + model: "profiles" + }, + type: DataTypes.UUID, + unique: true + } + }, + { + indexes: [ + { + fields: ["user_id"], + name: "idx_mykobo_customers_user_id", + unique: true + }, + { + fields: ["email"], + name: "idx_mykobo_customers_email", + unique: true + } + ], + modelName: "MykoboCustomer", + sequelize, + tableName: "mykobo_customers", + timestamps: true + } +); + +export default MykoboCustomer; diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 6b3930466..79a8dfdfe 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -3,7 +3,6 @@ "@fontsource/roboto": "^5.0.8", "@heroicons/react": "^2.1.3", "@hookform/resolvers": "^4.1.3", - "@monerium/sdk": "^3.4.2", "@pendulum-chain/api": "catalog:", "@pendulum-chain/api-solang": "catalog:", "@polkadot/api": "catalog:", @@ -43,7 +42,6 @@ "@walletconnect/universal-provider": "^2.21.10", "@walletconnect/utils": "catalog:", "@xstate/react": "^6.0.0", - "big.js": "catalog:", "bn.js": "^5.2.1", "buffer": "^6.0.3", diff --git a/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx b/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx index 8cf853765..950e17f61 100644 --- a/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx +++ b/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx @@ -1,8 +1,8 @@ import { useCallback } from "react"; import { useAlfredpayKycActor, useAlfredpayKycSelector } from "../../contexts/rampState"; +import { DoneScreen } from "../DoneScreen"; import { ColKycFormScreen } from "./ColKycFormScreen"; import { CustomerDefinitionScreen } from "./CustomerDefinitionScreen"; -import { DoneScreen } from "./DoneScreen"; import { FailureKycScreen } from "./FailureKycScreen"; import { FailureScreen } from "./FailureScreen"; import { FillingScreen } from "./FillingScreen"; diff --git a/apps/frontend/src/components/Alfredpay/DoneScreen.tsx b/apps/frontend/src/components/DoneScreen/index.tsx similarity index 66% rename from apps/frontend/src/components/Alfredpay/DoneScreen.tsx rename to apps/frontend/src/components/DoneScreen/index.tsx index 272873d9a..64caefbc6 100644 --- a/apps/frontend/src/components/Alfredpay/DoneScreen.tsx +++ b/apps/frontend/src/components/DoneScreen/index.tsx @@ -5,7 +5,7 @@ import { MenuButtons } from "../MenuButtons"; import { StepFooter } from "../StepFooter"; interface DoneScreenProps { - kycOrKyb: string; + kycOrKyb: "KYC" | "KYB"; onContinue?: () => void; } @@ -15,13 +15,17 @@ export const DoneScreen = memo(({ kycOrKyb, onContinue }: DoneScreenProps) => { return (
- Document verified -

{t("components.alfredpayKycFlow.completed", { kycOrKyb })}

-

{t("components.alfredpayKycFlow.accountVerified")}

+ {t("components.kycDoneScreen.documentVerifiedAlt")} +

{t("components.kycDoneScreen.completed", { kycOrKyb })}

+

{t("components.kycDoneScreen.accountVerified")}

{onContinue && ( )} diff --git a/apps/frontend/src/components/Footer/index.tsx b/apps/frontend/src/components/Footer/index.tsx index d4826de8e..93c7decd4 100644 --- a/apps/frontend/src/components/Footer/index.tsx +++ b/apps/frontend/src/components/Footer/index.tsx @@ -131,13 +131,6 @@ export function Footer() { > {t("components.footer.company.anclap")} - - {t("components.footer.company.monerium")} - ( +
+

{message}

+ +
+); + +interface FailurePanelProps { + message: string; + detail?: string; + onStartOver: () => void; + startOverLabel: string; +} + +const FailurePanel = ({ message, detail, onStartOver, startOverLabel }: FailurePanelProps) => ( +
+

{message}

+ {detail &&

{detail}

} + +
+); + +export const MykoboKycFlow = () => { + const { t } = useTranslation(); + const actor = useMykoboKycActor(); + const rampActor = useRampActor(); + const state = useMykoboKycSelector(); + + const submitForm = useCallback( + (formData: MykoboKycFormData, files: MykoboKycFiles) => actor?.send({ files, formData, type: "SubmitKycForm" }), + [actor] + ); + const confirmSuccess = useCallback(() => actor?.send({ type: "CONFIRM_SUCCESS" }), [actor]); + const startOver = useCallback(() => rampActor.send({ type: "RESET_RAMP" }), [rampActor]); + + if (!actor || !state) return null; + + const { stateValue, context } = state; + + if (stateValue === "CheckingProfile") { + return ; + } + + if (stateValue === "Submitting") { + return ; + } + + if (stateValue === "Verifying") { + return ; + } + + if (stateValue === "FormFilling") { + return ; + } + + if (stateValue === "VerificationDone" || stateValue === "Done") { + return ; + } + + if (stateValue === "Rejected") { + return ( + + ); + } + + if (stateValue === "Failure") { + return ( + + ); + } + + return null; +}; diff --git a/apps/frontend/src/components/Mykobo/MykoboKycForm.tsx b/apps/frontend/src/components/Mykobo/MykoboKycForm.tsx new file mode 100644 index 000000000..b76a40b67 --- /dev/null +++ b/apps/frontend/src/components/Mykobo/MykoboKycForm.tsx @@ -0,0 +1,237 @@ +import { DocumentTextIcon } from "@heroicons/react/24/outline"; +import { CheckCircleIcon } from "@heroicons/react/24/solid"; +import { ChangeEvent, ReactNode, useRef, useState } from "react"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { cn } from "../../helpers/cn"; +import type { MykoboKycFiles, MykoboKycFormData } from "../../machines/mykoboKyc.machine"; +import { Field } from "../Field"; +import { MenuButtons } from "../MenuButtons"; +import { StepFooter } from "../StepFooter"; + +interface MykoboKycFormProps { + onSubmit: (formData: MykoboKycFormData, files: MykoboKycFiles) => void; +} + +type SourceOfFunds = MykoboKycFormData["sourceOfFunds"]; +type IdType = MykoboKycFormData["idType"]; + +const SOURCE_OF_FUNDS_OPTIONS: SourceOfFunds[] = ["EMPLOYMENT", "SAVINGS", "LOANS", "INVESTMENT", "INHERITANCE"]; +const ID_TYPE_OPTIONS: IdType[] = ["PASSPORT", "ID_CARD", "DRIVERS_LICENSE"]; + +const FieldLabel = ({ children, htmlFor, className }: { children: ReactNode; htmlFor?: string; className?: string }) => ( + +); + +const selectClass = "input-vortex-primary input-ghost w-full rounded-lg border border-neutral-300 p-2"; + +interface FileFieldProps { + label: string; + placeholder: string; + inputRef: React.RefObject; + accept: string; + onChange?: (event: ChangeEvent) => void; + fileName?: string; +} + +const FileField = ({ label, placeholder, inputRef, accept, onChange, fileName }: FileFieldProps) => { + const hasFile = Boolean(fileName); + + return ( + + ); +}; + +export const MykoboKycForm = ({ onSubmit }: MykoboKycFormProps) => { + const { t } = useTranslation(); + const { + register, + handleSubmit, + watch, + formState: { errors, isSubmitting } + } = useForm({ + defaultValues: { + idType: "PASSPORT", + sourceOfFunds: "EMPLOYMENT" + } + }); + + const frontRef = useRef(null); + const backRef = useRef(null); + const faceRef = useRef(null); + const utilityRef = useRef(null); + const [fileError, setFileError] = useState(null); + const [fileNames, setFileNames] = useState<{ front?: string; back?: string; face?: string; utility?: string }>({}); + + const idType = watch("idType"); + const backRequired = idType === "ID_CARD" || idType === "DRIVERS_LICENSE"; + + const submit = handleSubmit(values => { + const front = frontRef.current?.files?.[0]; + const face = faceRef.current?.files?.[0]; + const utilityBill = utilityRef.current?.files?.[0]; + const back = backRef.current?.files?.[0]; + + if (!front || !face || !utilityBill) { + setFileError(t("components.mykoboKycFlow.form.errors.requiredFiles")); + return; + } + if (backRequired && !back) { + setFileError(t("components.mykoboKycFlow.form.errors.backRequired")); + return; + } + setFileError(null); + onSubmit(values, { back: backRequired ? back : undefined, face, front, utilityBill }); + }); + + const filePlaceholder = t("components.mykoboKycFlow.form.filePlaceholder"); + + return ( +
+ +
+

{t("components.mykoboKycFlow.form.title")}

+ +
+
+ {t("components.mykoboKycFlow.form.firstName")} + +
+
+ {t("components.mykoboKycFlow.form.lastName")} + +
+
+ {t("components.mykoboKycFlow.form.emailAddress")} + +
+
+ {t("components.mykoboKycFlow.form.addressLine1")} + +
+
+ {t("components.mykoboKycFlow.form.city")} + +
+
+ {t("components.mykoboKycFlow.form.idCountryCode")} + +
+
+ {t("components.mykoboKycFlow.form.bankAccountNumber")} + +
+
+ {t("components.mykoboKycFlow.form.taxCountry")} + +
+
+ {t("components.mykoboKycFlow.form.sourceOfFunds")} + +
+
+ {t("components.mykoboKycFlow.form.idType")} + +
+
+ +

{t("components.mykoboKycFlow.form.documents")}

+
+ setFileNames(prev => ({ ...prev, front: e.target.files?.[0]?.name }))} + placeholder={filePlaceholder} + /> + {backRequired && ( + setFileNames(prev => ({ ...prev, back: e.target.files?.[0]?.name }))} + placeholder={filePlaceholder} + /> + )} + setFileNames(prev => ({ ...prev, face: e.target.files?.[0]?.name }))} + placeholder={filePlaceholder} + /> + setFileNames(prev => ({ ...prev, utility: e.target.files?.[0]?.name }))} + placeholder={filePlaceholder} + /> +
+ + {fileError &&

{fileError}

} + {Object.keys(errors).length > 0 && ( +

{t("components.mykoboKycFlow.form.errors.requiredFields")}

+ )} +
+ + + + + + ); +}; diff --git a/apps/frontend/src/components/NetworkSelector/index.tsx b/apps/frontend/src/components/NetworkSelector/index.tsx index 68e9838d3..98db2fc7d 100644 --- a/apps/frontend/src/components/NetworkSelector/index.tsx +++ b/apps/frontend/src/components/NetworkSelector/index.tsx @@ -16,7 +16,7 @@ interface NetworkButtonProps { } const supportedNetworks = Object.values(Networks).filter( - network => network !== Networks.Pendulum && network !== Networks.Stellar && network !== Networks.Moonbeam + network => network !== Networks.Pendulum && network !== Networks.Moonbeam ); const NetworkButton = ({ selectedNetwork, isOpen, onClick, disabled }: NetworkButtonProps) => ( diff --git a/apps/frontend/src/components/RampSubmitButton/RampSubmitButton.tsx b/apps/frontend/src/components/RampSubmitButton/RampSubmitButton.tsx index f5431dc5a..ea1a55140 100644 --- a/apps/frontend/src/components/RampSubmitButton/RampSubmitButton.tsx +++ b/apps/frontend/src/components/RampSubmitButton/RampSubmitButton.tsx @@ -7,15 +7,15 @@ import { getAnyFiatTokenDetails, getOnChainTokenDetailsOrDefault, isAlfredpayToken, - RampDirection, - TokenType + isMoonbeamTokenDetails, + RampDirection } from "@vortexfi/shared"; import { useSelector } from "@xstate/react"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useFiatAccountSelector } from "../../contexts/FiatAccountMachineContext"; import { useNetwork } from "../../contexts/network"; -import { useMoneriumKycActor, useRampActor, useStellarKycSelector } from "../../contexts/rampState"; +import { useRampActor } from "../../contexts/rampState"; import { trimAddress } from "../../helpers/addressFormatter"; import { cn } from "../../helpers/cn"; import { useAlfredpayFiatAccounts } from "../../hooks/alfredpay/useFiatAccounts"; @@ -30,10 +30,38 @@ interface UseButtonContentProps { submitButtonDisabled: boolean; } +interface LockedWalletArgs { + walletLocked: string | undefined; + accountAddress: string | undefined; + isOfframp: boolean; + quoteFrom: string | undefined; +} + +function isLockedToAnotherWallet({ walletLocked, accountAddress, isOfframp, quoteFrom }: LockedWalletArgs): boolean { + if (!walletLocked || !accountAddress) return false; + if (!isOfframp && quoteFrom !== "sepa") return false; + return getAddressForFormat(accountAddress, 0) !== getAddressForFormat(walletLocked, 0); +} + +function getQuoteReadyContent(args: { + isOnramp: boolean; + isAnchorWithoutRedirect: boolean; + inputCurrency: string | undefined; + t: ReturnType["t"]; +}) { + const { isOnramp, isAnchorWithoutRedirect, inputCurrency, t } = args; + if (isOnramp && isAnchorWithoutRedirect) { + return { icon: null, text: t("components.SummaryPage.confirm") }; + } + if (isOnramp && inputCurrency === FiatToken.BRL) { + return { icon: null, text: t("components.SummaryPage.continue") }; + } + return { icon: null, text: t("components.SummaryPage.verifyWallet") }; +} + const useButtonContent = ({ toToken, submitButtonDisabled }: UseButtonContentProps) => { const { t } = useTranslation(); const rampActor = useRampActor(); - const stellarData = useStellarKycSelector(); const { address: accountAddress } = useVortexAccount(); const { isQuoteExpired, rampState, rampPaymentConfirmed, machineState, walletLocked, quote } = useSelector( @@ -51,18 +79,16 @@ const useButtonContent = ({ toToken, submitButtonDisabled }: UseButtonContentPro return useMemo(() => { const isOnramp = quote?.rampType === RampDirection.BUY; const isOfframp = quote?.rampType === RampDirection.SELL; - const isDepositQrCodeReady = Boolean(rampState?.ramp?.depositQrCode) || Boolean(rampState?.ramp?.achPaymentData); + const isDepositQrCodeReady = + Boolean(rampState?.ramp?.depositQrCode) || + Boolean(rampState?.ramp?.achPaymentData) || + Boolean(rampState?.ramp?.ibanPaymentData); const hasAchPaymentData = Boolean(rampState?.ramp?.achPaymentData); - if ( - walletLocked && - (isOfframp || quote?.from === "sepa") && - accountAddress && - getAddressForFormat(accountAddress, 0) !== getAddressForFormat(walletLocked, 0) - ) { + if (isLockedToAnotherWallet({ accountAddress, isOfframp, quoteFrom: quote?.from, walletLocked })) { return { icon: null, - text: t("components.RampSubmitButton.connectDesignatedWallet", { address: trimAddress(walletLocked) }) + text: t("components.RampSubmitButton.connectDesignatedWallet", { address: trimAddress(walletLocked as string) }) }; } @@ -71,99 +97,44 @@ const useButtonContent = ({ toToken, submitButtonDisabled }: UseButtonContentPro const isAnchorWithRedirect = !isAnchorWithoutRedirect; if (machineState === "QuoteReady") { - if (isOnramp && isAnchorWithoutRedirect) { - return { - icon: null, - text: t("components.SummaryPage.confirm") - }; - } else if (isOnramp && quote?.inputCurrency === FiatToken.BRL) { - return { - icon: null, - text: t("components.SummaryPage.continue") - }; - } else { - return { - icon: null, - text: t("components.SummaryPage.verifyWallet") - }; - } + return getQuoteReadyContent({ inputCurrency: quote?.inputCurrency, isAnchorWithoutRedirect, isOnramp, t }); } if (isQuoteExpired && !hasAchPaymentData) { - return { - icon: null, - text: t("components.SummaryPage.quoteExpired") - }; + return { icon: null, text: t("components.SummaryPage.quoteExpired") }; } if (machineState === "KycComplete") { - return { - icon: null, - text: t("components.SummaryPage.confirm") - }; + return { icon: null, text: t("components.SummaryPage.confirm") }; } - // XSTATE migrate: we can display this on failure, generic failure. - // Add check for signing rejection - // if (signingRejected) { - // return { - // icon: null, - // text: t("components.SummaryPage.tryAgain") - // }; - // } - if (submitButtonDisabled) { - return { - icon: , - text: t("components.swapSubmitButton.processing") - }; + return { icon: , text: t("components.swapSubmitButton.processing") }; } if (isOfframp && isAnchorWithoutRedirect) { - return { - icon: null, - text: t("components.SummaryPage.confirm") - }; + return { icon: null, text: t("components.SummaryPage.confirm") }; } if (isOfframp && rampState !== undefined) { - return { - icon: , - text: t("components.SummaryPage.processing") - }; + return { icon: , text: t("components.SummaryPage.processing") }; } if (isOnramp && isDepositQrCodeReady && !rampPaymentConfirmed) { - return { - icon: null, - text: t("components.swapSubmitButton.confirmPayment") - }; + return { icon: null, text: t("components.swapSubmitButton.confirmPayment") }; } if (isOnramp && !isDepositQrCodeReady) { - return { - icon: null, - text: t("components.SummaryPage.confirm") - }; + return { icon: null, text: t("components.SummaryPage.confirm") }; } if (isOfframp && isAnchorWithRedirect) { - if (stellarData?.stateValue === "Sep24Second") { - return { - icon: , - text: t("components.SummaryPage.continueOnPartnersPage") - }; - } else { - return { - icon: , - text: t("components.SummaryPage.continueWithPartner") - }; - } + return { + icon: , + text: t("components.SummaryPage.continueWithPartner") + }; } - return { - icon: , - text: t("components.swapSubmitButton.processing") - }; + return { icon: , text: t("components.swapSubmitButton.processing") }; }, [ submitButtonDisabled, isQuoteExpired, @@ -171,7 +142,6 @@ const useButtonContent = ({ toToken, submitButtonDisabled }: UseButtonContentPro machineState, t, toToken, - stellarData, rampPaymentConfirmed, quote, accountAddress, @@ -182,11 +152,9 @@ const useButtonContent = ({ toToken, submitButtonDisabled }: UseButtonContentPro export const RampSubmitButton = ({ className, hasValidationErrors }: { className?: string; hasValidationErrors?: boolean }) => { const rampActor = useRampActor(); const { onRampConfirm } = useRampSubmission(); - const stellarData = useStellarKycSelector(); const router = useRouter(); const params = useParams({ strict: false }); - const moneriumKycActor = useMoneriumKycActor(); const { address: accountAddress } = useVortexAccount(); const { rampState, quote, executionInput, isQuoteExpired, machineState, walletLocked } = useSelector(rampActor, state => ({ @@ -198,9 +166,6 @@ export const RampSubmitButton = ({ className, hasValidationErrors }: { className walletLocked: state.context.walletLocked })); - const stellarContext = stellarData?.context; - const anchorUrl = stellarContext?.redirectUrl; - const isOnramp = quote?.rampType === RampDirection.BUY; const isOfframp = quote?.rampType === RampDirection.SELL; const fiatToken = useFiatToken(); @@ -217,12 +182,7 @@ export const RampSubmitButton = ({ className, hasValidationErrors }: { className return true; } - if ( - walletLocked && - (isOfframp || quote?.from === "sepa") && - accountAddress && - getAddressForFormat(accountAddress, 0) !== getAddressForFormat(walletLocked, 0) - ) { + if (isLockedToAnotherWallet({ accountAddress, isOfframp, quoteFrom: quote?.from, walletLocked })) { return true; } if (machineState === "QuoteReady") { @@ -234,7 +194,7 @@ export const RampSubmitButton = ({ className, hasValidationErrors }: { className return false; } - if (machineState === "RegisterRamp" || moneriumKycActor) { + if (machineState === "RegisterRamp") { return true; } @@ -244,14 +204,15 @@ export const RampSubmitButton = ({ className, hasValidationErrors }: { className if (!executionInput) return true; if (isOfframp) { - if (!anchorUrl && getAnyFiatTokenDetails(fiatToken).type === TokenType.Stellar) return true; - if (stellarData?.stateValue !== "StartSep24") return true; + if (!isMoonbeamTokenDetails(getAnyFiatTokenDetails(fiatToken))) return true; if (!executionInput.brlaEvmAddress && getAnyFiatTokenDetails(fiatToken).type === "moonbeam") return true; } if (machineState === "UpdateRamp") { const isDepositQrCodeReady = - Boolean(isOnramp && rampState?.ramp?.depositQrCode) || Boolean(rampState?.ramp?.achPaymentData); + Boolean(isOnramp && rampState?.ramp?.depositQrCode) || + Boolean(rampState?.ramp?.achPaymentData) || + Boolean(isOnramp && rampState?.ramp?.ibanPaymentData); if (isOnramp && !isDepositQrCodeReady) return true; } @@ -264,12 +225,10 @@ export const RampSubmitButton = ({ className, hasValidationErrors }: { className isOnramp, rampState?.ramp?.depositQrCode, rampState?.ramp?.achPaymentData, - anchorUrl, + rampState?.ramp?.ibanPaymentData, fiatToken, effectiveSelectedFiatAccountId, - stellarData, machineState, - moneriumKycActor, walletLocked, accountAddress, quote?.from @@ -301,27 +260,9 @@ export const RampSubmitButton = ({ className, hasValidationErrors }: { className rampActor.send({ type: "SummaryConfirm" }); - // For BRL offramps, set canRegisterRamp to true - if (isOfframp && fiatToken === FiatToken.BRL && executionInput?.quote.rampType === RampDirection.SELL) { - //setCanRegisterRamp(true); - } - - if (isOnramp) { - if (machineState === "UpdateRamp") { - rampActor.send({ type: "PAYMENT_CONFIRMED" }); - } - } - - if (!isOnramp && (toToken as FiatTokenDetails).type !== "moonbeam" && anchorUrl) { - // If signing was rejected, we do not open the anchor URL again - // if (!signingRejected) { - // window.open(anchorUrl, "_blank"); - // } + if (isOnramp && machineState === "UpdateRamp") { + rampActor.send({ type: "PAYMENT_CONFIRMED" }); } - - // if (signingRejected) { - // setSigningRejected(false); - // } }; return ( diff --git a/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx b/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx index 8d3ec5700..978f1fc87 100644 --- a/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx +++ b/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx @@ -8,13 +8,13 @@ import { getEnumKeyByStringValue, getNetworkDisplayName, isEvmTokenDetails, + isMoonbeamTokenDetails, isNetworkEVM, moonbeamTokenConfig, Networks, OnChainToken, OnChainTokenDetails, - RampDirection, - stellarTokenConfig + RampDirection } from "@vortexfi/shared"; import { useMemo } from "react"; import { getEvmTokenConfig } from "../../../services/tokens"; @@ -138,27 +138,23 @@ export function invalidateOnChainTokensCache(): void { cachedEvmConfigRef = null; } -function getFiatTokens(filterEurcOnly = false): ExtendedTokenDefinition[] { +function getFiatTokens(): ExtendedTokenDefinition[] { const moonbeamEntries = Object.entries(moonbeamTokenConfig); const freeFiatCurrencyEntries = Object.entries(freeTokenConfig); - const stellarEntries = filterEurcOnly - ? Object.entries(stellarTokenConfig).filter(([key]) => key === FiatToken.EURC) - : Object.entries(stellarTokenConfig); - - return [...moonbeamEntries, ...freeFiatCurrencyEntries, ...stellarEntries].map(([key, value]) => ({ - assetIcon: value.fiat.assetIcon, - assetSymbol: value.fiat.symbol, - details: value as FiatTokenDetails, - name: value.fiat.name, - network: key === FiatToken.BRL ? Networks.Moonbeam : Networks.Stellar, - networkDisplayName: - key === FiatToken.BRL ? getNetworkDisplayName(Networks.Moonbeam) : getNetworkDisplayName(Networks.Stellar), - type: getEnumKeyByStringValue(FiatToken, key) as FiatToken - })); -} -function isFilterEurcOnly(type: "from" | "to", direction: RampDirection) { - return direction === RampDirection.BUY && type === "from"; + return [...moonbeamEntries, ...freeFiatCurrencyEntries].map(([, value]) => { + const details = value as FiatTokenDetails; + const network = isMoonbeamTokenDetails(details) ? Networks.Moonbeam : Networks.Base; + return { + assetIcon: details.fiat.assetIcon, + assetSymbol: details.fiat.symbol, + details, + name: details.fiat.name, + network, + networkDisplayName: getNetworkDisplayName(network), + type: getEnumKeyByStringValue(FiatToken, details.fiat.symbol) as FiatToken + }; + }); } export function useIsFiatDirection() { @@ -174,7 +170,7 @@ function isFiatDirection(type: "from" | "to", direction: RampDirection) { function getAllSupportedTokenDefinitions(type: "from" | "to", direction: RampDirection): ExtendedTokenDefinition[] { if (isFiatDirection(type, direction)) { - return getFiatTokens(isFilterEurcOnly(type, direction)); + return getFiatTokens(); } else { return getAllOnChainTokens(); } diff --git a/apps/frontend/src/components/widget-steps/DetailsStep/DetailsStepForm.tsx b/apps/frontend/src/components/widget-steps/DetailsStep/DetailsStepForm.tsx index 2d98be1d6..e9fb662e7 100644 --- a/apps/frontend/src/components/widget-steps/DetailsStep/DetailsStepForm.tsx +++ b/apps/frontend/src/components/widget-steps/DetailsStep/DetailsStepForm.tsx @@ -1,7 +1,6 @@ import { cn } from "../../../helpers/cn"; import { SigningBoxContent } from "../../SigningBox/SigningBoxContent"; import { AveniaFormStep } from "../AveniaFormStep"; -import { MoneriumAssethubFormStep } from "../MoneriumAssethubFormStep"; import { SigningState } from "./index"; export interface DetailsStepFormProps { @@ -9,15 +8,13 @@ export interface DetailsStepFormProps { signingState: SigningState; className?: string; isWalletAddressDisabled?: boolean; - showWalletAddressField?: boolean; } export const DetailsStepForm = ({ isBrazilLanding, signingState, className, - isWalletAddressDisabled, - showWalletAddressField + isWalletAddressDisabled }: DetailsStepFormProps) => { const { shouldDisplay: signingBoxVisible, progress } = signingState; @@ -25,7 +22,6 @@ export const DetailsStepForm = ({ <>
{isBrazilLanding && } - {showWalletAddressField && }
{signingBoxVisible && ( diff --git a/apps/frontend/src/components/widget-steps/DetailsStep/index.tsx b/apps/frontend/src/components/widget-steps/DetailsStep/index.tsx index a602370a0..9e7bd2160 100644 --- a/apps/frontend/src/components/widget-steps/DetailsStep/index.tsx +++ b/apps/frontend/src/components/widget-steps/DetailsStep/index.tsx @@ -1,5 +1,5 @@ import { InformationCircleIcon } from "@heroicons/react/24/outline"; -import { FiatToken, Networks } from "@vortexfi/shared"; +import { FiatToken, isFiatToken } from "@vortexfi/shared"; import { useSelector } from "@xstate/react"; import { useEffect } from "react"; import { FormProvider } from "react-hook-form"; @@ -31,7 +31,6 @@ export interface SigningState { export interface FormData { pixId?: string; taxId?: string; - moneriumWalletAddress?: string; walletAddress?: string; fiatToken?: FiatToken; } @@ -50,40 +49,31 @@ export const DetailsStep = ({ className }: DetailsStepProps) => { const pixId = usePixId(); const quote = useQuote(); - // When onramping from EUR -> Assethub, we need to show the wallet address field - const isMoneriumRamp = quote?.rampType === "BUY" && quote?.inputCurrency === FiatToken.EURC; - const isMoneriumToAssethubRamp = isMoneriumRamp && quote?.to === Networks.AssetHub; - const forceNetwork = isMoneriumToAssethubRamp ? Networks.Polygon : undefined; - - const { address, evmAddress, substrateAddress } = useVortexAccount(forceNetwork); + const { address } = useVortexAccount(); const walletForm = walletLockedFromState || address || undefined; + const rawFiatCurrency = quote?.rampType === "BUY" ? quote.inputCurrency : quote?.outputCurrency; + const fiatToken = rawFiatCurrency && isFiatToken(rawFiatCurrency) ? rawFiatCurrency : undefined; + const { form } = useRampForm({ - fiatToken: quote?.rampType === "BUY" ? (quote.inputCurrency as FiatToken) : (quote?.outputCurrency as FiatToken), - moneriumWalletAddress: evmAddress, + fiatToken, pixId, taxId, - walletAddress: isMoneriumToAssethubRamp ? substrateAddress : walletForm + walletAddress: walletForm }); useEffect(() => { - // If a Monerium wallet needs to be connected for the ramp, set it in the form - if (isMoneriumRamp) { - form.setValue("moneriumWalletAddress", evmAddress); - } - - if (isMoneriumToAssethubRamp && substrateAddress) { - form.setValue("walletAddress", substrateAddress); - } else if (!isMoneriumToAssethubRamp && address) { + if (address) { form.setValue("walletAddress", address); } else if (walletLockedFromState) { form.setValue("walletAddress", walletLockedFromState); } - const fiatToken = quote?.rampType === "BUY" ? (quote.inputCurrency as FiatToken) : (quote?.outputCurrency as FiatToken); - form.setValue("fiatToken", fiatToken); - }, [form, evmAddress, isMoneriumRamp, address, walletLockedFromState, isMoneriumToAssethubRamp, substrateAddress, quote]); + if (fiatToken) { + form.setValue("fiatToken", fiatToken); + } + }, [form, address, walletLockedFromState, fiatToken]); const { onRampConfirm } = useRampSubmission(); @@ -115,7 +105,6 @@ export const DetailsStep = ({ className }: DetailsStepProps) => { {isSep24Redo && ( @@ -129,7 +118,6 @@ export const DetailsStep = ({ className }: DetailsStepProps) => {
{ - const { t } = useTranslation(); - - // This component is required to be inside a FormProvider (react-hook-form) - const { register } = useFormContext(); - const { errors } = useFormState(); - - // This name has to match the field in the form data - const id = "walletAddress"; - const errorMessage = errors[id]?.message as string; - - return ( -
- - -
-

{t("components.moneriumFormStep.description.1")}

-
- - - {errorMessage && {errorMessage}} -

- - }} - i18nKey={"components.moneriumFormStep.description.2"} - > - Then, authenticate with our stablecoin partner - - Monerium - {" "} - by connecting your EVM wallet. - -

-
-
-
- ); -}; diff --git a/apps/frontend/src/components/widget-steps/MoneriumRedirectStep/index.tsx b/apps/frontend/src/components/widget-steps/MoneriumRedirectStep/index.tsx deleted file mode 100644 index f1e86d8b7..000000000 --- a/apps/frontend/src/components/widget-steps/MoneriumRedirectStep/index.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useParams, useRouter } from "@tanstack/react-router"; -import { useTranslation } from "react-i18next"; -import { useMoneriumKycActor, useRampActor } from "../../../contexts/rampState"; -import { cn } from "../../../helpers/cn"; -import { navigateToCleanOrigin } from "../../../lib/navigation"; -import { StepFooter } from "../../StepFooter"; - -interface MoneriumRedirectStepProps { - className?: string; -} - -export function MoneriumRedirectStep({ className }: MoneriumRedirectStepProps) { - const { t } = useTranslation(); - const moneriumKycActor = useMoneriumKycActor(); - const rampActor = useRampActor(); - const router = useRouter(); - const params = useParams({ strict: false }); - - if (!moneriumKycActor) { - return
; - } - - const onCancelClick = () => { - navigateToCleanOrigin(router, params); - rampActor.send({ type: "RESET_RAMP" }); - }; - - return ( -
-
-

{t("components.moneriumRedirect.description")}

-
- - - - -
- ); -} diff --git a/apps/frontend/src/components/widget-steps/SummaryStep/EUROnrampDetails.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/EUROnrampDetails.tsx index f161b375c..ba28c1c53 100644 --- a/apps/frontend/src/components/widget-steps/SummaryStep/EUROnrampDetails.tsx +++ b/apps/frontend/src/components/widget-steps/SummaryStep/EUROnrampDetails.tsx @@ -12,21 +12,22 @@ import { InfoBox } from "../../InfoBox"; export const EUROnrampDetails: FC = () => { const { t } = useTranslation(); const rampActor = useRampActor(); - const { isQuoteExpired, rampState, signingPhase } = useSelector(rampActor, state => ({ + const { isQuoteExpired, rampState } = useSelector(rampActor, state => ({ isQuoteExpired: state.context.isQuoteExpired, - rampState: state.context.rampState, - signingPhase: state.context.rampSigningPhase + rampState: state.context.rampState })); - if (!rampState?.ramp?.depositQrCode) return null; - if (!rampState?.ramp?.ibanPaymentData) return null; - if (signingPhase !== "finished") return null; // Only show details if the ramp is finished if (isQuoteExpired) return null; - const { iban, bic, receiverName } = rampState.ramp.ibanPaymentData; + const { iban, bic, receiverName, reference } = rampState.ramp.ibanPaymentData; const amount = rampState.quote.inputAmount; + // EPC QR (Girocode / SEPA QR) — version 002 allows empty BIC, scanned by most EU banking apps to autofill the transfer + const epcQrPayload = ["BCD", "002", "1", "SCT", bic ?? "", receiverName, iban, `EUR${amount}`, "", "", reference ?? ""].join( + "\n" + ); + return (

@@ -48,12 +49,22 @@ export const EUROnrampDetails: FC = () => {
-
- {t("components.SummaryPage.EUROnrampDetails.bic")} -
- + {bic && ( +
+ {t("components.SummaryPage.EUROnrampDetails.bic")} +
+ +
-
+ )} + {reference && ( +
+ {t("components.SummaryPage.EUROnrampDetails.reference")} +
+ +
+
+ )} {rampState.quote.outputCurrency === EvmToken.ETH && ( { title="When buying a non-stablecoin asset, you have to use instant SEPA. Otherwise your ramp might fail due to the delay." /> )} - {rampState.ramp?.depositQrCode && ( -
- - - -
- )} +
+ + + +

{t("components.SummaryPage.EUROnrampDetails.hint")}

{t("components.SummaryPage.EUROnrampDetails.footer")}

diff --git a/apps/frontend/src/components/widget-steps/SummaryStep/FeeDetails.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/FeeDetails.tsx index f6363deba..7085ac8a3 100644 --- a/apps/frontend/src/components/widget-steps/SummaryStep/FeeDetails.tsx +++ b/apps/frontend/src/components/widget-steps/SummaryStep/FeeDetails.tsx @@ -11,6 +11,7 @@ interface FeeDetailsProps { partnerUrl: string; direction: RampDirection; destinationAddress?: string; + iban?: string; } export const FeeDetails: FC = ({ @@ -20,7 +21,8 @@ export const FeeDetails: FC = ({ exchangeRate, partnerUrl, direction, - destinationAddress + destinationAddress, + iban }) => { const { t } = useTranslation(); @@ -60,6 +62,12 @@ export const FeeDetails: FC = ({ {destinationAddress}
)} + {iban && ( +
+

{t("components.SummaryPage.iban")}

+

{iban}

+
+ )}

{t("components.SummaryPage.partner")}

diff --git a/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx index 261baa29d..3fa80bd41 100644 --- a/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx +++ b/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx @@ -1,4 +1,5 @@ import { ArrowDownIcon } from "@heroicons/react/20/solid"; +import { useQuery } from "@tanstack/react-query"; import { BaseFiatTokenDetails, FiatToken, @@ -8,7 +9,6 @@ import { getOnChainTokenDetailsOrDefault, isAlfredpayToken, isMoonbeamTokenDetails, - isStellarOutputTokenDetails, OnChainTokenDetails, RampDirection } from "@vortexfi/shared"; @@ -23,6 +23,7 @@ import { trimAddress } from "../../../helpers/addressFormatter"; import { useCountdown } from "../../../hooks/useCountdown"; import { useTokenIcon } from "../../../hooks/useTokenIcon"; import { useVortexAccount } from "../../../hooks/useVortexAccount"; +import { MykoboService } from "../../../services/api/mykobo.service"; import { RampExecutionInput } from "../../../types/phases"; import { AssetDisplay } from "./AssetDisplay"; import { BRLOnrampDetails } from "./BRLOnrampDetails"; @@ -32,6 +33,15 @@ import { FeeDetails } from "./FeeDetails"; import { MXNOnrampDetails } from "./MXNOnrampDetails"; import { USOnrampDetails } from "./USOnrampDetails"; +const ONRAMP_DETAILS_BY_FIAT: Record = { + [FiatToken.ARS]: null, + [FiatToken.BRL]: BRLOnrampDetails, + [FiatToken.COP]: COPOnrampDetails, + [FiatToken.EURC]: EUROnrampDetails, + [FiatToken.MXN]: MXNOnrampDetails, + [FiatToken.USD]: USOnrampDetails +}; + interface TransactionTokensDisplayProps { executionInput: RampExecutionInput; isOnramp: boolean; @@ -46,16 +56,24 @@ export const TransactionTokensDisplay: FC = ({ ex const { apiComponents } = useAssetHubNode(); const { chainId } = useVortexAccount(); - const { connectedWalletAddress, isQuoteExpired, quote, quoteLocked } = useSelector(rampActor, state => ({ + const { connectedWalletAddress, isQuoteExpired, quote, quoteLocked, userEmail } = useSelector(rampActor, state => ({ connectedWalletAddress: state.context.connectedWalletAddress, isQuoteExpired: state.context.isQuoteExpired, quote: state.context.quote, quoteLocked: state.context.quoteLocked, - rampState: state.context.rampState + userEmail: state.context.userEmail })); const targetTimestampMs = quote ? new Date(quote.expiresAt).getTime() : null; + const isEurOfframp = !isOnramp && executionInput.fiatToken === FiatToken.EURC; + + const { data: mykoboProfile } = useQuery({ + enabled: isEurOfframp && !!userEmail, + queryFn: () => MykoboService.getProfile(userEmail as string), + queryKey: ["mykoboProfile", userEmail] + }); + const isAlfredpayFlow = isAlfredpayToken(executionInput.fiatToken); const countdownTarget = isAlfredpayFlow ? null : targetTimestampMs; const { minutes, seconds } = useCountdown(countdownTarget, () => rampActor.send({ type: "EXPIRE_QUOTE" })); @@ -75,14 +93,11 @@ export const TransactionTokensDisplay: FC = ({ ex const getPartnerUrl = (): string => { const fiatToken = (isOnramp ? fromToken : toToken) as FiatTokenDetails; - if (fromToken.assetSymbol === "EURC") { - return "https://monerium.com"; - } if (isAlfredpayToken(executionInput.fiatToken)) { return "https://alfredpay.io"; } - if (isStellarOutputTokenDetails(fiatToken)) { - return fiatToken.anchorHomepageUrl; + if (executionInput.fiatToken === FiatToken.EURC) { + return "https://mykobo.co"; } if (isMoonbeamTokenDetails(fiatToken)) { return fiatToken.partnerUrl; @@ -130,14 +145,15 @@ export const TransactionTokensDisplay: FC = ({ ex vortex: quote.vortexFeeFiat }} fromToken={fromToken} + iban={isEurOfframp ? mykoboProfile?.bankAccountNumber : undefined} partnerUrl={getPartnerUrl()} toToken={toToken} /> - {rampDirection === RampDirection.BUY && executionInput.fiatToken === FiatToken.BRL && } - {rampDirection === RampDirection.BUY && executionInput.fiatToken === FiatToken.EURC && } - {rampDirection === RampDirection.BUY && executionInput.fiatToken === FiatToken.USD && } - {rampDirection === RampDirection.BUY && executionInput.fiatToken === FiatToken.MXN && } - {rampDirection === RampDirection.BUY && executionInput.fiatToken === FiatToken.COP && } + {rampDirection === RampDirection.BUY && + (() => { + const Details = ONRAMP_DETAILS_BY_FIAT[executionInput.fiatToken]; + return Details ?
: null; + })()} {quoteLocked && !isAlfredpayFlow && targetTimestampMs !== null && !isQuoteExpired && (
{t("components.SummaryPage.BRLOnrampDetails.timerLabel")} {formattedTime} diff --git a/apps/frontend/src/components/widget-steps/SummaryStep/index.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/index.tsx index 0b5c79d08..f3fb294ed 100644 --- a/apps/frontend/src/components/widget-steps/SummaryStep/index.tsx +++ b/apps/frontend/src/components/widget-steps/SummaryStep/index.tsx @@ -1,5 +1,5 @@ -import { ExclamationCircleIcon, UserIcon } from "@heroicons/react/24/solid"; -import { FiatToken, isAlfredpayToken, MoneriumErrors, RampDirection } from "@vortexfi/shared"; +import { ExclamationCircleIcon } from "@heroicons/react/24/solid"; +import { FiatToken, isAlfredpayToken, RampDirection } from "@vortexfi/shared"; import { useSelector } from "@xstate/react"; import { FC, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; @@ -22,21 +22,14 @@ export const SummaryStep: FC = () => { const { shouldDisplay: signingBoxVisible, progress, signatureState, confirmations } = useSigningBoxState(); - const { visible, executionInput, rampDirection, rampState, signingPhase, rampRegistrationError } = useSelector( - rampActor, - state => ({ - executionInput: state.context.executionInput, - rampDirection: state.context.rampDirection, - rampRegistrationError: state.context.initializeFailedMessage, - rampState: state.context.rampState, - signingPhase: state.context.rampSigningPhase, - visible: - state.matches("KycComplete") || - state.matches("RegisterRamp") || - state.matches("UpdateRamp") || - state.matches("StartRamp") - }) - ); + const { visible, executionInput, rampDirection, rampState, rampRegistrationError } = useSelector(rampActor, state => ({ + executionInput: state.context.executionInput, + rampDirection: state.context.rampDirection, + rampRegistrationError: state.context.initializeFailedMessage, + rampState: state.context.rampState, + visible: + state.matches("KycComplete") || state.matches("RegisterRamp") || state.matches("UpdateRamp") || state.matches("StartRamp") + })); const rampType = rampDirection || RampDirection.BUY; const isOnramp = rampType === RampDirection.BUY; @@ -50,31 +43,27 @@ export const SummaryStep: FC = () => { }, [setDialogScrollRef]); useEffect(() => { - if (visible && isOnramp && executionInput?.fiatToken === FiatToken.BRL && rampState?.ramp?.depositQrCode) { - scrollToBottom(); - } - }, [visible, isOnramp, executionInput?.fiatToken, rampState?.ramp?.depositQrCode, scrollToBottom]); - - useEffect(() => { - if ( - visible && - isOnramp && - executionInput?.fiatToken === FiatToken.EURC && - rampState?.ramp?.ibanPaymentData && - signingPhase === "finished" - ) { + if (!visible || !isOnramp) return; + const fiatToken = executionInput?.fiatToken; + const isBrlReady = fiatToken === FiatToken.BRL && rampState?.ramp?.depositQrCode; + const isEurcReady = fiatToken === FiatToken.EURC && rampState?.ramp?.ibanPaymentData; + if (isBrlReady || isEurcReady) { scrollToBottom(); } - }, [visible, isOnramp, executionInput?.fiatToken, rampState?.ramp?.ibanPaymentData, signingPhase, scrollToBottom]); + }, [ + visible, + isOnramp, + executionInput?.fiatToken, + rampState?.ramp?.depositQrCode, + rampState?.ramp?.ibanPaymentData, + scrollToBottom + ]); const getRampRegistrationErrorMessage = useGetRampRegistrationErrorMessage(); if (!visible) return null; if (!executionInput) return null; - const isUserMintAddressNotFound = - rampRegistrationError && rampRegistrationError === MoneriumErrors.USER_MINT_ADDRESS_NOT_FOUND; - const rampRegistrationErrorMessage = getRampRegistrationErrorMessage(rampRegistrationError); const headerText = isOnramp ? t("components.SummaryPage.headerText.buy") : t("components.SummaryPage.headerText.sell"); @@ -96,17 +85,7 @@ export const SummaryStep: FC = () => {
)} - {isUserMintAddressNotFound && ( - } - title={rampRegistrationErrorMessage ?? ""} - > - - - )} - - {!isUserMintAddressNotFound && rampRegistrationErrorMessage && ( + {rampRegistrationErrorMessage && ( } diff --git a/apps/frontend/src/constants/localStorage.ts b/apps/frontend/src/constants/localStorage.ts index 9cf89a129..dff8171b6 100644 --- a/apps/frontend/src/constants/localStorage.ts +++ b/apps/frontend/src/constants/localStorage.ts @@ -1,6 +1,5 @@ export const storageKeys = { ACCOUNT: "ACCOUNT", - ANCHOR_SESSION_PARAMS: "ANCHOR_SESSION_PARAMS", BRLA_KYC_PIX_KEY: "BRLA_KYC_PIX_KEY", BRLA_KYC_TAX_ID: "BRLA_KYC_TAX_ID", @@ -10,14 +9,11 @@ export const storageKeys = { PENDULUM_SEED: "PENDULUM_SEED", POOL_SETTINGS: "POOL_SETTINGS", RAMP_SETTINGS: "RAMP_SETTINGS", - SEP_RESULT: "SEP24_RESULT", SIWE_SIGNATURE_KEY_PREFIX: "SIWE_SIGNATURE_", // Internal squidrouter recovery states SQUIDROUTER_RECOVERY_STATE_APPROVAL: "SQUIDROUTER_TRANSACTION_STATE_APPROVAL", SQUIDROUTER_RECOVERY_STATE_SWAP: "SQUIDROUTER_TRANSACTION_STATE_SWAP", - STELLAR_OPERATIONS: "STELLAR_OPERATIONS", - STELLAR_SEED: "STELLAR_SEED", TOKEN_BRIDGED_AMOUNT: "TOKEN_BRIDGED_AMOUNT" }; diff --git a/apps/frontend/src/contexts/events.tsx b/apps/frontend/src/contexts/events.tsx index 58903ee82..1e73f27b5 100644 --- a/apps/frontend/src/contexts/events.tsx +++ b/apps/frontend/src/contexts/events.tsx @@ -124,7 +124,6 @@ type InitializationErrorMessage = | "node_connection_issue" | "signer_service_issue" | "moonbeam_account_issue" - | "stellar_account_issue" | "pendulum_account_issue"; export type TrackableEvent = @@ -188,15 +187,12 @@ const useEvents = () => { } } - // Check if form error message has already been fired as we only want to fire each error message once if (event.event === "form_error") { const { error_message } = event; if (firedFormErrors.current.has(error_message)) { return; - } else { - // Add error message to fired form errors - firedFormErrors.current.add(error_message); } + firedFormErrors.current.add(error_message); } window.dataLayer.push(event); @@ -206,8 +202,8 @@ const useEvents = () => { trackedEventTypes.current = new Set(); }, []); - /// This function is used to schedule a quote returned by a quote service. Once all quotes are ready, it emits a compare_quote event. - /// Calling this function with a quote of '-1' will make the function emit the quote as undefined. + // Schedule a quote returned by a quote service. Once all quotes are ready, it emits a compare_quote event. + // A quote of '-1' is emitted as undefined. const schedulePrice = useCallback( (service: PriceProvider | "vortex", price: string, parameters: RampParameters, enableEventTracking: boolean) => { if (!enableEventTracking) return; diff --git a/apps/frontend/src/contexts/rampState.tsx b/apps/frontend/src/contexts/rampState.tsx index 9c3f210a0..4a441ee88 100644 --- a/apps/frontend/src/contexts/rampState.tsx +++ b/apps/frontend/src/contexts/rampState.tsx @@ -1,29 +1,66 @@ +import { EphemeralAccount } from "@vortexfi/shared"; import { createActorContext, useSelector } from "@xstate/react"; import React, { PropsWithChildren, useEffect } from "react"; -import { AlfredpayKycContext, AveniaKycContext, MoneriumKycContext, StellarKycContext } from "../machines/kyc.states"; +import { AnyActorRef, Snapshot } from "xstate"; +import { AlfredpayKycContext, AveniaKycContext, MykoboKycContext } from "../machines/kyc.states"; import { rampMachine } from "../machines/ramp.machine"; import { AlfredpayKycActorRef, - AlfredpayKycSnapshot, AveniaKycActorRef, - AveniaKycSnapshot, - MoneriumKycActorRef, - MoneriumKycSnapshot, - RampMachineSnapshot, + MykoboKycActorRef, SelectedAlfredpayData, SelectedAveniaData, - SelectedMoneriumData, - SelectedStellarData, - StellarKycActorRef, - StellarKycSnapshot + SelectedMykoboData } from "../machines/types"; +import { RampExecutionInput } from "../types/phases"; const RAMP_STATE_STORAGE_KEY = "rampState"; +const RAMP_EPHEMERALS_STORAGE_KEY = "rampEphemerals"; + +type RampEphemeralsMap = Record; + +export function updateRampEphemeral(rampId: string, ephemerals: RampExecutionInput["ephemerals"]): void { + try { + const existing = readRampEphemerals(); + existing[rampId] = ephemerals; + localStorage.setItem(RAMP_EPHEMERALS_STORAGE_KEY, JSON.stringify(existing)); + } catch { + // localStorage may be full or unavailable — non-critical backup + } +} + +export function readRampEphemerals(): RampEphemeralsMap { + try { + const raw = localStorage.getItem(RAMP_EPHEMERALS_STORAGE_KEY); + return raw ? JSON.parse(raw) : {}; + } catch { + return {}; + } +} + +export function removeRampEphemeral(rampId: string): void { + try { + const existing = readRampEphemerals(); + delete existing[rampId]; + localStorage.setItem(RAMP_EPHEMERALS_STORAGE_KEY, JSON.stringify(existing)); + } catch { + // non-critical + } +} + +function readPersistedRampState(): Snapshot | undefined { + try { + const raw = localStorage.getItem(RAMP_STATE_STORAGE_KEY); + if (!raw) return undefined; + const parsed = JSON.parse(raw); + return parsed?.status === "error" ? undefined : parsed; + } catch { + localStorage.removeItem(RAMP_STATE_STORAGE_KEY); + return undefined; + } +} -const restoredStateJSON = localStorage.getItem(RAMP_STATE_STORAGE_KEY); -let restoredState = restoredStateJSON ? JSON.parse(restoredStateJSON) : undefined; -// invalidate restored state if the machine is with error status. -restoredState = restoredState?.status === "error" ? undefined : restoredState; +const restoredState = readPersistedRampState(); export const RampStateContext = createActorContext(rampMachine, { snapshot: restoredState @@ -32,20 +69,31 @@ export const RampStateContext = createActorContext(rampMachine, { export const useRampActor = RampStateContext.useActorRef; export const useRampStateSelector = RampStateContext.useSelector; -const PersistenceEffect = () => { +type SelectableActorRef = Pick; +type ActorSnapshot = TActor extends { getSnapshot(): infer TSnapshot } ? TSnapshot : never; +type SelectedKycData = { stateValue: unknown; context: unknown }; + +function useKycChildActor(id: "aveniaKyc" | "mykoboKyc" | "alfredpayKyc"): T | undefined { const rampActor = useRampActor(); + return useSelector(rampActor, snapshot => (snapshot.children as Record)[id]) as T | undefined; +} - const stellarActor = useSelector(rampActor, (snapshot: RampMachineSnapshot) => (snapshot.children as any).stellarKyc) as - | StellarKycActorRef - | undefined; +function selectedKycDataEqual(prev: SelectedKycData | undefined, next: SelectedKycData | undefined) { + if (!prev || !next) return prev === next; + return prev.stateValue === next.stateValue && prev.context === next.context; +} - const moneriumActor = useSelector(rampActor, (snapshot: RampMachineSnapshot) => (snapshot.children as any).moneriumKyc) as - | MoneriumKycActorRef - | undefined; +function useKycChildSelector( + actor: TActor | undefined, + build: (snapshot: ActorSnapshot) => TSelected +): TSelected | undefined { + return useSelector(actor, snapshot => (snapshot === undefined ? undefined : build(snapshot)), selectedKycDataEqual); +} - const aveniaActor = useSelector(rampActor, (snapshot: RampMachineSnapshot) => (snapshot.children as any).aveniaKyc) as - | AveniaKycActorRef - | undefined; +const PersistenceEffect = () => { + const rampActor = useRampActor(); + const aveniaActor = useKycChildActor("aveniaKyc"); + const mykoboActor = useKycChildActor("mykoboKyc"); const { rampContext, rampState, isQuoteExpired, quote } = useSelector(rampActor, state => ({ isQuoteExpired: state?.context.isQuoteExpired, @@ -54,24 +102,20 @@ const PersistenceEffect = () => { rampState: state?.value })); - const { moneriumState } = useSelector(moneriumActor, state => ({ - moneriumState: state?.value - })); - - const { stellarState } = useSelector(stellarActor, state => ({ - stellarState: state?.value - })); + const aveniaState = useSelector(aveniaActor, state => state?.value); + const mykoboState = useSelector(mykoboActor, state => state?.value); - const { aveniaState } = useSelector(aveniaActor, state => ({ - aveniaState: state?.value - })); - - // biome-ignore lint/correctness/useExhaustiveDependencies: + // biome-ignore lint/correctness/useExhaustiveDependencies: run when selected snapshot pieces change; isQuoteExpired/quote must persist useEffect(() => { const persistedSnapshot = rampActor.getPersistedSnapshot(); - localStorage.setItem("rampState", JSON.stringify(persistedSnapshot)); - // It's important to have `isQuoteExpired` and `quote` here in the deps array to persist them when they change - }, [rampContext, rampState, moneriumState, stellarState, aveniaState, isQuoteExpired, quote, rampActor.getPersistedSnapshot]); + localStorage.setItem(RAMP_STATE_STORAGE_KEY, JSON.stringify(persistedSnapshot)); + + const rampId = rampContext.rampState?.ramp?.id; + const ephemerals = (rampContext.executionInput as RampExecutionInput | undefined)?.ephemerals; + if (rampId && ephemerals) { + updateRampEphemeral(rampId, ephemerals); + } + }, [rampContext, rampState, aveniaState, mykoboState, isQuoteExpired, quote, rampActor.getPersistedSnapshot]); return null; }; @@ -85,142 +129,38 @@ export const PersistentRampStateProvider: React.FC = ({ child ); }; -export function useStellarKycActor(): StellarKycActorRef | undefined { - const rampActor = useRampActor(); - - return useSelector(rampActor, (snapshot: RampMachineSnapshot) => (snapshot.children as any).stellarKyc) as - | StellarKycActorRef - | undefined; -} - -export function useStellarKycSelector(): SelectedStellarData | undefined { - const rampActor = useRampActor(); - - const stellarActor = useSelector(rampActor, (snapshot: RampMachineSnapshot) => (snapshot.children as any).stellarKyc) as - | StellarKycActorRef - | undefined; - - return useSelector( - stellarActor, - (snapshot: StellarKycSnapshot | undefined) => { - if (!snapshot) { - return undefined; - } - return { - context: snapshot.context as StellarKycContext, - stateValue: snapshot.value - }; - }, - (prev, next) => { - if (!prev || !next) { - return prev === next; - } - return prev.stateValue === next.stateValue && prev.context === next.context; - } - ); -} - -export function useMoneriumKycActor(): MoneriumKycActorRef | undefined { - const rampActor = useRampActor(); - - return useSelector(rampActor, (snapshot: RampMachineSnapshot) => (snapshot.children as any).moneriumKyc) as - | MoneriumKycActorRef - | undefined; +export function useAveniaKycActor(): AveniaKycActorRef | undefined { + return useKycChildActor("aveniaKyc"); } -export function useMoneriumKycSelector(): SelectedMoneriumData | undefined { - const rampActor = useRampActor(); - - const moneriumActor = useSelector(rampActor, (snapshot: RampMachineSnapshot) => (snapshot.children as any).moneriumKyc) as - | MoneriumKycActorRef - | undefined; - - return useSelector( - moneriumActor, - (snapshot: MoneriumKycSnapshot | undefined) => { - if (!snapshot) { - return undefined; - } - return { - context: snapshot.context as MoneriumKycContext, - stateValue: snapshot.value - }; - }, - (prev, next) => { - if (!prev || !next) { - return prev === next; - } - return prev.stateValue === next.stateValue && prev.context === next.context; - } - ); +export function useAveniaKycSelector(): SelectedAveniaData | undefined { + const actor = useAveniaKycActor(); + return useKycChildSelector(actor, snapshot => ({ + context: snapshot.context as AveniaKycContext, + stateValue: snapshot.value + })); } -export function useAveniaKycActor(): AveniaKycActorRef | undefined { - const rampActor = useRampActor(); - - return useSelector(rampActor, (snapshot: RampMachineSnapshot) => (snapshot.children as any).aveniaKyc) as - | AveniaKycActorRef - | undefined; +export function useMykoboKycActor(): MykoboKycActorRef | undefined { + return useKycChildActor("mykoboKyc"); } -export function useAveniaKycSelector(): SelectedAveniaData | undefined { - const rampActor = useRampActor(); - - const aveniaActor = useSelector(rampActor, (snapshot: RampMachineSnapshot) => (snapshot.children as any).aveniaKyc) as - | AveniaKycActorRef - | undefined; - - return useSelector( - aveniaActor, - (snapshot: AveniaKycSnapshot | undefined) => { - if (!snapshot) { - return undefined; - } - return { - context: snapshot.context as AveniaKycContext, - stateValue: snapshot.value - }; - }, - (prev, next) => { - if (!prev || !next) { - return prev === next; - } - return prev.stateValue === next.stateValue && prev.context === next.context; - } - ); +export function useMykoboKycSelector(): SelectedMykoboData | undefined { + const actor = useMykoboKycActor(); + return useKycChildSelector(actor, snapshot => ({ + context: snapshot.context as MykoboKycContext, + stateValue: snapshot.value + })); } export function useAlfredpayKycActor(): AlfredpayKycActorRef | undefined { - const rampActor = useRampActor(); - - return useSelector(rampActor, (snapshot: RampMachineSnapshot) => (snapshot.children as any).alfredpayKyc) as - | AlfredpayKycActorRef - | undefined; + return useKycChildActor("alfredpayKyc"); } export function useAlfredpayKycSelector(): SelectedAlfredpayData | undefined { - const rampActor = useRampActor(); - - const alfredpayActor = useSelector(rampActor, (snapshot: RampMachineSnapshot) => (snapshot.children as any).alfredpayKyc) as - | AlfredpayKycActorRef - | undefined; - - return useSelector( - alfredpayActor, - (snapshot: AlfredpayKycSnapshot | undefined) => { - if (!snapshot) { - return undefined; - } - return { - context: snapshot.context as AlfredpayKycContext, - stateValue: snapshot.value - }; - }, - (prev, next) => { - if (!prev || !next) { - return prev === next; - } - return prev.stateValue === next.stateValue && prev.context === next.context; - } - ); + const actor = useAlfredpayKycActor(); + return useKycChildSelector(actor, snapshot => ({ + context: snapshot.context as AlfredpayKycContext, + stateValue: snapshot.value + })); } diff --git a/apps/frontend/src/helpers/crypto.ts b/apps/frontend/src/helpers/crypto.ts index 897889234..f0d42e3ff 100644 --- a/apps/frontend/src/helpers/crypto.ts +++ b/apps/frontend/src/helpers/crypto.ts @@ -1,4 +1,4 @@ -import { multiplyByPowerOfTen, PermitSignature } from "@vortexfi/shared"; +import { multiplyByPowerOfTen } from "@vortexfi/shared"; import { getAccount, readContract, signTypedData, switchChain } from "@wagmi/core"; import { wagmiConfig } from "../wagmiConfig"; @@ -10,7 +10,7 @@ export async function signERC2612Permit( decimals: number, chainId: number, tokenName: string -): Promise { +): Promise<{ r: `0x${string}`; s: `0x${string}`; v: number; deadline: number }> { const account = getAccount(wagmiConfig); const originalChainId = account.chainId; @@ -80,23 +80,7 @@ export async function signERC2612Permit( const r = `0x${signature.slice(2, 66)}` as `0x${string}`; const s = `0x${signature.slice(66, 130)}` as `0x${string}`; - return { - context: { - chainId, - deadline: deadline.toString(), - nonce: nonce.toString(), - owner, - spender, - tokenAddress, - tokenName, - tokenVersion: "1", - valueRaw: value.toFixed(0, 0) - }, - deadline: Number(deadline), - r, - s, - v - }; + return { deadline: Number(deadline), r, s, v }; } catch (error) { throw new Error("Failed to sign ERC2612 permit: " + error); } finally { diff --git a/apps/frontend/src/hooks/monerium/useMoneriumFlow.ts b/apps/frontend/src/hooks/monerium/useMoneriumFlow.ts deleted file mode 100644 index 022c35fd7..000000000 --- a/apps/frontend/src/hooks/monerium/useMoneriumFlow.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useRouter, useRouterState } from "@tanstack/react-router"; -import { useEffect, useRef } from "react"; -import { useMoneriumKycActor, useMoneriumKycSelector } from "../../contexts/rampState"; - -/** - * Hook to manage Monerium's KYC state machine self-transitions for authentication flow. - */ -export const useMoneriumFlow = () => { - const moneriumKycActor = useMoneriumKycActor(); - const moneriumState = useMoneriumKycSelector(); - const router = useRouter(); - const routerState = useRouterState(); - - const codeProcessedRef = useRef(false); - - useEffect(() => { - if (!moneriumKycActor || !moneriumState || codeProcessedRef.current) { - return; - } - - const searchParams = routerState.location.search as { code?: string }; - const code = searchParams?.code; - - if (code && moneriumState.stateValue === "Redirect") { - codeProcessedRef.current = true; - moneriumKycActor.send({ code, type: "CODE_RECEIVED" }); - // Remove the code parameter from URL - router.navigate({ - replace: true, - search: {}, - to: routerState.location.pathname - }); - } - }, [moneriumKycActor, moneriumState, router, routerState]); -}; diff --git a/apps/frontend/src/hooks/offramp/useRampService/useRegisterRamp/helpers.ts b/apps/frontend/src/hooks/offramp/useRampService/useRegisterRamp/helpers.ts index 96b828e91..ebbcdfeb4 100644 --- a/apps/frontend/src/hooks/offramp/useRampService/useRegisterRamp/helpers.ts +++ b/apps/frontend/src/hooks/offramp/useRampService/useRegisterRamp/helpers.ts @@ -1,4 +1,4 @@ -import { MoneriumErrors, QuoteError } from "@vortexfi/shared"; +import { QuoteError } from "@vortexfi/shared"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; @@ -27,7 +27,6 @@ export const useSignatureTrace = (traceKey: string) => { }; const RampRegistrationErrorMessages = { - [MoneriumErrors.USER_MINT_ADDRESS_NOT_FOUND]: "hooks.useGetRampRegistrationErrorMessage.userMintAddressNotFound", [QuoteError.QuoteNotFound]: "hooks.useGetRampRegistrationErrorMessage.quoteNotFound" }; @@ -37,12 +36,6 @@ export const useGetRampRegistrationErrorMessage = () => { return useCallback( (error: unknown): string | undefined => { if (error instanceof Error) { - if (error.message?.includes(MoneriumErrors.USER_MINT_ADDRESS_NOT_FOUND)) { - return t( - RampRegistrationErrorMessages[MoneriumErrors.USER_MINT_ADDRESS_NOT_FOUND] || - "hooks.useGetRampRegistrationErrorMessage.default" - ); - } if (error.message?.includes(QuoteError.QuoteNotFound)) { return t( RampRegistrationErrorMessages[QuoteError.QuoteNotFound] || "hooks.useGetRampRegistrationErrorMessage.default" diff --git a/apps/frontend/src/hooks/offramp/useSEP24/useTrackSEP24Events.ts b/apps/frontend/src/hooks/offramp/useSEP24/useTrackSEP24Events.ts deleted file mode 100644 index 10ca0a4c1..000000000 --- a/apps/frontend/src/hooks/offramp/useSEP24/useTrackSEP24Events.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { getAnyFiatTokenDetails, getOnChainTokenDetailsOrDefault, Networks } from "@vortexfi/shared"; -import { createTransactionEvent, useEventsContext } from "../../../contexts/events"; -import { RampExecutionInput, RampState } from "../../../types/phases"; - -export const useTrackSEP24Events = () => { - const { trackEvent } = useEventsContext(); - - const trackKYCStarted = (executionInput: RampExecutionInput, selectedNetwork: Networks) => { - trackEvent({ - event: "kyc_started", - from_amount: executionInput.quote.inputAmount, - from_asset: getOnChainTokenDetailsOrDefault(selectedNetwork, executionInput.onChainToken).assetSymbol, - to_amount: executionInput.quote.outputAmount, - to_asset: getAnyFiatTokenDetails(executionInput.fiatToken).fiat.symbol - }); - }; - - const trackKYCCompleted = (initialState: RampState) => { - trackEvent(createTransactionEvent("kyc_completed", initialState)); - }; - - return { trackKYCCompleted, trackKYCStarted }; -}; diff --git a/apps/frontend/src/hooks/ramp/schema.ts b/apps/frontend/src/hooks/ramp/schema.ts index ce4fb53b6..d8b824da1 100644 --- a/apps/frontend/src/hooks/ramp/schema.ts +++ b/apps/frontend/src/hooks/ramp/schema.ts @@ -10,7 +10,6 @@ export type RampFormValues = { taxId?: string; pixId?: string; walletAddress?: string; - moneriumWalletAddress?: string; fiatToken?: FiatToken; }; @@ -26,12 +25,9 @@ const evmAddressSchema = z.string().regex(/^(0x)?[0-9a-f]{40}$/i); const isValidPolkadotAddress = (address: string) => { try { - const result = encodeAddress(isHex(address) ? hexToU8a(address) : decodeAddress(address)); - - console.log("Valid address:", address, "->", result); + encodeAddress(isHex(address) ? hexToU8a(address) : decodeAddress(address)); return true; - } catch (_error) { - console.error("Invalid address:", address, _error); + } catch { return false; } }; @@ -44,7 +40,6 @@ export const createRampFormSchema = ( return z .object({ fiatToken: z.string().optional() as z.ZodType, - moneriumWalletAddress: z.string().optional(), pixId: z.string().optional(), taxId: z.string().optional(), walletAddress: z.string().optional() diff --git a/apps/frontend/src/hooks/ramp/useRampSubmission.ts b/apps/frontend/src/hooks/ramp/useRampSubmission.ts index 074149cb3..a143b65c2 100644 --- a/apps/frontend/src/hooks/ramp/useRampSubmission.ts +++ b/apps/frontend/src/hooks/ramp/useRampSubmission.ts @@ -1,15 +1,6 @@ -import { - createMoonbeamEphemeral, - createPendulumEphemeral, - createStellarEphemeral, - FiatToken, - getNetworkId, - Networks, - RampDirection -} from "@vortexfi/shared"; +import { createMoonbeamEphemeral, createPendulumEphemeral, FiatToken, getNetworkId, Networks } from "@vortexfi/shared"; import { useSelector } from "@xstate/react"; import { useCallback, useState } from "react"; -import { useAccount } from "wagmi"; import { useEventsContext } from "../../contexts/events"; import { useRampActor } from "../../contexts/rampState"; import { usePreRampCheck } from "../../services/initialChecks"; @@ -26,7 +17,6 @@ interface SubmissionError extends Error { const createEphemerals = async () => { return { evmEphemeral: createMoonbeamEphemeral(), - stellarEphemeral: createStellarEphemeral(), substrateEphemeral: await createPendulumEphemeral() }; }; @@ -46,8 +36,6 @@ export const useRampSubmission = () => { const storeQuote = useQuote(); const quote = contextQuote || storeQuote; - const { address: connectedEvmAddress } = useAccount(); - const { inputAmount, fiatToken, onChainToken } = useQuoteFormStore(); const network = quote ? ((Object.values(Networks).includes(quote.to as Networks) ? quote.to : quote.from) as Networks) @@ -79,7 +67,7 @@ export const useRampSubmission = () => { ); const prepareExecutionInput = useCallback( - async (data: { pixId?: string; taxId?: string; walletAddress?: string; moneriumWalletAddress?: string }) => { + async (data: { pixId?: string; taxId?: string; walletAddress?: string }) => { validateSubmissionData(data); if (!quote) { throw new Error("No quote available. Please try again."); @@ -93,20 +81,10 @@ export const useRampSubmission = () => { } const ephemerals = await createEphemerals(); - // For EUR (Monerium) onramps the moneriumWalletAddress is the user's connected EVM wallet. - // Callers that don't pass it explicitly (e.g. the Onramp / RampSubmitButton flows) would - // otherwise leave it undefined and the API rejects the registerRamp request. - const isMoneriumOnramp = quote.rampType === RampDirection.BUY && fiatToken === FiatToken.EURC; - const moneriumWalletAddress = data.moneriumWalletAddress ?? (isMoneriumOnramp ? connectedEvmAddress : undefined); - - if (isMoneriumOnramp && !moneriumWalletAddress) { - throw new Error("No Monerium wallet address found. Please connect an EVM wallet or provide a Monerium wallet address."); - } const executionInput: RampExecutionInput = { ephemerals, fiatToken, - moneriumWalletAddress, network, onChainToken, pixId: data.pixId, @@ -119,7 +97,7 @@ export const useRampSubmission = () => { }; return executionInput; }, - [validateSubmissionData, quote, onChainToken, fiatToken, connectedWalletAddress, network, connectedEvmAddress] + [validateSubmissionData, quote, onChainToken, fiatToken, connectedWalletAddress, network] ); const handleSubmissionError = useCallback( @@ -139,12 +117,11 @@ export const useRampSubmission = () => { ); const onRampConfirm = useCallback( - async (data: { pixId?: string; taxId?: string; walletAddress?: string; moneriumWalletAddress?: string } = {}) => { + async (data: { pixId?: string; taxId?: string; walletAddress?: string } = {}) => { if (executionPreparing) return; setExecutionPreparing(true); try { - console.log("DEBUG: Ramp Submission Data: ", data); const executionInput = await prepareExecutionInput(data); // This callback is generic and used for any ramp type. @@ -161,7 +138,6 @@ export const useRampSubmission = () => { if (chainId === undefined) { throw new Error("ChainId must be defined at this stage"); } - console.log("DEBUG: Ramp Execution Input: ", { input: { chainId, executionInput, rampDirection } }); rampActor.send({ input: { chainId, executionInput, rampDirection }, type: "CONFIRM" }); } catch (error) { handleSubmissionError(error as SubmissionError); diff --git a/apps/frontend/src/hooks/ramp/useRampValidation.ts b/apps/frontend/src/hooks/ramp/useRampValidation.ts index 39db69077..469f0397c 100644 --- a/apps/frontend/src/hooks/ramp/useRampValidation.ts +++ b/apps/frontend/src/hooks/ramp/useRampValidation.ts @@ -17,7 +17,7 @@ import { config } from "../../config"; import { getTokenDisabledReason, isFiatTokenDisabled } from "../../config/tokenAvailability"; import { TrackableEvent, useEventsContext } from "../../contexts/events"; import { useNetwork } from "../../contexts/network"; -import { multiplyByPowerOfTen, stringifyBigWithSignificantDecimals } from "../../helpers/contracts"; +import { multiplyByPowerOfTen } from "../../helpers/contracts"; import { getEvmTokenConfig } from "../../services/tokens"; import { useQuoteFormStore } from "../../stores/quote/useQuoteFormStore"; import { useQuote, useQuoteError, useQuoteLoading } from "../../stores/quote/useQuoteStore"; @@ -25,17 +25,60 @@ import { useRampDirection } from "../../stores/rampDirectionStore"; import { useOnchainTokenBalance } from "../useOnchainTokenBalance"; import { useVortexAccount } from "../useVortexAccount"; +function formatLimitAmount(amount: Big, locale: string): string { + return amount.toNumber().toLocaleString(locale, { maximumFractionDigits: 2 }); +} + +// Backend limit errors carry the value in the suffix, e.g. "...limit of 10000.00 EUR". +const BACKEND_LIMIT_VALUE_REGEX = /of\s+(\d+(?:\.\d+)?)\s+([A-Z]{3})/; + +function extractBackendLimit(error: string, locale: string): { amount: string; symbol: string } | null { + const match = error.match(BACKEND_LIMIT_VALUE_REGEX); + if (!match) return null; + return { amount: formatLimitAmount(new Big(match[1]), locale), symbol: match[2] }; +} + +type LimitKind = "min" | "max"; +type LimitDirection = "buy" | "sell"; + +function buildLimitMessage( + t: TFunction<"translation", undefined>, + args: { + kind: LimitKind; + direction: LimitDirection; + fallbackRawAmount: string; + fallbackDecimals: number; + fallbackSymbol: string; + locale: string; + quoteError?: string | null; + } +): string { + const { kind, direction, fallbackRawAmount, fallbackDecimals, fallbackSymbol, locale, quoteError } = args; + const parsed = quoteError ? extractBackendLimit(quoteError, locale) : null; + const key = + kind === "min" + ? `pages.swap.error.lessThanMinimumWithdrawal.${direction}` + : `pages.swap.error.moreThanMaximumWithdrawal.${direction}`; + const valueField = kind === "min" ? "minAmountUnits" : "maxAmountUnits"; + return t(key, { + assetSymbol: parsed?.symbol ?? fallbackSymbol, + [valueField]: parsed?.amount ?? formatLimitAmount(multiplyByPowerOfTen(Big(fallbackRawAmount), -fallbackDecimals), locale) + }); +} + function validateOnramp( t: TFunction<"translation", undefined>, { inputAmount, fromToken, limits, + locale, trackEvent }: { inputAmount: Big; fromToken: FiatTokenDetails; limits?: AmountLimits; + locale: string; trackEvent: (event: TrackableEvent) => void; } ): string | null { @@ -55,11 +98,11 @@ function validateOnramp( event: "form_error", input_amount: inputAmount.toString() }); - const key = isTooHigh ? "pages.swap.error.amountOutOfRange.buyTooHigh" : "pages.swap.error.amountOutOfRange.buyTooLow"; + const key = isTooHigh ? "pages.swap.error.moreThanMaximumWithdrawal.buy" : "pages.swap.error.lessThanMinimumWithdrawal.buy"; return t(key, { assetSymbol: fromToken.fiat.symbol, - maxAmountUnits: stringifyBigWithSignificantDecimals(maxAmountUnits, 0), - minAmountUnits: stringifyBigWithSignificantDecimals(minAmountUnits, 0) + maxAmountUnits: formatLimitAmount(maxAmountUnits, locale), + minAmountUnits: formatLimitAmount(minAmountUnits, locale) }); } @@ -74,6 +117,7 @@ function validateOfframp( toToken, quote, limits, + locale, userInputTokenBalance, isDisconnected, trackEvent @@ -83,6 +127,7 @@ function validateOfframp( toToken: FiatTokenDetails; quote: QuoteResponse; limits?: AmountLimits; + locale: string; userInputTokenBalance: string | null; isDisconnected: boolean; trackEvent: (event: TrackableEvent) => void; @@ -114,11 +159,13 @@ function validateOfframp( event: "form_error", input_amount: inputAmount.toString() }); - const key = isTooHigh ? "pages.swap.error.amountOutOfRange.sellTooHigh" : "pages.swap.error.amountOutOfRange.sellTooLow"; + const key = isTooHigh + ? "pages.swap.error.moreThanMaximumWithdrawal.sell" + : "pages.swap.error.lessThanMinimumWithdrawal.sell"; return t(key, { assetSymbol: unitSymbol, - maxAmountUnits: stringifyBigWithSignificantDecimals(maxAmountUnits, 0), - minAmountUnits: stringifyBigWithSignificantDecimals(minAmountUnits, 0) + maxAmountUnits: formatLimitAmount(maxAmountUnits, locale), + minAmountUnits: formatLimitAmount(minAmountUnits, locale) }); } @@ -160,7 +207,8 @@ function validateTokenAvailability( } export const useRampValidation = () => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const locale = i18n.language; const { inputAmount: inputAmountString, onChainToken, fiatToken } = useQuoteFormStore(); const quote = useQuote(); @@ -198,21 +246,51 @@ export const useRampValidation = () => { const fiatTokenDetails = getAnyFiatTokenDetails(fiatToken); - if (quoteError?.includes(QuoteError.BelowLowerLimitSell)) { - const maxAmountUnits = multiplyByPowerOfTen(Big(fiatTokenDetails.maxSellAmountRaw), -toToken.decimals); - const minAmountUnits = multiplyByPowerOfTen(Big(fiatTokenDetails.minSellAmountRaw), -toToken.decimals); - return t("pages.swap.error.amountOutOfRange.sellTooLow", { - assetSymbol: toToken.assetSymbol, - maxAmountUnits: stringifyBigWithSignificantDecimals(maxAmountUnits, 0), - minAmountUnits: stringifyBigWithSignificantDecimals(minAmountUnits, 0) + const isBelowSellLimit = quoteError?.includes(QuoteError.BelowLowerLimitSell); + const isBelowBuyLimit = + quoteError?.includes(QuoteError.BelowLowerLimitBuy) || quoteError?.includes(QuoteError.InputAmountTooLow); + const isAboveSellLimit = quoteError?.includes(QuoteError.AboveUpperLimitSell); + const isAboveBuyLimit = quoteError?.includes(QuoteError.AboveUpperLimitBuy); + + if (isBelowSellLimit) { + return buildLimitMessage(t, { + direction: "sell", + fallbackDecimals: toToken.decimals, + fallbackRawAmount: fiatTokenDetails.minSellAmountRaw, + fallbackSymbol: toToken.assetSymbol, + kind: "min", + locale, + quoteError + }); + } else if (isBelowBuyLimit) { + return buildLimitMessage(t, { + direction: "buy", + fallbackDecimals: fromToken.decimals, + fallbackRawAmount: fiatTokenDetails.minBuyAmountRaw, + fallbackSymbol: fromToken.assetSymbol, + kind: "min", + locale, + quoteError + }); + } else if (isAboveSellLimit) { + return buildLimitMessage(t, { + direction: "sell", + fallbackDecimals: toToken.decimals, + fallbackRawAmount: fiatTokenDetails.maxSellAmountRaw, + fallbackSymbol: toToken.assetSymbol, + kind: "max", + locale, + quoteError }); - } else if (quoteError?.includes(QuoteError.BelowLowerLimitBuy) || quoteError?.includes(QuoteError.InputAmountTooLow)) { - const maxAmountUnits = multiplyByPowerOfTen(Big(fiatTokenDetails.maxBuyAmountRaw), -fromToken.decimals); - const minAmountUnits = multiplyByPowerOfTen(Big(fiatTokenDetails.minBuyAmountRaw), -fromToken.decimals); - return t("pages.swap.error.amountOutOfRange.buyTooLow", { - assetSymbol: fromToken.assetSymbol, - maxAmountUnits: stringifyBigWithSignificantDecimals(maxAmountUnits, 0), - minAmountUnits: stringifyBigWithSignificantDecimals(minAmountUnits, 0) + } else if (isAboveBuyLimit) { + return buildLimitMessage(t, { + direction: "buy", + fallbackDecimals: fromToken.decimals, + fallbackRawAmount: fiatTokenDetails.maxBuyAmountRaw, + fallbackSymbol: fromToken.assetSymbol, + kind: "max", + locale, + quoteError }); } else if (quoteError) return t(quoteError); @@ -223,6 +301,7 @@ export const useRampValidation = () => { fromToken: fromToken as FiatTokenDetails, inputAmount, limits, + locale, trackEvent }); } else { @@ -231,6 +310,7 @@ export const useRampValidation = () => { inputAmount, isDisconnected, limits, + locale, quote: quote as QuoteResponse, toToken: toToken as FiatTokenDetails, trackEvent, @@ -246,6 +326,7 @@ export const useRampValidation = () => { isDisconnected, isOnramp, t, + locale, inputAmount, fromToken, trackEvent, diff --git a/apps/frontend/src/hooks/useLocalStorage.ts b/apps/frontend/src/hooks/useLocalStorage.ts index e5530ba70..35c7cf665 100644 --- a/apps/frontend/src/hooks/useLocalStorage.ts +++ b/apps/frontend/src/hooks/useLocalStorage.ts @@ -13,8 +13,7 @@ export enum LocalStorageKeys { TERMS_AND_CONDITIONS = "TERMS_AND_CONDITIONS", RAMPING_STATE = "RAMPING_STATE", REGISTER_KEY_LOCAL_STORAGE = "rampRegisterKey", - START_KEY_LOCAL_STORAGE = "rampStartKey", - MONERIUM_STATE = "MONERIUM_STATE" + START_KEY_LOCAL_STORAGE = "rampStartKey" } export const debounce = (func: (...args: T) => void, timeout = 300) => { @@ -59,7 +58,7 @@ export interface UseLocalStorageResponse { const hasExpired = (timestamp: number, expiredMillis?: number) => { if (expiredMillis === undefined) return false; - return Date.now() < timestamp + expiredMillis; + return Date.now() > timestamp + expiredMillis; }; const getState = (key: string, defaultValue: T, parse: boolean, expire?: number): T => { diff --git a/apps/frontend/src/hooks/useRampUrlParams.ts b/apps/frontend/src/hooks/useRampUrlParams.ts index c68879fd1..ab02e1d84 100644 --- a/apps/frontend/src/hooks/useRampUrlParams.ts +++ b/apps/frontend/src/hooks/useRampUrlParams.ts @@ -40,7 +40,6 @@ interface RampUrlParams { apiKey?: string; partnerId?: string; providedQuoteId?: string; - moneriumCode?: string; fiat?: FiatToken; countryCode?: string; cryptoLocked?: OnChainTokenSymbol; @@ -170,7 +169,6 @@ export enum RampUrlParamsKeys { CALLBACK_URL = "callbackUrl", EXTERNAL_SESSION_ID = "externalSessionId", COUNTRY_CODE = "countryCode", - MONERIUM_CODE = "code", PROVIDED_QUOTE_ID = "quoteId" } @@ -186,7 +184,6 @@ export const useRampUrlParams = (): RampUrlParams => { const cryptoLockedParam = searchParams.cryptoLocked?.toUpperCase(); const countryCodeParam = searchParams.countryCode?.toUpperCase(); - const moneriumCode = searchParams.code?.toLowerCase(); const networkParam = searchParams.network?.toLowerCase(); const providedQuoteId = searchParams.quoteId?.toLowerCase(); const paymentMethodParam = searchParams.paymentMethod?.toLowerCase() as PaymentMethod | undefined; @@ -218,7 +215,6 @@ export const useRampUrlParams = (): RampUrlParams => { externalSessionId: externalSessionIdParam || undefined, fiat, inputAmount: inputAmountParam || undefined, - moneriumCode, network, partnerId: partnerIdParam || undefined, paymentMethod: paymentMethodParam || undefined, @@ -246,8 +242,7 @@ export const useSetRampUrlParams = () => { paymentMethod, walletLocked, callbackUrl, - externalSessionId, - moneriumCode + externalSessionId } = useRampUrlParams(); const onToggle = useRampDirectionToggle(); @@ -384,13 +379,6 @@ export const useSetRampUrlParams = () => { setApiKeyFn(null); } - const persistState = moneriumCode !== undefined; - - if (persistState) { - hasInitialized.current = true; - return; - } - resetRampForm(); onToggle(rampDirection); @@ -422,8 +410,7 @@ export const useSetRampUrlParams = () => { setApiKeyFn, setPartnerIdFn, onToggle, - handleFiatToken, - moneriumCode + handleFiatToken ]); useEffect(() => { diff --git a/apps/frontend/src/hooks/useSignChallenge.ts b/apps/frontend/src/hooks/useSignChallenge.ts deleted file mode 100644 index cb220d316..000000000 --- a/apps/frontend/src/hooks/useSignChallenge.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import { ActorRefFrom } from "xstate"; -import { DEFAULT_LOGIN_EXPIRATION_TIME_HOURS, SIGNING_SERVICE_URL } from "../constants/constants"; -import { storageKeys } from "../constants/localStorage"; -import { SignInMessage } from "../helpers/siweMessageFormatter"; -import { stellarKycMachine } from "../machines/stellarKyc.machine"; -import { useVortexAccount } from "./useVortexAccount"; - -export interface SiweSignatureData { - signatureSet: boolean; - expirationDate: string; -} - -function createSiweMessage(address: string, nonce: string) { - const siweMessage = new SignInMessage({ - address: address, - domain: window.location.host, - expirationTime: new Date(Date.now() + DEFAULT_LOGIN_EXPIRATION_TIME_HOURS * 60 * 60 * 1000).getTime(), // Constructor in ms. - nonce, - scheme: "https" - }); - - return siweMessage.toMessage(); -} - -export function useSiweSignature(stellarKycActor: ActorRefFrom | undefined) { - const { address, getMessageSignature } = useVortexAccount(); - const [isSigning, setIsSigning] = useState(false); - - const storageKey = `${storageKeys.SIWE_SIGNATURE_KEY_PREFIX}${address}`; - - const checkAuthStatus = useCallback(async () => { - if (!stellarKycActor) return; - if (!address) { - stellarKycActor.send({ type: "AUTH_INVALID" }); - return; - } - - const stored = localStorage.getItem(storageKey); - if (!stored) { - stellarKycActor.send({ type: "AUTH_INVALID" }); - return; - } - - const data: SiweSignatureData = JSON.parse(stored); - if (new Date(data.expirationDate) <= new Date()) { - localStorage.removeItem(storageKey); - stellarKycActor.send({ type: "AUTH_INVALID" }); - return; - } - - try { - const authCheckResponse = await fetch(`${SIGNING_SERVICE_URL}/v1/siwe/check`, { - credentials: "include" - }); - - if (authCheckResponse.ok) { - stellarKycActor.send({ type: "AUTH_VALID" }); - } else { - localStorage.removeItem(storageKey); - stellarKycActor.send({ type: "AUTH_INVALID" }); - } - } catch (error) { - console.error("Auth check failed:", error); - stellarKycActor.send({ error: "Failed to check auth status.", type: "SIGNATURE_FAILURE" }); - } - }, [address, storageKey, stellarKycActor]); - - const promptForSignature = useCallback(async () => { - if (!address || isSigning || !stellarKycActor) return; - setIsSigning(true); - - try { - const messageResponse = await fetch(`${SIGNING_SERVICE_URL}/v1/siwe/create`, { - body: JSON.stringify({ walletAddress: address }), - credentials: "include", - headers: { "Content-Type": "application/json" }, - method: "POST" - }); - - if (!messageResponse.ok) throw new Error("Failed to create message"); - const { nonce } = await messageResponse.json(); - - const siweMessage = createSiweMessage(address, nonce); - const message = SignInMessage.fromMessage(siweMessage); - const signature = await getMessageSignature(siweMessage); - - const validationResponse = await fetch(`${SIGNING_SERVICE_URL}/v1/siwe/validate`, { - body: JSON.stringify({ nonce, signature, siweMessage }), - credentials: "include", - headers: { "Content-Type": "application/json" }, - method: "POST" - }); - - if (!validationResponse.ok) throw new Error("Failed to validate signature"); - - const signatureData: SiweSignatureData = { - expirationDate: message.expirationTime!, - signatureSet: true - }; - - localStorage.setItem(storageKey, JSON.stringify(signatureData)); - stellarKycActor.send({ type: "SIGNATURE_SUCCESS" }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("User rejected") || errorMessage.includes("Cancelled")) { - stellarKycActor.send({ error: "User rejected signing request.", type: "SIGNATURE_FAILURE" }); - } else { - stellarKycActor.send({ error: "Failed to sign login challenge. " + errorMessage, type: "SIGNATURE_FAILURE" }); - } - } finally { - setIsSigning(false); - } - }, [address, isSigning, getMessageSignature, storageKey, stellarKycActor]); - - useEffect(() => { - if (!stellarKycActor) return; - // We react to the different state changes of the stellarKycActor. - stellarKycActor.on("CHECK_AUTH_STATUS", _event => { - checkAuthStatus(); - }); - - stellarKycActor.on("PROMPT_FOR_SIGNATURE", _event => { - promptForSignature(); - }); - - stellarKycActor.send({ type: "SIWE_READY" }); - }, [stellarKycActor, checkAuthStatus, promptForSignature]); - - return {}; -} diff --git a/apps/frontend/src/hooks/useSigningBoxState.ts b/apps/frontend/src/hooks/useSigningBoxState.ts index 7b3642eb1..d9a8e7045 100644 --- a/apps/frontend/src/hooks/useSigningBoxState.ts +++ b/apps/frontend/src/hooks/useSigningBoxState.ts @@ -23,12 +23,10 @@ const PROGRESS_CONFIGS: Record<"EVM" | "NON_EVM", Record { - if (!isEVM) return { current: 1, max: 1 }; - if (step === "login") return { current: 1, max: 1 }; - if (step === "started") return { current: 1, max: 2 }; - return { current: 2, max: 2 }; -}; +const getSignatureDetails = (current: number | undefined, max: number | undefined) => ({ + current: current ?? 0, + max: max ?? 0 +}); const isValidStep = (step: RampSigningPhase | undefined, isEVM: boolean): step is RampSigningPhase => { if (!step) return false; @@ -39,7 +37,9 @@ const isValidStep = (step: RampSigningPhase | undefined, isEVM: boolean): step i export const useSigningBoxState = (autoHideDelay = 2500, displayDelay = 100) => { const rampActor = useRampActor(); - const { step } = useSelector(rampActor, state => ({ + const { step, current, max } = useSelector(rampActor, state => ({ + current: state.context.rampSigningPhaseCurrent, + max: state.context.rampSigningPhaseMax, step: state.context.rampSigningPhase })); const { selectedNetwork } = useNetwork(); @@ -75,8 +75,8 @@ export const useSigningBoxState = (autoHideDelay = 2500, displayDelay = 100) => } setProgress(progressConfig[step]); - setSignatureState(getSignatureDetails(step, isEVM)); - }, [step, isEVM, progressConfig, shouldExit, autoHideDelay]); + setSignatureState(getSignatureDetails(current, max)); + }, [step, current, max, isEVM, progressConfig, shouldExit, autoHideDelay]); useEffect(() => { let timeoutId: number; diff --git a/apps/frontend/src/hooks/useTokenIcon.ts b/apps/frontend/src/hooks/useTokenIcon.ts index 7233c92d4..a7dfa09f3 100644 --- a/apps/frontend/src/hooks/useTokenIcon.ts +++ b/apps/frontend/src/hooks/useTokenIcon.ts @@ -65,7 +65,11 @@ export function useTokenIcon(currencyOrDetails: string | TokenDetails, network?: if (typeof currencyOrDetails === "string") { return currencyOrDetails.toLowerCase(); } - // For token details, use assetSymbol + // Fiat token details carry the canonical icon key in fiat.assetIcon + // (assetSymbol can diverge — e.g. EURC's assetSymbol is "EURC" but its icon is "eur"). + if (isFiatTokenDetails(currencyOrDetails)) { + return currencyOrDetails.fiat.assetIcon.toLowerCase(); + } return currencyOrDetails.assetSymbol.toLowerCase(); }, [currencyOrDetails]); diff --git a/apps/frontend/src/machines/actors/register.actor.test.ts b/apps/frontend/src/machines/actors/register.actor.test.ts new file mode 100644 index 000000000..9fa8bf444 --- /dev/null +++ b/apps/frontend/src/machines/actors/register.actor.test.ts @@ -0,0 +1,43 @@ +import { FiatToken, Networks, RampDirection } from "@vortexfi/shared"; +import { describe, expect, it } from "vitest"; +import { RampContext } from "../types"; +import { buildRegisterRampAdditionalData, RegisterRampError, RegisterRampErrorType } from "./registerAdditionalData"; + +const baseContext = { + connectedWalletAddress: "0x1111111111111111111111111111111111111111", + externalSessionId: "session-1", + executionInput: { + fiatToken: FiatToken.EURC, + network: Networks.Base, + quote: { + id: "quote-1", + rampType: RampDirection.SELL + }, + sourceOrDestinationAddress: "0x2222222222222222222222222222222222222222" + }, + paymentData: undefined, + userEmail: "user@example.com" +} as unknown as RampContext; + +describe("buildRegisterRampAdditionalData", () => { + it("passes email and destination address for Mykobo EUR offramps", () => { + expect(buildRegisterRampAdditionalData(baseContext, baseContext.connectedWalletAddress as string)).toMatchObject({ + destinationAddress: "0x2222222222222222222222222222222222222222", + email: "user@example.com", + sessionId: "session-1", + walletAddress: "0x1111111111111111111111111111111111111111" + }); + }); + + it("rejects Mykobo EUR offramps without an email", () => { + expect(() => + buildRegisterRampAdditionalData( + { + ...baseContext, + userEmail: undefined + }, + baseContext.connectedWalletAddress as string + ) + ).toThrow(new RegisterRampError("User email is required for Mykobo EUR offramp.", RegisterRampErrorType.InvalidInput)); + }); +}); diff --git a/apps/frontend/src/machines/actors/register.actor.ts b/apps/frontend/src/machines/actors/register.actor.ts index e1d421d0e..d5d186a1f 100644 --- a/apps/frontend/src/machines/actors/register.actor.ts +++ b/apps/frontend/src/machines/actors/register.actor.ts @@ -2,33 +2,18 @@ import { AccountMeta, ApiManager, EphemeralAccountType, - FiatToken, getAddressForFormat, - isAlfredpayToken, Networks, - RampDirection, - RegisterRampRequest, signUnsignedTransactions } from "@vortexfi/shared"; import { config } from "../../config"; import { RampService } from "../../services/api"; import { RampState } from "../../types/phases"; import { RampContext } from "../types"; - -export enum RegisterRampErrorType { - InvalidInput = "INVALID_INPUT" -} - -export class RegisterRampError extends Error { - type: RegisterRampErrorType; - constructor(message: string, type: RegisterRampErrorType) { - super(message); - this.type = type; - } -} +import { buildRegisterRampAdditionalData, RegisterRampError, RegisterRampErrorType } from "./registerAdditionalData"; export const registerRampActor = async ({ input }: { input: RampContext }): Promise => { - const { executionInput, chainId, connectedWalletAddress, authToken, paymentData, quote, userId } = input; + const { executionInput, chainId, connectedWalletAddress, quote, userId } = input; // TODO there should be a way to assert types in states, given transitions should ensure the type. if (!executionInput || !quote) { @@ -50,10 +35,6 @@ export const registerRampActor = async ({ input }: { input: RampContext }): Prom const quoteId = quote.id; const signingAccounts: AccountMeta[] = [ - { - address: executionInput.ephemerals.stellarEphemeral.address, - type: EphemeralAccountType.Stellar - }, { address: executionInput.ephemerals.evmEphemeral.address, type: EphemeralAccountType.EVM @@ -64,52 +45,7 @@ export const registerRampActor = async ({ input }: { input: RampContext }): Prom } ]; - let additionalData: RegisterRampRequest["additionalData"] = {}; - - if (quote.rampType === RampDirection.BUY && executionInput.fiatToken === FiatToken.BRL) { - additionalData = { - destinationAddress: executionInput.sourceOrDestinationAddress, - sessionId: input.externalSessionId, - taxId: executionInput.taxId - }; - } else if (executionInput.quote.rampType === RampDirection.BUY && executionInput.fiatToken === FiatToken.EURC) { - additionalData = { - destinationAddress: executionInput.sourceOrDestinationAddress, - moneriumAuthToken: authToken, - moneriumWalletAddress: executionInput.moneriumWalletAddress, - sessionId: input.externalSessionId - }; - } else if (executionInput.quote.rampType === RampDirection.SELL && executionInput.fiatToken === FiatToken.BRL) { - additionalData = { - pixDestination: executionInput.pixId, - receiverTaxId: executionInput.taxId, - sessionId: input.externalSessionId, - taxId: executionInput.taxId, - walletAddress: connectedWalletAddress - }; - } else if (executionInput.quote.rampType === RampDirection.BUY && isAlfredpayToken(executionInput.fiatToken)) { - additionalData = { - destinationAddress: executionInput.sourceOrDestinationAddress, - fiatAccountId: executionInput.selectedFiatAccountId, - sessionId: input.externalSessionId, - walletAddress: connectedWalletAddress - }; - } else if (executionInput.quote.rampType === RampDirection.SELL && isAlfredpayToken(executionInput.fiatToken)) { - additionalData = { - fiatAccountId: executionInput.selectedFiatAccountId, - sessionId: input.externalSessionId, - walletAddress: connectedWalletAddress - }; - } else { - additionalData = { - // moneriumAuthToken is only relevant after enabling Monerium offramps. - // moneriumAuthToken: authToken, - // moneriumWalletAddress: executionInput.moneriumWalletAddress, - paymentData, - sessionId: input.externalSessionId, - walletAddress: connectedWalletAddress - }; - } + const additionalData = buildRegisterRampAdditionalData(input, connectedWalletAddress); const rampProcess = await RampService.registerRamp(quoteId, signingAccounts, additionalData, userId); @@ -142,7 +78,6 @@ export const registerRampActor = async ({ input }: { input: RampContext }): Prom signedTransactions, userSigningMeta: { assethubToPendulumHash: undefined, - moneriumOnrampApproveHash: undefined, squidRouterApproveHash: undefined, squidRouterSwapHash: undefined } diff --git a/apps/frontend/src/machines/actors/registerAdditionalData.ts b/apps/frontend/src/machines/actors/registerAdditionalData.ts new file mode 100644 index 000000000..0c5875dae --- /dev/null +++ b/apps/frontend/src/machines/actors/registerAdditionalData.ts @@ -0,0 +1,93 @@ +import { FiatToken, isAlfredpayToken, RampDirection, RegisterRampRequest } from "@vortexfi/shared"; +import { RampContext } from "../types"; + +export enum RegisterRampErrorType { + InvalidInput = "INVALID_INPUT" +} + +export class RegisterRampError extends Error { + type: RegisterRampErrorType; + constructor(message: string, type: RegisterRampErrorType) { + super(message); + this.type = type; + } +} + +export function buildRegisterRampAdditionalData( + input: RampContext, + connectedWalletAddress: string +): RegisterRampRequest["additionalData"] { + const { executionInput, paymentData } = input; + + if (!executionInput) { + throw new RegisterRampError("Execution input is required to register ramp.", RegisterRampErrorType.InvalidInput); + } + + const rampType = executionInput.quote.rampType; + + if (rampType === RampDirection.BUY && executionInput.fiatToken === FiatToken.BRL) { + return { + destinationAddress: executionInput.sourceOrDestinationAddress, + sessionId: input.externalSessionId, + taxId: executionInput.taxId + }; + } + + if (rampType === RampDirection.BUY && executionInput.fiatToken === FiatToken.EURC) { + if (!input.userEmail) { + throw new RegisterRampError("User email is required for Mykobo EUR onramp.", RegisterRampErrorType.InvalidInput); + } + + return { + destinationAddress: executionInput.sourceOrDestinationAddress, + email: input.userEmail, + sessionId: input.externalSessionId + }; + } + + if (rampType === RampDirection.SELL && executionInput.fiatToken === FiatToken.EURC) { + if (!input.userEmail) { + throw new RegisterRampError("User email is required for Mykobo EUR offramp.", RegisterRampErrorType.InvalidInput); + } + + return { + destinationAddress: executionInput.sourceOrDestinationAddress, + email: input.userEmail, + sessionId: input.externalSessionId, + walletAddress: connectedWalletAddress + }; + } + + if (rampType === RampDirection.SELL && executionInput.fiatToken === FiatToken.BRL) { + return { + pixDestination: executionInput.pixId, + receiverTaxId: executionInput.taxId, + sessionId: input.externalSessionId, + taxId: executionInput.taxId, + walletAddress: connectedWalletAddress + }; + } + + if (rampType === RampDirection.BUY && isAlfredpayToken(executionInput.fiatToken)) { + return { + destinationAddress: executionInput.sourceOrDestinationAddress, + fiatAccountId: executionInput.selectedFiatAccountId, + sessionId: input.externalSessionId, + walletAddress: connectedWalletAddress + }; + } + + if (rampType === RampDirection.SELL && isAlfredpayToken(executionInput.fiatToken)) { + return { + fiatAccountId: executionInput.selectedFiatAccountId, + sessionId: input.externalSessionId, + walletAddress: connectedWalletAddress + }; + } + + return { + paymentData, + sessionId: input.externalSessionId, + walletAddress: connectedWalletAddress + }; +} diff --git a/apps/frontend/src/machines/actors/sign.actor.ts b/apps/frontend/src/machines/actors/sign.actor.ts index 3865512da..f83df4b6b 100644 --- a/apps/frontend/src/machines/actors/sign.actor.ts +++ b/apps/frontend/src/machines/actors/sign.actor.ts @@ -1,20 +1,12 @@ import { - ERC20_EURE_POLYGON_DECIMALS, - ERC20_EURE_POLYGON_TOKEN_NAME, - ERC20_EURE_POLYGON_V2, getAddressForFormat, getOnChainTokenDetails, isEvmTransactionData, isSignedTypedData, isSignedTypedDataArray, - Networks, - PermitSignature, - PresignedTx, - RampDirection + PresignedTx } from "@vortexfi/shared"; -import { signERC2612Permit } from "../../helpers/crypto"; import { RampService } from "../../services/api"; -import { MoneriumService } from "../../services/api/monerium.service"; import { signAndSubmitEvmTransaction, signAndSubmitSubstrateTransaction, @@ -40,28 +32,15 @@ export const signTransactionsActor = async ({ }: { input: { parent: RampMachineActor; context: RampContext }; }): Promise => { - const { - rampState, - rampDirection, - quote, - connectedWalletAddress, - chainId, - executionInput, - substrateWalletAccount, - getMessageSignature - } = input.context; + const { rampState, connectedWalletAddress, chainId, executionInput, substrateWalletAccount } = input.context; if (!rampState || !connectedWalletAddress || chainId === undefined) { throw new SignRampError("Missing required context for signing", SignRampErrorType.InvalidInput); } const userTxs = rampState?.ramp?.unsignedTxs?.filter(tx => { - // For substrate networks (Pendulum/AssetHub), always use connectedWalletAddress. - // moneriumWalletAddress is only for Monerium flows with EVM transactions. const isSubstrateTransaction = !isEvmTransactionData(tx.txData); - const signerAddress = isSubstrateTransaction - ? connectedWalletAddress - : executionInput?.moneriumWalletAddress || connectedWalletAddress; + const signerAddress = connectedWalletAddress; if (!signerAddress) { return false; @@ -75,18 +54,6 @@ export const signTransactionsActor = async ({ return match; }); - // Add userTx for monerium onramp. Signature is required, which is created in this process. - if (rampDirection === RampDirection.BUY && quote?.from === "sepa") { - userTxs?.push({ - meta: {}, - network: Networks.Polygon, - nonce: 0, - phase: "moneriumOnrampMint", - signer: executionInput?.moneriumWalletAddress as `0x${string}`, - txData: {} as any // Placeholder, actual txData is not needed for signing the permit - }); - } - if (!userTxs || userTxs.length === 0) { console.log("No user transactions found requiring signature."); return rampState; @@ -98,18 +65,9 @@ export const signTransactionsActor = async ({ let squidRouterNoPermitApproveHash: string | undefined = undefined; let squidRouterNoPermitSwapHash: string | undefined = undefined; let assethubToPendulumHash: string | undefined = undefined; - let moneriumOfframpSignature: string | undefined = undefined; - let moneriumOnrampPermit: PermitSignature | undefined = undefined; const sortedTxs = userTxs?.sort((a, b) => a.nonce - b.nonce); - // Monerium onramp - if (rampDirection === RampDirection.SELL && quote?.from === "sepa") { - if (!getMessageSignature) throw new Error("getMessageSignature not available"); - const offrampMessage = await MoneriumService.createRampMessage(rampState.quote.outputAmount, "THIS WILL BE THE IBAN"); - moneriumOfframpSignature = await getMessageSignature(offrampMessage); - } - if (!sortedTxs) { throw new SignRampError("Missing sorted transactions", SignRampErrorType.UnknownError); } @@ -120,10 +78,15 @@ export const signTransactionsActor = async ({ const signedTxs: PresignedTx[] = rampState.signedTransactions; + const total = sortedTxs.length; + try { - for (const tx of sortedTxs) { + for (let idx = 0; idx < sortedTxs.length; idx++) { + const tx = sortedTxs[idx]; + const current = idx + 1; + if (isSignedTypedData(tx.txData) || isSignedTypedDataArray(tx.txData)) { - input.parent.send({ phase: "started", type: "SIGNING_UPDATE" }); + input.parent.send({ current, max: total, phase: "started", type: "SIGNING_UPDATE" }); if (isSignedTypedData(tx.txData)) { const signedArray = await signMultipleTypedData([tx.txData]); tx.txData = signedArray[0]; @@ -133,48 +96,36 @@ export const signTransactionsActor = async ({ signedTxs.push(tx); - input.parent.send({ phase: "signed", type: "SIGNING_UPDATE" }); + input.parent.send({ current, max: total, phase: "signed", type: "SIGNING_UPDATE" }); } else if (tx.phase === "squidRouterApprove") { if (isNativeTokenTransfer) { - input.parent.send({ phase: "login", type: "SIGNING_UPDATE" }); + input.parent.send({ current, max: total, phase: "login", type: "SIGNING_UPDATE" }); continue; } - input.parent.send({ phase: "started", type: "SIGNING_UPDATE" }); + input.parent.send({ current, max: total, phase: "started", type: "SIGNING_UPDATE" }); squidRouterApproveHash = await signAndSubmitEvmTransaction(tx); - input.parent.send({ phase: "signed", type: "SIGNING_UPDATE" }); + input.parent.send({ current, max: total, phase: "signed", type: "SIGNING_UPDATE" }); } else if (tx.phase === "squidRouterSwap") { squidRouterSwapHash = await signAndSubmitEvmTransaction(tx); - input.parent.send({ phase: "finished", type: "SIGNING_UPDATE" }); + input.parent.send({ current, max: total, phase: "finished", type: "SIGNING_UPDATE" }); } else if (tx.phase === "squidRouterNoPermitTransfer") { - input.parent.send({ phase: "started", type: "SIGNING_UPDATE" }); + input.parent.send({ current, max: total, phase: "started", type: "SIGNING_UPDATE" }); squidRouterNoPermitTransferHash = await signAndSubmitEvmTransaction(tx); - input.parent.send({ phase: "finished", type: "SIGNING_UPDATE" }); + input.parent.send({ current, max: total, phase: "finished", type: "SIGNING_UPDATE" }); } else if (tx.phase === "squidRouterNoPermitApprove") { - input.parent.send({ phase: "started", type: "SIGNING_UPDATE" }); + input.parent.send({ current, max: total, phase: "started", type: "SIGNING_UPDATE" }); squidRouterNoPermitApproveHash = await signAndSubmitEvmTransaction(tx); - input.parent.send({ phase: "signed", type: "SIGNING_UPDATE" }); + input.parent.send({ current, max: total, phase: "signed", type: "SIGNING_UPDATE" }); } else if (tx.phase === "squidRouterNoPermitSwap") { squidRouterNoPermitSwapHash = await signAndSubmitEvmTransaction(tx); - input.parent.send({ phase: "finished", type: "SIGNING_UPDATE" }); + input.parent.send({ current, max: total, phase: "finished", type: "SIGNING_UPDATE" }); } else if (tx.phase === "assethubToPendulum") { if (!substrateWalletAccount) { throw new Error("Missing substrateWalletAccount, user needs to be connected to a wallet account. "); } - input.parent.send({ phase: "started", type: "SIGNING_UPDATE" }); + input.parent.send({ current, max: total, phase: "started", type: "SIGNING_UPDATE" }); assethubToPendulumHash = await signAndSubmitSubstrateTransaction(tx, substrateWalletAccount); - input.parent.send({ phase: "finished", type: "SIGNING_UPDATE" }); - } else if (tx.phase === "moneriumOnrampMint") { - input.parent.send({ phase: "login", type: "SIGNING_UPDATE" }); - moneriumOnrampPermit = await signERC2612Permit( - executionInput?.moneriumWalletAddress as `0x${string}`, - executionInput?.ephemerals.evmEphemeral.address as `0x${string}`, - rampState.quote.inputAmount, - ERC20_EURE_POLYGON_V2, // EURe V2 address on Polygon - ERC20_EURE_POLYGON_DECIMALS, - 137, // Polygon chainId - ERC20_EURE_POLYGON_TOKEN_NAME - ); - input.parent.send({ phase: "finished", type: "SIGNING_UPDATE" }); + input.parent.send({ current, max: total, phase: "finished", type: "SIGNING_UPDATE" }); } else { throw new Error(`Unknown transaction received to be signed by user: ${tx.phase}`); } @@ -193,8 +144,6 @@ export const signTransactionsActor = async ({ const additionalData = { assethubToPendulumHash, - moneriumOfframpSignature, - moneriumOnrampPermit, squidRouterApproveHash, squidRouterNoPermitApproveHash, squidRouterNoPermitSwapHash, @@ -212,7 +161,6 @@ export const signTransactionsActor = async ({ ramp: updatedRampProcess, userSigningMeta: { assethubToPendulumHash, - moneriumOnrampPermit, squidRouterApproveHash, squidRouterSwapHash } diff --git a/apps/frontend/src/machines/actors/stellar/sep24Second.actor.ts b/apps/frontend/src/machines/actors/stellar/sep24Second.actor.ts deleted file mode 100644 index 02730e37f..000000000 --- a/apps/frontend/src/machines/actors/stellar/sep24Second.actor.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { getTokenDetailsSpacewalk, PaymentData } from "@vortexfi/shared"; -import Big from "big.js"; -import { fromPromise } from "xstate"; -import { sep24Second } from "../../../services/anchor/sep24/second"; -import { IAnchorSessionParams, ISep24Intermediate } from "../../../types/sep"; -import { RampContext } from "../../types"; - -export const sep24SecondActor = fromPromise( - async ({ - input - }: { - input: RampContext & { - token: string; - tomlValues: any; - } & ISep24Intermediate; - }) => { - const { executionInput, token, tomlValues, id } = input; - if (!executionInput || !token || !tomlValues || !id) { - throw new Error("Missing required data for SEP-24 second step"); - } - const outputToken = getTokenDetailsSpacewalk(executionInput.fiatToken); - - const offrampAmountBeforeFees = Big(executionInput.quote.outputAmount).plus(executionInput.quote.anchorFeeFiat); - - const anchorSessionParams: IAnchorSessionParams = { - offrampAmount: offrampAmountBeforeFees.toFixed(2, 0), - token: token, - tokenConfig: outputToken, - tomlValues: tomlValues - }; - - const secondSep24Response = await sep24Second({ id, url: "" }, anchorSessionParams); - - const amountBeforeFees = Big(executionInput.quote.outputAmount).plus(executionInput.quote.anchorFeeFiat).toFixed(2); - - if (!Big(secondSep24Response.amount).eq(amountBeforeFees)) { - throw new Error("Amount mismatch"); - } - - const paymentData: PaymentData = { - amount: secondSep24Response.amount, - anchorTargetAccount: secondSep24Response.offrampingAccount, - memo: secondSep24Response.memo, - memoType: secondSep24Response.memoType as "text" | "hash" - }; - return paymentData; - } -); diff --git a/apps/frontend/src/machines/actors/stellar/startSep24.actor.ts b/apps/frontend/src/machines/actors/stellar/startSep24.actor.ts deleted file mode 100644 index 501c5726e..000000000 --- a/apps/frontend/src/machines/actors/stellar/startSep24.actor.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { getTokenDetailsSpacewalk } from "@vortexfi/shared"; -import Big from "big.js"; -import { fromCallback } from "xstate"; -import { sep10 } from "../../../services/anchor/sep10"; -import { sep24First } from "../../../services/anchor/sep24/first"; -import { fetchTomlValues } from "../../../services/stellar"; -import { IAnchorSessionParams } from "../../../types/sep"; -import { RampContext } from "../../types"; - -export const startSep24Actor = fromCallback(({ sendBack, input }) => { - let intervalId: NodeJS.Timeout; - - const { executionInput } = input; - if (!executionInput) { - throw new Error("Missing execution input"); - } - - const runSep24Logic = async () => { - try { - const stellarEphemeralSecret = executionInput.ephemerals.stellarEphemeral.secret; - const outputToken = getTokenDetailsSpacewalk(executionInput.fiatToken); - const tomlValues = await fetchTomlValues(outputToken.tomlFileUrl); - - const { token: sep10Token, sep10Account } = await sep10( - tomlValues, - stellarEphemeralSecret, - executionInput.fiatToken, - executionInput.sourceOrDestinationAddress - ); - - const offrampAmountBeforeFees = Big(executionInput.quote.outputAmount).plus(executionInput.quote.anchorFeeFiat); - - const anchorSessionParams: IAnchorSessionParams = { - offrampAmount: offrampAmountBeforeFees.toFixed(2, 0), - token: sep10Token, - tokenConfig: outputToken, - tomlValues - }; - - const fetchAndUpdateSep24Url = async () => { - const firstSep24Response = await sep24First(anchorSessionParams, sep10Account, executionInput.fiatToken); - const url = new URL(firstSep24Response.url); - url.searchParams.append("callback", "postMessage"); - sendBack({ - id: firstSep24Response.id, - type: "URL_UPDATED", - url: url.toString() - }); - }; - - sendBack({ - output: { sep10Account, token: sep10Token, tomlValues }, - type: "SEP24_STARTED" - }); - - // TODO edge case, if the Stellar actor is closed before this interval is returned, then nothing stops this interval on exit. - await fetchAndUpdateSep24Url(); - intervalId = setInterval(fetchAndUpdateSep24Url, 20000); - sendBack({ intervalId, type: "INTERVAL_STARTED" }); - } catch (error) { - sendBack({ error, type: "xstate.error" }); - } - }; - - runSep24Logic(); - - return () => { - if (intervalId) { - clearInterval(intervalId); - } - }; -}); diff --git a/apps/frontend/src/machines/kyc.states.ts b/apps/frontend/src/machines/kyc.states.ts index 1b1913f32..85199a9af 100644 --- a/apps/frontend/src/machines/kyc.states.ts +++ b/apps/frontend/src/machines/kyc.states.ts @@ -1,4 +1,4 @@ -import { FiatToken, isAlfredpayToken, KycFailureReason, RampDirection } from "@vortexfi/shared"; +import { FiatToken, KycFailureReason } from "@vortexfi/shared"; import { assign, DoneActorEvent, sendTo } from "xstate"; import { ALFREDPAY_FIAT_TOKEN_TO_COUNTRY } from "../constants/fiatAccountMethods"; import { KYCFormData } from "../hooks/brla/useKYCForm"; @@ -12,10 +12,20 @@ import { MxnKycFormData } from "./alfredpayKyc.machine"; import { AveniaKycMachineError, UploadIds } from "./brlaKyc.machine"; -import { MoneriumKycMachineError, MoneriumKycMachineErrorType } from "./moneriumKyc.machine"; -import { RampContext, SelectedAveniaData } from "./types"; +import { MykoboKycFiles, MykoboKycFormData, MykoboKycMachineError, MykoboKycMachineErrorType } from "./mykoboKyc.machine"; +import { RampContext } from "./types"; + +type KycChildId = "aveniaKyc" | "alfredpayKyc" | "mykoboKyc"; + +const KYC_CHILD_BY_FIAT: Record = { + [FiatToken.EURC]: "mykoboKyc", + [FiatToken.BRL]: "aveniaKyc", + [FiatToken.ARS]: "alfredpayKyc", + [FiatToken.USD]: "alfredpayKyc", + [FiatToken.MXN]: "alfredpayKyc", + [FiatToken.COP]: "alfredpayKyc" +}; -// Extended context types for child KYC machines export interface AlfredpayKycContext extends RampContext { verificationUrl?: string; submissionId?: string; @@ -40,7 +50,7 @@ export interface AveniaKycContext extends RampContext { rejectReason?: KycFailureReason | string; documentUploadIds?: UploadIds; error?: AveniaKycMachineError; - isCompany?: boolean; // Flag to identify if the user is a business (CNPJ) or individual (CPF) + isCompany?: boolean; kybAttemptId?: string; kybUrls?: { authorizedRepresentativeUrl: string; @@ -51,58 +61,31 @@ export interface AveniaKycContext extends RampContext { representativeVerificationStarted?: boolean; } -export interface MoneriumKycContext extends RampContext { - authCode?: string; - authUrl?: string; - codeVerifier?: string; - error?: MoneriumKycMachineError; - redirectReady?: boolean; +export interface MykoboKycContext extends RampContext { + formData?: MykoboKycFormData; + files?: MykoboKycFiles; + profileApproved?: boolean; + error?: MykoboKycMachineError; } -export interface StellarKycContext extends RampContext { - token?: string; - sep10Account?: any; - redirectUrl?: string; - tomlValues?: any; - id?: string; - error?: any; - sep24IntervalId?: NodeJS.Timeout; -} +type MykoboKycOutput = { profileApproved?: boolean; error?: MykoboKycMachineError }; -// Logic of the KYC node: -// The node attempts to abstract the generic "Started" -> "Verifying" -> "Done" flow of any KYC process. -// The "Verifying" state will invoke child actors based on the particula ramp. -// The output of these state-machine actors will always be assigned to the RampContext's `kycResponse` property. export const kycStateNode = { - entry: ({ context }: { context: RampContext }) => - console.log("DEBUG: Entering KYC state node. RampContext kycFormData:", context.kycFormData), initial: "Deciding", on: { GO_BACK: { - actions: [assign({ rampSigningPhase: undefined })], + actions: [assign({ rampSigningPhase: undefined, rampSigningPhaseCurrent: undefined, rampSigningPhaseMax: undefined })], target: "#ramp.QuoteReady" }, SummaryConfirm: { actions: [ - // TODO I would prefer to have this uncoupled from the specific implementations, and based on active child. sendTo( - ({ context }) => { - if (context.executionInput?.fiatToken === FiatToken.BRL) { - return "aveniaKyc"; - } - if (context.executionInput?.fiatToken === FiatToken.EURC && context.rampDirection === RampDirection.BUY) { - return "moneriumKyc"; - } - if (context.executionInput?.fiatToken && isAlfredpayToken(context.executionInput.fiatToken)) { - return "alfredpayKyc"; - } - return "stellarKyc"; + ({ context }: { context: RampContext }) => { + const fiatToken = context.executionInput?.fiatToken; + return fiatToken ? KYC_CHILD_BY_FIAT[fiatToken] : "aveniaKyc"; }, { type: "SummaryConfirm" } - ), - ({ event }: any) => { - console.log("SummaryConfirm event:", event); - } + ) ] } }, @@ -111,7 +94,6 @@ export const kycStateNode = { invoke: { id: "alfredpayKyc", input: ({ context }: { context: RampContext }): AlfredpayKycContext => { - console.log("Invoking Alfredpay KYC actor with RampContext input:", context); const fiatToken = context.executionInput?.fiatToken; const country = fiatToken ? (ALFREDPAY_FIAT_TOKEN_TO_COUNTRY[fiatToken] ?? "US") : "US"; return { @@ -122,18 +104,13 @@ export const kycStateNode = { onDone: [ { actions: assign({ - initializeFailedMessage: ({ event }: { event: any }) => - (event.output.error as AlfredpayKycMachineError)?.message || "An unknown error occurred" + initializeFailedMessage: ({ event }: { event: DoneActorEvent }) => + event.output.error?.message || "An unknown error occurred" }), - guard: ({ event }: { event: any }) => !!event.output.error, + guard: ({ event }: { event: DoneActorEvent }) => !!event.output.error, target: "#ramp.KycFailure" }, { - actions: assign(({ context }: { context: RampContext }) => { - return { - ...context - }; - }), target: "VerificationComplete" } ], @@ -150,11 +127,10 @@ export const kycStateNode = { invoke: { id: "aveniaKyc", input: ({ context }: { context: RampContext }): AveniaKycContext => { - console.log("Invoking Avenia KYC actor with RampContext input:", context); return { ...context, - kycFormData: context.kycFormData, // Pass kycFormData from parent RampContext to AveniaKycContext - taxId: context.executionInput?.taxId! + kycFormData: context.kycFormData, + taxId: context.executionInput?.taxId ?? "" }; }, onDone: [ @@ -168,7 +144,7 @@ export const kycStateNode = { { actions: assign({ initializeFailedMessage: ({ event }: { event: DoneActorEvent }) => - (event.output.error as AveniaKycMachineError).message + event.output.error?.message || "An unknown error occurred" }), target: "#ramp.KycFailure" } @@ -186,114 +162,60 @@ export const kycStateNode = { always: [ { guard: ({ context }: { context: RampContext }) => - !!context.executionInput?.fiatToken && isAlfredpayToken(context.executionInput.fiatToken), + !!context.executionInput?.fiatToken && KYC_CHILD_BY_FIAT[context.executionInput.fiatToken] === "alfredpayKyc", target: "Alfredpay" }, - { - guard: ({ context }: { context: RampContext }) => context.executionInput?.fiatToken === FiatToken.BRL, - target: "Avenia" - }, { guard: ({ context }: { context: RampContext }) => - context.executionInput?.fiatToken === FiatToken.EURC && context.rampDirection === RampDirection.BUY, - target: "Monerium" + !!context.executionInput?.fiatToken && KYC_CHILD_BY_FIAT[context.executionInput.fiatToken] === "mykoboKyc", + target: "Mykobo" }, { - target: "Stellar" + target: "Avenia" } ] }, - Monerium: { + Mykobo: { invoke: { - id: "moneriumKyc", - input: ({ context }: { context: RampContext }): MoneriumKycContext => ({ + id: "mykoboKyc", + input: ({ context }: { context: RampContext }): MykoboKycContext => ({ ...context }), onDone: [ { - actions: assign(({ context, event }: { context: RampContext; event: any }) => { - console.log("Monerium KYC completed with response:", event.output); - return { - ...context, - authToken: event.output.authToken - }; - }), - guard: ({ event }: { event: any }) => !!event.output.authToken, + guard: ({ event }: { event: DoneActorEvent }) => !!event.output.profileApproved, target: "VerificationComplete" }, { - actions: [assign({ rampSigningPhase: undefined }), { type: "showSigningRejectedErrorToast" }], - guard: ({ event }: { event: any }) => - (event.output.error as MoneriumKycMachineError)?.type === MoneriumKycMachineErrorType.UserRejected, + actions: assign({ + rampSigningPhase: undefined, + rampSigningPhaseCurrent: undefined, + rampSigningPhaseMax: undefined + }), + guard: ({ event }: { event: DoneActorEvent }) => + event.output.error?.type === MykoboKycMachineErrorType.UserRejected, target: "#ramp.QuoteReady" }, { actions: assign({ - initializeFailedMessage: ({ event }) => - (event.output.error as MoneriumKycMachineError)?.message || "An unknown error occurred" + initializeFailedMessage: ({ event }: { event: DoneActorEvent }) => + event.output.error?.message || "An unknown error occurred" }), target: "#ramp.KycFailure" } ], onError: { actions: assign({ - initializeFailedMessage: "Monerium KYC verification failed. Please retry." + initializeFailedMessage: "Mykobo KYC verification failed. Please retry." }), target: "#ramp.KycFailure" }, - src: "moneriumKyc" - } - }, - Stellar: { - invoke: { - id: "stellarKyc", - input: ({ context }: { context: RampContext }) => context, - onDone: [ - { - actions: assign(({ context, event }: { context: RampContext; event: any }) => { - console.log("Stellar KYC completed with response:", event.output); - return { - ...context, - paymentData: event.output.paymentData - }; - }), - guard: ({ event }: { event: any }) => !!event.output.paymentData, - target: "VerificationComplete" - }, - { - actions: [{ type: "showSigningRejectedErrorToast" }], - guard: ({ event }: { event: any }) => event.output?.error.includes("User rejected"), // TODO improve to error classes, as in moneriumKyc state machine. - target: "#ramp.QuoteReady" - }, - { - // TODO we probably want to parse the KYC sub-process error before assigning it to the parent ramp state machine. - actions: assign({ - initializeFailedMessage: ({ event }: { event: any }) => event.output.error - }), - target: "#ramp.KycFailure" - } - ], - onError: [ - { - actions: assign({ - initializeFailedMessage: "Stellar KYC verification failed. Please retry." - }), - target: "#ramp.KycFailure" - } - ], - src: "stellarKyc" + src: "mykoboKyc" } }, VerificationComplete: { always: { target: "#ramp.KycComplete" - }, - entry: { - actions: [ - ({ context }: any) => { - console.log("KYC verification completed successfully:", context.kycResponse); - } - ] } } } diff --git a/apps/frontend/src/machines/moneriumKyc.machine.ts b/apps/frontend/src/machines/moneriumKyc.machine.ts deleted file mode 100644 index bea1c7894..000000000 --- a/apps/frontend/src/machines/moneriumKyc.machine.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { AnyActorRef, assign, fromPromise, sendParent, setup } from "xstate"; - -import { MoneriumService } from "../services/api/monerium.service"; -import { - exchangeMoneriumCode, - handleMoneriumSiweAuth, - initiateMoneriumAuth, - MoneriumAuthError, - MoneriumAuthErrorType -} from "../services/monerium/moneriumAuth"; -import { RampSigningPhase } from "../types/phases"; -import { MoneriumKycContext } from "./kyc.states"; - -export enum MoneriumKycMachineErrorType { - UserRejected = "USER_REJECTED", - UnknownError = "UNKNOWN_ERROR" -} - -export class MoneriumKycMachineError extends Error { - type: MoneriumKycMachineErrorType; - constructor(message: string, type: MoneriumKycMachineErrorType) { - super(message); - this.type = type; - } -} - -export const moneriumKycMachine = setup({ - actors: { - checkUserStatus: fromPromise(async ({ input }: { input: MoneriumKycContext }) => { - const address = input.executionInput?.moneriumWalletAddress || input.connectedWalletAddress; - if (!address) { - throw new Error("Address is required"); - } - return MoneriumService.checkUserStatus(address); - }), - exchangeMoneriumCode: fromPromise(async ({ input }: { input: MoneriumKycContext }): Promise<{ authToken: string }> => { - if (!input.authCode) { - throw new Error("Auth code is required"); - } - return exchangeMoneriumCode(input.authCode, input.codeVerifier!); - }), - handleMoneriumSiwe: fromPromise( - async ({ - input - }: { - input: { context: MoneriumKycContext; parent: AnyActorRef }; - }): Promise<{ authUrl: string; codeVerifier: string }> => { - const address = input.context.executionInput?.moneriumWalletAddress || input.context.connectedWalletAddress; - if (!address || !input.context.getMessageSignature) { - throw new Error("Address and getMessageSignature are required"); - } - const { authUrl, codeVerifier } = await handleMoneriumSiweAuth( - address, - input.context.getMessageSignature, - input.parent - ); - return { authUrl, codeVerifier }; - } - ), - initiateMonerium: fromPromise( - async ({ - input - }: { - input: { context: MoneriumKycContext; parent: AnyActorRef }; - }): Promise<{ authUrl: string; codeVerifier: string }> => { - const address = input.context.executionInput?.moneriumWalletAddress || input.context.connectedWalletAddress; - if (!address || !input.context.getMessageSignature) { - throw new Error("Address and getMessageSignature are required"); - } - const { authUrl, codeVerifier } = await initiateMoneriumAuth(address, input.context.getMessageSignature, input.parent); - return { authUrl, codeVerifier }; - } - ) - }, - types: { - context: {} as MoneriumKycContext, - events: {} as - | { type: "SIGNING_UPDATE"; phase: RampSigningPhase | undefined } - | { type: "CANCEL" } - | { type: "CODE_RECEIVED"; code: string } - | { type: "RETRY_REDIRECT" }, - input: {} as MoneriumKycContext, - output: {} as { authToken?: string; error?: MoneriumKycMachineError } - } -}).createMachine({ - context: ({ input }) => ({ ...input }), - id: "moneriumKyc", - initial: "Started", - // We relay the SIGNING_UPDATE event to the parent (ramp machine). By convention, we subscribe to the main ramp state machine for UI signing updates. - on: { - SIGNING_UPDATE: { - actions: [ - sendParent(({ event }) => ({ - phase: event.phase, - type: "SIGNING_UPDATE" - })) - ] - } - }, - output: ({ context }) => ({ - authToken: context.authToken, - error: context.error - }), - states: { - Done: { - type: "final" - }, - ExchangingCode: { - invoke: { - id: "exchangeMoneriumCode", - input: ({ context }) => context, - onDone: { - actions: assign({ - authToken: ({ event }) => event.output.authToken - }), - target: "Done" - }, - onError: { - actions: assign({ - error: () => new MoneriumKycMachineError("Error exchanging Monerium code", MoneriumKycMachineErrorType.UnknownError) - }), - target: "Failure" - }, - src: "exchangeMoneriumCode" - } - }, - Failure: { - type: "final" - }, - MoneriumRedirect: { - exit: sendParent({ phase: undefined, type: "SIGNING_UPDATE" }), - invoke: { - id: "initiateMonerium", - input: ({ context, self }) => ({ context, parent: self }), - onDone: { - actions: assign({ - authUrl: ({ event }) => event.output.authUrl, - codeVerifier: ({ event }) => event.output.codeVerifier - }), - target: "Redirect" - }, - onError: { - actions: assign({ - error: ({ event }) => { - if (event.error instanceof MoneriumAuthError && event.error.type === MoneriumAuthErrorType.UserRejected) { - return new MoneriumKycMachineError(event.error.message, MoneriumKycMachineErrorType.UserRejected); - } - return new MoneriumKycMachineError("An unknown error occurred", MoneriumKycMachineErrorType.UnknownError); - } - }), - target: "Failure" - }, - src: "initiateMonerium" - } - }, - MoneriumSiwe: { - exit: sendParent({ phase: undefined, type: "SIGNING_UPDATE" }), - invoke: { - id: "handleMoneriumSiwe", - input: ({ context, self }) => ({ context, parent: self }), - onDone: { - actions: assign({ - authUrl: ({ event }) => event.output.authUrl, - codeVerifier: ({ event }) => event.output.codeVerifier - }), - target: "Redirect" - }, - onError: { - actions: assign({ - error: ({ event }) => { - if (event.error instanceof MoneriumAuthError && event.error.type === MoneriumAuthErrorType.UserRejected) { - return new MoneriumKycMachineError(event.error.message, MoneriumKycMachineErrorType.UserRejected); - } - return new MoneriumKycMachineError( - "An unknown error occurred during Monerium SIWE", - MoneriumKycMachineErrorType.UnknownError - ); - } - }), - target: "Failure" - }, - src: "handleMoneriumSiwe" - } - }, - // This state will redirect on entry and must be restored after redirect-back/refresh. - Redirect: { - entry: ({ context }) => { - if (context.authUrl) { - window.location.assign(context.authUrl); - } - }, - on: { - CANCEL: { - actions: assign({ - error: () => new MoneriumKycMachineError("Cancelled by the user", MoneriumKycMachineErrorType.UserRejected) - }), - target: "Failure" - }, - CODE_RECEIVED: { - actions: assign({ authCode: ({ event }) => event.code }), - target: "ExchangingCode" - }, - RETRY_REDIRECT: { - actions: ({ context }) => { - if (context.authUrl) { - window.location.assign(context.authUrl); - } - } - } - } - }, - Started: { - invoke: { - id: "checkUserStatus", - input: ({ context }) => context, - onDone: [ - { - guard: ({ event }) => event.output.isNewUser, - target: "MoneriumRedirect" - }, - { - target: "MoneriumSiwe" - } - ], - onError: { - actions: assign({ - error: () => new MoneriumKycMachineError("An unknown error occurred", MoneriumKycMachineErrorType.UnknownError) - }), - target: "Failure" - }, - src: "checkUserStatus" - } - } - } -}); diff --git a/apps/frontend/src/machines/mykoboKyc.machine.ts b/apps/frontend/src/machines/mykoboKyc.machine.ts new file mode 100644 index 000000000..d9151c9cd --- /dev/null +++ b/apps/frontend/src/machines/mykoboKyc.machine.ts @@ -0,0 +1,228 @@ +import { assign, fromPromise, sendParent, setup } from "xstate"; +import { isApiError } from "../services/api/api-client"; +import { MykoboProfilePayload, MykoboService } from "../services/api/mykobo.service"; +import { RampSigningPhase } from "../types/phases"; +import { MykoboKycContext } from "./kyc.states"; + +export type MykoboKycFormData = Omit; + +export interface MykoboKycFiles { + front: File; + back?: File; + face: File; + utilityBill: File; +} + +export enum MykoboKycMachineErrorType { + UserRejected = "USER_REJECTED", + KycRejected = "KYC_REJECTED", + UnknownError = "UNKNOWN_ERROR" +} + +export class MykoboKycMachineError extends Error { + type: MykoboKycMachineErrorType; + constructor(message: string, type: MykoboKycMachineErrorType) { + super(message); + this.type = type; + } +} + +const POLLING_INTERVAL_MS = 5000; +const POLLING_TIMEOUT_MS = 20 * 60 * 1000; + +function requireWalletAddress(input: MykoboKycContext): string { + const address = input.connectedWalletAddress; + if (!address) throw new Error("Wallet address is required"); + return address; +} + +function requireUserEmail(input: MykoboKycContext): string { + const email = input.userEmail; + if (!email) throw new Error("Authenticated user email is required"); + return email; +} + +export const mykoboKycMachine = setup({ + actors: { + checkExistingProfile: fromPromise(async ({ input }: { input: MykoboKycContext }) => { + return MykoboService.getProfile(requireUserEmail(input)); + }), + pollProfileStatus: fromPromise(async ({ input, signal }: { input: MykoboKycContext; signal: AbortSignal }) => { + const email = requireUserEmail(input); + const deadline = Date.now() + POLLING_TIMEOUT_MS; + while (Date.now() < deadline) { + if (signal.aborted) throw new Error("Polling aborted"); + try { + const profile = await MykoboService.getProfile(email); + const status = profile?.kycStatus.reviewStatus; + if (status === "approved") return { status: "approved" as const }; + if (status === "rejected") return { status: "rejected" as const }; + } catch (err) { + // Treat any 4xx (other than 404 — getProfile already maps that to null) as a hard failure; + // 5xx and network errors are transient and worth retrying within the polling window. + if (isApiError(err) && err.status >= 400 && err.status < 500) throw err; + console.warn("Mykobo profile poll failed, retrying:", err); + } + // Wait POLLING_INTERVAL_MS or until the actor is aborted, whichever comes first. + await new Promise(resolve => { + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, POLLING_INTERVAL_MS); + const onAbort = () => { + clearTimeout(timer); + resolve(); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); + } + throw new Error("KYC polling timed out"); + }), + submitProfile: fromPromise(async ({ input }: { input: MykoboKycContext }) => { + if (!input.formData || !input.files) { + throw new Error("Form data and files are required"); + } + return MykoboService.createProfile({ + ...input.formData, + ...input.files, + walletAddress: requireWalletAddress(input) + }); + }) + }, + types: { + context: {} as MykoboKycContext, + events: {} as + | { type: "SubmitKycForm"; formData: MykoboKycFormData; files: MykoboKycFiles } + | { type: "CONFIRM_SUCCESS" } + | { type: "CANCEL" } + | { type: "SIGNING_UPDATE"; phase: RampSigningPhase | undefined; current?: number; max?: number }, + input: {} as MykoboKycContext, + output: {} as { profileApproved?: boolean; error?: MykoboKycMachineError } + } +}).createMachine({ + context: ({ input }) => ({ ...input }), + id: "mykoboKyc", + initial: "CheckingProfile", + on: { + SIGNING_UPDATE: { + actions: [ + sendParent(({ event }) => ({ + current: event.current, + max: event.max, + phase: event.phase, + type: "SIGNING_UPDATE" + })) + ] + } + }, + output: ({ context }) => ({ + error: context.error, + profileApproved: context.profileApproved + }), + states: { + Cancelled: { + type: "final" + }, + CheckingProfile: { + invoke: { + id: "checkExistingProfile", + input: ({ context }) => context, + onDone: [ + { + // Already-approved profile: skip the Success screen and let the parent advance to Payment Summary directly. + actions: assign({ profileApproved: true }), + guard: ({ event }) => event.output?.kycStatus.reviewStatus === "approved", + target: "Done" + }, + { + guard: ({ event }) => event.output?.kycStatus.reviewStatus === "pending", + target: "Verifying" + }, + { + target: "FormFilling" + } + ], + onError: { + actions: assign({ + error: () => new MykoboKycMachineError("Failed to check Mykobo profile", MykoboKycMachineErrorType.UnknownError) + }), + target: "Failure" + }, + src: "checkExistingProfile" + } + }, + Done: { + type: "final" + }, + Failure: { + // Non-final: keep the actor alive so the failure screen stays rendered. The user dismisses via RESET_RAMP on the parent. + }, + FormFilling: { + on: { + CANCEL: { + actions: assign({ + error: () => new MykoboKycMachineError("Cancelled by the user", MykoboKycMachineErrorType.UserRejected) + }), + target: "Cancelled" + }, + SubmitKycForm: { + actions: assign(({ event }) => ({ + files: event.files, + formData: event.formData + })), + target: "Submitting" + } + } + }, + Rejected: { + // Non-final: keep the actor alive so the rejection screen stays rendered. The user dismisses via RESET_RAMP on the parent. + }, + Submitting: { + invoke: { + id: "submitProfile", + input: ({ context }) => context, + onDone: { + target: "Verifying" + }, + onError: { + actions: assign({ + error: () => new MykoboKycMachineError("Failed to submit Mykobo profile", MykoboKycMachineErrorType.UnknownError) + }), + target: "Failure" + }, + src: "submitProfile" + } + }, + VerificationDone: { + on: { + CONFIRM_SUCCESS: { target: "Done" } + } + }, + Verifying: { + invoke: { + id: "pollProfileStatus", + input: ({ context }) => context, + onDone: [ + { + actions: assign({ profileApproved: true }), + guard: ({ event }) => event.output.status === "approved", + target: "VerificationDone" + }, + { + actions: assign({ + error: () => new MykoboKycMachineError("KYC was rejected", MykoboKycMachineErrorType.KycRejected) + }), + target: "Rejected" + } + ], + onError: { + actions: assign({ + error: () => new MykoboKycMachineError("KYC verification failed", MykoboKycMachineErrorType.UnknownError) + }), + target: "Failure" + }, + src: "pollProfileStatus" + } + } + } +}); diff --git a/apps/frontend/src/machines/ramp.context.ts b/apps/frontend/src/machines/ramp.context.ts index 6a53620d6..e0f7e2424 100644 --- a/apps/frontend/src/machines/ramp.context.ts +++ b/apps/frontend/src/machines/ramp.context.ts @@ -24,6 +24,8 @@ export const initialRampContext: RampContext = { rampDirection: undefined, rampPaymentConfirmed: false, rampSigningPhase: undefined, + rampSigningPhaseCurrent: undefined, + rampSigningPhaseMax: undefined, rampState: undefined, substrateWalletAccount: undefined, userEmail: undefined, diff --git a/apps/frontend/src/machines/ramp.machine.ts b/apps/frontend/src/machines/ramp.machine.ts index 9e7afe611..dbdd3a6a0 100644 --- a/apps/frontend/src/machines/ramp.machine.ts +++ b/apps/frontend/src/machines/ramp.machine.ts @@ -10,7 +10,7 @@ import { validateKycActor } from "./actors/validateKyc.actor"; import { alfredpayKycMachine } from "./alfredpayKyc.machine"; import { aveniaKycMachine } from "./brlaKyc.machine"; import { kycStateNode } from "./kyc.states"; -import { moneriumKycMachine } from "./moneriumKyc.machine"; +import { mykoboKycMachine } from "./mykoboKyc.machine"; import { checkAndRefreshTokenActor, cleanUrlActor, @@ -20,10 +20,10 @@ import { refreshQuoteIfNeeded } from "./ramp.actors"; import { createResetRampContext, initialRampContext } from "./ramp.context"; -import { stellarKycMachine } from "./stellarKyc.machine"; import { RampContext, RampMachineActor, RampMachineEvents, RampState } from "./types"; export const SUCCESS_CALLBACK_DELAY_MS = 5000; // 5 seconds +const QUOTE_REFRESH_RETRY_DELAY_MS = 30000; function mergeRampStatePreservingPaymentInfo(prev: RampState | undefined, next: RampState): RampState { if (!prev?.ramp) return next; @@ -68,7 +68,7 @@ export const rampMachine = setup({ if (quoteLocked || !quote) { return; } - await new Promise(resolve => setTimeout(resolve, 30000)); + await new Promise(resolve => setTimeout(resolve, QUOTE_REFRESH_RETRY_DELAY_MS)); await refreshQuoteIfNeeded(quote, apiKey, partnerId, event => self.send(event)); }, @@ -87,7 +87,7 @@ export const rampMachine = setup({ checkAndRefreshToken: fromPromise(checkAndRefreshTokenActor), checkEmail: fromPromise(checkEmailActor), loadQuote: fromPromise(loadQuoteActor), - moneriumKyc: moneriumKycMachine, + mykoboKyc: mykoboKycMachine, quoteRefresher: fromCallback(({ sendBack, input }) => { return createQuoteRefresher(input.context, sendBack); }), @@ -95,7 +95,6 @@ export const rampMachine = setup({ requestOTP: fromPromise(requestOTPActor), signTransactions: fromPromise(signTransactionsActor), startRamp: fromPromise(startRampActor), - stellarKyc: stellarKycMachine, urlCleaner: fromPromise(cleanUrlActor), validateKyc: fromPromise(validateKycActor), verifyOTP: fromPromise(verifyOTPActor) @@ -141,36 +140,13 @@ export const rampMachine = setup({ connectedWalletAddress: ({ event }) => event.address }) }, - SET_EXTERNAL_ID: [ - { - actions: [ - assign({ - ...initialRampContext, - externalSessionId: ({ event }) => event.externalSessionId - }), - () => window.location.reload() - ], - // Assumed to be a new session, so we reset everything and reload the page. - // This will reload the new parameters and fetch a new quote. - guard: ({ context, event }) => - event.externalSessionId !== undefined && - context.externalSessionId !== undefined && - event.externalSessionId !== context.externalSessionId, - target: ".Idle" - }, - { - actions: [ - assign({ - ...initialRampContext, - externalSessionId: ({ event }) => event.externalSessionId - }), - () => window.location.reload() - ], - // If a sessionId is passed yet none is set in the context, we assume it's a new session and reload. - guard: ({ context, event }) => event.externalSessionId !== undefined && context.externalSessionId === undefined, - target: ".Idle" - } - ], + SET_EXTERNAL_ID: { + // New sessionId (different from the one in context, or none was set): hard-reload so the new params drive a fresh quote. + // The reload wipes XState state entirely, so an assign/target here would never execute. + actions: [() => window.location.reload()], + guard: ({ context, event }) => + event.externalSessionId !== undefined && event.externalSessionId !== context.externalSessionId + }, SET_GET_MESSAGE_SIGNATURE: { actions: assign({ getMessageSignature: ({ event }: { event: Extract }) => @@ -188,7 +164,14 @@ export const rampMachine = setup({ }) }, SIGNING_UPDATE: { - actions: [assign({ rampSigningPhase: ({ event }) => event.phase })] + actions: [ + assign({ + rampSigningPhase: ({ event }) => event.phase, + rampSigningPhaseCurrent: ({ context, event }) => + event.current !== undefined ? event.current : context.rampSigningPhaseCurrent, + rampSigningPhaseMax: ({ context, event }) => (event.max !== undefined ? event.max : context.rampSigningPhaseMax) + }) + ] } }, states: { @@ -373,7 +356,9 @@ export const rampMachine = setup({ Error: { entry: assign(({ context }) => ({ ...context, - rampSigningPhase: undefined + rampSigningPhase: undefined, + rampSigningPhaseCurrent: undefined, + rampSigningPhaseMax: undefined })), on: { RESET_RAMP: { @@ -528,7 +513,11 @@ export const rampMachine = setup({ target: "LoadingQuote" } ], - entry: assign({ rampSigningPhase: undefined }), + entry: assign({ + rampSigningPhase: undefined, + rampSigningPhaseCurrent: undefined, + rampSigningPhaseMax: undefined + }), on: { // This is the main confirm button. CONFIRM: { @@ -740,7 +729,9 @@ export const rampMachine = setup({ enteredViaForm: undefined, errorMessage: undefined, rampPaymentConfirmed: false, - rampSigningPhase: undefined + rampSigningPhase: undefined, + rampSigningPhaseCurrent: undefined, + rampSigningPhaseMax: undefined }), target: "QuoteReady" }, diff --git a/apps/frontend/src/machines/stellarKyc.machine.ts b/apps/frontend/src/machines/stellarKyc.machine.ts deleted file mode 100644 index bd538bd07..000000000 --- a/apps/frontend/src/machines/stellarKyc.machine.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { FiatToken, PaymentData } from "@vortexfi/shared"; -import { assign, emit, sendParent, setup } from "xstate"; -import { RampSigningPhase } from "../types/phases"; -import { sep24SecondActor } from "./actors/stellar/sep24Second.actor"; -import { startSep24Actor } from "./actors/stellar/startSep24.actor"; -import { StellarKycContext } from "./kyc.states"; -import { RampContext } from "./types"; - -export const stellarKycMachine = setup({ - actors: { - sep24Second: sep24SecondActor, - startSep24: startSep24Actor - }, - types: { - context: {} as StellarKycContext, - events: {} as - | { type: "SummaryConfirm" } - | { type: "URL_UPDATED"; url: string; id: string } - | { - type: "SEP24_STARTED"; - output: { token: string; sep10Account: any; tomlValues: any }; - } - | { type: "SEP24_FAILED"; error: any } - | { type: "INTERVAL_STARTED"; intervalId: NodeJS.Timeout } - | { type: "Cancel"; output: PaymentData } - | { type: "AUTH_VALID" } - | { type: "AUTH_INVALID" } - | { type: "SIGNATURE_SUCCESS" } - | { type: "SIGNATURE_FAILURE"; error: string } - | { type: "CHECK_AUTH_STATUS" } - | { type: "PROMPT_FOR_SIGNATURE" } - | { type: "SIWE_READY" } - | { type: "SIGNING_UPDATE"; phase: RampSigningPhase | undefined }, - input: {} as RampContext, - output: {} as { error?: any; paymentData?: PaymentData } - } -}).createMachine({ - context: ({ input }) => ({ - ...(input as RampContext), - redirectUrl: undefined, - sep24IntervalId: undefined - }), - id: "stellarKyc", - initial: "Authentication", - output: ({ context }) => ({ - error: context.error, - paymentData: context.paymentData - }), - states: { - // SIWE states. - Authentication: { - initial: "PreCheck", - on: { - SIGNATURE_FAILURE: [ - { - actions: assign({ - error: ({ event }) => event.error - }), // Maybe type this kind of error across the app. - guard: ({ event }) => event.error.includes("User rejected signing request."), - target: "Failed" - }, - { - actions: assign({ - error: ({ event }) => event.error - }), - target: "Failed" - } - ] - }, - states: { - // We emit this event which will trigger the siwe hooks to subscribe to this actor. - // Once that's ready, it will send back a SIWE_READY event - AwaitSiwe: { - on: { - SIWE_READY: { - target: "CheckingAuth" - } - } - }, - CheckingAuth: { - entry: emit({ type: "CHECK_AUTH_STATUS" }), - on: { - AUTH_INVALID: { - target: "RequestingSignature" - }, - AUTH_VALID: { - target: "#stellarKyc.StartSep24" - } - } - }, - PreCheck: { - always: [ - { - // Only ARS offramp requires SIWE - guard: ({ context }) => context.executionInput?.fiatToken === FiatToken.ARS, - target: "AwaitSiwe" - }, - { - target: "#stellarKyc.StartSep24" - } - ] - }, - RequestingSignature: { - entry: [ - emit({ type: "PROMPT_FOR_SIGNATURE" }), - sendParent(() => ({ - phase: "login", - type: "SIGNING_UPDATE" - })) - ], - exit: sendParent({ phase: undefined, type: "SIGNING_UPDATE" }), - on: { - SIGNATURE_SUCCESS: { - actions: sendParent(() => ({ - phase: "finished", - type: "SIGNING_UPDATE" - })), - target: "#stellarKyc.StartSep24" - } - } - } - } - }, - Done: { - type: "final" - }, - Failed: { - type: "final" - }, - Sep24Second: { - invoke: { - input: ({ context }) => ({ - ...context, - id: context.id!, - token: context.token!, - tomlValues: context.tomlValues!, - url: context.redirectUrl! - }), - onDone: { - actions: [ - assign({ - paymentData: ({ event }) => event.output - }) - ], - target: "Done" - }, - onError: { - actions: [ - assign({ - error: ({ event }) => event.error - }) - ], - target: "Failed" - }, - src: "sep24Second" - } - }, - StartSep24: { - invoke: { - id: "startSep24Actor", - input: ({ context }) => context, - src: "startSep24" - }, - on: { - INTERVAL_STARTED: { - actions: assign({ - sep24IntervalId: ({ event }) => event.intervalId - }) - }, - SEP24_FAILED: { - actions: assign({ - error: ({ event }) => event.error - }), - target: "Failed" - }, - SEP24_STARTED: { - actions: assign({ - sep10Account: ({ event }) => event.output.sep10Account, - token: ({ event }) => event.output.token, - tomlValues: ({ event }) => event.output.tomlValues - }) - }, - SummaryConfirm: { - actions: ({ context }) => { - if (context.redirectUrl) { - window.open(context.redirectUrl, "_blank"); - } - }, - guard: ({ context }) => !!context.redirectUrl, - target: "Sep24Second" - }, - URL_UPDATED: { - actions: assign({ - id: ({ event }) => event.id, - redirectUrl: ({ event }) => event.url - }) - } - } - } - } -}); diff --git a/apps/frontend/src/machines/types.ts b/apps/frontend/src/machines/types.ts index ecbb65a26..5e6cc57ed 100644 --- a/apps/frontend/src/machines/types.ts +++ b/apps/frontend/src/machines/types.ts @@ -1,14 +1,13 @@ import { WalletAccount } from "@talismn/connect-wallets"; import { PaymentData, QuoteResponse, RampDirection } from "@vortexfi/shared"; -import { ActorRef, ActorRefFrom, SnapshotFrom } from "xstate"; +import { ActorRef, ActorRefFrom, Snapshot, SnapshotFrom } from "xstate"; import { ToastMessage } from "../helpers/notifications"; import { KYCFormData } from "../hooks/brla/useKYCForm"; import { RampExecutionInput, RampSigningPhase, RampState } from "../types/phases"; import { alfredpayKycMachine } from "./alfredpayKyc.machine"; import { aveniaKycMachine } from "./brlaKyc.machine"; -import { AlfredpayKycContext, AveniaKycContext, MoneriumKycContext, StellarKycContext } from "./kyc.states"; -import { moneriumKycMachine } from "./moneriumKyc.machine"; -import { stellarKycMachine } from "./stellarKyc.machine"; +import { AlfredpayKycContext, AveniaKycContext, MykoboKycContext } from "./kyc.states"; +import { mykoboKycMachine } from "./mykoboKyc.machine"; export type { RampState } from "../types/phases"; export type GetMessageSignatureCallback = (message: string) => Promise<`0x${string}`>; @@ -29,6 +28,8 @@ export interface RampContext { rampDirection: RampDirection | undefined; rampPaymentConfirmed: boolean; rampSigningPhase: RampSigningPhase | undefined; + rampSigningPhaseCurrent: number | undefined; + rampSigningPhaseMax: number | undefined; rampState: RampState | undefined; substrateWalletAccount: WalletAccount | undefined; walletLocked?: string; @@ -43,7 +44,7 @@ export interface RampContext { userId?: string; isAuthenticated: boolean; isAuthLoading?: boolean; - alfredpayCustomer?: any; + alfredpayCustomer?: unknown; postAuthTarget?: "QuoteReady" | "RegisterRamp"; } @@ -55,7 +56,7 @@ export type RampMachineEvents = | { type: "SET_GET_MESSAGE_SIGNATURE"; getMessageSignature: GetMessageSignatureCallback | undefined } | { type: "SubmitLevel1"; formData: KYCFormData } // TODO: We should allow by default all child events | { type: "SummaryConfirm" } - | { type: "SIGNING_UPDATE"; phase: RampSigningPhase | undefined } + | { type: "SIGNING_UPDATE"; phase: RampSigningPhase | undefined; current?: number; max?: number } | { type: "PAYMENT_CONFIRMED" } | { type: "SET_RAMP_STATE"; rampState: RampState } | { type: "RESET_RAMP"; skipUrlCleaner?: boolean } @@ -82,36 +83,28 @@ export type RampMachineEvents = | { type: "LOGOUT" } | { type: "GO_BACK" }; -export type RampMachineActor = ActorRef; +export type RampMachineActor = ActorRef, RampMachineEvents>; export type RampMachineSnapshot = SnapshotFrom; -export type StellarKycActorRef = ActorRefFrom; -export type StellarKycSnapshot = SnapshotFrom; - -export type MoneriumKycActorRef = ActorRefFrom; -export type MoneriumKycSnapshot = SnapshotFrom; - export type AveniaKycActorRef = ActorRefFrom; export type AveniaKycSnapshot = SnapshotFrom; export type AlfredpayKycActorRef = ActorRefFrom; export type AlfredpayKycSnapshot = SnapshotFrom; -export type SelectedStellarData = { - stateValue: StellarKycSnapshot["value"]; - context: StellarKycContext; -}; - -export type SelectedMoneriumData = { - stateValue: MoneriumKycSnapshot["value"]; - context: MoneriumKycContext; -}; +export type MykoboKycActorRef = ActorRefFrom; +export type MykoboKycSnapshot = SnapshotFrom; export type SelectedAveniaData = { stateValue: AveniaKycSnapshot["value"]; context: AveniaKycContext; }; +export type SelectedMykoboData = { + stateValue: MykoboKycSnapshot["value"]; + context: MykoboKycContext; +}; + /** * Checks whether an XState v5 machine is currently in a compound (parent) state. * diff --git a/apps/frontend/src/pages/privacy/index.tsx b/apps/frontend/src/pages/privacy/index.tsx index 4a0aa54cf..516abc3e0 100644 --- a/apps/frontend/src/pages/privacy/index.tsx +++ b/apps/frontend/src/pages/privacy/index.tsx @@ -226,22 +226,6 @@ export function PrivacyPolicyPage() { https://home.anclap.com/
-
  • - {t("pages.privacyPolicy.sections.7.partners.monerium.name")} –{" "} - {t("pages.privacyPolicy.sections.7.partners.monerium.website.label")}{" "} - - https://monerium.com/ - {" "} - – {t("pages.privacyPolicy.sections.7.partners.monerium.privacyTos.label")}{" "} - - https://monerium.com/policies/personal-terms-of-service-2025-05-20/ - -
  • {t("pages.privacyPolicy.sections.7.partners.mykobo.name")} –{" "} {t("pages.privacyPolicy.sections.7.partners.mykobo.website.label")}{" "} diff --git a/apps/frontend/src/pages/progress/index.tsx b/apps/frontend/src/pages/progress/index.tsx index 7ffca886d..9695d33be 100644 --- a/apps/frontend/src/pages/progress/index.tsx +++ b/apps/frontend/src/pages/progress/index.tsx @@ -1,5 +1,5 @@ import { CheckIcon, ExclamationCircleIcon } from "@heroicons/react/20/solid"; -import { FiatToken, isNetworkEVM, Networks, RampDirection, RampPhase } from "@vortexfi/shared"; +import { FiatToken, isNetworkEVM, RampDirection, RampPhase } from "@vortexfi/shared"; import { useSelector } from "@xstate/react"; import { motion } from "motion/react"; import { FC, useEffect, useMemo, useRef, useState } from "react"; @@ -20,34 +20,24 @@ function getRampFlow(rampState: RampState | undefined): keyof typeof PHASE_FLOWS return null; } - const { type, from } = rampState.ramp; - const currentPhase = rampState.ramp.currentPhase; + const { type } = rampState.ramp; if (type === RampDirection.BUY) { if (rampState.quote?.inputCurrency === FiatToken.BRL) { return "onramp_brl"; } - - if (rampState.quote?.inputCurrency === FiatToken.EURC && rampState.quote?.to === Networks.AssetHub) { - if (rampState.quote?.outputCurrency === "USDC") { - return "onramp_eur_assethub"; - } else { - return "onramp_eur_assethub_via_hydration"; - } - } - return "onramp_eur_evm"; } - if (currentPhase === "brlaPayoutOnBase" || rampState.quote?.outputCurrency === FiatToken.BRL) { + if (rampState.quote?.outputCurrency === FiatToken.BRL) { return "offramp_brl"; } - if (from === Networks.AssetHub) { - return "assethub_offramp_through_stellar"; + if (rampState.quote?.outputCurrency === FiatToken.EURC) { + return "offramp_eur_evm"; } - return "evm_offramp_through_stellar"; + return null; } const useProgressUpdate = ( @@ -58,7 +48,9 @@ const useProgressUpdate = ( setDisplayedPercentage: (value: (prev: number) => number) => void, setShowCheckmark: (value: boolean) => void ) => { - const intervalRef = useRef(null); + const intervalRef = useRef | null>(null); + // Capture start-of-phase percentage in a ref so the effect doesn't re-run (and clear the interval) every tick. + const startPercentageRef = useRef(displayedPercentage); useEffect(() => { if (intervalRef.current) { @@ -68,7 +60,7 @@ const useProgressUpdate = ( const targetPercentage = Math.round((100 / numberOfPhases) * (currentPhaseIndex + 1)); const duration = PHASE_DURATIONS[currentPhase] * 1000; const startTime = Date.now(); - const startPercentage = displayedPercentage; + const startPercentage = startPercentageRef.current; const calculateProgress = () => { const elapsedTime = Date.now() - startTime; @@ -81,6 +73,7 @@ const useProgressUpdate = ( const newPercentage = calculateProgress(); setDisplayedPercentage(() => { + startPercentageRef.current = newPercentage; if (newPercentage >= targetPercentage) { if (intervalRef.current) { clearInterval(intervalRef.current); @@ -98,7 +91,7 @@ const useProgressUpdate = ( clearInterval(intervalRef.current); } }; - }, [currentPhase, currentPhaseIndex, displayedPercentage, numberOfPhases, setDisplayedPercentage, setShowCheckmark]); + }, [currentPhase, currentPhaseIndex, numberOfPhases, setDisplayedPercentage, setShowCheckmark]); }; const CIRCLE_RADIUS = 80; @@ -299,9 +292,16 @@ export const ProgressPage = () => { // the next poll lands. useEffect(() => { const newPhase = rampState?.ramp?.currentPhase ?? "initial"; + if (newPhase === prevPhaseRef.current) return; + const phaseIndex = phaseSequence.indexOf(newPhase); + trackEvent({ + event: "progress", + phase_index: phaseIndex >= 0 ? phaseIndex : 0, + phase_name: newPhase + }); prevPhaseRef.current = newPhase; setCurrentPhase(newPhase); - }, [rampState?.ramp?.currentPhase]); + }, [rampState?.ramp?.currentPhase, phaseSequence, trackEvent]); useEffect(() => { if (!rampId || !flowType) return; @@ -329,19 +329,6 @@ export const ProgressPage = () => { rampState: { ...latest, ramp: updatedRampProcess }, type: "SET_RAMP_STATE" }); - - const maybeNewPhase = updatedRampProcess.currentPhase; - if (maybeNewPhase !== prevPhaseRef.current) { - const phaseIndex = phaseSequence.indexOf(maybeNewPhase); - trackEvent({ - event: "progress", - phase_index: phaseIndex >= 0 ? phaseIndex : 0, - phase_name: maybeNewPhase - }); - - prevPhaseRef.current = maybeNewPhase; - setCurrentPhase(maybeNewPhase); - } } catch (error) { if (!cancelled) console.error("Failed to fetch ramp state:", error); } @@ -354,7 +341,7 @@ export const ProgressPage = () => { cancelled = true; clearInterval(intervalId); }; - }, [rampId, flowType, phaseSequence, rampActor, trackEvent]); + }, [rampId, flowType, rampActor]); return (
    diff --git a/apps/frontend/src/pages/progress/phaseFlows.ts b/apps/frontend/src/pages/progress/phaseFlows.ts index ffb61ff22..717c4461d 100644 --- a/apps/frontend/src/pages/progress/phaseFlows.ts +++ b/apps/frontend/src/pages/progress/phaseFlows.ts @@ -21,16 +21,15 @@ export const PHASE_DURATIONS: Record = { hydrationSwap: 30, hydrationToAssethubXcm: 30, initial: 0, - moneriumOnrampMint: 60, - moneriumOnrampSelfTransfer: 20, moonbeamToPendulum: 40, moonbeamToPendulumXcm: 30, + mykoboOnrampDeposit: 5 * 60, + mykoboPayoutOnBase: 60, nablaApprove: 24, nablaSwap: 24, pendulumToAssethubXcm: 30, pendulumToHydrationXcm: 30, pendulumToMoonbeamXcm: 40, - spacewalkRedeem: 130, squidRouterApprove: 10, squidRouterNoPermitApprove: 10, squidRouterNoPermitSwap: 60, @@ -38,45 +37,25 @@ export const PHASE_DURATIONS: Record = { squidRouterPay: 60, squidRouterPermitExecute: 30, squidRouterSwap: 10, - stellarCreateAccount: 0, - stellarPayment: 6, subsidizePostSwap: 24, subsidizePreSwap: 24, timedOut: 0 }; export const PHASE_FLOWS = { - assethub_offramp_through_stellar: [ - "initial", - "fundEphemeral", - "assethubToPendulum", - "subsidizePreSwap", - "nablaApprove", - "nablaSwap", - "subsidizePostSwap", - "assethubToPendulum", - "spacewalkRedeem", - "stellarPayment", - "distributeFees", - "complete" - ] as RampPhase[], - - evm_offramp_through_stellar: [ + offramp_brl: [ "initial", "fundEphemeral", - "moonbeamToPendulum", // or "assethubToPendulum", "distributeFees", "subsidizePreSwap", "nablaApprove", "nablaSwap", "subsidizePostSwap", - "assethubToPendulum", - "spacewalkRedeem", - "stellarPayment", + "brlaPayoutOnBase", "complete" ] as RampPhase[], - offramp_brl: [ + offramp_eur_evm: [ "initial", "fundEphemeral", "distributeFees", @@ -84,7 +63,7 @@ export const PHASE_FLOWS = { "nablaApprove", "nablaSwap", "subsidizePostSwap", - "brlaPayoutOnBase", + "mykoboPayoutOnBase", "complete" ] as RampPhase[], @@ -105,53 +84,19 @@ export const PHASE_FLOWS = { "complete" ] as RampPhase[], - onramp_eur_assethub: [ - "initial", - "moneriumOnrampMint", - "fundEphemeral", - "moneriumOnrampSelfTransfer", - "squidRouterApprove", - "squidRouterSwap", - "squidRouterPay", - "moonbeamToPendulum", - "subsidizePreSwap", - "nablaApprove", - "nablaSwap", - "distributeFees", - "subsidizePostSwap", - "pendulumToAssethubXcm", - "complete" - ] as RampPhase[], - - onramp_eur_assethub_via_hydration: [ + onramp_eur_evm: [ "initial", - "moneriumOnrampMint", + "mykoboOnrampDeposit", "fundEphemeral", - "moneriumOnrampSelfTransfer", - "squidRouterApprove", - "squidRouterSwap", - "squidRouterPay", - "moonbeamToPendulum", "subsidizePreSwap", "nablaApprove", "nablaSwap", - "distributeFees", "subsidizePostSwap", - "pendulumToHydrationXcm", - "hydrationSwap", - "hydrationToAssethubXcm", - "complete" - ] as RampPhase[], - - onramp_eur_evm: [ - "initial", - "moneriumOnrampMint", - "fundEphemeral", - "moneriumOnrampSelfTransfer", "squidRouterApprove", "squidRouterSwap", "squidRouterPay", "distributeFees", + "destinationTransfer", "complete" ] as RampPhase[] }; diff --git a/apps/frontend/src/pages/progress/phaseMessages.ts b/apps/frontend/src/pages/progress/phaseMessages.ts index aa78c84e0..8af888520 100644 --- a/apps/frontend/src/pages/progress/phaseMessages.ts +++ b/apps/frontend/src/pages/progress/phaseMessages.ts @@ -82,10 +82,10 @@ export function getMessageForPhase(ramp: RampState | undefined, t: TFunction<"tr assetSymbol: outputAssetSymbol }), initial: t("pages.progress.initial"), - moneriumOnrampMint: t("pages.progress.moneriumOnrampMint"), - moneriumOnrampSelfTransfer: t("pages.progress.moneriumOnrampSelfTransfer"), moonbeamToPendulum: getMoonbeamToPendulumMessage(), moonbeamToPendulumXcm: getMoonbeamToPendulumMessage(), + mykoboOnrampDeposit: t("pages.progress.mykoboOnrampDeposit"), + mykoboPayoutOnBase: getTransferringMessage(), nablaApprove: getSwappingMessage(), nablaSwap: getSwappingMessage(), pendulumToAssethubXcm: t("pages.progress.pendulumToAssethubXcm", { @@ -97,9 +97,6 @@ export function getMessageForPhase(ramp: RampState | undefined, t: TFunction<"tr pendulumToMoonbeamXcm: t("pages.progress.pendulumToMoonbeamXcm", { assetSymbol: outputAssetSymbol }), - spacewalkRedeem: t("pages.progress.executeSpacewalkRedeem", { - assetSymbol: outputAssetSymbol - }), squidRouterApprove: getSquidRouterSwapMessage(), squidRouterNoPermitApprove: t("pages.progress.squidRouterNoPermitApprove", { assetSymbol: inputAssetSymbol @@ -115,10 +112,6 @@ export function getMessageForPhase(ramp: RampState | undefined, t: TFunction<"tr squidRouterPay: getSquidRouterSwapMessage(), squidRouterPermitExecute: getSquidRouterPermitMessage(), squidRouterSwap: getSquidRouterSwapMessage(), - stellarCreateAccount: t("pages.progress.createStellarAccount"), - stellarPayment: t("pages.progress.stellarPayment", { - assetSymbol: outputAssetSymbol - }), subsidizePostSwap: getSwappingMessage(), // Not relevant for progress page subsidizePreSwap: getSwappingMessage(), timedOut: "" diff --git a/apps/frontend/src/pages/ramp/index.tsx b/apps/frontend/src/pages/ramp/index.tsx index 9eafac4c4..69c9bc304 100644 --- a/apps/frontend/src/pages/ramp/index.tsx +++ b/apps/frontend/src/pages/ramp/index.tsx @@ -1,11 +1,9 @@ import { useSelector } from "@xstate/react"; import { useEffect } from "react"; -import { useRampActor, useStellarKycActor } from "../../contexts/rampState"; +import { useRampActor } from "../../contexts/rampState"; import { useToastMessage } from "../../helpers/notifications"; -import { useMoneriumFlow } from "../../hooks/monerium/useMoneriumFlow"; import { useRampNavigation } from "../../hooks/ramp/useRampNavigation"; import { useAuthTokens } from "../../hooks/useAuthTokens"; -import { useSiweSignature } from "../../hooks/useSignChallenge"; import { useQuote, useQuoteActions } from "../../stores/quote/useQuoteStore"; import { FailurePage } from "../failure"; import { ProgressPage } from "../progress"; @@ -16,20 +14,17 @@ import { Widget } from "../widget"; export const Ramp = () => { const { getCurrentComponent } = useRampNavigation(, , , , ); const rampActor = useRampActor(); - const stellarKycActor = useStellarKycActor(); const quote = useQuote(); const { forceSetQuote } = useQuoteActions(); - useMoneriumFlow(); - useSiweSignature(stellarKycActor); useAuthTokens(rampActor); const { showToast } = useToastMessage(); useEffect(() => { - // How to restrict this to only send one notification? - rampActor.on("SHOW_ERROR_TOAST", event => { + const subscription = rampActor.on("SHOW_ERROR_TOAST", event => { showToast(event.message); }); + return () => subscription.unsubscribe(); }, [rampActor, showToast]); const { state, quoteFromState } = useSelector(rampActor, state => ({ diff --git a/apps/frontend/src/pages/widget/index.tsx b/apps/frontend/src/pages/widget/index.tsx index 552b9225e..0cf6e0428 100644 --- a/apps/frontend/src/pages/widget/index.tsx +++ b/apps/frontend/src/pages/widget/index.tsx @@ -6,6 +6,7 @@ import { LoadingScreen } from "../../components/Alfredpay/LoadingScreen"; import { AveniaKYBFlow } from "../../components/Avenia/AveniaKYBFlow"; import { AveniaKYBForm } from "../../components/Avenia/AveniaKYBForm"; import { AveniaKYCForm } from "../../components/Avenia/AveniaKYCForm"; +import { MykoboKycFlow } from "../../components/Mykobo/MykoboKycFlow"; import { HistoryMenu } from "../../components/menus/HistoryMenu"; import { SettingsMenu } from "../../components/menus/SettingsMenu"; import { AuthEmailStep } from "../../components/widget-steps/AuthEmailStep"; @@ -13,7 +14,6 @@ import { AuthOTPStep } from "../../components/widget-steps/AuthOTPStep"; import { DetailsStep } from "../../components/widget-steps/DetailsStep"; import { ErrorStep } from "../../components/widget-steps/ErrorStep"; import { InitialQuoteFailedStep } from "../../components/widget-steps/InitialQuoteFailedStep"; -import { MoneriumRedirectStep } from "../../components/widget-steps/MoneriumRedirectStep"; import { RampFollowUpRedirectStep } from "../../components/widget-steps/RampFollowUpRedirectStep"; import { SummaryStep } from "../../components/widget-steps/SummaryStep"; import { FiatAccountMachineContext, useFiatAccountSelector } from "../../contexts/FiatAccountMachineContext"; @@ -22,7 +22,7 @@ import { useAlfredpayKycSelector, useAveniaKycActor, useAveniaKycSelector, - useMoneriumKycActor, + useMykoboKycActor, useRampActor } from "../../contexts/rampState"; import { cn } from "../../helpers/cn"; @@ -52,9 +52,9 @@ export const Widget = ({ className }: WidgetProps) => ( const WidgetContent = () => { const rampActor = useRampActor(); const aveniaKycActor = useAveniaKycActor(); - const moneriumKycActor = useMoneriumKycActor(); const aveniaState = useAveniaKycSelector(); const alfredpayKycActor = useAlfredpayKycActor(); + const mykoboKycActor = useMykoboKycActor(); const showFiatAccountRegistration = useFiatAccountSelector(s => s.matches("Open")); const fiatRegistrationCountry = useFiatAccountSelector(s => s.context.fiatRegistrationCountry); @@ -62,33 +62,20 @@ const WidgetContent = () => { // Enable session persistence and auto-refresh useAuthTokens(rampActor); - const { rampState, isRedirectCallback, isError } = useSelector(rampActor, state => ({ - isError: state.matches("Error"), - isRedirectCallback: state.matches("RedirectCallback"), - rampState: state.value - })); + const { rampState, isRedirectCallback, isError, isInitialQuoteFailed, isAuthEmail, isLoadingAuthEmail, isAuthOTP } = + useSelector(rampActor, state => ({ + isAuthEmail: state.matches("EnterEmail") || state.matches("CheckingEmail") || state.matches("RequestingOTP"), + isAuthOTP: state.matches("EnterOTP") || state.matches("VerifyingOTP"), + isError: state.matches("Error"), + isInitialQuoteFailed: state.matches("InitialFetchFailed"), + isLoadingAuthEmail: state.matches("CheckAuth"), + isRedirectCallback: state.matches("RedirectCallback"), + rampState: state.value + })); const rampSummaryVisible = rampState === "KycComplete" || rampState === "RegisterRamp" || rampState === "UpdateRamp" || rampState === "StartRamp"; - const isMoneriumRedirect = useSelector(moneriumKycActor, state => { - if (state) { - return state.value === "Redirect"; - } - return false; - }); - - const isInitialQuoteFailed = useSelector(rampActor, state => state.matches("InitialFetchFailed")); - - const isAuthEmail = useSelector( - rampActor, - state => state.matches("EnterEmail") || state.matches("CheckingEmail") || state.matches("RequestingOTP") - ); - - const isLoadingAuthEmail = useSelector(rampActor, state => state.matches("CheckAuth")); - - const isAuthOTP = useSelector(rampActor, state => state.matches("EnterOTP") || state.matches("VerifyingOTP")); - if (isLoadingAuthEmail) { return ; } @@ -109,10 +96,6 @@ const WidgetContent = () => { return ; } - if (isMoneriumRedirect) { - return ; - } - if (rampSummaryVisible) { if (showFiatAccountRegistration && fiatRegistrationCountry) { return ; @@ -136,6 +119,10 @@ const WidgetContent = () => { return ; } + if (mykoboKycActor) { + return ; + } + if (isInitialQuoteFailed) { return ; } diff --git a/apps/frontend/src/services/anchor/sep10/challenge.ts b/apps/frontend/src/services/anchor/sep10/challenge.ts deleted file mode 100644 index bf09a6713..000000000 --- a/apps/frontend/src/services/anchor/sep10/challenge.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Memo, MemoType, Networks, Operation, Transaction } from "stellar-sdk"; -import { config } from "../../../config"; - -interface Sep10Challenge { - transaction: string; - network_passphrase: string; -} - -const EXPECTED_NETWORK_PASSPHRASE = config.isSandbox ? Networks.TESTNET : Networks.PUBLIC; - -async function validateChallenge( - transaction: Transaction, Operation[]>, - signingKey: string, - networkPassphrase: string -): Promise { - if (transaction.source !== signingKey) { - throw new Error(`sep10: Invalid source account: ${transaction.source}`); - } - if (transaction.sequence !== "0") { - throw new Error(`sep10: Invalid sequence number: ${transaction.sequence}`); - } - if (networkPassphrase !== EXPECTED_NETWORK_PASSPHRASE) { - throw new Error(`sep10: Invalid network passphrase: ${networkPassphrase}`); - } -} - -export async function fetchAndValidateChallenge( - webAuthEndpoint: string, - urlParams: URLSearchParams, - signingKey: string -): Promise, Operation[]>> { - const challenge = await fetch(`${webAuthEndpoint}?${urlParams.toString()}`); - if (challenge.status !== 200) { - throw new Error(`sep10: Failed to fetch SEP-10 challenge: ${challenge.statusText}`); - } - - const { transaction, network_passphrase } = (await challenge.json()) as Sep10Challenge; - const transactionSigned = new Transaction(transaction, EXPECTED_NETWORK_PASSPHRASE); - await validateChallenge(transactionSigned, signingKey, network_passphrase); - - return transactionSigned; -} diff --git a/apps/frontend/src/services/anchor/sep10/index.ts b/apps/frontend/src/services/anchor/sep10/index.ts deleted file mode 100644 index 0eb00871d..000000000 --- a/apps/frontend/src/services/anchor/sep10/index.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { FiatToken, getTokenDetailsSpacewalk } from "@vortexfi/shared"; -import { Keypair, Memo, MemoType, Operation, Transaction } from "stellar-sdk"; -import { TomlValues } from "../../../types/sep"; - -import { fetchAndValidateChallenge } from "./challenge"; -import { exists, fetchSep10Signatures, getUrlParams } from "./utils"; - -interface Sep10Response { - token: string; - sep10Account: string; -} - -interface Sep10JwtResponse { - token: string; -} - -async function submitSignedTransaction( - webAuthEndpoint: string, - transaction: Transaction, Operation[]> -): Promise { - const jwt = await fetch(webAuthEndpoint, { - body: JSON.stringify({ transaction: transaction.toXDR().toString() }), - headers: { "Content-Type": "application/json" }, - method: "POST" - }); - - if (jwt.status !== 200) { - throw new Error(`Failed to submit SEP-10 response: ${jwt.statusText}`); - } - - const { token } = (await jwt.json()) as Sep10JwtResponse; - return token; -} - -export async function sep10( - tomlValues: TomlValues, - stellarEphemeralSecret: string, - outputToken: FiatToken, - address: string -): Promise { - const { signingKey, webAuthEndpoint } = tomlValues; - - if (!exists(signingKey) || !exists(webAuthEndpoint)) { - throw new Error("sep10: Missing values in TOML file"); - } - - const ephemeralKeys = Keypair.fromSecret(stellarEphemeralSecret); - const accountId = ephemeralKeys.publicKey(); - const { usesMemo, supportsClientDomain } = getTokenDetailsSpacewalk(outputToken); - - const { urlParams, sep10Account } = await getUrlParams(accountId, usesMemo, supportsClientDomain, address); - const transactionSigned = await fetchAndValidateChallenge(webAuthEndpoint, urlParams, signingKey); - - const { masterClientSignature, clientSignature, clientPublic } = await fetchSep10Signatures({ - address: address, - challengeXDR: transactionSigned.toXDR(), - clientPublicKey: sep10Account, - outToken: outputToken, - usesMemo - }); - - if (supportsClientDomain) { - transactionSigned.addSignature(clientPublic, clientSignature); - } - - if (!usesMemo) { - transactionSigned.sign(ephemeralKeys); - } else { - transactionSigned.addSignature(sep10Account, masterClientSignature); - } - - const token = await submitSignedTransaction(webAuthEndpoint, transactionSigned); - return { sep10Account, token }; -} diff --git a/apps/frontend/src/services/anchor/sep10/utils.ts b/apps/frontend/src/services/anchor/sep10/utils.ts deleted file mode 100644 index 21b6b0c57..000000000 --- a/apps/frontend/src/services/anchor/sep10/utils.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Keyring } from "@polkadot/api"; -import { EvmAddress } from "@vortexfi/shared"; -import { keccak256 } from "viem/utils"; -import { config } from "../../../config"; -import { SIGNING_SERVICE_URL } from "../../../constants/constants"; -import { fetchSep10Signatures as fetchSignatures, SignerServiceSep10Request } from "../../signingService"; - -// Returns the hash value for the address. -// If it's a polkadot address, it will return raw data of the address. -function getHashValueForAddress(address: string) { - if (address.startsWith("0x")) { - return address as EvmAddress; - } else { - const keyring = new Keyring({ type: "sr25519" }); - return keyring.decodeAddress(address); - } -} - -// A memo derivation. -async function deriveMemoFromAddress(address: string) { - const hashValue = getHashValueForAddress(address); - const hash = keccak256(hashValue); - return BigInt(hash).toString().slice(0, 15); -} - -export const exists = (value?: string | null): value is string => !!value && value?.length > 0; - -export async function fetchSep10Signatures(args: SignerServiceSep10Request) { - try { - return await fetchSignatures(args); - } catch (_error: unknown) { - throw new Error("Could not fetch sep 10 signatures from backend"); - } -} - -// Return the URLSearchParams and the account (master/omnibus or ephemeral) that was used for SEP-10 -export async function getUrlParams( - ephemeralAccount: string, - usesMemo: boolean, - supportsClientDomain: boolean, - address: string -): Promise<{ urlParams: URLSearchParams; sep10Account: string }> { - let sep10Account: string; - const params = new URLSearchParams(); - - if (usesMemo) { - const response = await fetch(`${SIGNING_SERVICE_URL}/v1/stellar/sep10`); - if (!response.ok) { - throw new Error("Failed to fetch client master SEP-10 public account."); - } - - const { masterSep10Public } = await response.json(); - if (!masterSep10Public) { - throw new Error("masterSep10Public not found in response."); - } - - sep10Account = masterSep10Public; - params.append("account", sep10Account); - params.append("memo", await deriveMemoFromAddress(address)); - } else { - sep10Account = ephemeralAccount; - params.append("account", sep10Account); - } - - if (supportsClientDomain) { - params.append("client_domain", config.applicationClientDomain); - } - - return { sep10Account, urlParams: params }; -} diff --git a/apps/frontend/src/services/anchor/sep24/first.ts b/apps/frontend/src/services/anchor/sep24/first.ts deleted file mode 100644 index b95b79c36..000000000 --- a/apps/frontend/src/services/anchor/sep24/first.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { FiatToken, getTokenDetailsSpacewalk } from "@vortexfi/shared"; -import { config } from "../../../config"; -import { IAnchorSessionParams, ISep24Intermediate } from "../../../types/sep"; - -export async function sep24First( - sessionParams: IAnchorSessionParams, - ANCLAP_sep10Account: string, - outputToken: FiatToken -): Promise { - if (config.test.mockSep24) { - return { id: "1234", url: "https://www.example.com" }; - } - - const { token, tomlValues, offrampAmount } = sessionParams; - const { sep24Url } = tomlValues; - const { usesMemo } = getTokenDetailsSpacewalk(outputToken); - const assetCode = sessionParams.tokenConfig.stellarAsset.code.string; - - const params = { - amount: offrampAmount, - asset_code: assetCode, - ...(usesMemo && { account: ANCLAP_sep10Account }) - }; - - const response = await fetch(`${sep24Url}/transactions/withdraw/interactive`, { - body: JSON.stringify(params), - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json" - }, - method: "POST" - }); - - if (response.status !== 200) { - console.log(await response.json(), params.toString()); - throw new Error(`Failed to initiate SEP-24: ${response.statusText}`); - } - - const { type, url, id } = await response.json(); - if (type !== "interactive_customer_info_needed") { - throw new Error(`Unexpected SEP-24 type: ${type}`); - } - - return { id, url }; -} diff --git a/apps/frontend/src/services/anchor/sep24/second.ts b/apps/frontend/src/services/anchor/sep24/second.ts deleted file mode 100644 index 076cc7ec3..000000000 --- a/apps/frontend/src/services/anchor/sep24/second.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { config } from "../../../config"; -import { IAnchorSessionParams, ISep24Intermediate, SepResult } from "../../../types/sep"; - -interface Sep24TransactionStatus { - status: string; - amount_in: string; - withdraw_memo: string; - withdraw_memo_type: string; - withdraw_anchor_account: string; -} - -const POLLING_INTERVAL = 1000; - -async function fetchTransactionStatus(id: string, token: string, sep24Url: string): Promise { - const idParam = new URLSearchParams({ id }); - const statusResponse = await fetch(`${sep24Url}/transaction?${idParam.toString()}`, { - headers: { Authorization: `Bearer ${token}` } - }); - - if (statusResponse.status !== 200) { - throw new Error(`Failed to fetch SEP-24 status: ${statusResponse.statusText}`); - } - - const { transaction } = await statusResponse.json(); - return transaction; -} - -async function pollTransactionStatus(id: string, sessionParams: IAnchorSessionParams): Promise { - const { token, tomlValues } = sessionParams; - let status: Sep24TransactionStatus; - - if (!tomlValues.sep24Url) { - throw new Error("Missing SEP-24 URL in TOML values"); - } - - do { - console.log(`Polling SEP-24 transaction status for ID: ${id}`); - await new Promise(resolve => setTimeout(resolve, POLLING_INTERVAL)); - status = await fetchTransactionStatus(id, token, tomlValues.sep24Url); - } while (status.status !== "pending_user_transfer_start"); - - return status; -} - -export async function sep24Second(sep24Values: ISep24Intermediate, sessionParams: IAnchorSessionParams): Promise { - if (config.test.mockSep24) { - await new Promise(resolve => setTimeout(resolve, 10000)); - return { - amount: sessionParams.offrampAmount, - memo: "MYK1722323689", - memoType: "text", - offrampingAccount: "GBKGDLVV53YX36A32TGOGUJJPVFLL2FXBIALATAOYSQBNKLRDSNDEP3Y" - }; - } - - const status = await pollTransactionStatus(sep24Values.id, sessionParams); - - return { - amount: status.amount_in, - memo: status.withdraw_memo, - memoType: status.withdraw_memo_type, - offrampingAccount: status.withdraw_anchor_account - }; -} diff --git a/apps/frontend/src/services/api/index.ts b/apps/frontend/src/services/api/index.ts index 69fd9c7a5..19779258f 100644 --- a/apps/frontend/src/services/api/index.ts +++ b/apps/frontend/src/services/api/index.ts @@ -10,6 +10,5 @@ export * from "./quote.service"; export * from "./ramp.service"; export * from "./rating.service"; export * from "./siwe.service"; -export * from "./stellar.service"; export * from "./storage.service"; export * from "./subsidize.service"; diff --git a/apps/frontend/src/services/api/monerium.service.ts b/apps/frontend/src/services/api/monerium.service.ts deleted file mode 100644 index fcd922c6c..000000000 --- a/apps/frontend/src/services/api/monerium.service.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { MONERIUM_MINT_NETWORK } from "../monerium/moneriumAuth"; -import { apiClient, isApiError } from "./api-client"; - -export interface MoneriumUserStatus { - isNewUser: boolean; -} - -export const MoneriumService = { - async checkUserStatus(address: string): Promise { - try { - console.log("Checking Monerium user status for address:", address); - await apiClient.get("/monerium/address-exists", { - params: { address, network: MONERIUM_MINT_NETWORK } - }); - return { isNewUser: false }; - } catch (error: unknown) { - if (isApiError(error)) { - if (error.status === 404) { - console.log("Monerium user not found"); - return { isNewUser: true }; - } - throw new Error(`Error checking Monerium user status: ${error.message}`); - } - throw new Error(`An unexpected error occurred: ${error}`); - } - }, - - async createRampMessage(amount: string, iban: string): Promise { - const date = new Date(Date.now() + 1000 * 60 * 10).toISOString(); - return `Send EUR ${amount} to ${iban} at ${date}`; - }, - - async validateAuthTokens(authCode: string, codeVerifier: string): Promise { - try { - const data = await apiClient.post<{ valid: boolean }>("/monerium/validate-auth", { authCode, codeVerifier }); - return data.valid; - } catch (error) { - console.error("Error validating Monerium auth tokens:", error); - return false; - } - } -}; diff --git a/apps/frontend/src/services/api/mykobo.service.ts b/apps/frontend/src/services/api/mykobo.service.ts new file mode 100644 index 000000000..990ac73d6 --- /dev/null +++ b/apps/frontend/src/services/api/mykobo.service.ts @@ -0,0 +1,72 @@ +import { apiClient, isApiError } from "./api-client"; + +export type MykoboKycReviewStatus = "pending" | "approved" | "rejected"; + +export interface MykoboKycStatus { + receivedAt: string | null; + reviewStatus: MykoboKycReviewStatus; +} + +export interface MykoboProfile { + firstName: string; + lastName: string; + emailAddress: string; + bankAccountNumber: string; + kycStatus: MykoboKycStatus; + createdAt: string; +} + +export interface MykoboProfilePayload { + firstName: string; + lastName: string; + emailAddress: string; + addressLine1: string; + city: string; + idCountryCode: string; + bankAccountNumber: string; + walletAddress: string; + sourceOfFunds: "EMPLOYMENT" | "SAVINGS" | "LOANS" | "INVESTMENT" | "INHERITANCE"; + taxCountry: string; + idType: "PASSPORT" | "ID_CARD" | "DRIVERS_LICENSE"; + front: File; + back?: File; + face: File; + utilityBill: File; +} + +export const MykoboService = { + async createProfile(payload: MykoboProfilePayload): Promise { + const form = new FormData(); + form.append("first_name", payload.firstName); + form.append("last_name", payload.lastName); + form.append("email_address", payload.emailAddress); + form.append("address_line_1", payload.addressLine1); + form.append("city", payload.city); + form.append("id_country_code", payload.idCountryCode); + form.append("bank_account_number", payload.bankAccountNumber); + form.append("wallet_address", payload.walletAddress); + form.append("source_of_funds", payload.sourceOfFunds); + form.append("tax_country", payload.taxCountry); + form.append("id_type", payload.idType); + form.append("front", payload.front); + if (payload.back) form.append("back", payload.back); + form.append("face", payload.face); + form.append("utility_bill", payload.utilityBill); + + const data = await apiClient.post<{ profile: MykoboProfile }>("/mykobo/profiles", form); + return data.profile; + }, + async getProfile(email: string): Promise { + try { + const data = await apiClient.get<{ profile: MykoboProfile }>("/mykobo/profiles", { + params: { email } + }); + return data.profile; + } catch (error: unknown) { + if (isApiError(error) && error.status === 404) { + return null; + } + throw error; + } + } +}; diff --git a/apps/frontend/src/services/api/stellar.service.ts b/apps/frontend/src/services/api/stellar.service.ts deleted file mode 100644 index 3ec15aa07..000000000 --- a/apps/frontend/src/services/api/stellar.service.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { - CreateStellarTransactionRequest, - CreateStellarTransactionResponse, - FiatToken, - GetSep10MasterPKResponse, - SignSep10ChallengeRequest, - SignSep10ChallengeResponse -} from "@vortexfi/shared"; -import { apiRequest } from "./api-client"; - -/** - * Service for interacting with Stellar API endpoints - */ -export class StellarService { - private static readonly BASE_PATH = "/stellar"; - - /** - * Create a Stellar transaction - * @param accountId The account ID - * @param maxTime The maximum time - * @param assetCode The asset code - * @param baseFee The base fee - * @returns The transaction signature, sequence, and public key - */ - static async createTransaction( - accountId: string, - maxTime: number, - assetCode: string, - baseFee: string - ): Promise { - const request: CreateStellarTransactionRequest = { - accountId, - assetCode, - baseFee, - maxTime - }; - return apiRequest("post", `${this.BASE_PATH}/create`, request); - } - - /** - * Sign a SEP-10 challenge - * @param challengeXDR The challenge XDR - * @param outToken The output token - * @param clientPublicKey The client public key - * @param derivedMemo Optional derived memo - * @returns The signed challenge - */ - static async signSep10Challenge( - challengeXDR: string, - outToken: FiatToken, - clientPublicKey: string, - derivedMemo?: string - ): Promise { - const request: SignSep10ChallengeRequest = { - challengeXDR, - clientPublicKey, - derivedMemo, - outToken - }; - return apiRequest("post", `${this.BASE_PATH}/sep10`, request); - } - - /** - * Get the SEP-10 master public key - * @returns The master public key - */ - static async getSep10MasterPK(): Promise { - return apiRequest("get", `${this.BASE_PATH}/sep10`); - } -} diff --git a/apps/frontend/src/services/api/storage.service.ts b/apps/frontend/src/services/api/storage.service.ts index 99f3aaabf..1894a704a 100644 --- a/apps/frontend/src/services/api/storage.service.ts +++ b/apps/frontend/src/services/api/storage.service.ts @@ -1,10 +1,8 @@ import { AssethubToBrlaStorageRequest, - AssethubToStellarStorageRequest, BrlaToAssethubStorageRequest, BrlaToEvmStorageRequest, EvmToBrlaStorageRequest, - EvmToStellarStorageRequest, OfframpHandlerType, OnrampHandlerType, StoreDataRequest, @@ -27,38 +25,6 @@ export class StorageService { return apiRequest("post", `${this.BASE_PATH}/create`, request); } - /** - * Store data for EVM to Stellar flow - * @param data The EVM to Stellar flow data - * @returns Success message - */ - static async storeEvmToStellarData( - data: Omit - ): Promise { - const request: EvmToStellarStorageRequest = { - ...data, - flowType: OfframpHandlerType.EVM_TO_STELLAR, - timestamp: new Date().toISOString() - }; - return this.storeData(request); - } - - /** - * Store data for Assethub to Stellar flow - * @param data The Assethub to Stellar flow data - * @returns Success message - */ - static async storeAssethubToStellarData( - data: Omit - ): Promise { - const request: AssethubToStellarStorageRequest = { - ...data, - flowType: OfframpHandlerType.ASSETHUB_TO_STELLAR, - timestamp: new Date().toISOString() - }; - return this.storeData(request); - } - /** * Store data for EVM to BRLA flow * @param data The EVM to BRLA flow data diff --git a/apps/frontend/src/services/monerium/moneriumAuth.ts b/apps/frontend/src/services/monerium/moneriumAuth.ts deleted file mode 100644 index 6c870a20a..000000000 --- a/apps/frontend/src/services/monerium/moneriumAuth.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { siweMessage } from "@monerium/sdk"; -import CryptoJS from "crypto-js"; -import { config } from "../../config"; -import { MoneriumKycActorRef } from "../../machines/types"; - -export enum MoneriumAuthErrorType { - UserRejected = "USER_REJECTED", - UnknownError = "UNKNOWN_ERROR" -} -export class MoneriumAuthError extends Error { - type: MoneriumAuthErrorType; - constructor(message: string, type: MoneriumAuthErrorType) { - super(message); - this.type = type; - } -} - -export const MONERIUM_MINT_NETWORK = config.isSandbox ? "amoy" : "polygon"; -const MONERIUM_MINT_NETWORK_CHAIN_ID = config.isSandbox ? 80002 : 137; -const VORTEX_APP_CLIENT_ID = - import.meta.env.VITE_MONERIUM_CLIENT_ID || - (config.isSandbox ? "e7b56f39-b0ff-11f0-a4ad-fabb3106d2e3" : "eac7a71a-414d-11f0-bea7-ce527adad61b"); -// Use custom API URL if provided, otherwise use default sandbox/dev endpoints -const MONERIUM_API_URL = - import.meta.env.VITE_MONERIUM_API_URL || (config.isSandbox ? "https://api.monerium.dev" : "https://api.monerium.app"); -const LINK_MESSAGE = "I hereby declare that I am the address owner."; -const MONERIUM_APP_NAME = "Vortex"; - -export const initiateMoneriumAuth = async ( - address: string, - signMessage: (message: string) => Promise, - parent: MoneriumKycActorRef -): Promise<{ authUrl: string; codeVerifier: string }> => { - console.log("Initiating Monerium auth for address:", address); - // Generate PKCE code verifier and challenge - const codeVerifier = CryptoJS.lib.WordArray.random(64).toString(); - const codeChallenge = CryptoJS.enc.Base64url.stringify(CryptoJS.SHA256(codeVerifier)); - - try { - parent.send({ phase: "login", type: "SIGNING_UPDATE" }); - const signature = await signMessage(LINK_MESSAGE); - parent.send({ phase: "finished", type: "SIGNING_UPDATE" }); - - const redirectUri = window.location.origin + "/widget"; - - const params = new URLSearchParams({ - address: address, - chain: MONERIUM_MINT_NETWORK, - client_id: VORTEX_APP_CLIENT_ID, - code_challenge: codeChallenge, - code_challenge_method: "S256", - redirect_uri: redirectUri, - signature - }); - - const authUrl = `${MONERIUM_API_URL}/auth?${params.toString()}`; - return { authUrl, codeVerifier }; - } catch (error) { - if (error instanceof Error && error.message.includes("User rejected the request")) { - throw new MoneriumAuthError("User rejected the request.", MoneriumAuthErrorType.UserRejected); - } - console.log("Error during Monerium auth:", error); - throw error; - } -}; - -export const createMoneriumSiweMessage = (address: string) => { - const redirectUri = window.location.origin + "/widget"; - const domain = window.location.hostname; - - return siweMessage({ - address: address, - appName: MONERIUM_APP_NAME, - chainId: MONERIUM_MINT_NETWORK_CHAIN_ID, - domain: domain, - privacyPolicyUrl: "https://shorturl.at/QuMx8", - redirectUri, - termsOfServiceUrl: "https://shorturl.at/5tSgv" - }); -}; - -export const handleMoneriumSiweAuth = async ( - address: string, - signMessage: (message: string) => Promise, - parent: MoneriumKycActorRef -): Promise<{ authUrl: string; codeVerifier: string }> => { - console.log("Handling Monerium SIWE auth for address:", address); - - const codeVerifier = CryptoJS.lib.WordArray.random(64).toString(); - const codeChallenge = CryptoJS.enc.Base64url.stringify(CryptoJS.SHA256(codeVerifier)); - const redirectUri = window.location.origin + "/widget"; - - const message = createMoneriumSiweMessage(address); - - try { - parent.send({ phase: "login", type: "SIGNING_UPDATE" }); - const signature = await signMessage(message); - parent.send({ phase: "finished", type: "SIGNING_UPDATE" }); - const params = new URLSearchParams({ - authentication_method: "siwe", - client_id: VORTEX_APP_CLIENT_ID, - code_challenge: codeChallenge, - code_challenge_method: "S256", - message: message, - redirect_uri: redirectUri, - signature: signature - }); - - const authUrl = `${MONERIUM_API_URL}/auth?${params}`; - return { authUrl, codeVerifier }; - } catch (error) { - if (error instanceof Error && error.message.includes("User rejected the request")) { - throw new MoneriumAuthError("User rejected the request.", MoneriumAuthErrorType.UserRejected); - } - console.log("Error during Monerium SIWE auth:", error); - throw error; - } -}; - -export const exchangeMoneriumCode = async (code: string, codeVerifier: string): Promise<{ authToken: string }> => { - console.log("Exchanging Monerium code:", code, "with verifier:", codeVerifier); - const redirectUri = window.location.origin + "/widget"; - const response = await fetch(`${MONERIUM_API_URL}/auth/token`, { - body: new URLSearchParams({ - client_id: VORTEX_APP_CLIENT_ID, - code, - code_verifier: codeVerifier, - grant_type: "authorization_code", - redirect_uri: redirectUri // We MUST use the same redirect URI as in the initial request - }), - headers: { - "Content-Type": "application/x-www-form-urlencoded" - }, - method: "POST" - }); - - if (!response.ok) { - throw new Error("Failed to exchange code"); - } - - const responseData = await response.json(); - console.log("Monerium auth response:", responseData); - return { authToken: responseData.access_token }; -}; diff --git a/apps/frontend/src/services/signingService.tsx b/apps/frontend/src/services/signingService.tsx index d30a9e2c3..ceaa74a10 100644 --- a/apps/frontend/src/services/signingService.tsx +++ b/apps/frontend/src/services/signingService.tsx @@ -1,27 +1,8 @@ -import { useQuery } from "@tanstack/react-query"; -import { BrlaCreateSubaccountRequest, BrlaGetKycStatusResponse, FiatToken, KycLevel1Payload } from "@vortexfi/shared"; +import { BrlaCreateSubaccountRequest, BrlaGetKycStatusResponse, KycLevel1Payload } from "@vortexfi/shared"; import { SIGNING_SERVICE_URL } from "../constants/constants"; import { isApiError } from "./api/api-client"; import { BrlaService } from "./api/brla.service"; -interface AccountStatusResponse { - status: boolean; - public: string; -} - -interface SigningServiceStatus { - pendulum: AccountStatusResponse; - stellar: AccountStatusResponse; - moonbeam: AccountStatusResponse; -} - -interface SignerServiceSep10Response { - clientSignature: string; - clientPublic: string; - masterClientSignature: string; - masterClientPublic: string; -} - export enum KycStatus { PENDING = "PENDING", REJECTED = "REJECTED", @@ -46,123 +27,11 @@ export interface RegisterSubaccountPayload { fullName: string; cpf: string; cnpj?: string; - birthdate: number; // Denoted in milliseconds since epoch + birthdate: number; companyName?: string; - startDate?: number; // Denoted in milliseconds since epoch -} - -export interface SignerServiceSep10Request { - challengeXDR: string; - outToken: FiatToken; - clientPublicKey: string; - address: string; - usesMemo?: boolean; -} - -// Generic error for signing service -export class SigningServiceError extends Error { - constructor(message: string) { - super(message); - this.name = "SigningServiceError"; - } -} - -// Specific errors for each funding account -export class StellarFundingAccountError extends SigningServiceError { - constructor() { - super("Stellar account is inactive"); - this.name = "StellarFundingAccountError"; - } -} - -export class PendulumFundingAccountError extends SigningServiceError { - constructor() { - super("Pendulum account is inactive"); - this.name = "PendulumFundingAccountError"; - } -} - -export class MoonbeamFundingAccountError extends SigningServiceError { - constructor() { - super("Moonbeam account is inactive"); - this.name = "MoonbeamFundingAccountError"; - } + startDate?: number; } -export const fetchSigningServiceAccountId = async (): Promise => { - try { - const response = await fetch(`${SIGNING_SERVICE_URL}/v1/status`); - if (!response.ok) { - throw new SigningServiceError("Failed to fetch signing service status"); - } - - const serviceResponse: SigningServiceStatus = await response.json(); - - if (!serviceResponse.stellar?.status) { - throw new StellarFundingAccountError(); - } - if (!serviceResponse.pendulum?.status) { - throw new PendulumFundingAccountError(); - } - if (!serviceResponse.moonbeam?.status) { - throw new MoonbeamFundingAccountError(); - } - - return { - moonbeam: serviceResponse.moonbeam, - pendulum: serviceResponse.pendulum, - stellar: serviceResponse.stellar - }; - } catch (error) { - if (error instanceof SigningServiceError) { - throw error; - } - console.error("Signing service is down: ", error); - throw new SigningServiceError("Signing service is down"); - } -}; - -export const useSigningService = () => { - return useQuery({ - queryFn: fetchSigningServiceAccountId, - queryKey: ["signingService"], - retry: (failureCount, error) => { - if ( - error instanceof StellarFundingAccountError || - error instanceof PendulumFundingAccountError || - error instanceof MoonbeamFundingAccountError - ) { - return false; - } - return failureCount < 3; - } - }); -}; - -export const fetchSep10Signatures = async ({ - challengeXDR, - outToken, - clientPublicKey, - usesMemo, - address -}: SignerServiceSep10Request): Promise => { - const response = await fetch(`${SIGNING_SERVICE_URL}/v1/stellar/sep10`, { - body: JSON.stringify({ address, challengeXDR, clientPublicKey, outToken, usesMemo }), - credentials: "include", - headers: { "Content-Type": "application/json" }, - method: "POST" - }); - if (response.status !== 200) { - if (response.status === 401) { - throw new Error("Invalid signature"); - } - throw new Error(`Failed to fetch SEP10 challenge from server: ${response.statusText}`); - } - - const { clientSignature, clientPublic, masterClientSignature, masterClientPublic } = await response.json(); - return { clientPublic, clientSignature, masterClientPublic, masterClientSignature }; -}; - export const fetchKycStatus = async (taxId: string, quoteId: string, sessionId?: string) => { const statusResponse = await fetch( `${SIGNING_SERVICE_URL}/v1/brla/getKycStatus?taxId=${taxId}"eId=${quoteId}${sessionId ? `&sessionId=${sessionId}` : ""}` @@ -173,7 +42,6 @@ export const fetchKycStatus = async (taxId: string, quoteId: string, sessionId?: } const eventStatus: BrlaGetKycStatusResponse = await statusResponse.json(); - console.log(`Received event status: ${JSON.stringify(eventStatus)}`); return eventStatus; }; diff --git a/apps/frontend/src/services/stellar/index.ts b/apps/frontend/src/services/stellar/index.ts deleted file mode 100644 index 5f302db34..000000000 --- a/apps/frontend/src/services/stellar/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { TomlValues } from "../../types/sep"; - -export const fetchTomlValues = async (TOML_FILE_URL: string): Promise => { - const response = await fetch(TOML_FILE_URL); - if (response.status !== 200) { - throw new Error(`Failed to fetch TOML file: ${response.statusText}`); - } - - const tomlFileContent = (await response.text()).split("\n"); - const findValueInToml = (key: string): string | undefined => { - const keyValue = tomlFileContent.find(line => line.includes(key)); - return keyValue?.split("=")[1].trim().replaceAll('"', ""); - }; - - return { - kycServer: findValueInToml("KYC_SERVER"), - sep6Url: findValueInToml("TRANSFER_SERVER"), - sep24Url: findValueInToml("TRANSFER_SERVER_SEP0024"), - signingKey: findValueInToml("SIGNING_KEY"), - webAuthEndpoint: findValueInToml("WEB_AUTH_ENDPOINT") - }; -}; diff --git a/apps/frontend/src/stores/quote/useQuoteStore.ts b/apps/frontend/src/stores/quote/useQuoteStore.ts index e82556e92..ad29d6784 100644 --- a/apps/frontend/src/stores/quote/useQuoteStore.ts +++ b/apps/frontend/src/stores/quote/useQuoteStore.ts @@ -63,8 +63,11 @@ const friendlyErrorMessages: Record = { [QuoteError.InputAmountForSwapMustBeGreaterThanZero]: "pages.swap.error.tryLargerAmount", [QuoteError.InputAmountTooLow]: "pages.swap.error.tryLargerAmount", [QuoteError.InputAmountTooLowToCoverCalculatedFees]: "pages.swap.error.tryLargerAmount", - [QuoteError.BelowLowerLimitSell]: QuoteError.BelowLowerLimitSell, // We leave this as-is, as the replacement string depends on the context - [QuoteError.BelowLowerLimitBuy]: QuoteError.BelowLowerLimitBuy, // We leave this as-is, as the replacement string depends on the context + // Limit errors pass through; useRampValidation rewrites them with the actual min/max. + [QuoteError.BelowLowerLimitSell]: QuoteError.BelowLowerLimitSell, + [QuoteError.BelowLowerLimitBuy]: QuoteError.BelowLowerLimitBuy, + [QuoteError.AboveUpperLimitSell]: QuoteError.AboveUpperLimitSell, + [QuoteError.AboveUpperLimitBuy]: QuoteError.AboveUpperLimitBuy, [QuoteError.LowLiquidity]: "pages.swap.error.lowLiquidity", // Calculation failures - suggest different amount [QuoteError.UnableToGetPendulumTokenDetails]: "pages.swap.error.tryDifferentAmount", diff --git a/apps/frontend/src/stories/MoneriumAssethubFormStep.stories.tsx b/apps/frontend/src/stories/MoneriumAssethubFormStep.stories.tsx deleted file mode 100644 index cdeee2fe2..000000000 --- a/apps/frontend/src/stories/MoneriumAssethubFormStep.stories.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { FormProvider, useForm } from "react-hook-form"; -import { MoneriumAssethubFormStep } from "../components/widget-steps/MoneriumAssethubFormStep"; - -// Wrapper to provide form context -const FormWrapper = ({ children, defaultValues, errors }: { children: React.ReactNode; defaultValues?: any; errors?: any }) => { - const methods = useForm({ - defaultValues: defaultValues || { - walletAddress: "" - } - }); - - // Manually set errors if provided - if (errors) { - Object.keys(errors).forEach(key => { - methods.setError(key as any, { message: errors[key], type: "manual" }); - }); - } - - return ( - -
    {children}
    -
    - ); -}; - -const meta: Meta = { - component: MoneriumAssethubFormStep, - decorators: [ - (Story, context) => { - const defaultValues = context.parameters.defaultValues; - const errors = context.parameters.errors; - - return ( - -
    - -
    -
    - ); - } - ], - parameters: { - docs: { - description: { - component: - "The MoneriumAssethubFormStep component displays a wallet address input field for Monerium EUR onramps to AssetHub. Users enter their AssetHub wallet address where they want to receive assets." - } - }, - layout: "centered" - }, - tags: ["autodocs"], - title: "Components/Widget Steps/MoneriumAssethubFormStep" -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - parameters: { - docs: { - description: { - story: "Shows the form with an empty wallet address field." - } - } - } -}; - -export const WithWalletAddress: Story = { - parameters: { - defaultValues: { - walletAddress: "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" - }, - docs: { - description: { - story: "Shows the form with a pre-filled AssetHub (Polkadot) wallet address." - } - } - } -}; - -export const WithValidationError: Story = { - parameters: { - defaultValues: { - walletAddress: "" - }, - docs: { - description: { - story: "Shows the form with a validation error when the wallet address is required but not provided." - } - }, - errors: { - walletAddress: "Wallet address is required" - } - } -}; - -export const WithInvalidAddress: Story = { - parameters: { - defaultValues: { - walletAddress: "invalid_address_format" - }, - docs: { - description: { - story: "Shows the form with an invalid wallet address format error." - } - }, - errors: { - walletAddress: "Invalid Polkadot wallet address" - } - } -}; - -export const LongAddress: Story = { - parameters: { - defaultValues: { - walletAddress: "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQYVeryLongAddressExample" - }, - docs: { - description: { - story: "Shows how the component handles a very long wallet address." - } - } - } -}; diff --git a/apps/frontend/src/stories/MoneriumRedirectStep.stories.tsx b/apps/frontend/src/stories/MoneriumRedirectStep.stories.tsx deleted file mode 100644 index d817cfd1d..000000000 --- a/apps/frontend/src/stories/MoneriumRedirectStep.stories.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { MoneriumRedirectStep } from "../components/widget-steps/MoneriumRedirectStep"; -import { RampStateContext } from "../contexts/rampState"; - -// Helper to create a complete snapshot with Monerium KYC actor -const createSnapshot = () => ({ - children: { - moneriumKyc: { - getSnapshot: () => ({ - context: {}, - send: (event: any) => console.log("Monerium KYC event:", event), - value: "Redirect" - }), - send: (event: any) => console.log("Monerium KYC send:", event) - } - }, - context: { - apiKey: undefined, - authToken: undefined, - callbackUrl: undefined, - chainId: undefined, - connectedWalletAddress: undefined, - errorMessage: undefined, - executionInput: undefined, - externalSessionId: undefined, - getMessageSignature: undefined, - initializeFailedMessage: undefined, - isQuoteExpired: false, - isSep24Redo: false, - partnerId: undefined, - paymentData: undefined, - quote: undefined, - quoteId: undefined, - quoteLocked: undefined, - rampDirection: undefined, - rampPaymentConfirmed: false, - rampSigningPhase: undefined, - rampState: undefined, - substrateWalletAccount: undefined, - walletLocked: undefined - }, - error: undefined, - historyValue: undefined, - output: undefined, - status: "active" as const, - tags: new Set(), - value: "KYC" -}); - -const meta: Meta = { - component: MoneriumRedirectStep, - decorators: [ - Story => { - const snapshot = createSnapshot(); - - return ( - -
    - -
    -
    - ); - } - ], - parameters: { - docs: { - description: { - component: - "The MoneriumRedirectStep component is shown when the user needs to be redirected to Monerium for KYC verification. It provides options to cancel or proceed to the partner site." - } - }, - layout: "centered" - }, - tags: ["autodocs"], - title: "Components/Widget Steps/MoneriumRedirectStep" -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - parameters: { - docs: { - description: { - story: "Shows the Monerium redirect step with Cancel and Go to Partner buttons." - } - } - } -}; - -export const Interactive: Story = { - parameters: { - docs: { - description: { - story: "Interactive version showing button click behavior (check console for events)." - } - } - }, - play: async () => { - console.log("MoneriumRedirectStep is interactive - click buttons to test"); - } -}; diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index e729f6a18..cfac9b6e5 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -12,9 +12,7 @@ "title": "Special offer until 27 May" }, "alfredpayKycFlow": { - "accountVerified": "Your account has been verified. You can now proceed.", "cancel": "Cancel", - "completed": "{{kycOrKyb}} Completed!", "completeInNewWindow": "Please complete the {{kycOrKyb}} process in the new window. Once you are done, click 'I have finished the verification'.", "completeProcess": "To continue, please complete the {{kycOrKyb}} process with our partner AlfredPay.", "continue": "Continue", @@ -455,7 +453,6 @@ "avenia": "Avenia", "careers": "Careers", "licences": "Licences", - "monerium": "Monerium EMI ehf.", "mykobo": "MyKobo sp. z.o.o.", "privacyPolicy": "Privacy Policy", "termsAndConditions": "Terms and Conditions", @@ -511,6 +508,12 @@ "subtitle": "Please upload a photo or scan of the representative's ID document.", "title": "Representative ID ({{current}} of {{total}})" }, + "kycDoneScreen": { + "accountVerified": "Your account has been verified. You can now proceed.", + "completed": "{{kycOrKyb}} Completed!", + "continue": "Continue", + "documentVerifiedAlt": "Document verified" + }, "kycLevel2Toggle": { "temporarilyDisabled": "Temporarily disabled" }, @@ -522,20 +525,6 @@ }, "button": "Maintenance Mode" }, - "moneriumFormStep": { - "description": { - "1": "Please enter the address where you want to receive the assets on AssetHub.", - "2": "Then, authenticate with our stablecoin partner Monerium by connecting your EVM wallet." - }, - "field": { - "label": "AssetHub Wallet Address" - } - }, - "moneriumRedirect": { - "cancel": "Cancel", - "description": "You have been redirected to our partner...", - "goToPartner": "Go to partner" - }, "mxnDocumentUpload": { "backLabel": "Back of Document", "fileHint": "Accepted formats: JPG, PNG, PDF — max 5 MB each", @@ -568,6 +557,51 @@ "title": "Identity Verification", "zipCode": "ZIP Code" }, + "mykoboKycFlow": { + "checkingProfile": "Checking your verification status...", + "failure": "Verification failed", + "form": { + "addressLine1": "Address line 1", + "backOfId": "Back of ID", + "bankAccountNumber": "IBAN", + "city": "City", + "documents": "Documents", + "emailAddress": "Email address", + "errors": { + "backRequired": "Back of ID is required for ID cards and driver's licenses.", + "requiredFields": "Please fill in all required fields.", + "requiredFiles": "Front of ID, selfie, and utility bill are required." + }, + "filePlaceholder": "PNG, JPG or PDF", + "firstName": "First name", + "frontOfId": "Front of ID", + "idCountryCode": "Country (ISO alpha-2)", + "idType": "ID type", + "idTypeOptions": { + "DRIVERS_LICENSE": "Driver's license", + "ID_CARD": "ID card", + "PASSPORT": "Passport" + }, + "lastName": "Last name", + "selfie": "Selfie", + "sourceOfFunds": "Source of funds", + "sourceOfFundsOptions": { + "EMPLOYMENT": "Employment", + "INHERITANCE": "Inheritance", + "INVESTMENT": "Investment", + "LOANS": "Loans", + "SAVINGS": "Savings" + }, + "submit": "Submit", + "taxCountry": "Tax country (ISO alpha-2)", + "title": "KYC verification", + "utilityBill": "Utility bill" + }, + "rejected": "Your verification was rejected.", + "startOver": "Start over", + "submitting": "Submitting your verification...", + "verifying": "Verifying your identity. This may take a few minutes..." + }, "navbar": { "api": "API", "bookDemo": "Book demo", @@ -636,12 +670,14 @@ "hint": "Scan this QR code in your banking app to start the payment instantly.", "iban": "IBAN", "receiver": "Recipient", + "reference": "Reference", "title": "Do an instant bank transfer with the following details" }, "headerText": { "buy": "Payment Summary", "sell": "Payment Summary" }, + "iban": "IBAN", "MXNOnrampDetails": { "expiresAt": "Expires", "instruction": "Transfer funds via SPEI to the CLABE below", @@ -751,8 +787,7 @@ "hooks": { "useGetRampRegistrationErrorMessage": { "default": "Something went wrong", - "quoteNotFound": "The quote is expired. Please try again.", - "userMintAddressNotFound": "Account creation in progress... Please try again shortly." + "quoteNotFound": "The quote is expired. Please try again." }, "useSubmitOfframp": { "cnpjUserDoesntExist": "Please contact our support team at support@vortexfinance.co to get onboarded as a business user.", @@ -1072,15 +1107,6 @@ "label": "Website:" } }, - "monerium": { - "name": "Monerium EMI ehf. (EEA/Iceland)", - "privacyTos": { - "label": "Privacy/ToS:" - }, - "website": { - "label": "Website:" - } - }, "mykobo": { "name": "MYKOBO UAB (EU/Lithuania)", "privacy": { @@ -1181,9 +1207,8 @@ "hydrationSwap": "Swapping {{inputAssetSymbol}} to {{outputAssetSymbol}} on Hydration DEX", "hydrationToAssethubXcm": "Transferring {{assetSymbol}} from Hydration --> AssetHub", "initial": "Starting process", - "moneriumOnrampMint": "Waiting to receive payment", - "moneriumOnrampSelfTransfer": "Transferring EUR.e to the ephemeral account", "moonbeamToPendulum": "Transferring {{assetSymbol}} from Moonbeam --> Pendulum", + "mykoboOnrampDeposit": "Waiting to receive payment", "pendulumToAssethubXcm": "Transferring {{assetSymbol}} from Pendulum --> AssetHub", "pendulumToHydrationXcm": "Transferring {{assetSymbol}} from Pendulum --> Hydration", "pendulumToMoonbeamXcm": "Transferring {{assetSymbol}} from Pendulum --> Moonbeam", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index c6660f9c9..2fa092c84 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -12,9 +12,7 @@ "title": "Oferta especial até 27 Maio" }, "alfredpayKycFlow": { - "accountVerified": "Sua conta foi verificada. Você pode prosseguir agora.", "cancel": "Cancelar", - "completed": "{{kycOrKyb}} Concluído!", "completeInNewWindow": "Por favor, complete o processo {{kycOrKyb}} na nova janela. Quando terminar, clique no botão abaixo.", "completeProcess": "Para continuar, complete o processo {{kycOrKyb}} com nosso parceiro AlfredPay.", "continue": "Continuar", @@ -458,7 +456,6 @@ "avenia": "Avenia", "careers": "Carreiras", "licences": "Licenças", - "monerium": "Monerium", "mykobo": "MyKobo", "privacyPolicy": "Política de Privacidade", "termsAndConditions": "Termos e Condições", @@ -515,6 +512,12 @@ "subtitle": "Por favor, envie uma foto ou digitalização do documento de identidade do representante.", "title": "Documento do Representante ({{current}} de {{total}})" }, + "kycDoneScreen": { + "accountVerified": "Sua conta foi verificada. Você pode prosseguir agora.", + "completed": "{{kycOrKyb}} Concluído!", + "continue": "Continuar", + "documentVerifiedAlt": "Documento verificado" + }, "kycLevel2Toggle": { "temporarilyDisabled": "Temporariamente desativado" }, @@ -526,20 +529,6 @@ }, "button": "Modo de Manutenção" }, - "moneriumFormStep": { - "description": { - "1": "Por favor, insira o endereço onde deseja receber os ativos no AssetHub.", - "2": "Em seguida, autentique-se com nosso parceiro de stablecoin Monerium conectando sua carteira EVM." - }, - "field": { - "label": "Endereço da Carteira AssetHub" - } - }, - "moneriumRedirect": { - "cancel": "Cancelar", - "description": "Você foi redirecionado para nosso parceiro...", - "goToPartner": "Ir para o parceiro" - }, "mxnDocumentUpload": { "backLabel": "Verso do Documento", "fileHint": "Formatos aceitos: JPG, PNG, PDF — máx. 5 MB cada", @@ -572,6 +561,51 @@ "title": "Verificação de Identidade", "zipCode": "CEP" }, + "mykoboKycFlow": { + "checkingProfile": "Verificando o status da sua verificação...", + "failure": "Falha na verificação", + "form": { + "addressLine1": "Endereço linha 1", + "backOfId": "Verso do documento", + "bankAccountNumber": "IBAN", + "city": "Cidade", + "documents": "Documentos", + "emailAddress": "Endereço de e-mail", + "errors": { + "backRequired": "O verso do documento é obrigatório para carteiras de identidade e carteiras de motorista.", + "requiredFields": "Por favor, preencha todos os campos obrigatórios.", + "requiredFiles": "Frente do documento, selfie e comprovante de residência são obrigatórios." + }, + "filePlaceholder": "PNG, JPG ou PDF", + "firstName": "Nome", + "frontOfId": "Frente do documento", + "idCountryCode": "País (ISO alpha-2)", + "idType": "Tipo de documento", + "idTypeOptions": { + "DRIVERS_LICENSE": "Carteira de motorista", + "ID_CARD": "Carteira de identidade", + "PASSPORT": "Passaporte" + }, + "lastName": "Sobrenome", + "selfie": "Selfie", + "sourceOfFunds": "Origem dos fundos", + "sourceOfFundsOptions": { + "EMPLOYMENT": "Emprego", + "INHERITANCE": "Herança", + "INVESTMENT": "Investimento", + "LOANS": "Empréstimos", + "SAVINGS": "Poupança" + }, + "submit": "Enviar", + "taxCountry": "País de tributação (ISO alpha-2)", + "title": "Verificação KYC", + "utilityBill": "Comprovante de residência" + }, + "rejected": "Sua verificação foi rejeitada.", + "startOver": "Começar de novo", + "submitting": "Enviando sua verificação...", + "verifying": "Verificando sua identidade. Isso pode levar alguns minutos..." + }, "navbar": { "api": "API", "bookDemo": "Agendar demo", @@ -640,12 +674,14 @@ "hint": "Escaneie este QR code no seu aplicativo bancário para iniciar o pagamento instantaneamente.", "iban": "IBAN", "receiver": "Nome do Beneficiário", + "reference": "Referência", "title": "Faça uma transferência bancária instantânea com os seguintes detalhes" }, "headerText": { "buy": "Resumo do pagamento", "sell": "Resumo do pagamento" }, + "iban": "IBAN", "MXNOnrampDetails": { "expiresAt": "Expira", "instruction": "Transfira os fundos via SPEI para o CLABE abaixo", @@ -755,8 +791,7 @@ "hooks": { "useGetRampRegistrationErrorMessage": { "default": "Algo deu errado", - "quoteNotFound": "A cotação expirou. Por favor, tente novamente.", - "userMintAddressNotFound": "Sua conta está sendo criada, por favor tente novamente em alguns minutos." + "quoteNotFound": "A cotação expirou. Por favor, tente novamente." }, "useSubmitOfframp": { "cnpjUserDoesntExist": "Entre em contato com nossa equipe de suporte em support@vortexfinance.co para começar como usuário empresarial.", @@ -1076,15 +1111,6 @@ "label": "Website:" } }, - "monerium": { - "name": "Monerium EMI ehf. (EEA/Islândia)", - "privacyTos": { - "label": "Privacidade/ToS:" - }, - "website": { - "label": "Website:" - } - }, "mykobo": { "name": "MYKOBO UAB (UE/Lituânia)", "privacy": { @@ -1186,9 +1212,8 @@ "hydrationSwap": "Trocando {{inputAssetSymbol}} por {{outputAssetSymbol}} no Hydration DEX", "hydrationToAssethubXcm": "Transferindo {{assetSymbol}} de Hydration --> AssetHub", "initial": "Iniciando processo", - "moneriumOnrampMint": "Aguardando pagamento", - "moneriumOnrampSelfTransfer": "Transferindo EUR.e para a conta efêmera", "moonbeamToPendulum": "Transferindo {{assetSymbol}} de Moonbeam --> Pendulum", + "mykoboOnrampDeposit": "Aguardando recebimento do pagamento", "pendulumToAssethubXcm": "Transferindo {{assetSymbol}} de Pendulum --> AssetHub", "pendulumToHydrationXcm": "Transferindo {{assetSymbol}} de Pendulum --> Hydration", "pendulumToMoonbeamXcm": "Transferindo {{assetSymbol}} de Pendulum --> Moonbeam", diff --git a/apps/frontend/src/types/phases.ts b/apps/frontend/src/types/phases.ts index b186b597b..d7034c351 100644 --- a/apps/frontend/src/types/phases.ts +++ b/apps/frontend/src/types/phases.ts @@ -27,9 +27,7 @@ export interface RampExecutionInput { onChainToken: OnChainTokenSymbol; fiatToken: FiatToken; sourceOrDestinationAddress: string; // The source address for offramps, destination address for onramps - moneriumWalletAddress?: string; // Only needed for Monerium offramps to non-EVM chains (e.g. Monerium -> Assethub) ephemerals: { - stellarEphemeral: EphemeralAccount; substrateEphemeral: EphemeralAccount; evmEphemeral: EphemeralAccount; }; diff --git a/apps/frontend/src/types/sep.ts b/apps/frontend/src/types/sep.ts deleted file mode 100644 index 5855234f0..000000000 --- a/apps/frontend/src/types/sep.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { StellarTokenDetails } from "@vortexfi/shared"; - -export interface TomlValues { - signingKey?: string; - webAuthEndpoint?: string; - sep24Url?: string; - sep6Url?: string; - kycServer?: string; -} - -export interface ISep24Intermediate { - url: string; - id: string; -} - -export interface IAnchorSessionParams { - token: string; - tomlValues: TomlValues; - tokenConfig: StellarTokenDetails; - offrampAmount: string; -} - -export interface SepResult { - amount: string; - memo: string; - memoType: string; - offrampingAccount: string; -} diff --git a/bun.lock b/bun.lock index 5117a5b2e..8518c1464 100644 --- a/bun.lock +++ b/bun.lock @@ -106,7 +106,6 @@ "@fontsource/roboto": "^5.0.8", "@heroicons/react": "^2.1.3", "@hookform/resolvers": "^4.1.3", - "@monerium/sdk": "^3.4.2", "@pendulum-chain/api": "catalog:", "@pendulum-chain/api-solang": "catalog:", "@polkadot/api": "catalog:", @@ -998,8 +997,6 @@ "@metamask/utils": ["@metamask/utils@11.10.0", "", { "dependencies": { "@ethereumjs/tx": "^4.2.0", "@metamask/superstruct": "^3.1.0", "@noble/hashes": "^1.3.1", "@scure/base": "^1.1.3", "@types/debug": "^4.1.7", "@types/lodash": "^4.17.20", "debug": "^4.3.4", "lodash": "^4.17.21", "pony-cause": "^2.1.10", "semver": "^7.5.4", "uuid": "^9.0.1" } }, "sha512-+bWmTOANx1MbBW6RFM8Se4ZoigFYGXiuIrkhjj4XnG5Aez8uWaTSZ76yn9srKKClv+PoEVoAuVtcUOogFEMUNA=="], - "@monerium/sdk": ["@monerium/sdk@3.4.10", "", { "dependencies": { "crypto-js": "^4.2.0" } }, "sha512-WB9PS4D8DMiP2ufI2iTk5I5VbZFJFzJyEnltYl8crnn2SglJhW2i9PVu9hXUgrT5H5Q6MkaxU7eyuYLSRyCZXg=="], - "@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.4.6", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g=="], "@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="], diff --git a/docs/features/mykobo-eur-offramp.md b/docs/features/mykobo-eur-offramp.md new file mode 100644 index 000000000..86d8ec244 --- /dev/null +++ b/docs/features/mykobo-eur-offramp.md @@ -0,0 +1,247 @@ +# Mykobo EUR Offramp Integration Plan + +**Status**: In progress — backend ramping flow +**Owner**: this session +**Stellar EUR offramp**: untouched for now; removed in a later session after Mykobo is verified + +--- + +## Goal + +Replace the EUR offramp leg that currently runs through Stellar anchors (Spacewalk redeem → Stellar payment) with a new EVM-only flow that: + +1. Starts on any `supportsRamp: true` EVM chain (Polygon, Ethereum, BSC, Arbitrum, Base, Avalanche — **not** AssetHub, Hydration, or any substrate chain) +2. Uses Squidrouter (permit-based, AlfredPay-style) to deliver Circle USDC onto a Base EVM ephemeral account +3. Swaps USDC → EURC on Base Nabla DEX +4. Forwards EURC to Mykobo's receivables wallet (returned by their intent API) +5. Mykobo pays the user in EUR via SEPA + +KYC / profile creation is a **separate session**. This session focuses on ramping flow + quote engine only. + +--- + +## Mykobo API (https://api-dev.mykobo.app/docs/) + +### Base URLs +- Prod: `https://api.mykobo.app/v1` +- Dev: `https://api-dev.mykobo.app/v1` + +### Auth +Bearer token. Acquire via `POST /v1/auth/token` with `{access_key, secret_key}` → `{subject_id, token, refresh_token}`. Refresh via `POST /v1/auth/refresh`. Token TTL is unspecified in docs → lazy refresh on 401. + +### Required scopes (all on one token) +- `transaction:read` — list/get transactions, fees +- `transaction:write` — create intents +- `user:write` — create/get profiles (later session, but we'll request the scope now) + +### Endpoints we use in this session + +| Endpoint | Method | Purpose | +|---|---|---| +| `/v1/auth/token` | POST | Acquire bearer + refresh | +| `/v1/auth/refresh` | POST | Refresh bearer | +| `/v1/transactions/intent` | POST | Create `WITHDRAW` intent → returns `instructions.address` (Mykobo's receivables wallet) and `transaction.id` | +| `/v1/transactions/{id}` | GET | Poll status until `COMPLETED` (or fail states) | +| `/v1/fees` | GET `?value=X&kind=withdraw&client_domain=Y` | Returns fee in EURC (already correct currency) | + +### Endpoints used in later session (KYC) +- `POST /v1/profiles` (multipart, KYC docs) +- `GET /v1/profiles?email=` (lookup profile by email) + +### Critical Mykobo semantics + +- **Intent body fields**: `transaction_type="WITHDRAW"`, `wallet_address` (ephemeral 0x), `email_address` (persistent identity — auto-binds new ephemeral on each ramp), `value`, `currency="EURC"`, `ip_address`, optional `client_domain`. +- **WITHDRAW response** contains `instructions.address` = the **destination address we must send EURC to** (Mykobo's receivables wallet). It is **not** the user's IBAN. The user's IBAN is on their KYC'd profile; Mykobo pays out from their side. +- **Profile resolution errors**: + - `404 profile_not_found` — surface as registration error + - `403 kyc_required` — surface with `kyc_status` field to route to KYC flow + - `409 wallet_email_mismatch` — should not happen in our flow because Mykobo auto-binds on first use; if it does, surface +- **Fees**: returns `{total, asset: "EURC", details: [...]}`. When `client_domain` is set, fees come back in EURC for both deposit and withdraw kinds. + +--- + +## Locked Design Decisions + +| Decision | Choice | Reason | +|---|---|---| +| Source chains | All EVM chains with `supportsRamp: true` | Matches AlfredPay model | +| USDC → EURC swap venue | Nabla EURC pool on Base (`NABLA_ROUTER_BASE_EURC` / `NABLA_QUOTER_BASE_EURC`, selected via `getNablaBasePool()`) | Dedicated EURC<>USDC pool, separate from the BRLA<>USDC pool used by BRL flows | +| `wallet_address` on Mykobo intent | Ephemeral 0x | Mykobo auto-binds email→ephemeral; identity is email-based | +| When to create intent | At ramp **registration** (`prepareEvmToMykoboOfframpTransactions`) | Lets us presign the final EURC transfer to Mykobo's receivables address (BRL-EVM style) | +| Email source | Frontend reads the Supabase-authenticated user's email and passes it as the `email` query param to `GET /v1/mykobo/profiles`; backend cross-checks the param against `req.userEmail` and queries Mykobo by email via `MykoboApiService.getProfileByEmail` | Aligns with Supabase-auth profile model; avoids leaking wallet→profile linkage | +| Identity persistence | JSONB only on `RampState.state` (no new `MykoboCustomer` table yet) | "No over-engineering" rule; KYC session can normalize later | +| Mykobo client style | Singleton class mirroring `BrlaApiService` | Repo convention; easy mocking | +| Token strategy | Single shared bearer with all 3 scopes, lazy init, 401→refresh→re-acquire | Simplest robust model; matches docs | +| Fee currency | Returned as EURC directly from Mykobo (no conversion) | Confirmed by user with live API output | +| Anchor record | New migration file `0XX-mykobo-anchor.ts` inserting `mykobo_eurc` | Migrations are append-only | +| Permit pattern (cross-chain) | Reuse AlfredPay's `squidRouterPermitExecute` phase + `TokenRelayer.execute()` | TokenRelayer at `0xC9ECD03c89349B3EAe4613c7091c6c3029413785` (Polygon); for EUR offramp, Squidrouter brings funds onto **Base** ephemeral. If source chain doesn't support permit, fall back to `squidRouterApprove + squidRouterSwap` (same as AlfredPay no-permit fallback) | +| Stellar code | **Do not touch** in this session | Remove after Mykobo flow verified end-to-end | + +--- + +## Phase Sequence (EUR-EVM Offramp via Mykobo on Base) + +Mirror of BRL-EVM (`evm-to-brl-base.ts`) with USDC→EURC and Mykobo payout. + +``` +[User wallet, source EVM chain] + squidRouterApprove (nonce 0, source chain) — user approves squid router for input token + squidRouterSwap (nonce 1, source chain) — user swaps via squid → USDC lands on Base ephemeral + ─ OR (when permit supported) ─ + squidRouterPermitExecute — executor calls TokenRelayer.execute(permit + payload) + ─ OR (when permit NOT supported AND same chain) ─ + squidRouterNoPermitTransfer — direct transfer to ephemeral (Base only) + +[Backend executor / Base ephemeral] + fundEphemeral — backend sends ETH for gas + distributeFees (nonce 0, Base) — USDC fee slice to fee wallet + nablaApprove (nonce 1, Base) — approve Nabla router for USDC + nablaSwap (nonce 2, Base) — USDC → EURC on Base Nabla + mykoboPayoutOnBase (nonce 3, Base) — EURC transfer to Mykobo receivables address + → backend polls GET /v1/transactions/{id} until COMPLETED + complete + +[Cleanup — post-process worker] + baseCleanupUsdc (nonce 4, Base) — approve funding account to sweep residual USDC + baseCleanupEurc (nonce 5, Base) [NEW] — approve funding account to sweep residual EURC + baseCleanupAxlUsdc (nonce 6, Base) — approve funding account to sweep axlUSDC slippage +``` + +**Special case**: if user is already on Base with USDC, skip the squidrouter leg entirely (same shortcut as BRL-EVM line 73). + +--- + +## Quote Engine Pipeline + +New strategy `offrampToSepaEvmStrategy`, mirrors `offrampToPixEvmStrategy`: + +``` +[StageKey.Initialize] OffRampFromEvmInitializeEngine(Networks.Base) [squid quote → Base USDC] +[StageKey.NablaSwap] OffRampSwapEngineEvm(EvmToken.EURC) [USDC → EURC on Base Nabla] +[StageKey.Fee] OffRampFeeMykoboEngine [NEW] [GET /v1/fees → EURC fee] +[StageKey.Discount] OffRampDiscountEngine +[StageKey.MergeSubsidy] OffRampMergeSubsidyEvmEngine +[StageKey.Finalize] OffRampFinalizeEngine +``` + +Route resolver dispatch (in `route-resolver.ts`): + +```ts +case "sepa": + return ctx.from !== Networks.AssetHub + ? offrampToSepaEvmStrategy // EVM source → Mykobo + : offrampToStellarStrategy; // substrate source → Stellar (unchanged) +``` + +This preserves the Stellar EUR path for AssetHub sources (we'll remove it in a later session). + +--- + +## File Inventory + +### New files (8) + +1. `packages/shared/src/services/mykobo/types.ts` — request/response types +2. `packages/shared/src/services/mykobo/mykoboApiService.ts` — singleton HTTP client +3. `packages/shared/src/services/mykobo/index.ts` — re-exports +4. `apps/api/src/api/services/transactions/offramp/routes/evm-to-mykobo.ts` — presigned tx builder +5. `apps/api/src/api/services/phases/handlers/mykobo-payout-handler.ts` — payout handler +6. `apps/api/src/api/services/quote/engines/fee/offramp-mykobo.ts` — fee engine +7. `apps/api/src/api/services/quote/routes/strategies/offramp-to-sepa-evm.strategy.ts` — strategy +8. `apps/api/src/database/migrations/0XX-mykobo-anchor.ts` — anchor seed migration + +### Modified files (~12) + +1. `packages/shared/src/tokens/types/evm.ts` — add `EURC = "EURC"` to `EvmToken` +2. `packages/shared/src/tokens/evm/config.ts` — EURC entry for `Networks.Base` (`0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42`, 6 decimals); optionally `BaseSepolia` +3. `packages/shared/src/constants/constants.ts` or `apps/api/src/constants/vars.ts` — add `MYKOBO_BASE_URL`, `MYKOBO_ACCESS_KEY`, `MYKOBO_SECRET_KEY`, `MYKOBO_CLIENT_DOMAIN` env vars +4. `apps/api/src/api/services/phases/meta-state-types.ts` — add `mykoboEmail`, `mykoboTransactionId`, `mykoboReceivablesAddress`, `mykoboPayoutTxHash`, `mykoboTransactionReference` +5. `apps/api/src/api/controllers/ramp.controller.ts` + `apps/api/src/api/services/ramp/ramp.service.ts` — accept optional `email` on `POST /v1/ramp/register`; thread to `prepareOfframpTransactions` +6. `apps/api/src/api/services/transactions/offramp/index.ts` — add dispatch branch for EUR EVM +7. `apps/api/src/api/services/ramp/ramp-transaction-preparation.ts` — add `OfframpMykobo` discriminator (next to `OfframpBrl`) +8. `apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts` — extend `getRequiresBaseEphemeralAddress()` for EUR offramp +9. `apps/api/src/api/services/phases/register-handlers.ts` — register `MykoboPayoutOnBasePhaseHandler` +10. `apps/api/src/database/seeders/phase-metadata.seeder.ts` (or equivalent) — register `mykoboPayoutOnBase` and `baseCleanupEurc` phases + valid transitions +11. `apps/api/src/api/services/quote/routes/route-resolver.ts` — add EVM-source branch for `sepa` case +12. `apps/api/src/api/services/quote/core/quote-fees.ts` — use `mykobo_eurc` anchor identifier when `to=sepa` and source is EVM +13. `apps/api/src/api/services/phases/post-process/base-chain-post-process-handler.ts` — add `baseCleanupEurc` to cleanup sweep + +### Touch validation +- `apps/api/src/api/services/transactions/onramp/common/validation.ts:122` — currently enforces `inputCurrency === FiatToken.EURC` for onramp. **Onramp path is unaffected**; we're only adding offramp. Do not touch. +- `apps/api/src/api/services/transactions/offramp/index.ts:33` — currently `outputCurrency === FiatToken.EURC && moneriumAuthToken` routes to Monerium. Our new branch is `outputCurrency === FiatToken.EURC && isEvmSource && !moneriumAuthToken → Mykobo`. Monerium path stays as fallback for clients that still pass `moneriumAuthToken`. + +--- + +## State Metadata Fields (new on `StateMetadata`) + +```ts +mykoboEmail?: string; // persistent identity passed by frontend +mykoboTransactionId?: string; // UUID returned by POST /v1/transactions/intent (for polling) +mykoboTransactionReference?: string; // human reference from intent response +mykoboReceivablesAddress?: `0x${string}`; // instructions.address from intent (the destination of mykoboPayoutOnBase) +mykoboPayoutTxHash?: `0x${string}`; // on-chain hash of the EURC transfer (recovery support) +``` + +--- + +## Anchor record + +Insert into `Anchor` table: + +```ts +{ + identifier: "mykobo_eurc", + name: "Mykobo (EUR via Base)", + // ... whatever other fields the model requires (TBD by reading model) +} +``` + +`OffRampFeeMykoboEngine` will look it up via the same path as `offramp-avenia` does for `avenia` anchor. + +--- + +## Env Vars (new) + +| Var | Required | Default | Purpose | +|---|---|---|---| +| `MYKOBO_BASE_URL` | yes | — | `https://api-dev.mykobo.app/v1` (dev) or `https://api.mykobo.app/v1` (prod) | +| `MYKOBO_ACCESS_KEY` | yes | — | from Mykobo dashboard | +| `MYKOBO_SECRET_KEY` | yes | — | from Mykobo dashboard | +| `MYKOBO_CLIENT_DOMAIN` | no | (Mykobo defaults to `.mykobo.app`) | client domain for fee scope (e.g. `satoshipay.io`) | + +--- + +## Existing infrastructure reused (no changes needed) + +- ✅ `Networks.Base` configured with `supportsRamp: true` +- ✅ `NABLA_ROUTER_BASE_EURC` + `NABLA_QUOTER_BASE_EURC` constants (EURC pool) and `getNablaBasePool()` selector +- ✅ `calculateNablaSwapOutputEvm()` quote-time helper +- ✅ `addNablaSwapTransactionsOnBase()` tx builder +- ✅ `getEvmFundingAccount(Networks.Base)` for ephemeral derivation +- ✅ `FundEphemeralPhaseHandler.fundEvmEphemeralAccount(state, Networks.Base)` (needs `getRequiresBaseEphemeralAddress` extension) +- ✅ `BaseChainPostProcessHandler` cleanup sweep +- ✅ `createOfframpSquidrouterTransactionsToEvm()` for cross-chain bridge +- ✅ `SquidrouterPermitExecuteHandler` for permit + TokenRelayer +- ✅ `prepareBaseCleanupApproval()` for cleanup approvals +- ✅ `addEvmFeeDistributionTransaction()` for distributing protocol fees + +--- + +## Out of scope for this session + +- KYC / profile creation (`POST /v1/profiles`) — separate session +- Frontend changes +- SDK changes +- Removing Stellar EUR code — explicitly deferred to avoid merge conflicts during build-out +- ARS offramp (still goes through Stellar; Mykobo doesn't replace it) + +--- + +## Verification at end of session + +1. `bun build:shared` +2. `bun typecheck` clean +3. `bun lint:fix` clean +4. Manual: can a quote request with `from=Base, inputCurrency=USDC, to=sepa, outputCurrency=EUR` produce a quote routed through `offrampToSepaEvmStrategy`? +5. Manual: does `POST /v1/ramp/register` accept `email` and call Mykobo intent API? +6. Integration test with Mykobo dev credentials (deferred to a follow-up — needs credential setup). diff --git a/docs/security-spec/00-system-overview/architecture.md b/docs/security-spec/00-system-overview/architecture.md index a4384a42f..1af5b1112 100644 --- a/docs/security-spec/00-system-overview/architecture.md +++ b/docs/security-spec/00-system-overview/architecture.md @@ -2,7 +2,7 @@ ## What This Does -Vortex is a cross-border payment gateway built on the Pendulum blockchain. It converts between fiat currencies (BRL, EUR, ARS) and crypto assets across multiple chains (Pendulum, Moonbeam, Stellar, AssetHub, Hydration, Polygon). The system is a Bun monorepo with four main components: +Vortex is a cross-border payment gateway built on the Pendulum blockchain. It converts between fiat currencies (BRL, EUR, ARS) and crypto assets across multiple chains (Pendulum, Moonbeam, Stellar, AssetHub, Hydration, Polygon, Base). The system is a Bun monorepo with four main components: - **API** (`apps/api`) — Express backend handling ramp orchestration, quote generation, auth, and external service integration - **Frontend** (`apps/frontend`) — React SPA for end-user flows @@ -36,7 +36,7 @@ Vortex is a cross-border payment gateway built on the Pendulum blockchain. It co │ ┌────▼────┐ ┌────▼────┐ ┌───▼──────┐ ┌──▼──────────────┐ │ │ │Postgres │ │Supabase │ │Chains │ │External APIs │ │ │ │(DB) │ │(Auth) │ │(RPC) │ │(BRLA/Avenia, │ │ -│ └─────────┘ └─────────┘ │Pendulum │ │ Monerium, │ │ +│ └─────────┘ └─────────┘ │Pendulum │ │ Mykobo, │ │ │ │Moonbeam │ │ Alfredpay, │ │ │ │Stellar │ │ Squid, Stellar) │ │ │ │AssetHub │ └─────────────────┘ │ @@ -63,7 +63,7 @@ Vortex is a cross-border payment gateway built on the Pendulum blockchain. It co 2. **Trust boundaries MUST be enforced at the middleware layer** — auth checks happen before controller logic, never inside controllers. 3. **The API server MUST NOT hold user private keys** — ephemeral keys are generated client-side (SDK/frontend). The server only receives addresses, never secrets. 4. **Server-held secrets (funding keys, executor keys) MUST only be used for platform operations** — funding ephemeral accounts, executing subsidization, signing webhooks. Never for user-initiated transactions on behalf of the user's own assets. -5. **All external service calls (BRLA, Monerium, Alfredpay, chain RPCs) MUST be treated as untrusted** — responses must be validated, timeouts enforced, and failures handled without corrupting ramp state. +5. **All external service calls (BRLA, Mykobo, Alfredpay, chain RPCs) MUST be treated as untrusted** — responses must be validated, timeouts enforced, and failures handled without corrupting ramp state. 6. **Database state MUST be the single source of truth for ramp progress** — in-memory state is transient and may be lost on restart. 7. **No single component compromise should grant access to all user funds** — the system should limit blast radius through key separation and least-privilege access. 8. **All inter-chain transfers MUST be verified on both source and destination** — sending a transfer is not sufficient; the system must confirm receipt before advancing phases. @@ -75,18 +75,18 @@ Vortex is a cross-border payment gateway built on the Pendulum blockchain. It co | **Unauthorized ramp initiation** | Attacker starts ramps without valid auth, draining liquidity | Auth middleware on all ramp endpoints; quote binding to authenticated session | | **Server compromise** | Attacker gains access to API server, extracts env vars | Key separation (different keys per chain), rotation procedures, minimal secrets in memory | | **Stale RPC data** | Chain RPC returns outdated balances, causing incorrect subsidization | Verify balances at point of use, not cached; cross-check with on-chain finality | -| **External API manipulation** | BRLA/Monerium returns manipulated amounts | Validate external responses against quoted amounts; bound acceptable variance | +| **External API manipulation** | BRLA/Mykobo/Alfredpay returns manipulated amounts | Validate external responses against quoted amounts; bound acceptable variance | | **Database tampering** | Attacker with DB access modifies ramp state to skip phases | Phase transition validation in code (not just DB constraints); audit logging of all state changes | | **Cross-chain message failure** | XCM transfer succeeds on source but fails on destination | Phase handlers wait for destination confirmation before advancing; timeout + retry logic | | **Rebalancer key theft** | Rebalancer's chain keys compromised | Rebalancer uses dedicated keys separate from main API; limited balances; monitoring for unexpected transfers | ## Audit Checklist -- [x] Every route in `apps/api/src/api/routes/v1/` has appropriate auth middleware applied — **PASS: F-013 resolved. Legacy fundEphemeral/execute-xcm/subsidize endpoints removed. `/v1/ramp/*` and `/v1/ramp/quotes(/best)` enforce `requirePartnerOrUserAuth()` with per-principal ownership guards. `/v1/brla/*`, `/v1/maintenance/*`, `/v1/webhook/*` use `requireAuth`/`adminAuth`/`apiKeyAuth` respectively.** +- [x] Every route in `apps/api/src/api/routes/v1/` has appropriate auth middleware applied — **PASS: F-013 resolved. Legacy fundEphemeral/execute-xcm/subsidize endpoints removed. `/v1/ramp/*` and `/v1/ramp/quotes(/best)` enforce `requirePartnerOrUserAuth()` with per-principal ownership guards. `/v1/brla/*`, `/v1/mykobo/profiles` (F-068 resolved), `/v1/maintenance/*`, `/v1/webhook/*` use `requireAuth`/`adminAuth`/`apiKeyAuth` respectively.** - [FAIL] No controller directly accesses `process.env` for secrets — all go through `config/vars.ts` — **F-016: `PENDULUM_FUNDING_SEED` accessed directly in `pendulum.service.ts`; also `SLACK_WEB_HOOK_TOKEN`, `COINGECKO_API_KEY`** - [x] Ephemeral key secrets never appear in API request/response payloads or logs - [x] Phase processor always reads fresh state from DB before executing a phase (no stale cache) -- [FAIL] All external API calls have timeout configuration — **F-014: Most `fetch()` calls lack timeout/AbortController (Monerium, price feeds, Subscan, etc.)** +- [FAIL] All external API calls have timeout configuration — **F-014: Most `fetch()` calls lack timeout/AbortController (Mykobo, price feeds, Subscan, etc.)** - [PARTIAL] Error responses never leak internal state, stack traces, or secret material — **F-015: Stack traces stripped in prod, but raw `err.message` leaks in some paths** - [N/A] Database connection uses TLS in production — **F-017: Not configured in Sequelize options; relies on server-side enforcement** - [x] Rate limiting is applied at the network edge before auth middleware diff --git a/docs/security-spec/01-auth/supabase-otp.md b/docs/security-spec/01-auth/supabase-otp.md index 1765dcc49..5a6a03752 100644 --- a/docs/security-spec/01-auth/supabase-otp.md +++ b/docs/security-spec/01-auth/supabase-otp.md @@ -39,7 +39,7 @@ Two middleware variants exist: ## Audit Checklist -- [x] `requireAuth` is applied to all endpoints that mutate ramp state, access user data, or perform privileged operations — **PASS: F-013 resolved. `/v1/ramp/*` endpoints now use `requirePartnerOrUserAuth()` (sk_ partner key OR Supabase Bearer) with ownership guards; `/v1/brla/*` uses `requireAuth`; admin and webhook routes use `adminAuth`/`apiKeyAuth`.** +- [x] `requireAuth` is applied to all endpoints that mutate ramp state, access user data, or perform privileged operations — **PASS: F-013 resolved. `/v1/ramp/*` endpoints now use `requirePartnerOrUserAuth()` (sk_ partner key OR Supabase Bearer) with ownership guards; `/v1/brla/*` uses `requireAuth`; `/v1/mykobo/profiles` (GET + POST) uses `requireAuth` (F-068 resolved); admin and webhook routes use `adminAuth`/`apiKeyAuth`.** - [x] `optionalAuth` is only used on endpoints where unauthenticated access is intentionally allowed (e.g., public quote lookup) — **PASS** - [x] `SupabaseAuthService.verifyToken()` uses the service role key, not the anon key — **FAIL: Uses anon-key client (F-018). Functionally correct but deviates from spec.** - [x] The `Bearer ` prefix check uses `startsWith("Bearer ")` with the trailing space (not just `"Bearer"`) — **PASS** diff --git a/docs/security-spec/02-signing-keys/ephemeral-accounts.md b/docs/security-spec/02-signing-keys/ephemeral-accounts.md index fc73369bf..fc055c2f3 100644 --- a/docs/security-spec/02-signing-keys/ephemeral-accounts.md +++ b/docs/security-spec/02-signing-keys/ephemeral-accounts.md @@ -12,6 +12,8 @@ Ephemeral accounts are temporary blockchain accounts created per ramp operation. The SDK optionally stores ephemeral keys to a local JSON file (`ephemerals_{rampId}.json`) via the `storeEphemeralKeys` config option (defaults to `true`). +The frontend stores a backup of all ephemeral keypairs in `localStorage["rampEphemerals"]` as a `Record`, keyed by ramp ID. This is persisted by the `PersistenceEffect` in `apps/frontend/src/contexts/rampState.tsx` and is **not** cleared when the ramp context resets (unlike the main `rampState` localStorage item). The purpose is a user-side failsafe: if a ramp fails mid-flow and the main state is wiped, the ephemeral secret keys remain recoverable from this separate localStorage entry. + ## Security Invariants 1. **Ephemeral private keys MUST be generated client-side** — The API MUST never generate, receive, store, or have access to ephemeral private keys. Only addresses (`accountMetas`) are sent to the API. @@ -23,6 +25,7 @@ The SDK optionally stores ephemeral keys to a local JSON file (`ephemerals_{ramp 7. **The API MUST NOT assume the ephemeral address belongs to an honest user** — An attacker could register a ramp with an address they don't control or an address that's a contract (on EVM). Phase handlers must account for this. 8. **Pre-signed transactions MUST be bound to the specific ephemeral address** — Transactions generated by the API for client signing must include the ephemeral address as the source/signer, not a wildcard. 9. **Ephemeral addresses MUST be proven fresh on every chain the platform supports, at ramp registration time** — Before building any transactions, the API MUST verify on-chain that each submitted ephemeral address has zero nonce / zero balance / does not exist (chain-appropriate definition) on every supported chain of its type — not only the chains the specific ramp route will use. Checking the full supported set (rather than a route-derived subset) prevents a future phase-handler addition from silently reopening the freshness gap. Freshness checks MUST fail closed: any RPC error rejects the registration. Reused ephemerals cause mid-ramp halt because the server assumes a clean nonce and (for Stellar) creates the account from scratch. +10. **Frontend `rampEphemerals` localStorage backup MUST be local-only** — The `rampEphemerals` localStorage item stores ephemeral secret keys as plaintext in the browser. It MUST NOT be transmitted to any server, included in analytics, or accessible to third-party scripts. This backup exists solely as a user-side failsafe for debugging failed ramps. ## Threat Vectors & Mitigations @@ -34,7 +37,8 @@ The SDK optionally stores ephemeral keys to a local JSON file (`ephemerals_{ramp | **Funding account drain** | Attacker creates many ramps to drain the Stellar funding account's XLM balance | Rate limiting on ramp creation; monitoring funding account balance; bounded starting balance | | **Orphaned ephemerals** | Ramp fails mid-way, leaving funded ephemeral accounts unclaimed | Stellar 2-of-2 multisig allows the funding account to reclaim funds; Substrate/EVM ephemerals can be swept by the key holder | | **Malicious ephemeral address (contract)** | On EVM, attacker provides a smart contract address as ephemeral, which could behave unexpectedly when receiving tokens | Validate that EVM ephemeral addresses are externally-owned accounts (EOAs), not contracts, before sending funds | -| **Reused / non-fresh ephemeral** | Client (buggy SDK, attacker, or replay) submits an ephemeral address that already has on-chain history — non-zero nonce on Substrate/EVM, or an existing Stellar account. The server builds transactions assuming nonce 0 / no account, so mid-ramp execution halts with nonce-mismatch or "account already exists" errors after subsidies/funding have been spent. | **MITIGATED (F-068)**: `validateEphemeralAccountsFresh()` runs in `registerRamp` immediately after format validation. For each ephemeral type the client provides, it queries every supported chain of that type (Substrate: pendulum, hydration, assethub; EVM: all configured EVM networks including Moonbeam; Stellar) and rejects the registration if any check finds non-zero nonce / non-zero free balance / pre-existing Stellar account. Fail-closed on RPC errors. | +| **Reused / non-fresh ephemeral** | Client (buggy SDK, attacker, or replay) submits an ephemeral address that already has on-chain history — non-zero nonce on Substrate/EVM, or an existing Stellar account. The server builds transactions assuming nonce 0 / no account, so mid-ramp execution halts with nonce-mismatch or "account already exists" errors after subsidies/funding have been spent. | **MITIGATED (F-072)**: `validateEphemeralAccountsFresh()` runs in `registerRamp` immediately after format validation. For each ephemeral type the client provides, it queries every supported chain of that type (Substrate: pendulum, hydration, assethub; EVM: all configured EVM networks including Moonbeam; Stellar) and rejects the registration if any check finds non-zero nonce / non-zero free balance / pre-existing Stellar account. Fail-closed on RPC errors. | +| **XSS access to `rampEphemerals` localStorage** | An XSS vulnerability could read `localStorage["rampEphemerals"]` to steal ephemeral secret keys for in-flight ramps. Unlike `rampState` (which is wiped on reset), `rampEphemerals` persists across ramp resets, widening the attack window. | The entry is plaintext in localStorage with no additional encryption. Mitigation relies on the same XSS defenses that protect auth tokens and other localStorage secrets (CSP, input sanitization, no `eval`). The `rampEphemerals` map accumulates entries over time — users or support should clear it after debugging. `removeRampEphemeral(rampId)` is exported for targeted cleanup. | ## Audit Checklist @@ -48,4 +52,6 @@ The SDK optionally stores ephemeral keys to a local JSON file (`ephemerals_{ramp - [x] Each call to `generateEphemerals()` produces fresh, unique keypairs — no memoization or caching — ✅ PASS - [x] Unsigned transactions returned to the client are bound to the specific ephemeral addresses provided during registration — ✅ PASS - [ ] The API does not trust that an ephemeral address is an EOA on EVM — verify if contract address detection is needed — 🟡 PARTIAL (no check, but low self-harm risk) -- [x] **F-068**: Each submitted ephemeral address is verified fresh on every supported chain of its type at `registerRamp` — `validateEphemeralAccountsFresh()` in `apps/api/src/api/services/ramp/ephemeral-freshness.ts`, invoked after `normalizeAndValidateSigningAccounts`. Substrate (pendulum, hydration, assethub): `nonce === 0 && free === 0`. EVM (all configured EVM networks): `nonce === 0`. Stellar: account must not exist on Horizon. The supported-network lists `SUPPORTED_SUBSTRATE_NETWORKS` and `SUPPORTED_EVM_NETWORKS` MUST be updated whenever the platform adds a new chain an ephemeral can ever sign on. Fail-closed on RPC errors (`SERVICE_UNAVAILABLE`). — ✅ PASS +- [x] **F-072**: Each submitted ephemeral address is verified fresh on every supported chain of its type at `registerRamp` — `validateEphemeralAccountsFresh()` in `apps/api/src/api/services/ramp/ephemeral-freshness.ts`, invoked after `normalizeAndValidateSigningAccounts`. Substrate (pendulum, hydration, assethub): `nonce === 0 && free === 0`. EVM (all configured EVM networks): `nonce === 0`. Stellar: account must not exist on Horizon. The supported-network lists `SUPPORTED_SUBSTRATE_NETWORKS` and `SUPPORTED_EVM_NETWORKS` MUST be updated whenever the platform adds a new chain an ephemeral can ever sign on. Fail-closed on RPC errors (`SERVICE_UNAVAILABLE`). — ✅ PASS +- [x] Frontend `rampEphemerals` localStorage backup writes only from the `PersistenceEffect` in `rampState.tsx` — no network calls, no third-party access — ✅ PASS +- [x] `rampEphemerals` entries are keyed by ramp ID and accumulate across ramp resets — `removeRampEphemeral()` is exported for cleanup — ✅ PASS diff --git a/docs/security-spec/03-ramp-engine/discount-mechanism.md b/docs/security-spec/03-ramp-engine/discount-mechanism.md index 5e786f09b..b1fc5e0b2 100644 --- a/docs/security-spec/03-ramp-engine/discount-mechanism.md +++ b/docs/security-spec/03-ramp-engine/discount-mechanism.md @@ -50,9 +50,9 @@ For onramps to EVM destinations other than AssetHub, the engine also probes Squi | **Misleading `[CAPPED]` log on zero-discount partners** | `formatPartnerNote` appends `[CAPPED]` whenever `actualSubsidy < idealSubsidy`. When `targetDiscount=0`, line 79 of `offramp.ts` (and 211 of `onramp.ts`) force `actualSubsidy=0`, but `idealSubsidy` can still be positive whenever Nabla undershoots the oracle. Operators reading logs may interpret a flood of `[CAPPED]` notes as `maxSubsidy` exhaustion when the real reason is `targetDiscount=0`. | **OPEN (F-DISC-03).** `formatPartnerNote` SHOULD distinguish "no discount configured" (`targetDiscount=0`) from "discount configured but cap hit" (`targetDiscount>0 && actual 0` (otherwise `actualSubsidy = 0`). Provider quote TTL (30 seconds) limits the timing window; quote refresh at start (`refreshAlfredpayOnrampQuoteIfMatching`) only re-binds when the new provider quote is byte-identical on `toAmount` and `fee`. | | **AlfredPay offramp inverted-rate misconfiguration** | If the oracle returns a non-positive rate for `outputCurrency -> ALFREDPAY_ONCHAIN_CURRENCY`, dividing by it would yield `+∞` or NaN, corrupting downstream `expectedOutput` and subsidy math. | `OffRampAlfredpayDiscountEngine` throws when `effectiveRate ≤ 0`. The partner engine (`OfframpTransactionAlfredpayEngine`) has a symmetric `effectiveRate.gt(0)` guard with a fallback that uses `evmToEvm.outputAmountDecimal`. | +| **Subsidy on a discount-less route** | A partner with positive `targetDiscount` requests a quote on one of the discount-less routes (`offramp-evm-to-alfredpay`, `onramp-alfredpay-to-evm`, and the Mykobo EUR on/off-ramp routes which run end-to-end on Base without a Pendulum hop); they receive the bare Nabla rate and no subsidy, contradicting their partner configuration. | **Accepted product behavior.** These routes have no Pendulum-side Nabla swap that the discount engine is designed to subsidize (Alfredpay flows use direct stablecoin transfers; Mykobo flows swap on Nabla-on-Base, which is outside the Substrate-Nabla discount path). Operators SHOULD ensure partner-facing documentation makes the route-level discount applicability explicit. | ## Audit Checklist diff --git a/docs/security-spec/03-ramp-engine/ephemeral-accounts.md b/docs/security-spec/03-ramp-engine/ephemeral-accounts.md index 9954dbb2b..20e982479 100644 --- a/docs/security-spec/03-ramp-engine/ephemeral-accounts.md +++ b/docs/security-spec/03-ramp-engine/ephemeral-accounts.md @@ -10,12 +10,12 @@ The cleanup process runs as a background worker (`cleanup.worker.ts`) on a 5-min Ephemeral accounts may be created on: - **Stellar** — For Spacewalk bridge operations and direct Stellar payments -- **Pendulum** — For Nabla swaps (Substrate-side), Spacewalk redeems, XCM transfers -- **Moonbeam** — For legacy EVM operations (EUR Monerium → Moonbeam path), SquidRouter swaps, XCM to/from Pendulum -- **Polygon** — For Monerium EURe operations +- **Pendulum** — For Nabla swaps (Substrate-side), Spacewalk redeems, XCM transfers. **Not created** for BRL (BRLA) or EUR (Mykobo) Base-EVM corridors: `getRequiresPendulumEphemeralAddress` returns `false` when the input or output token is BRL or EURC, and registration skips Pendulum ephemeral creation + funding for those corridors. Only the Pendulum-routed corridors (Stellar/ARS, AssetHub, Hydration) still allocate a Pendulum ephemeral. +- **Moonbeam** — For legacy EVM operations (historical Monerium EUR → Moonbeam path is removed; still used for ARS-Stellar off-ramp's Moonbeam→Pendulum XCM hop, Alfredpay permit acquisition, and SquidRouter swaps), XCM to/from Pendulum +- **Polygon** — (Legacy) For Monerium EURe operations; no active corridor uses Polygon anymore but the post-process handler remains for safety on any still-in-flight legacy ramps - **AssetHub** — For XCM transfers to/from Pendulum and Hydration - **Hydration** — For Hydration DEX swaps and XCM transfers -- **Base** — Hub for all BRL on/off-ramp flows. Hosts BRLA mint/burn (via Avenia), Nabla-on-EVM swap (USDC↔BRLA), and EVM fee distribution via Multicall3. +- **Base** — Hub for all BRL **and EUR** on/off-ramp flows. Hosts BRLA mint/burn (via Avenia), Mykobo EUR settlement (EURC on Base), Nabla-on-EVM swap (USDC↔BRLA, USDC↔EURC), and EVM fee distribution via Multicall3. ### Cleanup Architecture @@ -24,11 +24,11 @@ Post-process handlers registered in `apps/api/src/api/services/phases/post-proce - **StellarPostProcessHandler** — Submits the `stellarCleanup` XDR to merge the Stellar ephemeral account back to the funding account. - **PendulumPostProcessHandler** — Submits the `pendulumCleanup` extrinsic to sweep Pendulum ephemeral tokens. - **MoonbeamPostProcessHandler** — Waits 3 hours for SquidRouter refunds to land, then submits `moonbeamCleanup` to sweep Moonbeam ephemeral tokens. -- **PolygonPostProcessHandler** — On Monerium-onramp BUY ramps with a `polygonCleanup` presigned tx, broadcasts the user's pre-signed `approve` and then runs `transferFrom(ephemeral, fundingAccount, balance)` from the funding key to sweep residual ERC-20 tokens. Skipped when ephemeral balance is zero. +- **PolygonPostProcessHandler** — (Legacy Monerium support) On Monerium-onramp BUY ramps with a `polygonCleanup` presigned tx, broadcasts the user's pre-signed `approve` and then runs `transferFrom(ephemeral, fundingAccount, balance)` from the funding key to sweep residual ERC-20 tokens. Skipped when ephemeral balance is zero. **No active corridor produces new Polygon ephemerals; this handler exists for in-flight legacy ramps and can be retired once Monerium ramps are fully drained.** - **HydrationPostProcessHandler** — On BUY ramps with a `hydrationCleanup` presigned extrinsic, submits the cleanup extrinsic. - **AssetHubPostProcessHandler** — Registered but inert. `shouldProcess` returns `false` unconditionally; `process` returns `[true, null]`. No on-chain action is performed. Effectively a placeholder for future AssetHub cleanup. -A post-process handler is registered for **Base** (`BaseChainPostProcessHandler`). After a BRL ramp completes, it sweeps any residual BRLA and USDC dust from the Base ephemeral to the funding account via the standard `approve(funding, MAX_UINT256)` + `transferFrom(ephemeral, funding, balance)` pattern (presigned by the ephemeral; `transferFrom` broadcast by the funding key). Per-token balance checks skip the sweep when the balance is zero. ETH gas dust on the ephemeral is not swept (accepted residual; gas is funded just-in-time and rarely accumulates meaningfully). +A post-process handler is registered for **Base** (`BaseChainPostProcessHandler`). After a Base-routed ramp completes (BRL via BRLA or EUR via Mykobo), it sweeps residual BRLA, EURC, USDC, and AxlUSDC dust from the Base ephemeral to the funding account via the standard `approve(funding, MAX_UINT256)` + `transferFrom(ephemeral, funding, balance)` pattern (presigned by the ephemeral; `transferFrom` broadcast by the funding key). Per-token balance checks skip the sweep when the balance is zero. ETH gas dust on the ephemeral is not swept (accepted residual; gas is funded just-in-time and rarely accumulates meaningfully). The cleanup worker (`cleanup.worker.ts`) selects ramps where `currentPhase ∈ {"complete", "failed", "timedOut"}` and cleanup is not yet completed, processing up to 5 ramps per cycle on a 5-minute cron. There is no longer a SEPA exclusion (the historical `from: { [Op.ne]: "sepa" }` filter has been removed). @@ -65,7 +65,7 @@ The cleanup worker (`cleanup.worker.ts`) selects ramps where `currentPhase ∈ { - [x] PolygonPostProcessHandler broadcasts the user-presigned `approve` and runs `transferFrom(ephemeral, fundingAccount, balance)` from `getEvmFundingAccount(Polygon)` — verified (`polygon-post-process-handler.ts:36-83`) - [x] HydrationPostProcessHandler submits `hydrationCleanup` extrinsic from ramp state — verified - [ ] **AssetHubPostProcessHandler is a no-op stub** (`shouldProcess` always returns `false`). Either implement an AssetHub cleanup or remove the handler from the registry. -- [ ] **No Base post-process handler**. Add a `BasePostProcessHandler` (chain) that sweeps residual USDC/BRLA on Base ephemerals to the funding account, mirroring the Polygon `approve + transferFrom` pattern, when custody requirements allow. +- [x] **Base post-process handler implemented** (`BaseChainPostProcessHandler`). Sweeps residual BRLA/USDC/EURC/AxlUSDC on Base ephemerals to the funding account via presigned `approve` + funding-key `transferFrom`. ETH gas dust is not swept (accepted residual). - [x] Cleanup worker runs every 5 minutes via `node-cron` — verified - [x] Cleanup worker processes at most 5 ramps per cycle — verified - [x] Cleanup worker marks ramps as cleaned (`postProcessDone: true` via `postCompleteState.cleanup.cleanupCompleted`) to prevent re-processing — verified diff --git a/docs/security-spec/03-ramp-engine/fee-integrity.md b/docs/security-spec/03-ramp-engine/fee-integrity.md index e78108cbb..f73feb608 100644 --- a/docs/security-spec/03-ramp-engine/fee-integrity.md +++ b/docs/security-spec/03-ramp-engine/fee-integrity.md @@ -73,6 +73,7 @@ The `distribute-fees-handler.ts` chooses the correct path at runtime based on th - [x] Rounding modes: on-ramp uses `round(6, 0)` (round half up to 6 decimals), off-ramp uses `round(2, 1)` (round half down to 2 decimals). **PASS** — verified rounding modes in both helper functions. - [x] `distributeFees` phase distributes exactly the amounts from the fee breakdown — no recalculation. **PASS** — fee distribution uses stored breakdown values. - [x] Anchor fee deduction by external services (BRLA, Stellar) is pre-accounted in the quoted amount. **PASS** — anchor fees factored into quote calculation. +- [ ] Mykobo anchor fee in the quote MUST match the tier Mykobo actually charges. The fee tier is selected by `MYKOBO_CLIENT_DOMAIN`; an unset env var silently degrades to Mykobo's default tier (~5x worse), causing `defaultDepositFee` / `defaultWithdrawFee` and on-chain settlement to diverge. See `07-operations/secret-management.md` (invariant 9) and `05-integrations/mykobo.md` (invariant 20). - [x] Fee changes in token config or database don't retroactively affect already-created quotes. **PASS** — quotes store immutable fee snapshots at creation time. - [x] **FINDING F-061 (MEDIUM)**: Verify quote finalization enforces maximum amount limits. **PASS (FIXED)** — added `validateAmountLimits(..., "max", ...)` calls in both `OnRampFinalizeEngine.validate()` and `OffRampFinalizeEngine.validate()`. - [x] **FINDING F-067 (MEDIUM)**: Verify `calculateFeeComponent()` cannot produce negative fee values. **PASS (FIXED)** — added `if (feeComponent.lt(0)) { feeComponent = new Big(0); }` floor check to clamp negative results to zero. diff --git a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md index 99abccf84..dff2fb686 100644 --- a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md +++ b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md @@ -13,11 +13,20 @@ There are 29+ phase handlers in `apps/api/src/api/services/phases/handlers/`. Th ### Major Ramp Corridors -**EUR Off-ramp (Stellar-based):** User's crypto → Pendulum (Nabla swap) → Stellar (Spacewalk bridge) → Stellar anchor (SEPA payout) -- Phases: `initial` → `subsidizePreSwap` → `nablaApprove` → `nablaSwap` → `subsidizePostSwap` → `spacewalkRedeem` → `stellarPayment` → `distributeFees` → `complete` - -**EUR On-ramp (Monerium SEPA):** SEPA payment → Monerium mints EURe on Polygon → SquidRouter to Moonbeam → XCM to Pendulum → Nabla swap → destination chain -- Phases: `initial` → `moneriumOnrampMint` (poll) → `moneriumOnrampSelfTransfer` → `squidRouterApprove` → `squidRouterSwap` → `moonbeamToPendulumXcm` → `nablaApprove` → `nablaSwap` → ... → `complete` +**EUR Off-ramp (Mykobo on Base):** User's crypto on source EVM → Squid bridge to Base USDC (user-signed, client-side) → Nabla-on-Base swap (USDC→EURC) → Mykobo SEPA payout +- Runtime backend phases: `initial` → `fundEphemeral` → `distributeFees` (on Base, USDC) → `subsidizePreSwap` → `nablaApprove` → `nablaSwap` → `subsidizePostSwap` → `mykoboPayoutOnBase` → `complete` +- The Squid bridge from the source EVM chain to Base is executed by the user's wallet (presigned `squidRouterApprove` + `squidRouterSwap` are submitted client-side). Skip-Squid case: source = Base USDC. +- Note: `distributeFees` runs **before** `nablaSwap` on offramp because fees are denominated in USDC and must be deducted before swapping to EURC. Mirrors the BRL-on-Base off-ramp. +- **Removed:** the previous Stellar-based EUR off-ramp (Pendulum → Spacewalk → Stellar anchor) is no longer active. See `stellar-anchors.md`. + +**EUR On-ramp (Mykobo SEPA on Base):** SEPA payment → Mykobo settles EURC on the Base ephemeral → Nabla-on-Base swap (EURC→USDC) → optional Squid → user destination +- Runtime backend phases: `initial` → `mykoboOnrampDeposit` (poll Base RPC, 24h outer / 5min inner) → `fundEphemeral` → `subsidizePreSwap` → `nablaApprove` → `nablaSwap` → `distributeFees` → `subsidizePostSwap` → `squidRouterSwap` → `destinationTransfer` → `complete` +- Note: like BRL on-ramp, `fundEphemeral` provides ETH gas to the Base ephemeral before swap/approve/squid txs. `mykoboOnrampDeposit` transitions to `fundEphemeral` (`mykobo-onramp-deposit-handler.ts`), which selects `subsidizePreSwap` next for the `BUY && inputCurrency === EURC` branch (`fund-ephemeral-handler.ts`). +- Skip-Squid case (destination = Base USDC): the `squidRouterSwap` handler short-circuits directly to `destinationTransfer`. +- Cross-chain case (destination ≠ Base USDC): `squidRouterSwap` → `squidRouterPay` → `finalSettlementSubsidy` → `destinationTransfer` for supported EVM destinations. +- **Degenerate EUR→EURC-on-Base case:** `isEurToEurcBaseDirect` short-circuits the entire pipeline to a single `destinationTransfer` (no Nabla, no Squid, no `finalSettlementSubsidy`, no cleanup), because Mykobo already settles EURC on the Base ephemeral and the generic path would otherwise swap EURC→USDC→EURC for itself. See `05-integrations/mykobo.md`. +- Base ephemeral cleanup (`baseCleanupUsdc`, `baseCleanupEurc`, `baseCleanupAxlUsdc`) is performed out-of-flow by `BaseChainPostProcessHandler` after `complete`. +- **Removed:** the previous Monerium EUR on-ramp (EURe on Polygon → Squid → Moonbeam → XCM → Pendulum) is no longer active. See `monerium.md`. **BRL Off-ramp (Avenia/BRLA on Base):** User's crypto on source EVM → Squid bridge to Base USDC (user-signed, client-side) → Nabla-on-Base swap (USDC→BRLA) → Avenia PIX payout - Runtime backend phases: `initial` → `fundEphemeral` → `distributeFees` (on Base, USDC) → `subsidizePreSwap` → `nablaApprove` → `nablaSwap` → `subsidizePostSwap` → `brlaPayoutOnBase` → `complete` @@ -30,16 +39,18 @@ There are 29+ phase handlers in `apps/api/src/api/services/phases/handlers/`. Th - Runtime backend phases: `initial` → `brlaOnrampMint` (poll Base RPC, 30min outer / 5min inner) → `fundEphemeral` → `subsidizePreSwap` → `nablaApprove` → `nablaSwap` → `distributeFees` → `subsidizePostSwap` → `squidRouterSwap` → `destinationTransfer` → `complete` - Skip-Squid case (destination = Base USDC): the `squidRouterSwap` handler short-circuits directly to `destinationTransfer`. - Cross-chain case (destination ≠ Base USDC): `squidRouterSwap` → `squidRouterPay` → `finalSettlementSubsidy` → `destinationTransfer` for supported EVM destinations. **BRL→AssetHub quotes are temporarily disabled** and should not enter this phase chain. +- **Degenerate BRL→BRLA-on-Base case:** `isBrlToBrlaBaseDirect` short-circuits the entire pipeline to a single `destinationTransfer` (no Nabla, no `distributeFees`, no Squid, no `finalSettlementSubsidy`, no cleanup), because Avenia already mints BRLA on the Base ephemeral and the generic path would otherwise swap BRLA→USDC→BRLA for itself. Mirrors the EUR→EURC-on-Base bypass. See `05-integrations/brla.md`. - Base ephemeral cleanup (`baseCleanupUsdc`, `baseCleanupBrla`) is performed out-of-flow by a separate sweeper after `complete`; cleanup approvals are presigned but not part of the runtime nextPhase chain. **Alfredpay corridors:** Similar structure with `alfredpayOfframpTransfer` / `alfredpayOnrampMint` replacing the fiat provider phases. +- **Degenerate Polygon same-token onramp case:** Alfredpay mints `ALFREDPAY_EVM_TOKEN` (USDT) on Polygon. When the user requests that same token on Polygon (`quote.metadata.request.to === Networks.Polygon` **and** `quote.outputCurrency === ALFREDPAY_EVM_TOKEN`), the `squidRouterSwap` handler short-circuits to `finalSettlementSubsidy` with no swap. Any **other** Polygon output (e.g. USDC) still runs the real USDT→output swap — `quote.metadata.request.to` is the destination network, not the output token, so the short-circuit MUST also check `outputCurrency`. See `05-integrations/alfredpay.md`. **Cross-chain delivery (post-swap):** After the Nabla swap, tokens are routed to their final destination: -- From Pendulum to Stellar: `spacewalkRedeem` → `stellarPayment` +- ~~From Pendulum to Stellar (ARS-only since EUR was migrated to Mykobo): `spacewalkRedeem` → `stellarPayment`~~ — **REMOVED.** The Stellar/Spacewalk backend infrastructure was removed in commits `f89554d46` and `82761ba91`. `spacewalkRedeemHandler` and `stellarPaymentHandler` are no longer registered in `register-handlers.ts`. See `stellar-anchors.md`. - From Pendulum to Moonbeam: `pendulumToMoonbeamXcm` - From Pendulum to AssetHub: `pendulumToAssethubXcm` - From Pendulum to Hydration: `pendulumToHydrationXcm` → `hydrationToAssethubXcm` (if needed) -- From Base to supported EVM destinations (BRL onramp): `squidRouterApprove` → `squidRouterSwap` → `squidRouterPay` → optional `backupSquidRouter*` on destination → `destinationTransfer` +- From Base to supported EVM destinations (BRL and EUR onramps): `squidRouterApprove` → `squidRouterSwap` → `squidRouterPay` → optional `backupSquidRouter*` on destination → `destinationTransfer` - Trivial case (Base→Base USDC): direct `destinationTransfer` only (Squid skipped) ### Phase Transition Diagrams @@ -48,104 +59,88 @@ The following diagrams show the phase transitions for all on-ramp and off-ramp c #### On-Ramp Phase Flow +The BRL (BRLA) and EUR (Mykobo) on-ramp corridors share the entire post-fiat phase chain on Base, including `fundEphemeral`. Only the initial fiat-watch phase differs (`brlaOnrampMint` vs `mykoboOnrampDeposit`). + ```mermaid graph TD Start([Start On-Ramp]) --> Init[initial] Init --> Provider{Fiat provider?} - %% --- Monerium EUR on Polygon --- - Provider -->|Monerium EUR| MonMint[moneriumOnrampMint] - MonMint --> MonFund[fundEphemeral] - MonFund --> MonSelf[moneriumOnrampSelfTransfer] - MonSelf --> MonSquidApprove[squidRouterApprove] - MonSquidApprove --> MonSquidSwap[squidRouterSwap] - MonSquidSwap --> MonDest{Destination?} - MonDest -->|EVM| FinalSubsidy[finalSettlementSubsidy] - MonDest -->|AssetHub / Hydration| MonToPendulum[moonbeamToPendulumXcm] - MonToPendulum --> SubPre[subsidizePreSwap] - - %% --- BRL via Avenia/BRLA on Base --- - Provider -->|BRLA BRL on Base| BrlaMint[brlaOnrampMint - poll Base RPC] - BrlaMint --> BrlaFund[fundEphemeral] - BrlaFund --> BrlaSubPreEvm[subsidizePreSwap] - BrlaSubPreEvm --> BrlaApproveEvm["nablaApprove (EVM branch)"] - BrlaApproveEvm --> BrlaSwapEvm["nablaSwap (EVM branch)"] - BrlaSwapEvm --> BrlaDistEvm["distributeFees (EVM branch)"] - BrlaDistEvm --> BrlaSubPostEvm[subsidizePostSwap] - BrlaSubPostEvm --> BrlaSquidSwap[squidRouterSwap] - BrlaSquidSwap --> BrlaDest{Destination = Base USDC?} - BrlaDest -->|Yes - short-circuit| DestTransfer[destinationTransfer] - BrlaDest -->|No - supported EVM only| BrlaSquidPay[squidRouterPay] - %% BRL -> AssetHub is temporarily disabled at quote eligibility. - BrlaSquidPay --> BrlaFinalSubsidy[finalSettlementSubsidy] - BrlaFinalSubsidy --> BrlaBackup{Backup bridge needed?} - BrlaBackup -->|Yes| BrlaBackupSquid[backupSquidRouter*] - BrlaBackup -->|No| DestTransfer - BrlaBackupSquid --> DestTransfer - - %% --- Alfredpay --- + %% --- Per-corridor fiat-watch entry phases --- + Provider -->|BRLA BRL on Base| BrlaMint[brlaOnrampMint - poll Base RPC, 30min/5min] + Provider -->|Mykobo EUR on Base| MykMint[mykoboOnrampDeposit - poll Base RPC, 24h/5min] Provider -->|Alfredpay| AfMint[alfredpayOnrampMint] + + %% --- Both BRL and EUR fund the Base ephemeral with ETH gas before swap --- + BrlaMint --> BrlaFund[fundEphemeral] + MykMint --> MykFund[fundEphemeral] + BrlaFund --> SubPreEvm + MykFund --> SubPreEvm + + %% --- Shared Base-EVM swap chain (BRL + EUR) --- + SubPreEvm["subsidizePreSwap (EVM branch)"] --> ApproveEvm["nablaApprove (EVM branch)"] + ApproveEvm --> SwapEvm["nablaSwap (EVM branch, fiat-stable to USDC)"] + SwapEvm --> DistEvm["distributeFees (EVM branch)"] + DistEvm --> SubPostEvm["subsidizePostSwap (EVM branch)"] + SubPostEvm --> SquidSwap[squidRouterSwap] + + %% --- Destination routing (shared) --- + SquidSwap --> Dest{Destination = Base USDC?} + Dest -->|Yes - short-circuit| DestTransfer[destinationTransfer] + Dest -->|No - supported EVM| SquidPay[squidRouterPay] + SquidPay --> FinalSubsidy[finalSettlementSubsidy] + FinalSubsidy --> Backup{Backup bridge needed?} + Backup -->|Yes| BackupSquid[backupSquidRouter*] + Backup -->|No| DestTransfer + BackupSquid --> DestTransfer + + %% --- Alfredpay branch: squidRouterSwap handler short-circuits to destinationTransfer --- + %% (squid-router-phase-handler.ts:72 detects isAlfredpayOnramp and skips squidRouterPay + finalSettlementSubsidy) AfMint --> AfFund[fundEphemeral] AfFund --> AfSquidSwap[squidRouterSwap] - AfSquidSwap --> AfSquidPay[squidRouterPay] - AfSquidPay --> FinalSubsidy - - %% --- Common Pendulum swap path (Monerium AssetHub / Hydration) --- - SubPre --> NablaApprove[nablaApprove] - NablaApprove --> NablaSwap[nablaSwap] - NablaSwap --> SubPost[subsidizePostSwap] - SubPost --> Dist[distributeFees] - Dist --> AhRoute{Output token?} - AhRoute -->|USDC| ToAh[pendulumToAssethubXcm] - AhRoute -->|DOT / USDT| ToHydra[pendulumToHydrationXcm] - ToHydra --> HydraSwap[hydrationSwap] - HydraSwap --> HydraToAh[hydrationToAssethubXcm] - - %% --- Final settlement (EVM via Squid) --- - FinalSubsidy --> DestTransfer + AfSquidSwap -.short-circuit.-> DestTransfer %% --- Terminal --- DestTransfer --> Complete([complete]) - ToAh --> Complete - HydraToAh --> Complete ``` +> Notes: +> - **EUR onramp funds the ephemeral.** `mykoboOnrampDeposit` transitions to `fundEphemeral` (`mykobo-onramp-deposit-handler.ts`), which then transitions to `subsidizePreSwap` (`fund-ephemeral-handler.ts` `BUY && inputCurrency === EURC` branch). This matches BRL onramp behavior and ensures the Base ephemeral has ETH gas for `nablaApprove`/`nablaSwap`/squid txs. +> - **EUR/BRL onramps skip Pendulum funding.** `getRequiresPendulumEphemeralAddress` returns `false` for EURC and BRL inputs, so the registration flow never creates or funds a Pendulum ephemeral for these corridors. All movement is Base-EVM only. See `ephemeral-accounts.md`. +> - **SquidRouter RPC selection is sourced from `bridgeMeta.fromNetwork`, not the input currency.** `squid-router-phase-handler.ts` computes the source network from `bridgeMeta.fromNetwork` (set at registration time by the route builder) and passes it to `getClient(network)` for both approve and swap calls. The earlier heuristic that selected the RPC from `inputCurrency` was removed because EUR-onramp presigned transactions both carry `network: Networks.Base` (`mykobo-to-evm.ts`), which would have triggered a wrong-chain signer error on cross-chain destinations (e.g., `invalid chain id for signer: have 8453 want 137` for EUR → Polygon USDT). +> - **Alfredpay onramp short-circuits.** `squid-router-phase-handler.ts:72` detects `isAlfredpayOnramp` and transitions directly to `destinationTransfer`, skipping `squidRouterPay` and `finalSettlementSubsidy`. +> - The Pendulum-side on-ramp swap chain (`subsidizePreSwap` → `nablaApprove` → `nablaSwap` → `subsidizePostSwap` → `distributeFees` → `pendulumToAssethubXcm` / `pendulumToHydrationXcm` → `hydrationSwap` → `hydrationToAssethubXcm`) was used by the legacy Monerium-EUR-via-Pendulum corridor and by `avenia-to-assethub` BRL→AssetHub. Both corridors are **inactive**: Monerium was replaced by Mykobo-on-Base, and BRL↔AssetHub is temporarily disabled at quote eligibility. The Substrate-branch on-ramp handlers remain registered but are not reached by any active route. + #### Off-Ramp Phase Flow +The BRL (BRLA) and EUR (Mykobo) off-ramp corridors share the entire chain from `fundEphemeral` through `subsidizePostSwap`. Only the terminal payout phase differs (`brlaPayoutOnBase` vs `mykoboPayoutOnBase`). + ```mermaid graph TD Start([Start Off-Ramp]) --> Init[initial] Init --> Corridor{Output fiat?} - %% --- BRL via Avenia/BRLA on Base --- + %% --- Shared Base entry: BRL + EUR --- %% The user-signed Squid bridge (source EVM -> Base USDC) is submitted client-side - %% before the backend runtime starts; squidRouterPay is a no-op for SELL. - %% AssetHub -> BRL is temporarily disabled at quote eligibility. - Corridor -->|BRL on Base| BrlFund[fundEphemeral] - BrlFund --> BrlDistEvm["distributeFees (EVM branch)"] - BrlDistEvm --> BrlSubPreEvm[subsidizePreSwap] - BrlSubPreEvm --> BrlApproveEvm["nablaApprove (EVM branch)"] - BrlApproveEvm --> BrlSwapEvm["nablaSwap (EVM branch, USDC to BRLA)"] - BrlSwapEvm --> BrlSubPostEvm[subsidizePostSwap] - BrlSubPostEvm --> BrlPayout[brlaPayoutOnBase] + %% before the backend runtime starts. AssetHub -> BRL is temporarily disabled at quote eligibility. + Corridor -->|BRL or EUR on Base| BaseFund[fundEphemeral] + BaseFund --> BaseDistEvm["distributeFees (EVM branch)"] + BaseDistEvm --> BaseSubPreEvm["subsidizePreSwap (EVM branch)"] + BaseSubPreEvm --> BaseApproveEvm["nablaApprove (EVM branch)"] + BaseApproveEvm --> BaseSwapEvm["nablaSwap (EVM branch, USDC to BRLA or USDC to EURC)"] + BaseSwapEvm --> BaseSubPostEvm["subsidizePostSwap (EVM branch)"] + BaseSubPostEvm --> Payout{Output fiat?} + + %% --- Per-corridor terminal payout phase --- + Payout -->|BRL| BrlPayout[brlaPayoutOnBase] + Payout -->|EUR| MykPayout[mykoboPayoutOnBase] BrlPayout --> Complete([complete]) - Complete -.post-process.-> BaseCleanup[BaseChainPostProcessHandler
    sweeps BRLA + USDC] - - %% --- Stellar-anchored fiat (EUR / ARS) --- - Corridor -->|EUR / ARS via Stellar| StellarStart{Source chain?} - StellarStart -->|EVM| MoonToPendulum[moonbeamToPendulumXcm] - StellarStart -->|AssetHub| AhDist[distributeFees] - MoonToPendulum --> EvmDist[distributeFees] - EvmDist --> SubPre[subsidizePreSwap] - AhDist --> SubPre - SubPre --> NablaApprove[nablaApprove] - NablaApprove --> NablaSwap[nablaSwap - input to wrapped EURC] - NablaSwap --> SubPost[subsidizePostSwap] - SubPost --> Spacewalk[spacewalkRedeem] - Spacewalk --> StellarPay[stellarPayment] - StellarPay --> Complete - - %% --- Alfredpay --- + MykPayout --> Complete + + %% --- Base post-process cleanup (after complete, out-of-flow) --- + Complete -.post-process.-> BaseCleanup[BaseChainPostProcessHandler
    sweeps BRLA + USDC + EURC + AxlUSDC] + + %% --- Alfredpay (Polygon, different chain) --- Corridor -->|Alfredpay| AfPermit[squidRouterPermitExecute] AfPermit --> AfFund[fundEphemeral] AfFund --> AfFinalSubsidy[finalSettlementSubsidy] @@ -153,7 +148,10 @@ graph TD AfTransfer --> Complete ``` -> Note: `pendulumCleanup` and any chain-specific post-process handlers (`PolygonPostProcessHandler`, `HydrationPostProcessHandler`, `BaseChainPostProcessHandler`) execute after `complete` via the post-process subsystem, not as in-flow phases. See `ephemeral-accounts.md`. +> Notes: +> - The ARS-via-Stellar off-ramp is **REMOVED.** Backend infrastructure was removed in commits `f89554d46` and `82761ba91`. `spacewalkRedeemHandler` and `stellarPaymentHandler` are no longer registered. See `stellar-anchors.md`. +> - `BaseChainPostProcessHandler` sweeps **all four** Base tokens regardless of corridor (`base-chain-post-process-handler.ts:9`: `BASE_CLEANUP_PHASES = ["baseCleanupBrla", "baseCleanupUsdc", "baseCleanupEurc", "baseCleanupAxlUsdc"]`). Per-corridor route builders only presign the subset they need. +> - `pendulumCleanup` and other chain-specific post-process handlers (`PolygonPostProcessHandler`, `HydrationPostProcessHandler`) execute after `complete` via the post-process subsystem, not as in-flow phases. See `ephemeral-accounts.md`. ### Phase Handler Categories @@ -164,7 +162,7 @@ graph TD | **DEX Swap (Substrate)** | `nabla-approve-handler` (Substrate branch), `nabla-swap-handler` (Substrate branch), `hydration-swap-handler` | Ephemeral → DEX contract → ephemeral | | **DEX Swap (EVM)** | `nabla-approve-handler` (EVM branch), `nabla-swap-handler` (EVM branch) | Base ephemeral → Nabla-on-Base contract → Base ephemeral | | **Bridge / XCM** | `moonbeam-to-pendulum-handler`, `moonbeam-to-pendulum-xcm-handler`, `pendulum-to-moonbeam-xcm-handler`, `pendulum-to-assethub-phase-handler`, `pendulum-to-hydration-xcm-phase-handler`, `hydration-to-assethub-xcm-phase-handler`, `spacewalk-redeem-handler` | Source chain ephemeral → destination chain ephemeral | -| **Fiat provider** | `stellar-payment-handler`, `brla-payout-base-handler` (Base), `brla-onramp-mint-handler` (polls Base BRLA arrival), `monerium-onramp-mint-handler`, `monerium-onramp-self-transfer-handler`, `alfredpay-offramp-transfer-handler`, `alfredpay-onramp-mint-handler` | Ephemeral ↔ provider | +| **Fiat provider** | `stellar-payment-handler`, `brla-payout-base-handler` (Base), `brla-onramp-mint-handler` (polls Base BRLA arrival), `mykobo-payout-handler` (Base EURC payout), `mykobo-onramp-deposit-handler` (polls Base EURC arrival), `alfredpay-offramp-transfer-handler`, `alfredpay-onramp-mint-handler` | Ephemeral ↔ provider | | **SquidRouter** | `squid-router-phase-handler`, `squid-router-pay-phase-handler`, `squidrouter-permit-execution-handler` (incl. no-permit fallback) | Ephemeral/executor → SquidRouter → destination | | **Fee distribution** | `distribute-fees-handler` (Substrate Pendulum + EVM Multicall3 on Base) | Ephemeral → platform fee collection address(es) | | **Lifecycle** | `initial-phase-handler`, `destination-transfer-handler` | Setup and final delivery | @@ -178,6 +176,9 @@ graph TD 5. **Cross-chain transfers MUST wait for finalization before advancing** — XCM and bridge transfers must confirm the source chain has finalized the send before the destination chain phase begins. Non-finalized transfers can be reverted by chain reorganization. 6. **Fee distribution MUST happen after all user-facing phases complete** — The `distributeFees` phase occurs near the end of the flow. Deducting fees before the user receives their funds risks the ramp failing after fees are taken. 7. **Each phase handler MUST be idempotent or have re-execution guards** — If the phase processor retries a phase (due to timeout or recoverable error), the handler must not double-execute (double-swap, double-transfer, double-fund). Nonce checks and balance pre-checks serve this purpose. +8. **SquidRouter RPC selection MUST be driven by `bridgeMeta.fromNetwork`** — `squid-router-phase-handler.ts` resolves the network via `bridgeMeta.fromNetwork` (set at registration by the route builder) and passes it to `getClient(network)` for both approve and swap calls. Selecting the RPC from `inputCurrency` would mis-route EUR onramps whose presigned txs carry `network: Networks.Base` to non-Base chains (causing `invalid chain id for signer: have X want Y` errors on cross-chain destinations). +9. **On same-chain destinations, `destinationTransfer` MUST be the first executable nonce after the broadcast SquidRouter txs — no nonce gap** — When the SquidRouter source chain equals the destination chain (e.g., EUR → Base EURC, BRL → Base USDC, Alfredpay Polygon-internal), the ephemeral shares ONE nonce sequence for `squidRouterApprove` → `squidRouterSwap` → `destinationTransfer`. The runtime broadcasts these in order, so `destinationTransfer` MUST carry the nonce immediately following `squidRouterSwap`. Two failure modes must both be avoided: (a) **collision** — reusing a nonce already consumed by an earlier tx; (b) **gap** — signing `destinationTransfer` with a nonce *above* the next live nonce, which the chain rejects as "nonce too high" so the tx never mines and user funds strand on the ephemeral (root cause of the EUR→Base 0-delivery incident: `destinationTransfer` was signed after the post-`complete` cleanup approvals — and, originally, after handler-less backup re-swap txs — leaving a 1–2 nonce gap). The route builders therefore place `destinationTransfer` directly after `squidRouterSwap`, then append the post-`complete` cleanup approvals, and OMIT the backup re-swap txs on the same-chain branch (those have no registered handler — F-054 — and on a shared sequence would only widen the gap). Enforced in `mykobo-to-evm.ts`, `alfredpay-to-evm.ts`, and `avenia-to-evm-base.ts`. +10. **`destinationTransfer` MUST fail fast on a detectable nonce gap rather than retry-and-strand** — `destination-transfer-handler.ts` reads the ephemeral's live nonce (`getTransactionCount`, `blockTag: "pending"`) and compares it to the presigned `destinationTransfer` nonce before broadcasting. If the presigned nonce is *ahead* of the live nonce the transfer can never mine, so the handler raises an `UnrecoverablePhaseError` for manual review instead of looping until the retry budget silently exhausts (which previously stranded funds with no terminal signal). Using `"pending"` rather than `"latest"` ensures the check accounts for mempool transactions — a prior ephemeral tx still in the mempool would otherwise lower the observed nonce and falsely flag a gap. The live-nonce read is best-effort: an RPC failure or a malformed presigned transaction logs a warning and falls through to the normal balance-poll path, so a transient RPC outage or an unparseable tx cannot wedge the happy path. ## Threat Vectors & Mitigations @@ -189,6 +190,8 @@ graph TD | **Stale presigned transaction** | Client registers a ramp, waits for market movement, then starts the ramp with presigned transactions based on the old quote. | `RAMP_START_EXPIRATION_TIME_SECONDS` limits the window between registration and start. Quote expiry (10 minutes) limits how old the amounts can be. | | **Cross-chain race condition** | XCM transfer submitted but not finalized. Next phase on destination chain reads a zero balance. | Most XCM handlers use `waitForFinalization=true`. Exception: Hydration skips finalization (F-009, deferred). | | **Fee distribution failure** | `distributeFees` fails, but ramp is already marked `complete`. Platform loses fee revenue. | `distributeFees` is a phase — if it fails, the ramp enters retry, not `complete`. However, if the ramp fails after user delivery but before fee distribution, fees may be lost. | +| **Wrong-chain signer on SquidRouter** | RPC selected from `inputCurrency` heuristic instead of `bridgeMeta.fromNetwork`; EUR-onramp presigned txs (`network: Networks.Base`) submitted on Polygon RPC → `invalid chain id for signer` and the ramp stalls in `squidRouterSwap`. | `squid-router-phase-handler.ts` reads `bridgeMeta.fromNetwork` (set by the route builder) and routes both approve+swap to that chain's client. Heuristic removed. | +| **Same-chain destination nonce gap (0-delivery)** | SquidRouter source chain == destination chain (e.g. EUR → Base EURC). `destinationTransfer` is signed *after* the post-`complete` cleanup approvals (and handler-less backup re-swap txs), leaving its nonce above the live ephemeral nonce. The chain rejects it as "nonce too high"; it never mines, the ramp retries until the budget exhausts, and user funds strand on the ephemeral with no terminal signal. | Route builders (`mykobo-to-evm.ts`, `alfredpay-to-evm.ts`, `avenia-to-evm-base.ts`) place `destinationTransfer` at the first nonce after `squidRouterSwap`, append cleanups afterward, and omit the same-chain backup re-swap txs. `destination-transfer-handler.ts` additionally fails fast (`UnrecoverablePhaseError`) when the presigned nonce is detected ahead of the live nonce. | ## Audit Checklist @@ -207,8 +210,14 @@ graph TD - [EXISTING FINDING] **F-054**: Backup presigned transactions (`backupSquidRouterApprove`, `backupSquidRouterSwap`, `backupApprove`) have no registered phase handlers — dead code or missing implementation. - [ ] No aggregate cross-ramp subsidy rate limiting — many concurrent ramps could drain funding account - [x] Active BRL corridors are end-to-end on Base — no Moonbeam/Pendulum/XCM involvement. **PASS** — `register-handlers.ts` does not register any `brlaPayoutOnMoonbeam` phase; active BRL quotes are limited to the Base/EVM route builders (`evm-to-brl-base.ts` and `avenia-to-evm-base.ts`). BRL↔AssetHub is temporarily disabled at quote eligibility. +- [x] Active EUR corridors are end-to-end on Base — no Pendulum/Spacewalk/Stellar involvement for EUR. **PASS** — `register-handlers.ts` registers `mykoboOnrampDeposit` and `mykoboPayoutOnBase`. EUR off-ramp uses `evm-to-mykobo.ts`; EUR on-ramp uses `mykobo-to-evm.ts`. Stellar-EUR off-ramp and Monerium-EUR on-ramp are removed. See `05-integrations/mykobo.md`. +- [x] On the EUR/Base corridor, `distributeFees` is positioned **before** `nablaSwap` on offramp (USDC fees deducted pre-EUR-swap) and **after** `nablaSwap` on onramp (USDC fees deducted post-EUR→USDC swap). **PASS** — verified in `evm-to-mykobo.ts` and `mykobo-to-evm.ts`, mirroring the BRL/Base structure. - [x] On the BRL/Base corridor, `distributeFees` is positioned **before** `nablaSwap` on offramp (USDC fees deducted pre-BRL-swap) and **after** `nablaSwap` on onramp (USDC fees deducted post-BRL→USDC swap). **PASS** — verified in `evm-to-brl-base.ts` and `avenia-to-evm-base.ts`. - [x] EVM subsidy phases enforce a USD-equivalent cap. **PASS** — `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION="0.05"` clamps subsidy to ≤5% of the quote's input/output amount in the EVM branches of `subsidize-pre-swap-handler.ts` and `subsidize-post-swap-handler.ts` (F-NEW-02 resolved). Over-cap cases are intentionally recoverable retries: no transfer is submitted, and the ramp waits for operator intervention instead of moving to `failed`. - [x] BRL on-ramp `backupApprove` allowance is bounded (no `maxUint256`). **PASS** — `avenia-to-evm-base.ts` `backupApprove` is set to `inputAmountRawFinalBridge × 1.05` (F-NEW-03 resolved). - [x] EVM ephemeral cleanup coverage. **PASS** — **Polygon** (`PolygonPostProcessHandler`), **Hydration** (`HydrationPostProcessHandler`), and **Base** (`BaseChainPostProcessHandler`, sweeping both BRLA and USDC) are registered and active. **AssetHub** handler is registered but a no-op stub (`shouldProcess` always returns `false`). ETH gas dust on EVM ephemerals is not swept (intentional). F-NEW-05 resolved. See `ephemeral-accounts.md` for the full cleanup architecture. - [x] Subsidy phase handlers extend the recoverable-retry budget. **PASS** — `subsidize-pre-swap-handler.ts` and `subsidize-post-swap-handler.ts` declare `getMaxRetries(): 200`, overriding the global `MAX_RETRIES = 8` in `phase-processor.ts`. Recoverable-exhausted ramps in subsidy phases wait (no `failed` transition) until a human tops up the funding account or cancels the ramp. +- [x] `squid-router-phase-handler.ts` resolves the source network from `bridgeMeta.fromNetwork` (not from `inputCurrency`); both `squidRouterApprove` and `squidRouterSwap` use the same `getClient(network)`. +- [x] On same-chain destinations (source == destination, e.g. EUR → Base EURC), `destinationTransfer` is placed at the first nonce **immediately after** `squidRouterSwap` (no gap), cleanups are appended after it, and the handler-less backup re-swap txs are omitted — verified in `mykobo-to-evm.ts`, `alfredpay-to-evm.ts`, `avenia-to-evm-base.ts`. Prevents the "nonce too high" 0-delivery strand. +- [x] `destination-transfer-handler.ts` fails fast on a nonce gap. **PASS** — before broadcasting it compares the presigned `destinationTransfer` nonce against the live ephemeral nonce (`getTransactionCount`, `blockTag: "pending"`) and throws `UnrecoverablePhaseError` if the presigned nonce is ahead, instead of retrying until the budget exhausts. The live-nonce read is best-effort (RPC failure warns and falls through), so a transient RPC outage cannot wedge the happy path. +- [x] EUR (Mykobo) and BRL (BRLA) onramps/offramps do NOT require a Pendulum ephemeral. `getRequiresPendulumEphemeralAddress` returns `false` for EURC and BRL inputs; registration skips Pendulum funding for these corridors. diff --git a/docs/security-spec/03-ramp-engine/transaction-validation.md b/docs/security-spec/03-ramp-engine/transaction-validation.md index 939e6375d..5bfc50323 100644 --- a/docs/security-spec/03-ramp-engine/transaction-validation.md +++ b/docs/security-spec/03-ramp-engine/transaction-validation.md @@ -23,7 +23,7 @@ Several phases are broadcast from the user's wallet, not from an ephemeral key, User-wallet phases: -- `moneriumOnrampMint` — User wallet authorizes Monerium mint. +- `moneriumOnrampMint` — (Deprecated; Monerium is removed — see `05-integrations/monerium.md`. Validation logic retained for any in-flight legacy ramps.) User wallet authorizes Monerium mint. - `squidRouterApprove` / `squidRouterSwap` — SELL direction only (BUY direction is ephemeral-signed). - `squidRouterNoPermitTransfer` — Direct ERC-20 transfer from user wallet (when source ERC-20 lacks EIP-2612 permit and direction is direct-transfer). - `squidRouterNoPermitApprove` — User wallet approves Squid spender. @@ -64,7 +64,7 @@ The two layers together guarantee that the client cannot (a) sneak a malicious p | **Transaction data substitution via metadata matching** | Client submits transactions with correct phase/network/nonce/signer metadata but different txData content. | **MITIGATED (F-043)**: `validatePresignedTxs` resolves the matching unsigned transaction by the same identity keys and performs content validation before `areAllTxsIncluded` is used as the final inclusion guard. | | **EVM contract target or execution-parameter substitution** | Client signs a raw EVM transaction to an attacker-controlled contract, or signs the expected transaction with gas/fee parameters too low to execute reliably. | **MITIGATED (F-050)**: Raw signed EVM transactions are recovered and compared to the server-issued unsigned `to`, `data`, `value`, and `nonce`; gas limit and fee caps must be at least the server-issued values, and contract-creation transactions are rejected. | | **New phase/format added without validation** | A developer adds a new phase and the validator silently treats it as EVM because the phase type falls through to a default. | **MITIGATED (F-047)**: `getTransactionTypeForPhase` now throws for unknown phases instead of defaulting to EVM. | -| **Non-fresh ephemeral submitted at registration** | Client submits an ephemeral address that already has on-chain history (non-zero nonce on Substrate/EVM, or an existing Stellar account). Backend builds presigned transactions assuming nonce 0; execution halts mid-ramp on the first signed broadcast after subsidies/funding have already been committed. | **MITIGATED (F-068)**: `registerRamp` invokes `validateEphemeralAccountsFresh()` after `normalizeAndValidateSigningAccounts`. For each ephemeral type the client provides, it checks every supported chain of that type (Substrate: pendulum, hydration, assethub; EVM: all configured EVM networks including Moonbeam; Stellar). Substrate: requires `nonce === 0 && free === 0`. EVM: requires `nonce === 0`. Stellar: account must not exist on Horizon. Fail-closed on RPC errors. | +| **Non-fresh ephemeral submitted at registration** | Client submits an ephemeral address that already has on-chain history (non-zero nonce on Substrate/EVM, or an existing Stellar account). Backend builds presigned transactions assuming nonce 0; execution halts mid-ramp on the first signed broadcast after subsidies/funding have already been committed. | **MITIGATED (F-072)**: `registerRamp` invokes `validateEphemeralAccountsFresh()` after `normalizeAndValidateSigningAccounts`. For each ephemeral type the client provides, it checks every supported chain of that type (Substrate: pendulum, hydration, assethub; EVM: all configured EVM networks including Moonbeam; Stellar). Substrate: requires `nonce === 0 && free === 0`. EVM: requires `nonce === 0`. Stellar: account must not exist on Horizon. Fail-closed on RPC errors. | ## Audit Checklist @@ -77,7 +77,7 @@ The two layers together guarantee that the client cannot (a) sneak a malicious p - [x] **F-047**: `getTransactionTypeForPhase` throws on unknown phases instead of defaulting to EVM. - [x] **F-048**: Stellar payment validation requires exactly one operation. - [x] **F-049**: `stellarCleanup` no longer falls through with only parse/signature checks; it validates transaction source and an expected cleanup operation count range. -- [x] **F-050**: EVM validation checks raw transaction `to`, `data`, `value`, `nonce`, signer, chain ID, gas limit, and fee caps against the server-issued unsigned transaction; contract creation is rejected. +- [x] **F-050**: EVM validation checks raw transaction `to`, `data`, `value`, `nonce`, signer, chain ID, gas limit, and fee caps against the server-issued unsigned transaction; contract creation is rejected. Native-token destination transfers (where viem's `parseTransaction` returns `data: undefined`) are normalized: both sides of the calldata equality check coerce empty/undefined calldata to `"0x"` so legitimate native transfers are not rejected (`apps/api/src/api/services/transactions/validation.ts:126`). - [x] `validatePresignedTxs` is called in both `updateRamp` and `startRamp` — dual validation confirmed - [x] `validateAllPresignedTransactionsSigned` checks every expected transaction has a corresponding signed entry - [x] EVM raw transaction validation (`validateEvmTransaction`) checks `from`, `chainId`, `nonce`, `to`, `data`, `value`, gas limit, and fee caps against expected signer, chain, and server-issued unsigned payload @@ -99,4 +99,4 @@ The two layers together guarantee that the client cannot (a) sneak a malicious p - [x] **Chainless EVM tx rejection**: `verifySignedEvmTransaction` rejects raw txs whose decoded `chainId` is `undefined` (pre-EIP-155 legacy txs), closing a cross-chain replay bypass that existed even when `sandboxEnabled` was false. - [x] **Backup re-verification**: `meta.additionalTxs` must contain exactly the expected backup set, and every backup is re-run through the primary's validator (EVM signer + nonce + content; Substrate signer + call-equality via `method.toHex()`; Stellar signer + per-phase shape), so a malicious client cannot register ignored extras or backups that encode a different call or signer than the primary tx. - [x] **`updateRamp` subset submissions**: `validatePresignedTxs` accepts `{ requireComplete: false }` for partial submissions but still rejects extra/unknown txs and still applies full per-tx content validation; `requireComplete` defaults to `true` for `startRamp`. -- [x] **F-068**: `registerRamp` proves each submitted ephemeral fresh on every supported chain of its type before building transactions. `validateEphemeralAccountsFresh()` (`apps/api/src/api/services/ramp/ephemeral-freshness.ts`) is invoked after `normalizeAndValidateSigningAccounts`. The supported-network lists (`SUPPORTED_SUBSTRATE_NETWORKS`: pendulum, hydration, assethub; `SUPPORTED_EVM_NETWORKS`: all configured EVM networks including Moonbeam) MUST be kept in sync with any chain an ephemeral may ever sign on. Substrate `nonce === 0 && free === 0`; EVM `nonce === 0`; Stellar account must not exist. RPC errors fail closed with `SERVICE_UNAVAILABLE`. +- [x] **F-072**: `registerRamp` proves each submitted ephemeral fresh on every supported chain of its type before building transactions. `validateEphemeralAccountsFresh()` (`apps/api/src/api/services/ramp/ephemeral-freshness.ts`) is invoked after `normalizeAndValidateSigningAccounts`. The supported-network lists (`SUPPORTED_SUBSTRATE_NETWORKS`: pendulum, hydration, assethub; `SUPPORTED_EVM_NETWORKS`: all configured EVM networks including Moonbeam) MUST be kept in sync with any chain an ephemeral may ever sign on. Substrate `nonce === 0 && free === 0`; EVM `nonce === 0`; Stellar account must not exist. RPC errors fail closed with `SERVICE_UNAVAILABLE`. diff --git a/docs/security-spec/05-integrations/alfredpay.md b/docs/security-spec/05-integrations/alfredpay.md index 96c472d4c..ecb920bb9 100644 --- a/docs/security-spec/05-integrations/alfredpay.md +++ b/docs/security-spec/05-integrations/alfredpay.md @@ -2,7 +2,7 @@ ## What This Does -Alfredpay is a fiat payment provider supporting on-ramp and off-ramp operations across multiple currencies and countries. It is used for ramps where BRLA and Monerium do not cover the target market (e.g. ARS, MXN, COP, USD via ACH). +Alfredpay is a fiat payment provider supporting on-ramp and off-ramp operations across multiple currencies and countries. It is used for ramps where BRLA and Mykobo do not cover the target market (e.g. ARS, MXN, COP, USD via ACH). **Provider type:** Both (on-ramp and off-ramp) **Fiat currencies:** Multiple (varies by country, validated via `AlfredPayCountry` enum) @@ -45,6 +45,7 @@ Alfredpay is a fiat payment provider supporting on-ramp and off-ramp operations 9. **Off-ramp expired-quote recovery MUST re-create the AlfredPay order, not the Vortex quote** — `alfredpay-offramp-transfer-handler.ts` re-quotes against the provider and re-issues `createOfframp` against the same Vortex quote; it MUST NOT mutate the Vortex `QuoteTicket`. 10. **KYB and KYC status mapping MUST be branched by `AlfredpayCustomerType`** — Business customers use `mapKybStatus`; individuals use `mapKycStatus`. Treating one as the other would allow incomplete due-diligence states to pass as `Success`. 11. **Polygon passthrough MUST preserve amount integrity** — The same-chain same-token shortcut in `squid-router-phase-handler.ts` MUST round down (`toFixed(0, 0)`) and MUST use `evmToEvm.inputAmountRaw` as the source-of-truth amount (matching the subsidy target). +12. **The Polygon swap short-circuit MUST be gated on the output token, not on the destination network alone** — Alfredpay mints `ALFREDPAY_EVM_TOKEN` (USDT) directly on Polygon, so the `squidRouterSwap` handler short-circuits straight to `finalSettlementSubsidy` (no swap) only when `quote.metadata.request.to === Networks.Polygon` **and** `quote.outputCurrency === ALFREDPAY_EVM_TOKEN`. `quote.metadata.request.to` is the destination *network*, not the output token; gating on the network alone would mis-deliver — a user requesting any other Polygon output (e.g. USDC) would silently receive the minted USDT instead of their swapped asset. The quote engine (`onramp-polygon-to-evm-alfredpay.ts`) mirrors this: it emits `skipRouteCalculation: true` only for the same-token (`outputCurrency === ALFREDPAY_EVM_TOKEN`) case and otherwise produces a real USDT→output swap. ## Threat Vectors & Mitigations @@ -59,6 +60,7 @@ Alfredpay is a fiat payment provider supporting on-ramp and off-ramp operations | **Provider quote-quote-fall fallback abuse** | Attacker times provider quote drift between Vortex quote and ramp start to maximise the discount-engine fallback subsidy | Provider quote TTL is ~30s; `refreshAlfredpayOnrampQuoteIfMatching` only re-binds on byte-identical `toAmount`/`fee`; otherwise the fallback path is bounded by `maxSubsidy × expectedOutput` and only fires when `targetDiscount > 0` | | **Expired provider quote on offramp transfer** | Provider rejects the stored `quoteId` at transfer time, blocking settlement | `alfredpay-offramp-transfer-handler.ts` re-quotes at execute time and emits `alfredpayOfframpTransferFallback`; the Vortex `QuoteTicket` is untouched | | **Polygon passthrough rounding** | Same-chain same-token shortcut rounds the bridge output incorrectly, leaking dust or under-funding the destination | `toFixed(0, 0)` round-down in the squid-router finalize; downstream subsidy ensures the destination receives the quoted amount | +| **Polygon wrong-token delivery** | A user on-ramps via Alfredpay and requests a non-USDT Polygon output (e.g. USDC); the handler skips the swap on destination-network alone and transfers the minted USDT, delivering the wrong asset | The `squidRouterSwap` short-circuit is gated on `quote.outputCurrency === ALFREDPAY_EVM_TOKEN` (not just `quote.metadata.request.to === Networks.Polygon`); non-USDT Polygon outputs run the real USDT→output swap. Matched in the quote engine's `skipRouteCalculation` branch. | ## Audit Checklist @@ -77,5 +79,6 @@ Alfredpay is a fiat payment provider supporting on-ramp and off-ramp operations - [x] Offramp fallback emits `alfredpayOfframpTransferFallback` for expired-quote recovery; phase is registered as an EVM phase in `transactions/validation.ts:250`. **PASS**. - [x] KYB vs KYC status mapping is branched by `AlfredpayCustomerType.BUSINESS` in `alfredpay.controller.ts`. **PASS** — `mapKybStatus` for business, `mapKycStatus` for individual. - [x] Polygon same-chain same-token passthrough uses `isSameChainSameTokenPassthrough` shortcut, rounds down (`toFixed(0, 0)`), and uses `evmToEvm.inputAmountRaw` as the source amount. **PASS** — `squid-router-phase-handler.ts` + `squidrouter/index.ts` finalize. +- [x] Alfredpay Polygon onramp swap short-circuit is gated on `quote.outputCurrency === ALFREDPAY_EVM_TOKEN`, not on `quote.metadata.request.to === Networks.Polygon` alone. **PASS** — `squid-router-phase-handler.ts` checks both; `onramp-polygon-to-evm-alfredpay.ts` only sets `skipRouteCalculation` for the same-token case. Prevents a non-USDT Polygon output (e.g. USDC) from being delivered as the minted USDT. - [x] `refreshAlfredpayOnrampQuoteIfMatching` only re-binds the provider `quoteId` when `toAmount` and `fee` match byte-identically. **PASS** — `ramp.service.ts:1480-1491`. - [x] AlfredPay offramp order is created at prep time (`evm-to-alfredpay.ts:229`); `processAlfredpayOfframpStart` is a defensive validation-only no-op. **PASS** — verified. diff --git a/docs/security-spec/05-integrations/brla.md b/docs/security-spec/05-integrations/brla.md index c2dbabd76..a771bfa8e 100644 --- a/docs/security-spec/05-integrations/brla.md +++ b/docs/security-spec/05-integrations/brla.md @@ -22,6 +22,10 @@ BRLA is the Brazilian Real stablecoin used for BRL on/off-ramp operations, acces 5. `subsidizePostSwap` (if needed) → `distributeFees` (Multicall3 batch on Base, see `fee-integrity.md`). 6. If destination is Base + USDC → direct `destinationTransfer` (Squid skipped — see `squid-router.md`). Otherwise → `squidRouterApprove` / `squidRouterSwap` → bridge to user's supported destination EVM chain → optional fallback `backupSquidRouter*` swap on the destination chain → `destinationTransfer`. BRL→AssetHub is temporarily disabled at quote eligibility and should not reach registration. +#### Degenerate BRL→BRLA-on-Base route (direct bypass) + +When the user on-ramps BRL and asks for **BRLA delivered on Base** (input BRL, output BRLA, network Base), the generic pipeline would pointlessly swap BRLA→USDC on Nabla and then bridge/swap USDC→BRLA back to itself — burning two swaps of slippage and fees, and inviting the over-subsidy race documented in `06-cross-chain/fund-routing.md`. Avenia already mints BRLA directly on the Base ephemeral, so the route builder detects this case via `isBrlToBrlaBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network)` (`api/services/quote/utils.ts`) and emits a **single** `destinationTransfer` of the quoted output amount straight from the ephemeral to the user — no `nablaApprove`/`nablaSwap`, no `distributeFees`, no `squidRouter*`, no `finalSettlementSubsidy`, no Base cleanup. `stateMeta.isDirectTransfer = true` is set so the downstream `squidRouterSwap` and `finalSettlementSubsidy` handlers also short-circuit defensively if ever reached (`avenia-to-evm-base.ts`). The destination-transfer nonce is `0` (the ephemeral has signed nothing else on this corridor). This mirrors the EUR→EURC-on-Base bypass (`mykobo.md`). + ### Off-ramp flow (User EVM → Base USDC → BRLA → PIX) 1. User signs Squid permit / no-permit fallback / direct transfer (depending on source chain) → tokens arrive on Base ephemeral as USDC. @@ -65,6 +69,7 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou 11. **Recovery on resumed on-chain transfer MUST detect existing tx hashes** — If `brlaPayoutTxHash` is in state, the handler waits for that receipt rather than re-broadcasting (prevents double on-chain BRLA transfer). 12. **PIX deposit details (QR code) MUST be generated server-side** — Returned via API response only after presigned transactions are validated, never client-modifiable. 13. **BRL↔AssetHub MUST stay disabled while the Base BRL rail is active-only** — The quote engine should return no quote for BRL→AssetHub or AssetHub→BRL, preventing users from registering legacy Moonbeam/Pendulum BRL routes. +14. **The BRL→BRLA-on-Base on-ramp MUST take the direct-transfer bypass** — When `inputCurrency === BRL`, `outputCurrency === BRLA`, and `network === Base`, `isBrlToBrlaBaseDirect` MUST short-circuit the route to a single `destinationTransfer` from the ephemeral to the user, with `stateMeta.isDirectTransfer = true`. The Nabla swap, `distributeFees`, SquidRouter, `finalSettlementSubsidy`, and Base cleanup phases MUST NOT run — routing BRLA through USDC and back would burn double-swap slippage/fees against the user and expose the over-subsidy race (`06-cross-chain/fund-routing.md`). The `squidRouterSwap` and `finalSettlementSubsidy` handlers MUST also honor `isDirectTransfer`/`isBrlToBrlaBaseDirect` defensively and skip to `destinationTransfer` if reached. ## Threat Vectors & Mitigations @@ -80,6 +85,7 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou | **Subaccount data leak** | Avenia subaccount details exposed via API | Only `subAccountId`, EVM wallet address, and balances are stored locally; no PII beyond CPF (which is itself a regulatory requirement). | | **Underdelivery from Nabla** | Nabla swap returns less BRLA than quoted, balance poll times out, ramp stuck | Balance-poll timeout (5min) fails the phase as recoverable; `subsidizePostSwap` (EVM branch) tops up shortfalls subject to the quote-relative EVM subsidy cap documented in `fund-routing.md`. | | **Disabled AssetHub corridor accidentally re-enabled** | Legacy BRL↔AssetHub route files are selected and a user registers a route that the Base BRL rail no longer supports | Quote eligibility must return no quote for BRL→AssetHub and AssetHub→BRL. Treat any successful quote for those corridors as a regression until the corridor is intentionally re-enabled. | +| **BRL→BRLA-Base self-swap drain** | The generic pipeline swaps the user's already-minted BRLA to USDC and back, charging two swaps of slippage/fees and triggering `finalSettlementSubsidy` against bridge-less dust (over-subsidy + strand) | `isBrlToBrlaBaseDirect` collapses the corridor to a single `destinationTransfer` with `isDirectTransfer = true`; Nabla/distributeFees/Squid/finalSettlementSubsidy/cleanup are skipped at both route-build and handler level. | ## Audit Checklist @@ -101,6 +107,8 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou - [x] PIX deposit details released to user only after presign validation. **PASS** — gated by `ephemeralPresignChecksPass` (see `transaction-validation.md`). - [PARTIAL] Avenia interactions logged for reconciliation (amounts, not credentials). **PARTIAL** — info logs include amounts; no formal reconciliation log with structured fields. - [x] **FINDING F-064 (MEDIUM)**: BRLA KYC callback endpoint requires authentication. **PASS (FIXED)** — `/kyc/record-attempt` uses `requireAuth`. +- [x] BRL→BRLA-on-Base on-ramps (`isBrlToBrlaBaseDirect`) emit a single `destinationTransfer` with `isDirectTransfer = true` — no Nabla swap, `distributeFees`, SquidRouter, `finalSettlementSubsidy`, or Base cleanup phases. **PASS** — `avenia-to-evm-base.ts` early direct branch (single tx at nonce 0). +- [x] `squidRouterSwap` and `finalSettlementSubsidy` honor `isDirectTransfer` / `isBrlToBrlaBaseDirect` and short-circuit to `destinationTransfer` if ever reached on a direct route. **PASS** — `squid-router-phase-handler.ts`, `final-settlement-subsidy.ts`. ## Remediation Notes diff --git a/docs/security-spec/05-integrations/monerium.md b/docs/security-spec/05-integrations/monerium.md index 5b298866f..c07ecc34d 100644 --- a/docs/security-spec/05-integrations/monerium.md +++ b/docs/security-spec/05-integrations/monerium.md @@ -1,7 +1,11 @@ # Monerium Integration +> **⚠️ DEPRECATED / REMOVED.** Monerium is no longer used for EUR on-ramps. EUR on-ramp has been migrated to **Mykobo on Base** (see `mykobo.md`). The `moneriumOnrampMint` and `moneriumOnrampSelfTransfer` phase handlers, the Polygon Monerium EURe path, and the Polygon ephemeral cleanup for SEPA ramps are **no longer reached by any active corridor**. This file is retained for historical context and audit traceability of past Monerium-related findings; new ramps do not flow through Monerium. Active EUR architecture: `05-integrations/mykobo.md`. + ## What This Does +(Historical — Monerium is removed. See top-of-file deprecation banner and `mykobo.md`.) + Monerium is a European e-money institution that issues EURe (Monerium EUR) tokens. Vortex uses Monerium for EUR on-ramp operations via SEPA bank transfers. **Provider type:** On-ramp only diff --git a/docs/security-spec/05-integrations/mykobo.md b/docs/security-spec/05-integrations/mykobo.md new file mode 100644 index 000000000..2d36c3d66 --- /dev/null +++ b/docs/security-spec/05-integrations/mykobo.md @@ -0,0 +1,142 @@ +# Mykobo Integration + +## What This Does + +Mykobo is the EUR fiat anchor used by Vortex for EUR on/off-ramp operations on **Base (Ethereum L2)**. Mykobo settles SEPA bank transfers into / out of EURC (Circle's EUR stablecoin, ERC-20) on Base. There is no Stellar, Pendulum, or Moonbeam involvement for EUR liquidity: all EUR flow now happens on Base, mirroring the BRLA-on-Base architecture. + +Mykobo replaces two earlier EUR rails: + +- The **Stellar SEP-24 EUR off-ramp** (Mykobo anchor reached via Spacewalk) — removed for EUR. See `stellar-anchors.md` for the deprecation note. +- The **Monerium EUR on-ramp** (Monerium EURe minted on Moonbeam) — removed. See `monerium.md` for the deprecation note. + +**Provider type:** Both (on-ramp and off-ramp) +**Fiat currency:** EUR (Euro, SEPA) +**Chain involved:** Base (EURC is an ERC-20 on Base; USDC on Base is the Nabla swap counter-asset) +**Phase handlers:** +- `mykobo-onramp-deposit-handler.ts` — On-ramp: After the user's SEPA transfer is received, Mykobo settles EURC on Base to the user's Base ephemeral; the handler polls the Base RPC until the expected balance arrives. +- `mykobo-payout-handler.ts` — Off-ramp: Sends a presigned ERC-20 EURC transfer from the Base ephemeral to the Mykobo-controlled `receivables` address, then polls Mykobo's transaction status until `COMPLETED`. + +**API surface:** Mykobo HTTP API client `MykoboApiService` (`packages/shared/src/services/mykobo/mykoboApiService.ts`). Singleton. +**API auth method:** Access key + secret key exchanged for a short-lived bearer token via `POST /auth/token`; refresh token via `POST /auth/refresh`. Cached in-process; re-acquired on `401`. Credentials sourced from `MYKOBO_ACCESS_KEY`, `MYKOBO_SECRET_KEY`, `MYKOBO_BASE_URL`, `MYKOBO_CLIENT_DOMAIN` env vars. + +**`MYKOBO_CLIENT_DOMAIN` operational note:** The client domain is sent as `client_domain` on every Mykobo API call (`MykoboApiService`). It identifies the Vortex deployment to Mykobo and determines the **fee tier** applied to that deployment's intents. When unset, Mykobo falls back to its default tier (observed: ~0.31 EUR fixed deposit fee vs. ~0.06 EUR for the negotiated Vortex tier on `satoshipay.io`). Because the constant is loaded via `getEnvVar` with no default, a missing value silently degrades fees rather than failing fast — operators MUST verify it is set at deploy time. + +### On-ramp flow (EUR SEPA → Base EURC → Nabla swap → user EVM destination) + +1. At ramp registration (`prepareMykoboOnrampTransactions` in `ramp.service.ts`), Vortex calls Mykobo `POST /transactions/intent` with `transaction_type=DEPOSIT`, `currency=EURC`, the user's email + IP, the **Base ephemeral address** as `wallet_address`, and `value` as the EUR amount floored to **2 decimal places** (Mykobo silently truncates any extra precision; see invariant below). Mykobo returns IBAN payment instructions (IBAN, bank account name, transaction reference). +2. IBAN instructions are returned to the user **only after** presigned-transaction validation passes (see `transaction-validation.md`). +3. User makes the SEPA bank transfer to Mykobo's IBAN with the returned reference. +4. `mykoboOnrampDeposit`: handler polls the Base RPC for EURC arrival at `evmEphemeralAddress`. + - **Outer timeout** (`PAYMENT_TIMEOUT_MS`): **24 hours**, matching SEPA business-day cutoffs. + - **Inner balance-arrival timeout** (`EVM_BALANCE_CHECK_TIMEOUT_MS`): 5 minutes per `checkEvmBalancePeriodically` invocation. Inner timeouts throw `RecoverablePhaseError` and the phase processor re-enters the handler until the outer 24h cap is reached. + - **Recovery shortcut**: if the ephemeral already holds ≥ 95% of `quote.metadata.mykoboMint.outputAmountRaw` EURC (`EPHEMERAL_FUNDED_TOLERANCE_FACTOR = 0.95`), the handler skips the wait. The 5% tolerance absorbs fee variance between quote-creation time and SEPA settlement time. + - On outer-timeout expiry, the ramp transitions to `failed` (the user did not pay). +5. `fundEphemeral` (Base ETH gas top-up; same as BRL onramp) → `subsidizePreSwap` (if needed) → `nablaApprove` → `nablaSwap`: Nabla DEX **on Base** swaps EURC → USDC. +6. `subsidizePostSwap` (if needed) → `distributeFees` (Multicall3 batch on Base, see `fee-integrity.md`). +7. If destination is Base + USDC → direct `destinationTransfer` (Squid skipped — see `squid-router.md`). Otherwise → `squidRouterApprove` / `squidRouterSwap` → bridge to user's destination EVM chain → optional `backupSquidRouter*` fallback → `destinationTransfer`. + +#### Degenerate EUR→EURC-on-Base route (direct bypass) + +When the user on-ramps EUR and asks for **EURC delivered on Base** (input EURC, output EURC, network Base), the generic pipeline would pointlessly swap EURC→USDC on Nabla and then bridge/swap USDC→EURC back to itself — burning two swaps of slippage and fees, and inviting the over-subsidy race documented in `06-cross-chain/fund-routing.md`. Mykobo already settles EURC directly on the Base ephemeral, so the route builder detects this case via `isEurToEurcBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network)` (`api/services/quote/utils.ts`) and emits a **single** `destinationTransfer` of the quoted output amount straight from the ephemeral to the user — no `nablaApprove`/`nablaSwap`, no `squidRouter*`, no `finalSettlementSubsidy`, no Base cleanup. `stateMeta.isDirectTransfer = true` is set so the downstream `finalSettlementSubsidy` handler also short-circuits defensively if ever reached (`mykobo-to-evm.ts`). The quote engine produces the matching single-phase plan, and the destination-transfer nonce is `0` (the ephemeral has signed nothing else on this corridor). + +### Off-ramp flow (User EVM → Base USDC → Base EURC → SEPA payout) + +1. User signs Squid permit / no-permit fallback / direct transfer → tokens arrive on Base ephemeral as USDC. If the source is already Base USDC, Squid is skipped. +2. At registration (`prepareEvmToMykoboOfframpTransactions`), Vortex calls Mykobo `POST /transactions/intent` with `transaction_type=WITHDRAW`, `currency=EURC`, the Base ephemeral as `wallet_address`, and `value` set to `quote.metadata.nablaSwapEvm.outputAmount` floored to **2 decimal places** via `Big.toFixed(2, 0)` (Mykobo silently truncates anything beyond 2 dp; intent value, on-chain transfer amount, and Mykobo's accounting MUST agree on the same floored figure). Mykobo returns withdraw instructions including the **`receivables` Base address** (the EVM address that Mykobo monitors for incoming EURC). The Mykobo transaction id and reference are stored in `state.state.mykoboTransactionId` / `mykoboTransactionReference`. +3. `distributeFees` runs **before** Nabla swap so partner/vortex fees are taken in USDC (consistent with the BRLA-on-Base flow; see `fee-integrity.md`). +4. `subsidizePreSwap` → `nablaApprove` → `nablaSwap`: Nabla DEX on Base swaps USDC → EURC. +5. `mykoboPayoutOnBase`: + 1. Sends the presigned ERC-20 EURC transfer of the **2dp-floored** Mykobo intent value (`mykoboFlooredValue × 10^ERC20_EURC_BASE_DECIMALS`) from the ephemeral to the Mykobo `receivables` address. The transfer amount is fixed at registration time and **MUST equal the Mykobo intent `value`** so on-chain credit and Mykobo accounting agree. The sub-cent EURC remainder between `nablaSwapEvm.outputAmountRaw` and the floored transfer amount stays on the ephemeral and is swept by `baseCleanupEurc` in step 6. + 2. On retry, if `mykoboPayoutTxHash` is already in state, the handler waits for that receipt instead of re-broadcasting. If the prior tx reverted, it re-sends the same presigned tx (EVM nonce uniqueness still prevents double-spend). + 3. After the on-chain transfer is confirmed, the handler polls Mykobo `GET /transactions/{id}` every **5s for up to 10 minutes**, looking for `MykoboTransactionStatus.COMPLETED`. `FAILED`, `CANCELLED`, or `EXPIRED` raise an **unrecoverable** error. Polling-error timeouts raise an unrecoverable error if the last polling attempt errored, otherwise a recoverable error. +6. `baseCleanupUsdc` / `baseCleanupEurc` / `baseCleanupAxlUsdc`: sweep dust from the Base ephemeral back to the Base funding account. `baseCleanupEurc` is load-bearing here — it claims the sub-cent EURC remainder left behind by the 2dp floor in step 5.1. + +### Profile / KYC flow + +Mykobo profiles are user records keyed by the user's email address. They carry KYC fields and are required before Mykobo will accept SEPA deposits / WITHDRAW intents for that user. + +- `GET /v1/mykobo/profiles?email=...&memo=...` — Vortex backend proxies `MykoboApiService.getProfileByEmail`. Used by the frontend to detect whether the authenticated user already has a profile. The `email` query parameter MUST match the Supabase-authenticated user's email (`req.userEmail`); mismatched values are rejected at the controller boundary. +- `POST /v1/mykobo/profiles` — Vortex backend proxies `MykoboApiService.createProfile` with multipart form-data (ID document, source-of-funds document, demographics). + +The Mykobo KYC widget on the frontend (`MykoboKycFlow`) drives the user through profile creation. The ramp state machine treats Mykobo KYC as a **pre-ramp gate**: the `Deciding` step in the ramp XState machine checks profile presence and routes to the Mykobo KYC flow before allowing ramp registration. + +### Why no `mykoboMint` phase + +Unlike Monerium (`moneriumOnrampMint` + `moneriumOnrampSelfTransfer`), Vortex does **not** call any Mykobo API to trigger minting. Mykobo's SEPA→EURC settlement is initiated by the user's bank transfer and is observed entirely on-chain via the Base EURC balance of the ephemeral. The handler is therefore a pure balance-poller; there is no Vortex-controlled minting step that can fail mid-flight. + +## Security Invariants + +1. **Mykobo API credentials MUST be stored as environment variables** — `MYKOBO_ACCESS_KEY`, `MYKOBO_SECRET_KEY`, `MYKOBO_BASE_URL`, and `MYKOBO_CLIENT_DOMAIN` are loaded via `packages/shared` config. Never hardcoded, never in the database. +2. **The Mykobo bearer token MUST never appear in logs or error messages** — `MykoboApiError` captures status + body but not the request headers; review log redaction for any context that includes `Authorization`. +3. **SEPA payment confirmation MUST come from on-chain EURC arrival, not from user input** — `mykoboOnrampDeposit` polls the Base RPC for the ephemeral's EURC balance; it never accepts a "user claims paid" signal. +4. **The on-chain EURC transfer amount (off-ramp) MUST equal the Mykobo intent `value` floored to 2 decimal places** — Computed in `evm-to-mykobo.ts` as `Big(quote.metadata.nablaSwapEvm.outputAmount).toFixed(2, 0)` and converted to raw via `× 10^ERC20_EURC_BASE_DECIMALS`. The presigned `mykoboPayoutOnBase` tx, the Mykobo intent `value`, and the Mykobo `receivables` credit MUST all reference the same floored figure. The sub-cent EURC remainder is intentionally left on the ephemeral for `baseCleanupEurc`. The Mykobo anchor fee was already factored into `quote.outputAmount` at quote-creation time. +5. **The Mykobo `receivables` address MUST come from the Mykobo intent response, not from any client-supplied field** — `mykoboReceivablesAddress` is read from `intent.instructions.address` server-side and stored in `stateMeta`. The user has no way to redirect the off-ramp transfer. +6. **The Mykobo `transaction_type` MUST match the ramp direction** — `DEPOSIT` for on-ramp intents, `WITHDRAW` for off-ramp intents. A mismatch is rejected by Mykobo, but Vortex must not allow client-controlled selection of the type. +7. **The on-ramp intent's `wallet_address` MUST be the Base ephemeral, not the user's destination address** — EURC is settled to the ephemeral so the Nabla swap pipeline can run. Using the user's destination address would bypass the swap and fee distribution. +8. **The off-ramp intent's `wallet_address` MUST be the Base ephemeral** — Mykobo correlates the incoming EURC transfer by the sender wallet; using any other address breaks the WITHDRAW settlement. +9. **SEPA payment details (IBAN, reference) MUST be generated server-side** — Returned by Mykobo's intent response, surfaced to the user only after presigned-transaction validation succeeds. Never client-modifiable. +10. **`mykoboOnrampDeposit` MUST bound its wait** — The 24h `PAYMENT_TIMEOUT_MS` prevents indefinite ramp parking. After 24h with no EURC arrival, the ramp transitions to `failed`. +11. **The 5% pre-funding tolerance MUST only apply to recovery, not to live settlements** — The recovery shortcut compares against 95% of `mykoboMint.outputAmountRaw` to avoid missing an already-funded ephemeral on a rerun. Live `checkEvmBalancePeriodically` still requires the full `expectedAmountRaw`. +12. **`mykoboPayoutOnBase` MUST not advance until both the on-chain transfer is confirmed and Mykobo reports `COMPLETED`** — Confirming only the on-chain side would mark the ramp complete while Mykobo could still reject the deposit. +13. **`MykoboTransactionStatus` of `FAILED` / `CANCELLED` / `EXPIRED` MUST be treated as unrecoverable** — The handler throws via `createUnrecoverableError` so the ramp transitions to a failed state instead of looping. +14. **Recovery on resumed `mykoboPayoutOnBase` MUST detect existing tx hashes** — If `mykoboPayoutTxHash` is in state, the handler waits for that receipt rather than blindly re-broadcasting. If the prior tx reverted, the same presigned tx is re-broadcast — EVM nonce uniqueness prevents double-spend of the ephemeral's EURC. +15. **Mykobo KYC profile creation MUST be gated by Vortex auth** — The `/v1/mykobo/profiles` endpoints require a Supabase OTP session (see `01-auth/supabase-otp.md`); anonymous profile creation is rejected. +16. **Mykobo KYC documents MUST NOT be stored by Vortex** — The frontend submits ID and source-of-funds files directly to the backend, which forwards them to Mykobo as multipart form-data without persisting. No Mykobo profile fields are stored in Vortex's database beyond the email→profile linkage used to look up profile state. +17. **Mykobo HTTP responses MUST be validated** — `MykoboApiService.request` checks `response.ok`, raises `MykoboApiError` with status + body on failure, and re-acquires the token on `401` exactly once before re-throwing. `MykoboApiError` MUST be caught and translated to `RecoverablePhaseError` (transient) or `UnrecoverablePhaseError` (terminal status) at the handler boundary. +18. **Mykobo bearer-token refresh MUST be safe under concurrent requests** — `MykoboApiService.tokenPromise` debounces concurrent `acquireToken` calls so multiple in-flight requests share a single token acquisition. Token refresh is single-use per cached token; on refresh failure the service falls back to re-acquiring with the access/secret keys. +19. **The Mykobo base URL normalization MUST enforce a `/v1` suffix** — `MykoboApiService` trims trailing slashes and appends `/v1` unless the configured `MYKOBO_BASE_URL` already ends in `/v`. This prevents accidental cross-version calls if an operator sets `MYKOBO_BASE_URL` to a root domain. +20. **`MYKOBO_CLIENT_DOMAIN` MUST be set in every deployment** — The constant is sent as `client_domain` on every Mykobo API call and selects the negotiated fee tier. Because it is loaded via `getEnvVar` with no default, a missing value silently falls back to Mykobo's default tier (worse fees, observed ~5x higher). Deploy-time checks MUST treat an unset `MYKOBO_CLIENT_DOMAIN` as a hard failure. +21. **Mykobo intent `value` MUST be floored to 2 decimal places** — Mykobo silently truncates anything beyond 2 dp, which would otherwise cause the on-chain transfer amount and the Mykobo-credited amount to diverge. Both the on-ramp `DEPOSIT` intent and the off-ramp `WITHDRAW` intent MUST send a 2dp-floored `value`, and the off-ramp on-chain transfer MUST be derived from that same floored value (not from the unrounded Nabla output). The sub-cent EURC remainder on the ephemeral MUST be swept by `baseCleanupEurc`. +22. **The EUR→EURC-on-Base on-ramp MUST take the direct-transfer bypass** — When `inputCurrency === EURC`, `outputCurrency === EURC`, and `network === Base`, `isEurToEurcBaseDirect` MUST short-circuit the route to a single `destinationTransfer` from the ephemeral to the user, with `stateMeta.isDirectTransfer = true`. The Nabla swap, SquidRouter, `finalSettlementSubsidy`, and Base cleanup phases MUST NOT run — routing EURC through USDC and back would burn double-swap slippage/fees against the user and expose the over-subsidy race (`06-cross-chain/fund-routing.md`). The `finalSettlementSubsidy` handler MUST also honor `isDirectTransfer`/`isEurToEurcBaseDirect` defensively and skip to `destinationTransfer` if reached. + +## Threat Vectors & Mitigations + +| Threat | Attack Scenario | Mitigation | +|---|---|---| +| **SEPA payment spoofing (on-ramp)** | User claims to have paid without making the SEPA transfer | `mykoboOnrampDeposit` polls the Base RPC for actual EURC arrival; never trusts user signals. | +| **Wrong reference on SEPA** | User sends SEPA with wrong reference, causing misattribution at Mykobo | Reference is generated by Mykobo per intent and shown to the user verbatim; Mykobo correlates by reference at its end. If misattributed, Mykobo will not settle and the 24h timeout fails the ramp. | +| **Off-ramp receivables redirection** | Attacker tries to redirect the EURC payout to a wallet they control | `mykoboReceivablesAddress` is sourced from Mykobo's intent response and baked into the presigned tx at registration time. No client-supplied field reaches the presigned tx target. | +| **Off-ramp amount manipulation** | Attacker modifies the EURC payout amount between quote and execution | Transfer amount is `quote.metadata.nablaSwapEvm.outputAmountRaw`, fixed in the presigned tx at registration. `quote` is immutable post-creation. | +| **Mykobo bearer token compromise** | Bearer token captured in transit or from logs | HTTPS enforced; token never logged; cached in-process only; rotated on `401`. Rotate access/secret keys via Mykobo dashboard on suspected leak. | +| **Mykobo access/secret-key compromise** | Env-var leak exposes long-lived credentials | Keys live in environment only (see `07-operations/secret-management.md`); rotate via Mykobo dashboard; monitor Mykobo dashboard for unauthorized intents. | +| **Mykobo API unavailability** | Mykobo is down during off-ramp polling or on-ramp settlement | Off-ramp `mykoboPayoutOnBase`: polling errors continue retrying up to the 10-minute window; on poll-timeout the handler throws `RecoverablePhaseError` (or unrecoverable if last attempt errored). On-ramp `mykoboOnrampDeposit`: inner balance-check timeouts are recoverable; only the outer 24h cap fails the ramp. | +| **Double on-chain EURC transfer (off-ramp)** | Crash between sending the EURC transfer and storing the hash | Handler waits for receipt before persisting `mykoboPayoutTxHash`. On retry, if no hash is stored, the same presigned tx is re-broadcast — EVM nonce uniqueness on the ephemeral prevents double-spend. | +| **Double Mykobo payout completion** | Bug causes the handler to advance to `complete` before Mykobo `COMPLETED` | Handler awaits `pollMykoboUntilCompleted` before `transitionToNextPhase("complete")`. Any non-`COMPLETED` terminal status raises unrecoverable. | +| **Mykobo terminal status (FAILED / CANCELLED / EXPIRED) mishandled** | Handler retries indefinitely on a permanently failed Mykobo transaction | `createUnrecoverableError` is thrown for those three statuses; ramp transitions to failed instead of looping. | +| **Long-lived on-ramp DOS** | Attacker creates many on-ramps to occupy ephemeral accounts for 24h | Per-user concurrent ramp limit (cross-cutting; see `07-operations/api-surface.md` rate limiting). Ephemerals are user-funded so blast radius is bounded. | +| **TLS downgrade / MITM** | Attacker intercepts Mykobo API calls | HTTPS-only base URL; bearer-token auth depends on TLS; refuse any non-HTTPS `MYKOBO_BASE_URL` configuration at deploy time. | +| **Profile-doc PII leak** | KYC document or PII surfaces in Vortex storage or logs | Documents are streamed through to Mykobo as multipart form-data without persistence; no PII fields stored locally beyond the email→profile-existence linkage. | +| **Cross-version Mykobo API drift** | Operator misconfigures `MYKOBO_BASE_URL` to a root domain, hitting an unintended version | `MykoboApiService` enforces a `/v` suffix; misconfiguration fails fast on the first auth call. | +| **`MYKOBO_CLIENT_DOMAIN` unset → wrong fee tier** | Operator forgets to set `MYKOBO_CLIENT_DOMAIN`; Mykobo silently applies its default tier (~5x worse fees) and quotes/distributions drift from reality | Deploy-time check fails fast if the env var is missing; alarms on observed Mykobo fees exceeding `defaultDepositFee` / `defaultWithdrawFee` (see `07-operations/secret-management.md`). | +| **Intent-value precision drift** | EURC payout amount carries >2 dp; Mykobo silently truncates and credits less than the on-chain transfer, leaving the user short | Both `DEPOSIT` and `WITHDRAW` intents send `Big.toFixed(2, 0)`-floored `value`; the off-ramp on-chain EURC transfer is derived from the same floored value; sub-cent dust is swept by `baseCleanupEurc`. | +| **EUR→EURC-Base self-swap drain** | The generic pipeline swaps the user's already-settled EURC to USDC and back, charging two swaps of slippage/fees and triggering `finalSettlementSubsidy` against bridge-less dust (over-subsidy + strand) | `isEurToEurcBaseDirect` collapses the corridor to a single `destinationTransfer` with `isDirectTransfer = true`; Nabla/Squid/finalSettlementSubsidy/cleanup are skipped at both route-build and handler level. | + +## Audit Checklist + +- [ ] Mykobo API credentials loaded from environment variables (not hardcoded, not in database) +- [ ] `MYKOBO_BASE_URL` is HTTPS and resolves to a `/v`-suffixed Mykobo endpoint +- [ ] `mykoboOnrampDeposit` polls Base RPC for EURC arrival before advancing +- [ ] 24h outer payment timeout enforced; on expiry the ramp transitions to `failed` +- [ ] 5% recovery tolerance applied only to the pre-funded shortcut, not to live polling +- [ ] On-ramp intent `wallet_address` is the Base ephemeral (not the user destination address) +- [ ] Off-ramp intent `wallet_address` is the Base ephemeral +- [ ] Off-ramp `mykoboReceivablesAddress` comes from `intent.instructions.address` (server-side) +- [ ] Off-ramp EURC transfer amount equals the 2dp-floored Mykobo intent `value` (derived via `Big.toFixed(2, 0)`), not the raw `nablaSwapEvm.outputAmountRaw` +- [ ] Both on-ramp `DEPOSIT` and off-ramp `WITHDRAW` Mykobo intents send `value` floored to 2 decimal places +- [ ] `MYKOBO_CLIENT_DOMAIN` is set in every environment (deploy-time check); missing value is a hard failure +- [ ] `mykoboPayoutOnBase` advances to `complete` only after both on-chain confirmation and Mykobo `COMPLETED` +- [ ] `FAILED` / `CANCELLED` / `EXPIRED` Mykobo statuses raise unrecoverable errors +- [ ] Recovery: `mykoboPayoutTxHash` short-circuits on-chain transfer re-broadcast (waits for receipt; re-sends only if prior tx reverted) +- [ ] Mykobo HTTP responses are validated (`response.ok`, status, body) +- [ ] `MykoboApiError` is caught at the handler boundary and mapped to recoverable / unrecoverable +- [ ] Bearer-token refresh is debounced (no thundering-herd on `401`) +- [ ] Bearer token, access key, and secret key do not appear in logs or error messages +- [ ] IBAN payment details surfaced to the user only after presigned-transaction validation passes +- [ ] `/v1/mykobo/profiles` endpoints require Supabase OTP auth (anonymous requests rejected) +- [ ] Mykobo KYC documents are not persisted by Vortex; only the email→profile linkage is stored +- [ ] `GET /v1/mykobo/profiles` rejects requests whose `email` query parameter does not match `req.userEmail` (case-insensitive) +- [ ] HTTPS enforced for all Mykobo API calls +- [ ] Timeout / `AbortController` configured for Mykobo HTTP client (cross-cutting; tracked as F-014 across providers) +- [ ] No Mykobo API call is invoked from a phase handler without an explicit recoverable/unrecoverable mapping +- [ ] EUR→EURC-on-Base on-ramps (`isEurToEurcBaseDirect`) emit a single `destinationTransfer` with `isDirectTransfer = true` — no Nabla swap, SquidRouter, `finalSettlementSubsidy`, or Base cleanup phases +- [ ] `finalSettlementSubsidy` honors `isDirectTransfer` / `isEurToEurcBaseDirect` and short-circuits to `destinationTransfer` if ever reached on a direct route diff --git a/docs/security-spec/05-integrations/squid-router.md b/docs/security-spec/05-integrations/squid-router.md index 9d2892c72..7c3317a42 100644 --- a/docs/security-spec/05-integrations/squid-router.md +++ b/docs/security-spec/05-integrations/squid-router.md @@ -5,9 +5,12 @@ Squid Router is a cross-chain swap/routing protocol built on Axelar's General Message Passing (GMP). Vortex uses it for: - **BRL on-ramp**: Base USDC → user's destination EVM chain (any token). - **BRL off-ramp**: User's source EVM chain → Base USDC. -- **EUR on-ramp (Monerium)**: Polygon EURe → Moonbeam. +- **EUR on-ramp (Mykobo on Base)**: Base USDC → user's destination EVM chain (after EURC→USDC Nabla swap). +- **EUR off-ramp (Mykobo on Base)**: User's source EVM chain → Base USDC (client-side user-signed). - **Off-ramp permit acquisition (Alfredpay)**: User EVM → Moonbeam via `TokenRelayer.execute()` with EIP-2612 permit. +> **Removed:** the previous Monerium-EUR Squid usage (Polygon EURe → Moonbeam) is no longer active; Monerium is deprecated (see `monerium.md`). + It handles cross-chain swap execution, Axelar bridge status monitoring, and gas subsidization on the destination chain. **Provider type:** Cross-chain router diff --git a/docs/security-spec/05-integrations/stellar-anchors.md b/docs/security-spec/05-integrations/stellar-anchors.md index c7009dcbb..808020bb8 100644 --- a/docs/security-spec/05-integrations/stellar-anchors.md +++ b/docs/security-spec/05-integrations/stellar-anchors.md @@ -1,17 +1,19 @@ # Stellar Anchors Integration +> **⚠️ FULLY DEPRECATED.** The Stellar-anchored off-ramp path (Spacewalk + Stellar payment) is no longer an active corridor. EUR has migrated to **Mykobo on Base** (see `mykobo.md`) and ARS has been removed entirely. The `spacewalkRedeem` and `stellarPayment` phase handlers are **not registered** in `register-handlers.ts`; presigned-transaction builders for these flows have been removed. This document is retained for historical reference and to document the security model of the prior implementation. **Do not treat any flow below as currently reachable.** + ## What This Does -Stellar anchors are used for off-ramp flows that terminate on the Stellar network — specifically EUR (EURC) and ARS off-ramps. The flow bridges assets from Pendulum to Stellar via the Spacewalk bridge, then makes a Stellar payment from the ephemeral account to the user's off-ramp destination. +Stellar anchors were historically used for off-ramp flows that terminated on the Stellar network (EUR via wrapped EURC; ARS via the Anclap anchor). Both corridors are now removed. The historical flow bridged assets from Pendulum to Stellar via the Spacewalk bridge, then made a Stellar payment from the ephemeral account to the user's off-ramp destination. -**Provider type:** Off-ramp -**Fiat currencies:** EUR (via EURC on Stellar), ARS +**Provider type:** Off-ramp (deprecated) +**Fiat currencies:** None active. EUR migrated to Mykobo on Base; ARS removed. **Chains involved:** Pendulum (Nabla swap output) → Stellar (via Spacewalk bridge) → Stellar anchor -**Phase handlers:** -- `spacewalk-redeem-handler.ts` — Submits a Spacewalk redeem request on Pendulum, then waits up to 10 minutes for tokens to arrive on the ephemeral Stellar account -- `stellar-payment-handler.ts` — Submits the presigned Stellar payment transaction to Horizon, sending tokens from the ephemeral to the user's destination +**Phase handlers (no longer registered):** +- `spacewalk-redeem-handler.ts` — Submitted a Spacewalk redeem request on Pendulum, then waited up to 10 minutes for tokens to arrive on the ephemeral Stellar account +- `stellar-payment-handler.ts` — Submitted the presigned Stellar payment transaction to Horizon, sending tokens from the ephemeral to the user's destination -**Flow (off-ramp):** +**Flow (off-ramp, historical):** 1. After Nabla swap on Pendulum, the output token (e.g., wrapped EURC) is held by the substrate ephemeral account 2. `spacewalkRedeem` phase: Calls a Spacewalk vault to redeem Pendulum-wrapped tokens for native Stellar tokens. The redeem extrinsic is presigned and submitted from the substrate ephemeral. The handler polls the Stellar ephemeral account balance until tokens arrive (1s polling, 10min timeout). 3. `stellarPayment` phase: Submits the presigned XDR transaction to Horizon. This transaction moves tokens from the Stellar ephemeral account to the user's Stellar address (the anchor's deposit address). diff --git a/docs/security-spec/06-cross-chain/bridge-security.md b/docs/security-spec/06-cross-chain/bridge-security.md index c2c2a5271..53aced9d1 100644 --- a/docs/security-spec/06-cross-chain/bridge-security.md +++ b/docs/security-spec/06-cross-chain/bridge-security.md @@ -2,7 +2,7 @@ ## What This Does -Spacewalk is the bridge between the **Pendulum** parachain and the **Stellar** network. It enables off-ramp flows that terminate on Stellar (EUR via EURC, ARS) by converting Pendulum-wrapped Stellar tokens back to native Stellar tokens. +Spacewalk is the bridge between the **Pendulum** parachain and the **Stellar** network. It enables off-ramp flows that terminate on Stellar (ARS today; EUR was migrated to Mykobo on Base — see `05-integrations/mykobo.md`) by converting Pendulum-wrapped Stellar tokens back to native Stellar tokens. The bridge operates through a **vault-based model**: independent vault operators lock collateral on Pendulum and process redeem requests. When a user (or ephemeral account) wants to redeem Pendulum-wrapped tokens for their Stellar originals, a vault is selected, the wrapped tokens are burned on Pendulum, and the vault releases the native tokens on Stellar. diff --git a/docs/security-spec/06-cross-chain/fund-routing.md b/docs/security-spec/06-cross-chain/fund-routing.md index 5f7956027..3eeb1a56e 100644 --- a/docs/security-spec/06-cross-chain/fund-routing.md +++ b/docs/security-spec/06-cross-chain/fund-routing.md @@ -42,6 +42,8 @@ The current code resolves this through `EVM_FUNDING_PRIVATE_KEY` and the `getEvm 7. **Post-swap subsidization next-phase routing MUST be deterministic** — `subsidize-post-swap-handler.ts` contains branching logic that selects the next phase based on ramp direction (on/off), destination chain, and output token. This routing must be consistent with the flow defined at ramp creation. 8. **No subsidization handler MUST proceed if the funding account has insufficient balance** — If the funding account cannot cover the subsidy, the handler should fail with a recoverable error, not silently skip the top-up. 9. **EVM subsidy caps MUST stop transfers without forcing manual phase repair** — If an EVM pre/post-swap subsidy exceeds the quote-relative cap, the handler must not submit a transfer. The cap breach is intentionally recoverable so operators can investigate, top up, or cancel the ramp without repairing an unrecoverably failed phase. +10. **`finalSettlementSubsidy` MUST subsidize the gap to *actual bridge delivery*, not to the ephemeral's total balance** — The subsidy is `expectedAmountRaw - delivered`, where `delivered = actualBalance - preSettlementBalance` and `preSettlementBalance` is the destination-token balance snapshotted right after the `squidRouterSwap` confirms (before `squidRouterPay`). Computing the subsidy from total balance is unsafe: leftover Nabla-swap dust in the destination token would make the handler return early and over-subsidize before the Squid bridge output has landed. To avoid racing the bridge, the balance poll waits for ≥90% of `expectedAmountRaw` to arrive (the 90% floor absorbs bridge slippage while still confirming the bridge actually delivered) rather than returning on any non-zero balance. (Incident: a EUR→EURC Base ramp was over-funded ~29.36 EURC and stranded ~59 EURC because the pre-fix handler returned on dust and subtracted total balance.) +11. **Degenerate same-token settlement routes MUST skip `finalSettlementSubsidy` entirely** — When the ramp is a direct transfer (`state.state.isDirectTransfer === true`), a EUR→EURC-on-Base route (`isEurToEurcBaseDirect`), or a BRL→BRLA-on-Base route (`isBrlToBrlaBaseDirect`), the handler short-circuits to `destinationTransfer` without subsidizing. There is no Squid bridge to settle, so any subsidy computation would be against a balance the funder never needs to top up. ## Threat Vectors & Mitigations @@ -55,6 +57,7 @@ The current code resolves this through `EVM_FUNDING_PRIVATE_KEY` and the `getEvm | **Destination transfer replay** — The presigned EVM transaction is somehow submitted multiple times | EVM nonce prevents replay. Each transaction is valid for exactly one nonce value. | | **Balance check race condition in destination transfer** — Balance changes between the check and the transaction submission | Possible but unlikely for ephemeral accounts (no other senders). If balance drops between check and submission, the EVM transaction reverts (no fund loss, just a failed phase that retries). | | **Post-swap routing logic inconsistency** — The next-phase selection in `subsidize-post-swap-handler.ts` routes to a phase that doesn't match the ramp's intended flow | Routing logic uses `direction`, `toChain`, and `outputTokenType` from ramp state. A mismatch would cause the ramp to enter an unexpected phase. Since phases are handler-specific, executing the wrong phase could fail or produce incorrect results. | +| **Final settlement over-subsidy race** — `finalSettlementSubsidy` returns as soon as *any* destination-token balance appears (e.g. Nabla-swap dust) and computes `subsidy = expected − totalBalance` before the Squid bridge output lands. The funder then tops up the full expected amount on top of the later bridge delivery, double-paying the ephemeral and stranding the excess. | **Mitigated.** The handler now snapshots `preSettlementBalance` after `squidRouterSwap` confirms, waits for ≥90% of `expectedAmountRaw` to arrive, and subsidizes only `expected − (actualBalance − preSettlementBalance)`. Direct-transfer / EUR→EURC-Base routes skip the phase outright. Regression-test this: the failure mode silently over-pays from the funding key. | ## Audit Checklist @@ -63,7 +66,7 @@ The current code resolves this through `EVM_FUNDING_PRIVATE_KEY` and the `getEvm - [x] Verify `subsidize-post-swap-handler.ts` calculates subsidy the same way — no off-by-one, no rounding errors. **PASS** — same calculation pattern confirmed. - [x] Verify both pre/post swap handlers skip subsidization when `currentBalance >= expectedAmount` (no negative transfers). **PASS** — skip condition verified in both handlers. - [x] Verify `getFundingAccount()` derives the keypair from `PENDULUM_FUNDING_SEED` and this seed is not reused for other purposes. **PASS** — seed used only for funding account derivation. -- [FAIL] Verify `MOONBEAM_FUNDING_PRIVATE_KEY` is used only for EVM subsidization, not other Moonbeam operations. **FAIL F-029** — `MOONBEAM_FUNDING_PRIVATE_KEY` equals `MOONBEAM_EXECUTOR_PRIVATE_KEY`; same key used for funding, executor, Monerium, and SquidRouter operations. With the BRL-on-Base flow this key is now also used for ephemeral subsidization on Base, BRLA payouts on Base, and EVM fee distribution on Base — a single private key compromise drains funds across Moonbeam, Base, Polygon, and any other EVM chain in scope, including the dedicated BRLA payout path. +- [FAIL] Verify `MOONBEAM_FUNDING_PRIVATE_KEY` is used only for EVM subsidization, not other Moonbeam operations. **FAIL F-029** — `MOONBEAM_FUNDING_PRIVATE_KEY` equals `MOONBEAM_EXECUTOR_PRIVATE_KEY`; same key used for funding, executor, legacy Monerium signing, Mykobo-related Base operations, and SquidRouter operations. With the BRL-on-Base and EUR-on-Base (Mykobo) flows this key is now also used for ephemeral subsidization on Base, BRLA + Mykobo EURC payouts on Base, and EVM fee distribution on Base — a single private key compromise drains funds across Moonbeam, Base, Polygon, and any other EVM chain in scope, including the dedicated BRLA and Mykobo payout paths. - [x] Verify `destination-transfer-handler.ts` checks ephemeral balance before submitting the presigned transaction. **PASS** — balance check before submission confirmed. - [x] Verify the presigned destination transfer is submitted as-is — no server-side modification of recipient or amount. **PASS** — presigned transaction submitted unmodified. - [PARTIAL] Verify `final-settlement-subsidy.ts` SquidRouter swap: check that the swap input amount is bounded and that the swap output is verified against expectations. **PARTIAL** — input amount is capped (F-001 fixed); no output verification against expectations. @@ -75,3 +78,5 @@ The current code resolves this through `EVM_FUNDING_PRIVATE_KEY` and the `getEvm - [x] **FINDING F-060 (MEDIUM)**: Verify `validateSubsidyAmount` rejects negative, zero, NaN, and Infinity amounts. **PASS (FIXED)** — added try/catch around `Big()` construction to reject non-numeric strings, and `lte(0)` guard to reject zero and negative values. - [x] **EVM subsidy handlers (`subsidize-pre-swap-evm-handler.ts`, `subsidize-post-swap-evm-handler.ts`) enforce a USD cap** via `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION`; over-cap subsidies throw `RecoverablePhaseError` before any transfer is submitted, leaving the ramp waiting for operator action instead of moving to `failed`. - [x] **`MOONBEAM_FUNDING_PRIVATE_KEY` rename/refactor**: EVM funding now uses the `EVM_FUNDING_PRIVATE_KEY` / `getEvmFundingAccount(network)` path, with the old env name retained only as backward-compatible fallback. +- [x] **`finalSettlementSubsidy` subsidizes against actual bridge delivery, not total balance.** **PASS** — snapshots `preSettlementBalance` after `squidRouterSwap` confirms (`squid-router-phase-handler.ts`, stored in `meta-state-types.ts`), waits for ≥90% of `expectedAmountRaw`, and computes `subsidy = expected − (actualBalance − preSettlementBalance)`. Prevents the dust-triggered over-subsidy race that stranded ~59 EURC. +- [x] **`finalSettlementSubsidy` short-circuits degenerate same-token routes.** **PASS** — returns to `destinationTransfer` when `state.state.isDirectTransfer === true`, `isEurToEurcBaseDirect(...)`, or `isBrlToBrlaBaseDirect(...)`, so no subsidy is computed for routes that have no Squid bridge to settle. diff --git a/docs/security-spec/07-operations/api-surface.md b/docs/security-spec/07-operations/api-surface.md index 4d5803621..3037e381c 100644 --- a/docs/security-spec/07-operations/api-surface.md +++ b/docs/security-spec/07-operations/api-surface.md @@ -61,7 +61,7 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api - [N/A] Verify `NODE_ENV` is set to `"production"` in production — stack traces are only stripped when not in development mode. **N/A** — requires deployment configuration inspection. - [x] Verify error responses do not include internal error types, database error codes, or SQL fragments. **PASS** — error handler wraps errors in generic `APIError` format. - [x] Verify the `errors` array in `APIError` contains only user-facing messages, not internal field names or database column names. **PASS** — error messages are user-facing validation messages. -- [PARTIAL] Map all 27 route files and verify each has appropriate auth middleware (Supabase, API key, admin, or public). **PARTIAL F-037** — multiple sensitive endpoints lack authentication: `/ramp/update`, `/ramp/start`, `/pendulum/fundEphemeral`, `/moonbeam/execute-xcm`, `/maintenance/schedules/:id/active`, `/webhook`. +- [x] Map all 27 route files and verify each has appropriate auth middleware (Supabase, API key, admin, or public). **PASS** — F-013 resolved (legacy `/pendulum/fundEphemeral`, `/moonbeam/execute-xcm`, `/subsidize/*` endpoints removed); `/v1/ramp/*` and `/v1/ramp/quotes(/best)` use `requirePartnerOrUserAuth()` with ownership guards; `/v1/brla/*` uses `requireAuth`; `/v1/mykobo/profiles` (GET + POST) use `requireAuth` (F-068 resolved); `/v1/maintenance/*` uses `adminAuth`; `/v1/webhook/*` uses `apiKeyAuth`. - [x] Verify no route accidentally uses `publicKeyAuth` (public key only, no secret key) for operations that should require `apiKeyAuth` (secret key). **PASS** — auth middleware usage reviewed per route. - [N/A] Verify controllers do not pass raw `req.body` to database operations — check for Sequelize `.create(req.body)` or `.update(req.body)` patterns. **N/A** — deferred; requires comprehensive Sequelize usage audit. - [x] Verify no endpoint returns `process.env`, server config, or internal paths in responses. **PASS** — no endpoint exposes internal configuration. diff --git a/docs/security-spec/07-operations/secret-management.md b/docs/security-spec/07-operations/secret-management.md index 7f45d1a15..33abb4a53 100644 --- a/docs/security-spec/07-operations/secret-management.md +++ b/docs/security-spec/07-operations/secret-management.md @@ -22,6 +22,9 @@ This spec catalogs every secret, its purpose, its blast radius if compromised, a | `SUPABASE_SERVICE_KEY` | Supabase admin access (bypasses RLS) | Full database read/write — all ramp data, user data, keys | | `SUPABASE_ANON_KEY` | Supabase public access (subject to RLS) | Limited by RLS policies — lower blast radius than service key | | `DB_PASSWORD` | Direct PostgreSQL access | Full database read/write — bypasses Supabase entirely | +| `MYKOBO_ACCESS_KEY` / `MYKOBO_SECRET_KEY` | Mykobo API authentication (HMAC-style; exchanged for bearer token) | Forge Mykobo SEPA payout requests on Base — could redirect EUR off-ramp payouts; also enables submission of arbitrary KYC profiles under the Vortex client domain | +| `MYKOBO_BASE_URL` | Mykobo API endpoint (`/v1` suffix normalized by client) | Not a secret; misconfiguration could route requests to an attacker-controlled host if env is tampered with | +| `MYKOBO_CLIENT_DOMAIN` | Vortex's registered client identifier with Mykobo; sent as `client_domain` on every Mykobo API call and selects the negotiated fee tier | Not a secret. **Operationally critical:** loaded via `getEnvVar` with no default — if unset, Mykobo silently falls back to its default fee tier (~5x higher than the negotiated rate, observed: ~0.31 EUR vs ~0.06 EUR fixed deposit fee). Quote engine fee defaults (`defaultDepositFee` / `defaultWithdrawFee`) will not match what Mykobo actually charges, corrupting fee accounting. Treat as required in production. | | `ALCHEMYPAY_APP_ID` / `ALCHEMYPAY_SECRET_KEY` | AlchemyPay price provider | Access to AlchemyPay API — price manipulation, data access | | `TRANSAK_API_KEY` | Transak price provider | Access to Transak API | | `MOONPAY_API_KEY` | MoonPay price provider | Access to MoonPay API | @@ -51,6 +54,7 @@ This spec catalogs every secret, its purpose, its blast radius if compromised, a 6. **`SUPABASE_SERVICE_KEY` MUST NOT be exposed to clients** — This key bypasses Row Level Security. It must only be used server-side. 7. **Database credentials (`DB_*`) MUST NOT be accessible from the public internet** — Direct PostgreSQL access should be restricted to the application server's network. 8. **No secret MUST be passed as a URL query parameter** — Query parameters are logged by proxies, CDNs, and web servers. Secrets must only travel in headers or request bodies. +9. **`MYKOBO_CLIENT_DOMAIN` MUST be set in production** — Not a secret, but operationally critical: when unset, Mykobo silently applies its default fee tier (~5x worse than the negotiated rate). Quote-engine fee defaults will then diverge from what Mykobo actually charges. Deployment automation MUST treat a missing `MYKOBO_CLIENT_DOMAIN` as a hard failure rather than letting it fall through to default-tier fees. ## Threat Vectors & Mitigations diff --git a/docs/security-spec/AUDIT-RESULTS.md b/docs/security-spec/AUDIT-RESULTS.md index 227de93da..4f9b1c4d6 100644 --- a/docs/security-spec/AUDIT-RESULTS.md +++ b/docs/security-spec/AUDIT-RESULTS.md @@ -29,7 +29,7 @@ Clients send `signingAccounts` (addresses only). No private keys in request/resp Fresh `RampState.findByPk(rampId)` on every `processRamp()` call. Lock mechanism prevents concurrent modification (though non-atomic — F-003). #### 5. `[FAIL]` All external API calls have timeout configuration -Most external `fetch()` calls (Monerium, BRLA, CoinGecko, Moonpay, Transak, AlchemyPay, Slack, Subscan) lack `AbortController`/timeout. Only `webhook-delivery.service.ts` has a 30s timeout. → [F-014](FINDINGS.md) +Most external `fetch()` calls (Mykobo, BRLA, CoinGecko, Moonpay, Transak, AlchemyPay, Slack, Subscan) lack `AbortController`/timeout. Only `webhook-delivery.service.ts` has a 30s timeout. → [F-014](FINDINGS.md) #### 6. `[PARTIAL]` Error responses never leak internal state, stack traces, or secret material Stack traces stripped in production. However, raw `err.message` from internal errors passed to API responses in some paths. → [F-015](FINDINGS.md) @@ -627,9 +627,11 @@ No new findings. All 12 prior findings verified fixed. OZ caret range is a minor --- -### 5.2 Monerium Integration +### 5.2 Monerium Integration (DEPRECATED — replaced by Mykobo) -**Spec:** `05-integrations/monerium.md` +**Spec:** `05-integrations/monerium.md` (deprecated; see `05-integrations/mykobo.md` for the active EUR rail) + +> Monerium is no longer used. Active EUR on/off-ramp goes through Mykobo on Base. The checks below describe the historical Monerium audit state and are retained for traceability of F-023 / F-024 lineage. | # | Check | Result | |---|---|---| @@ -648,6 +650,38 @@ No new findings. All 12 prior findings verified fixed. OZ caret range is a minor --- +### 5.2b Mykobo Integration (ACTIVE EUR RAIL) + +**Spec:** `05-integrations/mykobo.md` + +Mykobo replaces Monerium for EUR on-ramp and Stellar/EURC for EUR off-ramp. Both directions now flow on Base, mirroring the BRLA-on-Base architecture. + +| # | Check | Result | +|---|---|---| +| 1 | Mykobo access/secret keys + base URL from env vars | ✅ PASS — loaded via `packages/shared` config; `MykoboApiService` throws on missing config | +| 2 | `MYKOBO_BASE_URL` HTTPS and `/v` enforced | ✅ PASS — F-070 fixed: `assertSecureMykoboBaseUrl` enforces HTTPS at construction (localhost permitted in non-production) | +| 3 | On-ramp `mykoboOnrampDeposit` polls Base RPC for EURC arrival | ✅ PASS — `checkEvmBalancePeriodically` against `evmEphemeralAddress` until `mykoboMint.outputAmountRaw` arrives | +| 4 | 24h outer payment timeout; on expiry → `failed` | ✅ PASS — `PAYMENT_TIMEOUT_MS = 24h`, transition to `failed` enforced in handler | +| 5 | 5% recovery tolerance scoped to pre-funded shortcut only | ✅ PASS — `EPHEMERAL_FUNDED_TOLERANCE_FACTOR=0.95` applies only to `ephemeralAlreadyFunded` pre-check; live polling uses full `expectedAmountRaw` | +| 6 | On-ramp intent `wallet_address` = Base ephemeral (not user destination) | ✅ PASS — `prepareMykoboOnrampTransactions` passes `evmEphemeralEntry.address` | +| 7 | Off-ramp intent `wallet_address` = Base ephemeral | ✅ PASS — `prepareEvmToMykoboOfframpTransactions` passes `evmEphemeralEntry.address` | +| 8 | Off-ramp `receivables` address sourced server-side from intent response | ✅ PASS — `mykoboReceivablesAddress = intent.instructions.address` | +| 9 | Off-ramp EURC transfer amount equals `nablaSwapEvm.outputAmountRaw` | ✅ PASS — encoded into the `mykoboPayoutOnBase` presigned tx at registration time | +| 10 | `mykoboPayoutOnBase` advances to `complete` only after on-chain + Mykobo `COMPLETED` | ✅ PASS — `sendMykoboPayoutTransaction` waits for receipt; `pollMykoboUntilCompleted` blocks on `COMPLETED` | +| 11 | `FAILED` / `CANCELLED` / `EXPIRED` → unrecoverable error | ✅ PASS — `createUnrecoverableError` for all three terminal statuses | +| 12 | Recovery: `mykoboPayoutTxHash` short-circuits re-broadcast | ✅ PASS — waits for prior receipt; re-sends only if prior tx reverted | +| 13 | `MykoboApiError` mapped to recoverable/unrecoverable at handler boundary | ✅ PASS — payout handler wraps send failures in `createRecoverableError`; status terminal → unrecoverable | +| 14 | Bearer-token refresh debounced (no thundering-herd on 401) | ✅ PASS — F-071 fixed: `authFailurePromise` debounce added to `handleAuthFailure`, mirroring `tokenPromise` pattern | +| 15 | Token / access / secret keys absent from logs | ⚠️ PARTIAL — `MykoboApiError.body` may carry raw response bodies into logs; no explicit redaction | +| 16 | IBAN payment details surfaced only after presigned-tx validation | ✅ PASS — `ibanPaymentData` returned from `prepareRampTransactions` only after `validatePresignedTxs` succeeds upstream | +| 17 | `/v1/mykobo/profiles` endpoints require Supabase OTP auth | ✅ PASS — F-068 fixed: `requireAuth` added to both GET and POST routes | +| 18 | Mykobo KYC documents not persisted by Vortex | ✅ PASS — multipart form-data streamed through to Mykobo; no local persistence of files or PII beyond the email→profile linkage | +| 19 | HTTPS enforced for all Mykobo API calls | ✅ PASS — F-070 fixed: `assertSecureMykoboBaseUrl` rejects non-HTTPS schemes at construction (localhost permitted in non-production) | +| 20 | Timeout / AbortController on Mykobo HTTP client | 🔴 FAIL — F-014 (cross-cutting; Mykobo `fetch` calls lack explicit `AbortController`, same gap as BRLA/Monerium/CoinGecko/etc.) | +| 21 | Phase handlers never call Mykobo API without explicit recoverable/unrecoverable mapping | ✅ PASS — `mykobo-payout-handler.ts` catches `PhaseError` directly and wraps non-PhaseError exceptions | + +--- + ### 5.3 Alfredpay Integration **Spec:** `05-integrations/alfredpay.md` @@ -713,11 +747,15 @@ No new findings. All 12 prior findings verified fixed. OZ caret range is a minor | ID | Severity | Finding | Module | |---|---|---|---| -| F-023 | 🟡 Medium | Monerium SEPA timeout (30min) may be too short for SEPA settlement | Monerium | -| F-024 | 🟡 Medium | No concurrent SEPA ramp limit per user | Monerium | +| F-023 | ⚪ Superseded | (Historical) Monerium 30-min SEPA timeout — Monerium removed; Mykobo uses 24h | Monerium → Mykobo | +| F-024 | 🟡 Medium | No concurrent SEPA ramp limit per user (now applies to Mykobo) | Mykobo | | F-025 | 🔵 Low | `HORIZON_URL` import inconsistency between modules | Stellar | | F-026 | 🔵 Low | `@ts-ignore` on `.nonce.toNumber()` hides potential API incompatibility | Stellar | | F-027 | 🟡 Medium | `squidRouterPermitExecutionValue` used as `msg.value` without validation | Squid Router | +| F-068 | 🔴 Critical | Mykobo `/v1/mykobo/profiles` GET/POST have no `requireAuth` — anonymous KYC ingestion | Mykobo | +| F-069 | 🟠 High | EUR off-ramp `fundEphemeral.nextPhaseSelector` falls through to `moonbeamToPendulum` (latent stuck-phase bug) | Mykobo / Ramp Engine | +| F-070 | 🟡 Medium | `MYKOBO_BASE_URL` accepts any URL scheme — no HTTPS enforcement | Mykobo | +| F-071 | 🔵 Low | `MykoboApiService.handleAuthFailure` is not debounced — concurrent-401 thundering herd | Mykobo | --- @@ -1034,7 +1072,7 @@ Full security audit covering all 8 modules (00–07) across 23 specification fil | 02 — Signing Keys | Ephemeral Accounts, Server-Side Signing | 23 | | 03 — Ramp Engine | State Machine, Quote Lifecycle, Fee Integrity | 39 | | 04 — Smart Contracts | Token Relayer | 18 | -| 05 — Integrations | BRLA, Monerium, Alfredpay, Stellar Anchors, Squid Router | 60 | +| 05 — Integrations | BRLA, Mykobo (active EUR), Monerium (deprecated), Alfredpay, Stellar Anchors, Squid Router | 60 | | 06 — Cross-chain | XCM Transfers, Bridge Security, Fund Routing | 40 | | 07 — Operations | Rebalancer, Secret Management, API Surface | 44 | | **Total** | **22 sub-modules** | **~266 checklist items** | @@ -1043,11 +1081,13 @@ Full security audit covering all 8 modules (00–07) across 23 specification fil | Severity | Fixed | Accepted | Deferred | Open | Total | |---|---|---|---|---|---| -| 🔴 Critical | 5 | 0 | 0 | 0 | 5 | -| 🟠 High | 11 | 3 | 3 | 0 | 17 | -| 🟡 Medium | 25 | 3 | 6 | 0 | 34 | -| 🔵 Low / ⚪ Info | 8 | 3 | 0 | 0 | 11 | -| **Total** | **49** | **9** | **9** | **0** | **67** | +| 🔴 Critical | 6 | 0 | 0 | 0 | 6 | +| 🟠 High | 12 | 3 | 3 | 0 | 18 | +| 🟡 Medium | 26 | 3 | 6 | 0 | 35 | +| 🔵 Low / ⚪ Info | 9 | 3 | 0 | 0 | 12 | +| **Total** | **53** | **9** | **9** | **0** | **71** | + +Findings F-068 through F-071 from the Mykobo integration audit (2026-05-22) were resolved in the same audit cycle; see `FINDINGS.md` Phase 5 section for full descriptions and resolutions. A companion fix wired `fundEphemeral` into the EUR (Mykobo) onramp flow — the EUR ephemeral on Base previously had no source of native ETH, which would have caused `nablaApprove`/`nablaSwap`/squid txs to fail with insufficient gas had any Mykobo onramp progressed past deposit. ### Recommended Remediation Order @@ -1064,7 +1104,7 @@ Full security audit covering all 8 modules (00–07) across 23 specification fil **Week 3 — Integration Hardening:** 8. Add output amount validation to SquidRouter swaps (F-027, F-030, F-034) -9. Add Monerium webhook signature verification (F-024) +9. Add concurrent SEPA ramp limit per user (F-024, now applies to Mykobo flows) 10. Add pre-balance checks to subsidy handlers (F-032) **Month 2 — Architectural Improvements:** @@ -1073,6 +1113,12 @@ Full security audit covering all 8 modules (00–07) across 23 specification fil 13. Add structured audit logging (F-015) 14. Implement proper admin auth (F-020) +**Mykobo Integration Audit (2026-05-22) — Open:** +15. ✅ Done — Added `requireAuth` to `/v1/mykobo/profiles` GET/POST (F-068, Critical). The GET endpoint now identifies profiles by the authenticated user's email (`req.userEmail`) via `MykoboApiService.getProfileByEmail`, and rejects requests whose `email` query parameter does not match the authenticated user. POST profile creation continues to bind `wallet_address` to the user's ephemeral, so no separate wallet-ownership check is required there. +16. ✅ Done — Added explicit EURC SELL branch to `fund-ephemeral-handler.nextPhaseSelector` returning `distributeFees`; also added the missing EURC BUY branch returning `subsidizePreSwap` and wired `fundEphemeral` into the Mykobo onramp flow via `mykobo-onramp-deposit-handler` and `getRequiresBaseEphemeralAddress` (F-069, High) +17. ✅ Done — Enforced HTTPS scheme on `MYKOBO_BASE_URL` at `MykoboApiService` construction via `assertSecureMykoboBaseUrl` (F-070, Medium) +18. ✅ Done — Debounced `MykoboApiService.handleAuthFailure` with `authFailurePromise` mirroring `getToken`'s `tokenPromise` (F-071, Low) + ### Files Reference - **Specifications:** `docs/security-spec/` (23 spec files — see `README.md` for index) diff --git a/docs/security-spec/FINDINGS.md b/docs/security-spec/FINDINGS.md index 77722c53d..aeabb201c 100644 --- a/docs/security-spec/FINDINGS.md +++ b/docs/security-spec/FINDINGS.md @@ -1,18 +1,18 @@ # Audit Findings Tracker -> **Generated:** 2026-04-02 | **Last Updated:** 2026-05-29 | **Status:** F-001 through F-068: 50 fixed, 9 accepted risk, 9 deferred, 0 open. Additional discount-mechanism findings F-DISC-01 through F-DISC-05 remain open in `03-ramp-engine/discount-mechanism.md` and are not included in the counts below. +> **Generated:** 2026-04-02 | **Last Updated:** 2026-05-22 | **Status:** F-001 through F-067: 49 fixed, 9 accepted risk, 9 deferred, 0 open. F-068 through F-071 raised by the Mykobo integration audit: 4 open. F-072 raised by the ephemeral-account lifecycle review: 1 fixed. Additional discount-mechanism findings F-DISC-01 through F-DISC-05 remain open in `03-ramp-engine/discount-mechanism.md` and are not included in the counts below. -This file consolidates all security findings from the Vortex platform audit. Findings were discovered across five phases: specification writing (F-001 through F-012), code-vs-spec audit across all 8 modules (F-013 through F-037), transaction validation / ephemeral account / phase flow audit (F-038 through F-058), fresh security audit pass (F-059 through F-067), and ephemeral lifecycle review (F-068). +This file consolidates all security findings from the Vortex platform audit. Findings were discovered across six phases: specification writing (F-001 through F-012), code-vs-spec audit across all 8 modules (F-013 through F-037), transaction validation / ephemeral account / phase flow audit (F-038 through F-058), fresh security audit pass (F-059 through F-067), Mykobo integration audit (F-068 through F-071), and ephemeral-account lifecycle review (F-072). ## Summary | Severity | Fixed | Accepted | Deferred | Open | Total | |---|---|---|---|---|---| -| 🔴 Critical | 5 | 0 | 0 | 0 | 5 | -| 🟠 High | 11 | 3 | 3 | 0 | 17 | -| 🟡 Medium | 26 | 3 | 6 | 0 | 35 | -| 🔵 Low / ⚪ Info | 8 | 3 | 0 | 0 | 11 | -| **Total** | **50** | **9** | **9** | **0** | **68** | +| 🔴 Critical | 5 | 0 | 0 | 1 | 6 | +| 🟠 High | 11 | 3 | 3 | 1 | 18 | +| 🟡 Medium | 25 | 3 | 6 | 1 | 35 | +| 🔵 Low / ⚪ Info | 8 | 3 | 0 | 1 | 12 | +| **Total** | **49** | **9** | **9** | **4** | **71** | > **Fixed** = code change implemented and verified. **Accepted** = CTO reviewed and accepted risk, no code change. **Deferred** = requires architectural work, separate app changes, or future investigation. **Open** = newly identified, awaiting fix or CTO decision. @@ -222,7 +222,7 @@ A malicious client could sign a Stellar payment for 0.0001 XLM to their own addr | **Found** | Code audit, iteration 2 | | **Impact** | A hanging external service can block the caller indefinitely. For phase handlers, this stalls ramp processing. For price feeds, this stalls quote generation. | -**Description:** Of 16+ `fetch()` calls to external services, only `webhook-delivery.service.ts` uses `AbortController` with a timeout. All others (Monerium, CoinGecko, Moonpay, Transak, AlchemyPay, Subscan, Slack, ramp helpers) make HTTP requests without any timeout or `AbortSignal`. +**Description:** Of 16+ `fetch()` calls to external services, only `webhook-delivery.service.ts` uses `AbortController` with a timeout. All others (Mykobo, BRLA, CoinGecko, Moonpay, Transak, AlchemyPay, Subscan, Slack, ramp helpers) make HTTP requests without any timeout or `AbortSignal`. The historical Monerium `fetch` calls had the same gap and have been carried forward into the Mykobo client. **Fix:** Add `AbortController` with appropriate timeouts (e.g., 10-30s) to all external `fetch()` calls. Consider a shared utility function like `fetchWithTimeout(url, options, timeoutMs)`. @@ -654,39 +654,37 @@ The backup nonce is set to `0` (or `polygonAccountNonce` for Polygon), meaning t --- -### F-023: Monerium SEPA Timeout May Be Too Short +### F-023: Monerium SEPA Timeout May Be Too Short (SUPERSEDED) | Field | Value | |---|---| -| **Location** | `apps/api/src/api/services/phases/handlers/monerium-onramp-mint-handler.ts` | -| **Spec** | `05-integrations/monerium.md` | -| **Status** | 🟡 **DEFERRED** — needs runtime testing to validate | +| **Location** | `apps/api/src/api/services/phases/handlers/monerium-onramp-mint-handler.ts` (legacy) | +| **Spec** | `05-integrations/monerium.md` (deprecated) → see `05-integrations/mykobo.md` | +| **Status** | ⚪ **SUPERSEDED** — Monerium is removed; EUR on-ramp now uses Mykobo on Base with a 24h outer payment timeout | | **Found** | Code audit, iteration 2, Module 05 | -| **Impact** | Legitimate SEPA on-ramp payments could be marked as failed if Monerium takes longer than 30 minutes to mint EURe after SEPA settlement. | +| **Impact** | (Historical) Legitimate SEPA on-ramp payments could be marked as failed if Monerium took longer than 30 minutes to mint EURe after SEPA settlement. | -**Description:** The `monerium-onramp-mint-handler.ts` uses `PAYMENT_TIMEOUT_MS` (30 minutes) to wait for EURe token arrival on Polygon. SEPA transfers take 1-3 business days to settle. The 30-minute timeout may be too short if Monerium's processing itself takes time after SEPA arrives. +**Description:** The legacy `monerium-onramp-mint-handler.ts` used `PAYMENT_TIMEOUT_MS` (30 minutes) to wait for EURe token arrival on Polygon. SEPA transfers take 1-3 business days to settle. -**CTO Clarification (2026-04-02):** The timer starts at ramp creation — NOT after Monerium confirms SEPA settlement. The flow works because the ramp isn't created until the SEPA transfer is expected to have already settled and Monerium is expected to mint EURe imminently. However, if Monerium processing is delayed beyond 30 minutes after the ramp is created, the ramp will fail even if the payment was legitimate. - -**Fix:** Verify that the 30-minute window is sufficient for the expected Monerium processing time after SEPA settlement. If not, extend the timeout or implement a webhook-based flow where Monerium notifies completion rather than polling. +**Resolution:** The EUR on-ramp has been migrated to Mykobo (`mykobo-onramp-deposit-handler.ts`) which uses a **24-hour `PAYMENT_TIMEOUT_MS`** with a 5-minute inner balance-check timeout that surfaces as a recoverable error. This matches SEPA business-day cutoffs and removes the original 30-minute tightness. The legacy 30-minute window is no longer reached by any active corridor. --- -### F-024: No Concurrent SEPA Ramp Limit Per User +### F-024: No Concurrent SEPA Ramp Limit Per User (CARRIED FORWARD TO MYKOBO) | Field | Value | |---|---| | **Location** | Ramp creation flow (no per-user limit enforcement) | -| **Spec** | `05-integrations/monerium.md` | -| **Status** | 🟡 **DEFERRED** — requires new DB queries and ramp creation changes | +| **Spec** | `05-integrations/mykobo.md` (formerly `monerium.md`) | +| **Status** | 🟡 **DEFERRED — STILL APPLIES** — requires new DB queries and ramp creation changes; same risk now applies to Mykobo SEPA flows | | **Found** | Code audit, iteration 2, Module 05 | -| **Impact** | Resource exhaustion — an attacker could create many SEPA-based ramps without paying, tying up system resources (polling, state tracking, phase processing). | +| **Impact** | Resource exhaustion — an attacker could create many SEPA-based ramps without paying, tying up system resources (polling, state tracking, phase processing). With Mykobo's 24h outer timeout the exposure window per pending ramp is **larger** than under the previous 30-minute Monerium window. | -**Description:** No per-user concurrent ramp limit is enforced for Monerium SEPA flows. A user can create unlimited pending SEPA ramps. Each ramp consumes: (1) a database row with state tracking, (2) periodic phase processing cycles (polling for token arrival), (3) a slot in the phase processor queue. The 30-minute timeout per ramp partially mitigates this (each ramp auto-fails after 30 min), but during those 30 minutes the system is actively polling for each ramp. +**Description:** No per-user concurrent ramp limit is enforced for Mykobo SEPA on-ramp flows (previously: Monerium). A user can create unlimited pending SEPA ramps. Each ramp consumes: (1) a database row with state tracking, (2) periodic phase processing cycles (polling for EURC arrival on Base), (3) a slot in the phase processor queue. With Mykobo's 24h timeout, each unpaid ramp now stays active for up to 24 hours rather than 30 minutes. **CTO Clarification (2026-04-02):** Yes, add a per-user limit on concurrent pending SEPA ramps. Suggested max: 3. -**Fix:** Add a per-user limit on concurrent pending ramps (e.g., max 3 pending SEPA ramps per user). Enforce at ramp creation time. +**Fix:** Add a per-user limit on concurrent pending ramps (e.g., max 3 pending SEPA Mykobo ramps per user). Enforce at ramp creation time. The original Monerium-specific recommendation now applies to the Mykobo handler. --- @@ -1455,7 +1453,193 @@ If a database partner record has `markupValue = -0.01` and `markupType = "relati --- -### F-068: Ephemeral Account Freshness Not Validated at Ramp Registration +## Phase 5: Mykobo Integration Audit (F-068 — F-071) + +Findings raised during the Mykobo-on-Base EUR rail audit. See `05-integrations/mykobo.md` and `03-ramp-engine/ramp-phase-flows.md`. + +--- + +### F-068: Mykobo KYC Profile Endpoints Have No Authentication + +| Field | Value | +|---|---| +| **Severity** | 🔴 **Critical** | +| **Location** | `apps/api/src/api/routes/v1/mykobo.route.ts` (lines 14-15); parent mount at `apps/api/src/api/routes/v1/index.ts` (line 150) | +| **Spec** | `05-integrations/mykobo.md` (Invariant 15), `01-auth/supabase-otp.md` | +| **Status** | ✅ **FIXED** (2026-05-22) | +| **Found** | Mykobo integration audit, 2026-05-22 | +| **Impact** | Anonymous callers can enumerate KYC profiles and submit arbitrary KYC documents (ID, source-of-funds, demographics) tied to any wallet address. This bypasses the spec's "Supabase OTP required" invariant, enables KYC-document submission floods against the Mykobo upstream, and allows attackers to associate attacker-controlled documents with arbitrary identities. | +| **Resolution** | `requireAuth` added to both `/v1/mykobo/profiles` GET and POST routes, mirroring the `alfredpay.route.ts` pattern. The GET endpoint now identifies profiles by the authenticated user's email (`req.userEmail`) via `MykoboApiService.getProfileByEmail`, and rejects requests whose `email` query parameter does not match the authenticated user's email. POST profile creation still ties `wallet_address` to the user's ephemeral, so no separate wallet-ownership check is needed there. Supabase OTP gating plus the email/`req.userEmail` match closes the anonymous-flood and oracle vectors. | + +**Description:** `mykobo.route.ts` mounts two endpoints with **no authentication middleware**: + +```typescript +router.route("/profiles").get(mykoboController.getProfileController); +router.route("/profiles").post(profileUpload, mykoboController.createProfileController); +``` + +The parent mount in `routes/v1/index.ts:150` (`router.use("/mykobo", mykoboRoute)`) does not wrap with `requireAuth` either. Compare against the sibling `alfredpay.route.ts`, which applies `requireAuth` on every user-facing endpoint. + +Spec `mykobo.md` invariant 15 requires: *"Mykobo KYC profile creation MUST be gated by Vortex auth — The `/v1/mykobo/profiles` endpoints require a Supabase OTP session; anonymous profile creation is rejected."* The code violates this invariant. + +The `GET /profiles?email=...&memo=...` endpoint is an email-keyed KYC profile-existence oracle. The `POST /profiles` endpoint accepts multipart form-data (ID document, utility bill, selfie) and forwards it to Mykobo — anonymous callers can submit forged KYC documents linked to arbitrary identities. + +**Fix:** Add `requireAuth` middleware to both routes (mirroring `alfredpay.route.ts`): + +```typescript +router.route("/profiles").get(requireAuth, mykoboController.getProfileController); +router.route("/profiles").post(requireAuth, profileUpload, mykoboController.createProfileController); +``` + +After adding auth, also verify that the `email` query parameter on GET matches the authenticated user's email (`req.userEmail`), to prevent an authenticated user from enumerating other users' KYC profile existence. + +--- + +### F-069: EUR Off-Ramp `fundEphemeral` Falls Through to Non-Existent Next Phase + +| Field | Value | +|---|---| +| **Severity** | 🟠 **High** | +| **Location** | `apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts` (`nextPhaseSelector`, lines 230-250) | +| **Spec** | `03-ramp-engine/ramp-phase-flows.md`, `05-integrations/mykobo.md` | +| **Status** | ✅ **FIXED** (2026-05-22) | +| **Found** | Mykobo integration audit, 2026-05-22 | +| **Impact** | If a EUR off-ramp ever transitions through `fundEphemeral`, `nextPhaseSelector` returns `"moonbeamToPendulum"` — a phase that has no role in the Base-only EUR off-ramp routing (`evm-to-mykobo.ts`). The phase processor would attempt to execute a handler with no presigned transaction registered for this corridor, putting the ramp into a stuck/failed state mid-flow. | +| **Resolution** | Explicit `SELL && outputCurrency === EURC → "distributeFees"` branch added to `nextPhaseSelector`. Additionally, while fixing the latent bug, also added the **active** missing branch `BUY && inputCurrency === EURC → "subsidizePreSwap"` to wire `fundEphemeral` into the Mykobo onramp flow (see the EUR onramp `fundEphemeral` companion fix below). | + +**Description:** `nextPhaseSelector` enumerates the SELL-direction branches: + +```typescript +if (state.type === RampDirection.SELL && state.from === Networks.AssetHub) { + return "distributeFees"; +} else if (state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken)) { + return "finalSettlementSubsidy"; +} else if (state.type === RampDirection.SELL && quote.outputCurrency === FiatToken.BRL) { + return "distributeFees"; +} else { + return "moonbeamToPendulum"; // Via contract.subsidizePreSwap +} +``` + +There is no branch for `outputCurrency === FiatToken.EURC`. The BRL off-ramp uses `distributeFees` as the next phase after `fundEphemeral`; EUR off-ramps need the same routing (the Mykobo Base off-ramp presigned-tx order in `evm-to-mykobo.ts` is `distributeFees(0) → nablaApprove(1) → nablaSwap(2) → mykoboPayoutOnBase(3) → cleanup(4-6)`), but instead they fall through to the `else` branch and target `moonbeamToPendulum`. + +**Why this isn't currently surfaced:** The active EUR off-ramp dispatch path (`initial-phase-handler.ts`) does not currently route EUR through `fundEphemeral`. The bug is latent. However, any future routing change, recovery flow, or replay that reaches `fundEphemeral` with `outputCurrency === FiatToken.EURC` will hit the stuck-phase scenario. The integration tests don't catch it because they only exercise `registerRamp`, not `startRamp` through this path. + +**Fix:** Add an explicit EURC branch mirroring the BRL behavior: + +```typescript +} else if (state.type === RampDirection.SELL && quote.outputCurrency === FiatToken.EURC) { + return "distributeFees"; +} +``` + +Also consider replacing the default `else → "moonbeamToPendulum"` with an unrecoverable error for unrecognized SELL combinations, so future corridor additions fail loudly instead of silently falling through. + +--- + +### F-070: `MYKOBO_BASE_URL` Accepts Any Scheme — No HTTPS Enforcement + +| Field | Value | +|---|---| +| **Severity** | 🟡 **Medium** | +| **Location** | `packages/shared/src/services/mykobo/mykoboApiService.ts` (constructor, lines 47-53) | +| **Spec** | `05-integrations/mykobo.md` (Invariant: HTTPS enforced for all Mykobo API calls) | +| **Status** | ✅ **FIXED** (2026-05-22) | +| **Found** | Mykobo integration audit, 2026-05-22 | +| **Impact** | A misconfigured `MYKOBO_BASE_URL` (e.g., `http://...` instead of `https://...`) will silently transmit bearer tokens, KYC document references, and IBAN payment instructions over cleartext. There is no startup or runtime check that rejects non-HTTPS base URLs. | +| **Resolution** | `assertSecureMykoboBaseUrl` helper added; called from constructor. Throws on any non-HTTPS scheme. Exception: when `NODE_ENV !== "production"`, `http://localhost` and `http://127.0.0.1` are permitted for local development. | + +**Description:** The `MykoboApiService` constructor performs only path-shape normalization on the base URL: + +```typescript +if (!MYKOBO_BASE_URL) { + throw new Error("MYKOBO_BASE_URL not defined"); +} +const trimmedBase = MYKOBO_BASE_URL.replace(/\/$/, ""); +this.baseUrl = /\/v\d+$/.test(trimmedBase) ? trimmedBase : `${trimmedBase}/v1`; +``` + +No `new URL(...).protocol === "https:"` check. An operator with shell access to env vars (or a misconfigured deployment) could set `MYKOBO_BASE_URL=http://mykobo.example` and the service would silently use cleartext for all `/auth/token`, `/auth/refresh`, intent creation, and payout polling calls. This contradicts the audit-results PASS for "HTTPS enforced" (currently marked PASS for Mykobo on the strength of constructor normalization alone, which does not in fact enforce HTTPS). + +**Fix:** Add an explicit scheme check at construction time: + +```typescript +const parsed = new URL(trimmedBase); +if (parsed.protocol !== "https:" && process.env.NODE_ENV === "production") { + throw new Error("MYKOBO_BASE_URL must use HTTPS in production"); +} +``` + +For local development, allow `http://localhost` via an explicit allowlist. Also document the requirement in `.env.example`. + +--- + +### F-071: Concurrent-401 Race in `MykoboApiService.handleAuthFailure` + +| Field | Value | +|---|---| +| **Severity** | 🔵 **Low** | +| **Location** | `packages/shared/src/services/mykobo/mykoboApiService.ts` (`handleAuthFailure`, lines 109-122; `getToken`, lines 96-107) | +| **Spec** | `05-integrations/mykobo.md` (Invariant 14: Bearer-token refresh debounced) | +| **Status** | ✅ **FIXED** (2026-05-22) | +| **Found** | Mykobo integration audit, 2026-05-22 | +| **Impact** | Under concurrent load, multiple in-flight requests that each receive HTTP 401 will each independently call `handleAuthFailure()`, causing concurrent `refresh` / `acquireToken` calls to Mykobo. This is a "thundering herd" mini-race: it does not corrupt state, but it can produce redundant token rotations, brief windows where `this.cachedToken` is overwritten by a stale value, and unnecessary Mykobo `/auth/refresh` traffic that can itself trip Mykobo-side rate limiting. | +| **Resolution** | `authFailurePromise` debounce added, mirroring the existing `tokenPromise` pattern in `getToken`. Concurrent 401s now share a single in-flight refresh/re-acquire; the actual logic moved to `doHandleAuthFailure`. | + +**Description:** The happy-path token acquisition in `getToken()` is correctly debounced via `this.tokenPromise`: + +```typescript +if (!this.tokenPromise) { + this.tokenPromise = this.acquireToken().finally(() => { + this.tokenPromise = undefined; + }); +} +this.cachedToken = await this.tokenPromise; +``` + +But the 401-recovery path in `handleAuthFailure()` has no equivalent guard: + +```typescript +private async handleAuthFailure(): Promise { + if (this.cachedToken) { + try { + const refreshed = await this.refreshAccessToken(this.cachedToken.refreshToken); + this.cachedToken = refreshed; + return refreshed.token; + } catch (error) { + logger.current.warn("Mykobo refresh failed; re-acquiring token", error); + } + } + const reAcquired = await this.acquireToken(); + this.cachedToken = reAcquired; + return reAcquired.token; +} +``` + +If two requests race and both receive 401, they both enter `handleAuthFailure()` and both fire `refreshAccessToken` (or `acquireToken`) concurrently. Each then independently assigns to `this.cachedToken`, so the second write clobbers the first. The first caller may receive a token that is immediately replaced. + +**Mitigating factor:** The Mykobo handlers run inside the phase processor, which is single-threaded per ramp; the practical concurrency surface today is small (e.g., poll loops overlapping with `getProfile` calls from the HTTP layer). But the spec invariant ("Bearer-token refresh debounced — no thundering-herd on 401") is currently held only on the cold-start path. + +**Fix:** Apply the same `tokenPromise` debounce pattern to `handleAuthFailure`: + +```typescript +private authFailurePromise: Promise | undefined; + +private async handleAuthFailure(): Promise { + if (!this.authFailurePromise) { + this.authFailurePromise = this.doHandleAuthFailure().finally(() => { + this.authFailurePromise = undefined; + }); + } + return this.authFailurePromise; +} +``` + +Move the existing body of `handleAuthFailure` into `doHandleAuthFailure`. + +--- + +### F-072: Ephemeral Account Freshness Not Validated at Ramp Registration | Field | Value | |---|---| diff --git a/docs/security-spec/PUBLIC-RELEASE-READINESS.md b/docs/security-spec/PUBLIC-RELEASE-READINESS.md index 24bd27977..296c65ed1 100644 --- a/docs/security-spec/PUBLIC-RELEASE-READINESS.md +++ b/docs/security-spec/PUBLIC-RELEASE-READINESS.md @@ -94,8 +94,10 @@ Fifteen environment variables are read by `apps/api/src` but not documented in ` ``` EVM_FUNDING_PRIVATE_KEY -MONERIUM_CLIENT_ID_APP -MONERIUM_CLIENT_SECRET +MYKOBO_ACCESS_KEY +MYKOBO_SECRET_KEY +MYKOBO_BASE_URL +MYKOBO_CLIENT_DOMAIN ALCHEMY_API_KEY SLACK_USER_ID SLACK_WEB_HOOK_TOKEN diff --git a/docs/security-spec/README.md b/docs/security-spec/README.md index 43457f6b8..cd7f4437e 100644 --- a/docs/security-spec/README.md +++ b/docs/security-spec/README.md @@ -34,9 +34,10 @@ This directory contains the security specification for the Vortex cross-border p | Token Relayer | `04-smart-contracts/token-relayer.md` | EIP-712, permit, known findings | | Integration Template | `05-integrations/_template.md` | Template for new provider specs | | BRLA | `05-integrations/brla.md` | BRLA anchor for BRL on/off-ramp | -| Monerium | `05-integrations/monerium.md` | Monerium EUR on-ramp | +| Mykobo | `05-integrations/mykobo.md` | Mykobo EUR on/off-ramp on Base (active EUR rail) | +| Monerium | `05-integrations/monerium.md` | (Deprecated) Monerium EUR on-ramp — replaced by Mykobo | | Alfredpay | `05-integrations/alfredpay.md` | Alfredpay on/off-ramp | -| Stellar Anchors | `05-integrations/stellar-anchors.md` | SEP-24, Spacewalk, Stellar payment | +| Stellar Anchors | `05-integrations/stellar-anchors.md` | SEP-24, Spacewalk, Stellar payment (fully deprecated; EUR migrated to Mykobo, ARS removed) | | Squid Router | `05-integrations/squid-router.md` | Cross-chain EVM routing | | XCM Transfers | `06-cross-chain/xcm-transfers.md` | Pendulum↔Moonbeam↔AssetHub↔Hydration | | Bridge Security | `06-cross-chain/bridge-security.md` | Spacewalk bridge trust model | @@ -65,7 +66,8 @@ Every spec file uses exactly four sections: | **Spacewalk** | Bridge between Pendulum and Stellar | | **XCM** | Cross-Consensus Messaging — the cross-chain transfer protocol between Polkadot parachains | | **BRLA** | Brazilian Real stablecoin anchor (BRL on/off-ramp) | -| **Monerium** | EUR stablecoin issuer (EUR on-ramp via SEPA) | +| **Mykobo** | EUR fiat anchor for SEPA on/off-ramp on Base (settles EURC on Base) | +| **Monerium** | (Deprecated) EUR stablecoin issuer; previously used for EUR on-ramp via SEPA. Replaced by Mykobo. | | **Alfredpay** | Fiat payment provider supporting multiple currencies | | **Squid Router** | Cross-chain swap/routing protocol for EVM chains | | **Subsidization** | When the platform tops up an ephemeral account to ensure the user receives the quoted amount | diff --git a/packages/sdk/src/VortexSdk.ts b/packages/sdk/src/VortexSdk.ts index c96f938bc..e02d0b515 100644 --- a/packages/sdk/src/VortexSdk.ts +++ b/packages/sdk/src/VortexSdk.ts @@ -3,7 +3,6 @@ import { CreateQuoteRequest, createMoonbeamEphemeral, createPendulumEphemeral, - createStellarEphemeral, EphemeralAccount, EphemeralAccountType, QuoteResponse, @@ -173,16 +172,9 @@ export class VortexSdk { const ephemerals: { [key in EphemeralAccountType]?: EphemeralAccount } = {}; const accountMetas: AccountMeta[] = []; - const stellarEphemeral = createStellarEphemeral(); const substrateEphemeral = await createPendulumEphemeral(); const evmEphemeral = createMoonbeamEphemeral(); - accountMetas.push({ - address: stellarEphemeral.address, - type: EphemeralAccountType.Stellar - }); - ephemerals[EphemeralAccountType.Stellar] = stellarEphemeral; - accountMetas.push({ address: substrateEphemeral.address, type: EphemeralAccountType.Substrate @@ -201,7 +193,6 @@ export class VortexSdk { private async signTransactions( unsignedTxs: UnsignedTx[], ephemerals: { - stellarEphemeral?: EphemeralAccount; substrateEphemeral?: EphemeralAccount; evmEphemeral?: EphemeralAccount; } diff --git a/packages/sdk/src/handlers/BrlHandler.ts b/packages/sdk/src/handlers/BrlHandler.ts index 87ac6f397..981a1d8b9 100644 --- a/packages/sdk/src/handlers/BrlHandler.ts +++ b/packages/sdk/src/handlers/BrlHandler.ts @@ -28,7 +28,6 @@ export class BrlHandler implements RampHandler { private signTransactions: ( unsignedTxs: UnsignedTx[], ephemerals: { - stellarEphemeral?: EphemeralAccount; substrateEphemeral?: EphemeralAccount; evmEphemeral?: EphemeralAccount; } @@ -44,7 +43,6 @@ export class BrlHandler implements RampHandler { signTransactions: ( unsignedTxs: UnsignedTx[], ephemerals: { - stellarEphemeral?: EphemeralAccount; substrateEphemeral?: EphemeralAccount; evmEphemeral?: EphemeralAccount; } @@ -81,7 +79,6 @@ export class BrlHandler implements RampHandler { const signedTxs = await this.signTransactions(rampProcess.unsignedTxs || [], { evmEphemeral: ephemerals.EVM, - stellarEphemeral: ephemerals.Stellar, substrateEphemeral: ephemerals.Substrate }); @@ -124,7 +121,6 @@ export class BrlHandler implements RampHandler { const signedTxs = await this.signTransactions(rampProcess.unsignedTxs || [], { evmEphemeral: ephemerals.EVM, - stellarEphemeral: ephemerals.Stellar, substrateEphemeral: ephemerals.Substrate }); diff --git a/packages/sdk/src/services/NetworkManager.ts b/packages/sdk/src/services/NetworkManager.ts index f28e0bbb8..0ff711ed4 100644 --- a/packages/sdk/src/services/NetworkManager.ts +++ b/packages/sdk/src/services/NetworkManager.ts @@ -18,7 +18,7 @@ const DEFAULT_NETWORKS: NetworkConfig[] = [ }, { name: "hydration", - wsUrl: "wss://rpc.hydradx.cloud" + wsUrl: "wss://hydration.ibp.network" } ]; diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 81c7cc700..9aec268b6 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -136,7 +136,6 @@ export interface RampState { rampId: string; quoteId: string; ephemerals: { - stellarEphemeral?: EphemeralAccount; substrateEphemeral?: EphemeralAccount; evmEphemeral?: EphemeralAccount; }; diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 13289f3b7..5f2231413 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -16,3 +16,10 @@ export const ALCHEMY_API_KEY = getEnvVar("ALCHEMY_API_KEY"); export const ALFREDPAY_BASE_URL = getEnvVar("ALFREDPAY_BASE_URL") || "https://penny-api-restricted-dev.alfredpay.io"; export const ALFREDPAY_API_KEY = getEnvVar("ALFREDPAY_API_KEY"); export const ALFREDPAY_API_SECRET = getEnvVar("ALFREDPAY_API_SECRET"); + +export const MYKOBO_BASE_URL = + getEnvVar("MYKOBO_BASE_URL") || (SANDBOX_ENABLED ? "https://api-dev.mykobo.app" : "https://api.mykobo.app"); +export const MYKOBO_ACCESS_KEY = getEnvVar("MYKOBO_ACCESS_KEY"); +export const MYKOBO_SECRET_KEY = getEnvVar("MYKOBO_SECRET_KEY"); +// Optional. Mykobo defaults the fee scope to `.mykobo.app` when omitted. +export const MYKOBO_CLIENT_DOMAIN = getEnvVar("MYKOBO_CLIENT_DOMAIN"); diff --git a/packages/shared/src/endpoints/index.ts b/packages/shared/src/endpoints/index.ts index e5c3b52ca..565f0e6ad 100644 --- a/packages/shared/src/endpoints/index.ts +++ b/packages/shared/src/endpoints/index.ts @@ -3,7 +3,6 @@ export * from "./alfredpay.endpoints"; export * from "./brla.endpoints"; export * from "./contact.endpoints"; export * from "./email.endpoints"; -export * from "./monerium"; export * from "./moonbeam.endpoints"; export * from "./payment-methods.endpoints"; export * from "./pendulum.endpoints"; @@ -13,7 +12,6 @@ export * from "./ramp.endpoints"; export * from "./rating.endpoints"; export * from "./session"; export * from "./siwe.endpoints"; -export * from "./stellar.endpoints"; export * from "./storage.endpoints"; export * from "./subsidize.endpoints"; export * from "./supported-countries.endpoints"; diff --git a/packages/shared/src/endpoints/monerium.ts b/packages/shared/src/endpoints/monerium.ts deleted file mode 100644 index 6ce57eaec..000000000 --- a/packages/shared/src/endpoints/monerium.ts +++ /dev/null @@ -1,95 +0,0 @@ -export enum MoneriumAddressStatus { - REQUESTED = "requested", - APPROVED = "approved" -} - -export interface MoneriumAddress { - address: string; - profile: string; - chains: string[]; - status: MoneriumAddressStatus; -} - -export interface MoneriumResponse { - addresses: MoneriumAddress[]; - total: number; -} - -export interface FetchIbansParams { - authToken: string; - profileId: string; -} - -export interface FetchProfileParams extends FetchIbansParams { - profileId: string; -} - -export interface IbanData { - iban: string; - bic: string; - profile: string; - address: string; - chain: string; - name: string; -} - -export interface IbanDataResponse { - ibans: IbanData[]; -} - -export interface MoneriumTokenResponse { - access_token: string; - token_type: string; - expires_in: number; - scope: string; -} - -export interface MoneriumUserProfile { - id: string; - kind: string; - name: string; - state: string; -} - -export interface BeneficiaryDetails { - name: string; - iban: string; - bic: string; - amount: string; -} - -export type AddressExistsResponse = MoneriumAddress; - -export interface AuthContextProfile { - id: string; - name: string; - state: string; - kind: string; -} - -export interface AuthContext { - defaultProfile: string; - profiles: AuthContextProfile[]; -} - -export enum MoneriumErrors { - USER_MINT_ADDRESS_NOT_FOUND = "User mint address not found", - USER_MINT_ADDRESS_IS_NOT_READY = "User mint address is not ready yet" -} - -// TODO: Move these types to a more generic file if they are used outside of Monerium endpoints -export type Signature = { v: number; r: `0x${string}`; s: `0x${string}`; deadline: number }; - -export interface PermitSignatureContext { - owner: `0x${string}`; - spender: `0x${string}`; - valueRaw: string; - nonce: string; - deadline: string; - tokenAddress: `0x${string}`; - tokenName: string; - tokenVersion: string; - chainId: number; -} - -export type PermitSignature = Signature & { context?: PermitSignatureContext }; diff --git a/packages/shared/src/endpoints/quote.endpoints.ts b/packages/shared/src/endpoints/quote.endpoints.ts index 2953ec3af..62bd9c13d 100644 --- a/packages/shared/src/endpoints/quote.endpoints.ts +++ b/packages/shared/src/endpoints/quote.endpoints.ts @@ -105,6 +105,8 @@ export enum QuoteError { LowLiquidity = "Low liquidity for this route. Please try a smaller amount.", BelowLowerLimitSell = "Output amount below minimum SELL limit of", BelowLowerLimitBuy = "Input amount below minimum BUY limit of", + AboveUpperLimitSell = "Output amount exceeds maximum SELL limit of", + AboveUpperLimitBuy = "Input amount exceeds maximum BUY limit of", // Availability errors UnsupportedCurrency = "Currency not supported", diff --git a/packages/shared/src/endpoints/ramp.endpoints.ts b/packages/shared/src/endpoints/ramp.endpoints.ts index 9c1b71336..217f0ac09 100644 --- a/packages/shared/src/endpoints/ramp.endpoints.ts +++ b/packages/shared/src/endpoints/ramp.endpoints.ts @@ -4,22 +4,19 @@ import { EvmAddress, Networks, PaymentMethod, - PermitSignature, RampCurrency, - RampDirection, - Signature + RampDirection } from "../index"; import { TransactionStatus } from "./webhook.endpoints"; +export type Signature = { v: number; r: `0x${string}`; s: `0x${string}`; deadline: number }; + export type RampPhase = | "initial" - | "moneriumOnrampSelfTransfer" - | "moneriumOnrampMint" | "squidRouterPermitExecute" | "squidRouterNoPermitTransfer" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" - | "stellarCreateAccount" | "squidRouterApprove" | "squidRouterSwap" | "squidRouterPay" @@ -35,8 +32,6 @@ export type RampPhase = | "pendulumToHydrationXcm" | "assethubToPendulum" | "pendulumToAssethubXcm" - | "spacewalkRedeem" - | "stellarPayment" | "subsidizePreSwap" | "subsidizePostSwap" | "distributeFees" @@ -46,6 +41,8 @@ export type RampPhase = | "alfredpayOfframpTransferFallback" | "brlaOnrampMint" | "brlaPayoutOnBase" + | "mykoboOnrampDeposit" + | "mykoboPayoutOnBase" | "baseTransfer" | "failed" | "timedOut" @@ -59,17 +56,16 @@ export type RampPhase = export type CleanupPhase = | "moonbeamCleanup" | "pendulumCleanup" - | "stellarCleanup" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "hydrationCleanup" | "assetHubCleanup" | "baseCleanupUsdc" | "baseCleanupBrla" + | "baseCleanupEurc" | "baseCleanupAxlUsdc"; export enum EphemeralAccountType { - Stellar = "Stellar", Substrate = "Substrate", EVM = "EVM" } @@ -133,7 +129,7 @@ export function isSignedTypedData( export function isSignedTypedDataArray( data: string | EvmTransactionData | SignedTypedData | SignedTypedData[] ): data is SignedTypedData[] { - return Array.isArray(data) && data.length > 0 && data.every(item => isSignedTypedData(item as any)); + return Array.isArray(data) && data.length > 0 && data.every(item => isSignedTypedData(item as unknown as SignedTypedData)); } export interface UnsignedTx { @@ -162,13 +158,14 @@ export interface PaymentData { amount: string; memo: string; memoType: "text" | "hash" | "id"; - anchorTargetAccount: string; // The account of the Stellar anchor where the payment is sent } export interface IbanPaymentData { receiverName: string; iban: string; bic: string; + /** Optional payment reference (e.g. Mykobo deposit SCOR). Caller should include it in the SEPA transfer. */ + reference?: string; } export interface RegisterRampRequest { @@ -179,13 +176,13 @@ export interface RegisterRampRequest { fiatAccountId?: string; // For determine the correct payment method for AlfredPay flows walletAddress?: string; destinationAddress?: string; - moneriumWalletAddress?: string; paymentData?: PaymentData; pixDestination?: string; receiverTaxId?: string; taxId?: string; - moneriumAuthToken?: string | null; // Monerium authentication code for Monerium offramps. sessionId?: string; + email?: string; // Required for Mykobo EUR ramps (binds ramp to anchor profile) + ipAddress?: string; // Required for Mykobo EUR ramps (user IP for fraud checks; auto-filled from req.ip if omitted) [key: string]: unknown; }; } @@ -209,8 +206,6 @@ export interface UpdateRampRequest { squidRouterNoPermitApproveHash?: string; squidRouterNoPermitSwapHash?: string; assethubToPendulumHash?: string; - moneriumOfframpSignature?: string; // Required to trigger Monerium offramp - moneriumOnrampPermit?: PermitSignature; [key: string]: unknown; }; } diff --git a/packages/shared/src/endpoints/stellar.endpoints.ts b/packages/shared/src/endpoints/stellar.endpoints.ts deleted file mode 100644 index bc943a45d..000000000 --- a/packages/shared/src/endpoints/stellar.endpoints.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { FiatToken } from "../index"; - -// POST /stellar/create -export interface CreateStellarTransactionRequest { - accountId: string; - maxTime: number; - assetCode: string; - baseFee: string; -} - -export interface CreateStellarTransactionResponse { - signature: string; - sequence: string; - public: string; -} - -// POST /stellar/sep10 -export interface SignSep10ChallengeRequest { - challengeXDR: string; - outToken: FiatToken; - clientPublicKey: string; - derivedMemo?: string; -} - -export interface SignSep10ChallengeResponse { - masterClientSignature: string; - masterClientPublic: string; - clientSignature: string; - clientPublic: string; -} - -// GET /stellar/sep10 -export interface GetSep10MasterPKResponse { - masterSep10Public: string; -} - -export interface StellarErrorResponse { - error: string; - details?: string; -} diff --git a/packages/shared/src/endpoints/storage.endpoints.ts b/packages/shared/src/endpoints/storage.endpoints.ts index 5320a5196..419a3f452 100644 --- a/packages/shared/src/endpoints/storage.endpoints.ts +++ b/packages/shared/src/endpoints/storage.endpoints.ts @@ -1,7 +1,5 @@ // Flow types for storage export enum OfframpHandlerType { - EVM_TO_STELLAR = "evm-to-stellar", - ASSETHUB_TO_STELLAR = "assethub-to-stellar", EVM_TO_BRLA = "evm-to-brla", ASSETHUB_TO_BRLA = "assethub-to-brla" } @@ -26,23 +24,6 @@ export interface StorageRequestBase { outputTokenType: string; } -// Specific fields for each flow type -export interface EvmToStellarStorageRequest extends StorageRequestBase { - flowType: OfframpHandlerType.EVM_TO_STELLAR; - offramperAddress: string; - squidRouterReceiverId: string; - squidRouterReceiverHash: string; -} - -export interface AssethubToStellarStorageRequest extends StorageRequestBase { - flowType: OfframpHandlerType.ASSETHUB_TO_STELLAR; - offramperAddress: string; - stellarEphemeralPublicKey: string; - spacewalkRedeemTx: string; - stellarOfframpTx: string; - stellarCleanupTx: string; -} - export interface EvmToBrlaStorageRequest extends StorageRequestBase { flowType: OfframpHandlerType.EVM_TO_BRLA; offramperAddress: string; @@ -73,8 +54,6 @@ export interface BrlaToAssethubStorageRequest extends StorageRequestBase { // Union type for all storage requests export type StoreDataRequest = - | EvmToStellarStorageRequest - | AssethubToStellarStorageRequest | EvmToBrlaStorageRequest | AssethubToBrlaStorageRequest | BrlaToEvmStorageRequest diff --git a/packages/shared/src/helpers/conversions.ts b/packages/shared/src/helpers/conversions.ts index 0a341c36e..f0a664017 100644 --- a/packages/shared/src/helpers/conversions.ts +++ b/packages/shared/src/helpers/conversions.ts @@ -2,13 +2,8 @@ import { ApiPromise, Keyring } from "@polkadot/api"; import { SubmittableExtrinsic } from "@polkadot/api/types"; import { Extrinsic } from "@polkadot/types/interfaces"; import { ISubmittableResult } from "@polkadot/types/types"; -import { StrKey } from "stellar-sdk"; import logger from "../logger"; -export function stellarHexToPublic(hexString: string) { - return StrKey.encodeEd25519PublicKey(hexToBuffer(hexString)); -} - export function hexToBuffer(hexString: string) { if (hexString.length % 2 !== 0) { throw new Error("The provided hex string has an odd length. It must have an even length."); diff --git a/packages/shared/src/helpers/ephemerals.ts b/packages/shared/src/helpers/ephemerals.ts index f96f070ef..23d01aa4f 100644 --- a/packages/shared/src/helpers/ephemerals.ts +++ b/packages/shared/src/helpers/ephemerals.ts @@ -2,7 +2,6 @@ import { Keyring } from "@polkadot/api"; import { u8aToHex } from "@polkadot/util"; import { cryptoWaitReady, hdEthereum, mnemonicGenerate } from "@polkadot/util-crypto"; import { mnemonicToSeedSync } from "@scure/bip39"; -import { Keypair } from "stellar-sdk"; import { EphemeralAccount } from "../index"; export function deriveEvmPrivateKeyFromMnemonic(mnemonic: string): Uint8Array { @@ -32,10 +31,3 @@ export async function createPendulumEphemeral(): Promise { return { address: ephemeralAccountKeypair.address, secret: seedPhrase }; } - -export function createStellarEphemeral(): EphemeralAccount { - const ephemeralKeys = Keypair.random(); - const address = ephemeralKeys.publicKey(); - - return { address, secret: ephemeralKeys.secret() }; -} diff --git a/packages/shared/src/helpers/networks.ts b/packages/shared/src/helpers/networks.ts index 4b8aed162..55f70230a 100644 --- a/packages/shared/src/helpers/networks.ts +++ b/packages/shared/src/helpers/networks.ts @@ -15,7 +15,6 @@ export enum Networks { Polygon = "polygon", Moonbeam = "moonbeam", Pendulum = "pendulum", - Stellar = "stellar", PolygonAmoy = "polygonAmoy", BaseSepolia = "base-sepolia" } @@ -44,12 +43,11 @@ export function getNetworkFromDestination(destination: DestinationType): Network return undefined; } -// For the AssetHub/Pendulum/Stellar network, we use a chain ID of -x. This is not a valid chain ID -// but we just use it to differentiate between the EVM and Polkadot/Stellar accounts. +// For the AssetHub/Pendulum networks, we use a chain ID of -x. This is not a valid chain ID +// but we just use it to differentiate between the EVM and Polkadot accounts. export const ASSETHUB_CHAIN_ID = -1; export const PENDULUM_CHAIN_ID = -2; export const HYDRATION_CHAIN_ID = -3; -export const STELLAR_CHAIN_ID = -99; interface NetworkMetadata { id: number; @@ -136,12 +134,6 @@ const NETWORK_METADATA: Record = { id: PENDULUM_CHAIN_ID, isEVM: false, supportsRamp: false - }, - [Networks.Stellar]: { - displayName: "Stellar", - id: STELLAR_CHAIN_ID, - isEVM: false, - supportsRamp: false } }; diff --git a/packages/shared/src/helpers/parseNumbers.ts b/packages/shared/src/helpers/parseNumbers.ts index 60f23ff68..6bc693f6f 100644 --- a/packages/shared/src/helpers/parseNumbers.ts +++ b/packages/shared/src/helpers/parseNumbers.ts @@ -5,10 +5,6 @@ import Big from "big.js"; export const ChainDecimals = 12; export const USDC_DECIMALS = 6; -// These are the decimals used by the Stellar network -// We actually up-scale the amounts on Stellar now to match the expected decimals of the other tokens. -export const StellarDecimals = ChainDecimals; - // These are the decimals used by the FixedU128 type export const FixedU128Decimals = 18; @@ -35,17 +31,6 @@ export const decimalToCustom = (value: Big | number | string, decimals: number) return bigIntValue.mul(multiplier); }; -export const decimalToStellarNative = (value: Big | number | string) => { - let bigIntValue; - try { - bigIntValue = new Big(value); - } catch (_error) { - bigIntValue = new Big(0); - } - const multiplier = new Big(10).pow(StellarDecimals); - return bigIntValue.mul(multiplier); -}; - export const fixedPointToDecimal = (value: Big | number | string) => { const bigIntValue = new Big(value); const divisor = new Big(10).pow(FixedU128Decimals); @@ -70,13 +55,6 @@ export const nativeToDecimal = (value: Big | number | string | u128 | UInt, deci return bigIntValue.div(divisor); }; -export const nativeStellarToDecimal = (value: Big | number | string) => { - const bigIntValue = new Big(value); - const divisor = new Big(10).pow(StellarDecimals); - - return bigIntValue.div(divisor); -}; - export const toBigNumber = (value: Big | number | string, decimals: number) => { if (typeof value === "string" || value instanceof u128) { // Replace the unnecessary ',' with '' to prevent BigNumber from throwing an error diff --git a/packages/shared/src/helpers/signUnsigned.ts b/packages/shared/src/helpers/signUnsigned.ts index cc1536489..4830ae813 100644 --- a/packages/shared/src/helpers/signUnsigned.ts +++ b/packages/shared/src/helpers/signUnsigned.ts @@ -2,7 +2,6 @@ import { ApiPromise, Keyring } from "@polkadot/api"; import { AddressOrPair } from "@polkadot/api/types"; import { hexToU8a } from "@polkadot/util"; import { cryptoWaitReady } from "@polkadot/util-crypto"; -import { Keypair, Networks as StellarNetworks, Transaction } from "stellar-sdk"; import { createWalletClient, fallback, http, WalletClient } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { arbitrum, avalanche, base, bsc, mainnet, moonbeam, polygon, polygonAmoy } from "viem/chains"; @@ -15,7 +14,6 @@ import { Networks, NUMBER_OF_PRESIGNED_TXS, PresignedTx, - SANDBOX_ENABLED, UnsignedTx } from "../index"; import logger from "../logger"; @@ -41,37 +39,6 @@ export function addAdditionalTransactionsToMeta(primaryTx: PresignedTx, multiSig }; } -/** - * Signs multiple Stellar transactions with increasing sequence numbers - */ -async function signMultipleStellarTransactions( - tx: UnsignedTx, - keypair: Keypair, - networkPassphrase: string -): Promise { - 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 - }; - // iterate objects of array meta - for (const key in signedTx.meta.additionalTxs) { - if (!key.includes(tx.phase)) continue; - - const extraTransactionUnsigned = signedTx.meta.additionalTxs[key].txData; - const extraTransaction = new Transaction(extraTransactionUnsigned as string, networkPassphrase); - extraTransaction.sign(keypair); - - const extraTransactionSigned = extraTransaction.toEnvelope().toXDR().toString("base64"); - signedTx.meta.additionalTxs[key].txData = extraTransactionSigned; - } - return signedTx; -} - /** * Signs multiple Substrate transactions with increasing nonces */ @@ -225,7 +192,6 @@ async function signMultipleEvmTransactions( export async function signUnsignedTransactions( unsignedTxs: UnsignedTx[], ephemerals: { - stellarEphemeral?: EphemeralAccount; substrateEphemeral?: EphemeralAccount; evmEphemeral?: EphemeralAccount; }, @@ -247,35 +213,18 @@ export async function signUnsignedTransactions( const hydrationTxs = unsignedTxs.filter(tx => tx.network === Networks.Hydration); const destinationNetworkTxs = unsignedTxs.filter( tx => - tx.phase === "destinationTransfer" || - tx.phase === "backupSquidRouterApprove" || - tx.phase === "backupSquidRouterSwap" || - tx.phase === "backupApprove" + (tx.phase === "destinationTransfer" || + tx.phase === "backupSquidRouterApprove" || + tx.phase === "backupSquidRouterSwap" || + tx.phase === "backupApprove") && + tx.network !== Networks.Polygon && + tx.network !== Networks.PolygonAmoy && + tx.network !== Networks.Base ); try { - const stellarTxs = unsignedTxs.filter(tx => tx.network === "stellar").sort((a, b) => a.nonce - b.nonce); const pendulumTxs = unsignedTxs.filter(tx => tx.network === "pendulum"); - // Process Stellar transactions first in sequence order - if (stellarTxs.length > 0) { - if (!ephemerals.stellarEphemeral) { - throw new Error("Missing Stellar ephemeral account"); - } - - const keypair = Keypair.fromSecret(ephemerals.stellarEphemeral.secret); - - for (const tx of stellarTxs) { - if (isEvmTransactionData(tx.txData) || isSignedTypedData(tx.txData)) { - throw new Error("Invalid Stellar transaction data format"); - } - - const networkPassphrase = SANDBOX_ENABLED ? StellarNetworks.TESTNET : StellarNetworks.PUBLIC; - const txWithMeta = await signMultipleStellarTransactions(tx, keypair, networkPassphrase); - signedTxs.push(txWithMeta); - } - } - for (const tx of hydrationTxs) { if (!ephemerals.substrateEphemeral) { throw new Error("Missing Substrate ephemeral account"); diff --git a/packages/shared/src/services/alfredpay/alfredpayApiService.ts b/packages/shared/src/services/alfredpay/alfredpayApiService.ts index 97074f09f..e8725830d 100644 --- a/packages/shared/src/services/alfredpay/alfredpayApiService.ts +++ b/packages/shared/src/services/alfredpay/alfredpayApiService.ts @@ -109,8 +109,13 @@ export class AlfredpayApiService { try { const parsed = JSON.parse(errorText); if (parsed.errorCode === 111426 && parsed.errorMetadata) { - const { minQuantity, fromCurrency } = parsed.errorMetadata; - throw new AlfredpayTradeLimitError(minQuantity, fromCurrency); + const { minQuantity, maxQuantity, fromCurrency } = parsed.errorMetadata; + logger.current.warn( + `Alfredpay trade limit hit: minQuantity=${minQuantity} maxQuantity=${maxQuantity} fromCurrency=${fromCurrency}` + ); + throw maxQuantity !== undefined + ? AlfredpayTradeLimitError.above(maxQuantity, fromCurrency) + : AlfredpayTradeLimitError.below(minQuantity, fromCurrency); } } catch (parseError) { if (parseError instanceof AlfredpayTradeLimitError) { diff --git a/packages/shared/src/services/alfredpay/types.ts b/packages/shared/src/services/alfredpay/types.ts index 3b0b02d88..b4a4c2dd3 100644 --- a/packages/shared/src/services/alfredpay/types.ts +++ b/packages/shared/src/services/alfredpay/types.ts @@ -376,15 +376,25 @@ export interface GetAllConfigsResponse { } export class AlfredpayTradeLimitError extends Error { - readonly minQuantity: string; + readonly kind: "above" | "below"; + readonly quantity: string; readonly fromCurrency: string; - constructor(minQuantity: string, fromCurrency: string) { - super(`Trade below minimum: ${minQuantity} ${fromCurrency}`); + private constructor(kind: "above" | "below", quantity: string, fromCurrency: string) { + super(`Trade ${kind === "below" ? "below minimum" : "above maximum"}: ${quantity} ${fromCurrency}`); this.name = "AlfredpayTradeLimitError"; - this.minQuantity = minQuantity; + this.kind = kind; + this.quantity = quantity; this.fromCurrency = fromCurrency; } + + static below(minQuantity: string, fromCurrency: string): AlfredpayTradeLimitError { + return new AlfredpayTradeLimitError("below", minQuantity, fromCurrency); + } + + static above(maxQuantity: string, fromCurrency: string): AlfredpayTradeLimitError { + return new AlfredpayTradeLimitError("above", maxQuantity, fromCurrency); + } } // MXN KYC form submission types diff --git a/packages/shared/src/services/index.ts b/packages/shared/src/services/index.ts index e3eb64dca..17c170987 100644 --- a/packages/shared/src/services/index.ts +++ b/packages/shared/src/services/index.ts @@ -2,6 +2,7 @@ export * from "../contracts"; export * from "./alfredpay"; export * from "./brla"; export * from "./evm"; +export * from "./mykobo"; export * from "./nabla"; export * from "./pendulum"; export * from "./slack.service"; diff --git a/packages/shared/src/services/mykobo/index.ts b/packages/shared/src/services/mykobo/index.ts new file mode 100644 index 000000000..ae4e66566 --- /dev/null +++ b/packages/shared/src/services/mykobo/index.ts @@ -0,0 +1,2 @@ +export * from "./mykoboApiService"; +export * from "./types"; diff --git a/packages/shared/src/services/mykobo/mykoboApiService.ts b/packages/shared/src/services/mykobo/mykoboApiService.ts new file mode 100644 index 000000000..7e780c52c --- /dev/null +++ b/packages/shared/src/services/mykobo/mykoboApiService.ts @@ -0,0 +1,284 @@ +import { MYKOBO_ACCESS_KEY, MYKOBO_BASE_URL, MYKOBO_CLIENT_DOMAIN, MYKOBO_SECRET_KEY } from "../.."; +import { isProduction } from "../../helpers/environment"; +import logger from "../../logger"; +import { + MykoboAuthTokenResponse, + MykoboCreateIntentRequest, + MykoboCreateIntentResponse, + MykoboFeeKind, + MykoboFeeResponse, + MykoboGetProfileResponse, + MykoboGetTransactionResponse, + MykoboLookupFeesParams +} from "./types"; + +export class MykoboApiError extends Error { + public readonly status: number; + public readonly body: unknown; + + constructor(status: number, body: unknown, message: string) { + super(message); + this.name = "MykoboApiError"; + this.status = status; + this.body = body; + } +} + +interface CachedToken { + token: string; + refreshToken: string; +} + +export class MykoboApiService { + private static instance: MykoboApiService; + + private readonly accessKey: string; + private readonly secretKey: string; + private readonly baseUrl: string; + private readonly clientDomain?: string; + + private cachedToken: CachedToken | undefined; + + private tokenPromise: Promise | undefined; + + private authFailurePromise: Promise | undefined; + + private constructor() { + if (!MYKOBO_ACCESS_KEY || !MYKOBO_SECRET_KEY) { + throw new Error("MYKOBO_ACCESS_KEY or MYKOBO_SECRET_KEY not defined"); + } + if (!MYKOBO_BASE_URL) { + throw new Error("MYKOBO_BASE_URL not defined"); + } + this.accessKey = MYKOBO_ACCESS_KEY; + this.secretKey = MYKOBO_SECRET_KEY; + const trimmedBase = MYKOBO_BASE_URL.replace(/\/$/, ""); + assertSecureMykoboBaseUrl(trimmedBase); + this.baseUrl = /\/v\d+$/.test(trimmedBase) ? trimmedBase : `${trimmedBase}/v1`; + this.clientDomain = MYKOBO_CLIENT_DOMAIN || undefined; + } + + public static getInstance(): MykoboApiService { + if (!MykoboApiService.instance) { + MykoboApiService.instance = new MykoboApiService(); + } + return MykoboApiService.instance; + } + + public getClientDomain(): string | undefined { + return this.clientDomain; + } + + private async acquireToken(): Promise { + const response = await fetch(`${this.baseUrl}/auth/token`, { + body: JSON.stringify({ access_key: this.accessKey, secret_key: this.secretKey }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST" + }); + if (!response.ok) { + const body = await safeReadBody(response); + throw new MykoboApiError(response.status, body, `Mykobo /auth/token failed: ${response.status}`); + } + const parsed = (await response.json()) as MykoboAuthTokenResponse; + return { refreshToken: parsed.refresh_token, token: parsed.token }; + } + + private async refreshAccessToken(refreshToken: string): Promise { + const response = await fetch(`${this.baseUrl}/auth/refresh`, { + body: JSON.stringify({ refresh_token: refreshToken }), + headers: { Accept: "application/json", "Content-Type": "application/json" }, + method: "POST" + }); + if (!response.ok) { + const body = await safeReadBody(response); + throw new MykoboApiError(response.status, body, `Mykobo /auth/refresh failed: ${response.status}`); + } + const parsed = (await response.json()) as MykoboAuthTokenResponse; + return { refreshToken: parsed.refresh_token, token: parsed.token }; + } + + private async getToken(): Promise { + if (this.cachedToken) { + return this.cachedToken.token; + } + if (!this.tokenPromise) { + this.tokenPromise = this.acquireToken().finally(() => { + this.tokenPromise = undefined; + }); + } + this.cachedToken = await this.tokenPromise; + return this.cachedToken.token; + } + + private async handleAuthFailure(): Promise { + if (!this.authFailurePromise) { + this.authFailurePromise = this.doHandleAuthFailure().finally(() => { + this.authFailurePromise = undefined; + }); + } + return this.authFailurePromise; + } + + private async doHandleAuthFailure(): Promise { + if (this.cachedToken) { + try { + const refreshed = await this.refreshAccessToken(this.cachedToken.refreshToken); + this.cachedToken = refreshed; + return refreshed.token; + } catch (error) { + logger.current.warn("Mykobo refresh failed; re-acquiring token", error); + } + } + const reAcquired = await this.acquireToken(); + this.cachedToken = reAcquired; + return reAcquired.token; + } + + private async request( + method: "GET" | "POST", + path: string, + options: { query?: Record; body?: unknown } = {} + ): Promise { + const url = this.buildUrl(path, options.query); + const body = options.body !== undefined ? JSON.stringify(options.body) : undefined; + let token = await this.getToken(); + let response = await fetch(url, { + body, + headers: this.buildHeaders(token, body !== undefined), + method + }); + + if (response.status === 401) { + token = await this.handleAuthFailure(); + response = await fetch(url, { + body, + headers: this.buildHeaders(token, body !== undefined), + method + }); + } + + if (!response.ok) { + const errorBody = await safeReadBody(response); + throw new MykoboApiError(response.status, errorBody, `Mykobo ${method} ${path} failed: ${response.status}`); + } + + if (response.status === 204) { + return undefined as T; + } + return (await response.json()) as T; + } + + private buildUrl(path: string, query?: Record): string { + const base = `${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`; + if (!query) return base; + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== "") params.append(key, value); + } + const queryString = params.toString(); + return queryString ? `${base}?${queryString}` : base; + } + + private buildHeaders(token: string, hasBody: boolean): Record { + const headers: Record = { + Accept: "application/json", + Authorization: `Bearer ${token}` + }; + if (hasBody) headers["Content-Type"] = "application/json"; + return headers; + } + + public async createTransactionIntent(request: MykoboCreateIntentRequest): Promise { + const body: MykoboCreateIntentRequest = { + ...request, + client_domain: request.client_domain ?? this.clientDomain + }; + return this.request("POST", "/transactions/intent", { body }); + } + + public async getTransaction(transactionId: string): Promise { + return this.request("GET", `/transactions/${transactionId}`); + } + + public async lookupFees(params: MykoboLookupFeesParams): Promise { + return this.request("GET", "/fees", { + query: { + client_domain: params.client_domain ?? this.clientDomain, + kind: params.kind, + value: params.value + } + }); + } + + public async getProfileByWalletAddress(walletAddress: string, memo?: string): Promise { + return this.request("GET", "/profiles", { + query: { address: walletAddress, memo } + }); + } + + public async getProfileByEmail(email: string, memo?: string): Promise { + return this.request("GET", "/profiles", { + query: { email, memo } + }); + } + + public async createProfile(formData: FormData): Promise { + const url = this.buildUrl("/profiles"); + let token = await this.getToken(); + let response = await fetch(url, { + body: formData, + headers: { Accept: "application/json", Authorization: `Bearer ${token}` }, + method: "POST" + }); + + if (response.status === 401) { + token = await this.handleAuthFailure(); + response = await fetch(url, { + body: formData, + headers: { Accept: "application/json", Authorization: `Bearer ${token}` }, + method: "POST" + }); + } + + if (!response.ok) { + const errorBody = await safeReadBody(response); + throw new MykoboApiError(response.status, errorBody, `Mykobo POST /profiles failed: ${response.status}`); + } + + return (await response.json()) as MykoboGetProfileResponse; + } + + public defaultWithdrawFee(value: string): Promise { + return this.lookupFees({ kind: MykoboFeeKind.WITHDRAW, value }); + } + + public defaultDepositFee(value: string): Promise { + return this.lookupFees({ kind: MykoboFeeKind.DEPOSIT, value }); + } +} + +async function safeReadBody(response: Response): Promise { + try { + return await response.clone().json(); + } catch { + try { + return await response.text(); + } catch { + return undefined; + } + } +} + +function assertSecureMykoboBaseUrl(rawUrl: string): void { + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + throw new Error(`MYKOBO_BASE_URL is not a valid URL: ${rawUrl}`); + } + if (parsed.protocol === "https:") return; + if (parsed.protocol === "http:" && !isProduction() && (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1")) { + return; + } + throw new Error(`MYKOBO_BASE_URL must use https:// (got ${parsed.protocol}//${parsed.hostname})`); +} diff --git a/packages/shared/src/services/mykobo/types.ts b/packages/shared/src/services/mykobo/types.ts new file mode 100644 index 000000000..dcdc49c45 --- /dev/null +++ b/packages/shared/src/services/mykobo/types.ts @@ -0,0 +1,169 @@ +export enum MykoboTransactionType { + DEPOSIT = "DEPOSIT", + WITHDRAW = "WITHDRAW" +} + +export enum MykoboCurrency { + EURC = "EURC", + USDC = "USDC" +} + +export enum MykoboNetwork { + STELLAR = "STELLAR", + SOLANA = "SOLANA", + ETHEREUM = "ETHEREUM", + BASE = "BASE" +} + +export enum MykoboFeeKind { + DEPOSIT = "deposit", + WITHDRAW = "withdraw" +} + +export enum MykoboTransactionStatus { + PENDING_PAYER = "PENDING_PAYER", + PENDING_PAYEE = "PENDING_PAYEE", + COMPLETED = "COMPLETED", + FAILED = "FAILED", + CANCELLED = "CANCELLED", + EXPIRED = "EXPIRED" +} + +export enum MykoboCustomerStatus { + CONSULTED = "CONSULTED", // checked Mykobo but no profile/review exists yet + PENDING = "PENDING", + APPROVED = "APPROVED", + REJECTED = "REJECTED" +} + +export enum MykoboCustomerType { + INDIVIDUAL = "INDIVIDUAL", + BUSINESS = "BUSINESS" +} + +export function mapMykoboReviewStatus(reviewStatus: string | null | undefined): MykoboCustomerStatus { + switch ((reviewStatus ?? "").toLowerCase()) { + case "approved": + return MykoboCustomerStatus.APPROVED; + case "pending": + return MykoboCustomerStatus.PENDING; + case "rejected": + return MykoboCustomerStatus.REJECTED; + default: + return MykoboCustomerStatus.CONSULTED; + } +} + +export interface MykoboAuthTokenRequest { + access_key: string; + secret_key: string; +} + +export interface MykoboAuthTokenResponse { + subject_id: string; + token: string; + refresh_token: string; +} + +export interface MykoboRefreshTokenRequest { + refresh_token: string; +} + +export interface MykoboCreateIntentRequest { + transaction_type: MykoboTransactionType; + wallet_address: string; + email_address: string; + value: string; + currency: MykoboCurrency; + ip_address: string; + memo?: string; + client_domain?: string; +} + +export interface MykoboTransaction { + id: string; + reference: string; + transaction_type: MykoboTransactionType; + status: MykoboTransactionStatus | string; + incoming_currency?: string; + outgoing_currency?: string; + value: string; + fee: string; + wallet_address: string; + network: MykoboNetwork | string; + tx_hash: string | null; + created_at: string; + updated_at: string; +} + +export interface MykoboDepositInstructions { + bank_account_name: string; + iban: string; +} + +export interface MykoboWithdrawInstructions { + address: string; +} + +export type MykoboTransactionInstructions = MykoboDepositInstructions | MykoboWithdrawInstructions; + +export interface MykoboCreateIntentResponse { + transaction: MykoboTransaction; + instructions?: MykoboTransactionInstructions; +} + +export interface MykoboGetTransactionResponse { + transaction: MykoboTransaction; + instructions?: MykoboTransactionInstructions; +} + +export interface MykoboFeeDetail { + amount: string; + description: string; + name: string; +} + +export interface MykoboFeeResponse { + total: string; + asset?: string; + percentage?: string; + details?: MykoboFeeDetail[]; +} + +export interface MykoboLookupFeesParams { + value: string; + kind: MykoboFeeKind; + client_domain?: string; +} + +export interface MykoboProfileKycStatus { + received_at: string | null; + review_status: string; +} + +export interface MykoboProfile { + first_name: string; + last_name: string; + email_address: string; + bank_account_number: string; + kyc_status: MykoboProfileKycStatus; + created_at: string; +} + +export interface MykoboGetProfileResponse { + profile: MykoboProfile; +} + +export interface MykoboErrorResponse { + error: string; + fields?: Record; + kyc_status?: MykoboProfileKycStatus | string; + email_address?: string; + service?: string; +} + +export function isWithdrawInstructions( + instructions: MykoboTransactionInstructions | undefined +): instructions is MykoboWithdrawInstructions { + return Boolean(instructions && "address" in instructions); +} diff --git a/packages/shared/src/services/nabla/transactions/index.ts b/packages/shared/src/services/nabla/transactions/index.ts index b88de3a93..9f41cc091 100644 --- a/packages/shared/src/services/nabla/transactions/index.ts +++ b/packages/shared/src/services/nabla/transactions/index.ts @@ -9,7 +9,6 @@ import { Networks, PendulumTokenDetails } from "../../../index"; -import { NABLA_ROUTER_BASE } from "../../../tokens/constants/misc"; import { prepareNablaApproveTransaction } from "./approve"; import { prepareNablaSwapTransaction } from "./swap"; @@ -66,7 +65,8 @@ export async function createNablaTransactionsForOnrampOnEVM( inputTokenAddress: `0x${string}`, outputTokenAddress: `0x${string}`, nablaHardMinimumOutputRaw: string, - deadlineMinutes: number + deadlineMinutes: number, + routerAddress: `0x${string}` ) { if (ephemeral.type !== "EVM") { throw new Error(`Can't create Nabla EVM transactions for ${ephemeral.type}`); @@ -91,7 +91,7 @@ export async function createNablaTransactionsForOnrampOnEVM( type: "function" } ], - args: [NABLA_ROUTER_BASE, BigInt(amountRaw)], + args: [routerAddress, BigInt(amountRaw)], functionName: "approve" }); @@ -145,7 +145,7 @@ export async function createNablaTransactionsForOnrampOnEVM( gas: "500000", // Higher gas limit for swap maxFeePerGas: swapMaxFee.toString(), maxPriorityFeePerGas: swapMaxPriority.toString(), - to: NABLA_ROUTER_BASE, + to: routerAddress, value: "0" }; diff --git a/packages/shared/src/services/pendulum/apiManager.ts b/packages/shared/src/services/pendulum/apiManager.ts index 000d740b6..c5d27972e 100644 --- a/packages/shared/src/services/pendulum/apiManager.ts +++ b/packages/shared/src/services/pendulum/apiManager.ts @@ -18,7 +18,7 @@ const NETWORKS: NetworkConfig[] = [ }, { name: "hydration", - wsUrls: ["wss://rpc.hydradx.cloud"] + wsUrls: ["wss://hydration.ibp.network"] }, { name: "moonbeam", diff --git a/packages/shared/src/services/squidrouter/onramp.ts b/packages/shared/src/services/squidrouter/onramp.ts index bbdfb2637..aad24dc3c 100644 --- a/packages/shared/src/services/squidrouter/onramp.ts +++ b/packages/shared/src/services/squidrouter/onramp.ts @@ -3,9 +3,7 @@ import { decodeAddress } from "@polkadot/util-crypto"; import { AXL_USDC_MOONBEAM, createRandomString, - createRouteParamsWithMoonbeamPostHook, createSquidRouterHash, - ERC20_EURE_POLYGON_V1, EvmClientManager, EvmNetworks, EvmTransactionData, @@ -179,52 +177,6 @@ export async function createOnrampSquidrouterTransactionsFromBaseToEvm( } // Onramp from Polygon directly to any token on any EVM chain. -export async function createOnrampSquidrouterTransactionsFromPolygonToMoonbeamWithPendulumPosthook( - params: Omit -): Promise { - const evmClientManager = EvmClientManager.getInstance(); - const polygonClient = evmClientManager.getClient(Networks.Polygon); - const fromNetwork = Networks.Polygon; - - const squidRouterReceiverId = createRandomString(32); - const pendulumEphemeralAccountHex = u8aToHex(decodeAddress(params.destinationAddress)); - const squidRouterPayload = encodePayload(pendulumEphemeralAccountHex); - const squidRouterReceiverHash = createSquidRouterHash(squidRouterReceiverId, squidRouterPayload); - const { receivingContractAddress } = getSquidRouterConfig(fromNetwork); - - const routeParams = createRouteParamsWithMoonbeamPostHook({ - ...params, - amount: params.rawAmount, - fromNetwork, - receivingContractAddress, - squidRouterReceiverHash - }); - - try { - const routeResult = await getRoute(routeParams); - const { route } = routeResult.data; - - const { approveData, swapData, squidRouterQuoteId } = await createTransactionDataFromRoute({ - inputTokenErc20Address: ERC20_EURE_POLYGON_V1, - publicClient: polygonClient, - rawAmount: params.rawAmount, - route, - swapValue: computeSwapValueWithSafetyMargin(route.transactionRequest.value) - }); - - return { - approveData, - route, - squidRouterQuoteId, - squidRouterReceiverHash, - squidRouterReceiverId, - swapData - }; - } catch (e) { - throw new Error(`Error getting route: ${JSON.stringify(routeParams)}. Error: ${e}`); - } -} - export async function createOnrampSquidrouterTransactionsOnDestinationChain( params: OnrampSquidrouterParamsOnDestinationChain ): Promise { diff --git a/packages/shared/src/substrateEvents/eventListener.ts b/packages/shared/src/substrateEvents/eventListener.ts index 4618a5dd0..751b86c0f 100644 --- a/packages/shared/src/substrateEvents/eventListener.ts +++ b/packages/shared/src/substrateEvents/eventListener.ts @@ -1,7 +1,7 @@ import { ApiPromise } from "@polkadot/api"; import { EventRecord } from "@polkadot/types/interfaces"; import logger from "../logger"; -import { parseEventRedeemExecution, parseEventXcmSent } from "./eventParsers"; +import { parseEventXcmSent } from "./xcmParsers"; interface IPendingEvent { id: string; @@ -11,7 +11,6 @@ interface IPendingEvent { export class EventListener { static eventListeners = new Map(); - pendingRedeemEvents: IPendingEvent[] = []; pendingXcmSentEvents: IPendingEvent[] = []; api: ApiPromise | undefined = undefined; private unsubscribeHandle: (() => void) | null = null; @@ -39,39 +38,11 @@ export class EventListener { this.unsubscribeHandle = ((await this.api?.query.system.events((events: EventRecord[]) => { events.forEach((event: EventRecord) => { - this.processEvents(event, this.pendingRedeemEvents); this.processEvents(event, this.pendingXcmSentEvents); }); })) as unknown as () => void) || null; } - waitForRedeemExecuteEvent(redeemId: string, maxWaitingTimeMs: number) { - const filter = (event: EventRecord) => { - if (event.event.section === "redeem" && event.event.method === "ExecuteRedeem") { - const eventParsed = parseEventRedeemExecution({ event: event.event }); - if (eventParsed.redeemId === redeemId) { - return eventParsed; - } - } - return null; - }; - - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error(`Max waiting time exceeded for Redeem Execution with id: ${redeemId}`)); - }, maxWaitingTimeMs); - - this.pendingRedeemEvents.push({ - filter, - id: redeemId, - resolve: event => { - clearTimeout(timeout); - resolve(event); - } - }); - }); - } - waitForXcmSentEvent(originAddress: string, maxWaitingTimeMs: number) { const filter = (event: EventRecord) => { if (event.event.section === "polkadotXcm" && event.event.method === "Sent") { @@ -111,17 +82,7 @@ export class EventListener { } async checkForMissedEvents() { - const freshApiPromise = this.api; - if (!freshApiPromise || !freshApiPromise.isConnected) return; - - this.pendingRedeemEvents.forEach(pendingEvent => { - const redeemId = pendingEvent.id; - freshApiPromise.query.redeem.redeemRequests(redeemId).then(redeem => { - if (redeem) { - pendingEvent.resolve(redeem); - } - }); - }); + // No-op: redeem/spacewalk event recovery removed with Stellar/Spacewalk deprecation. } unsubscribe() { @@ -130,7 +91,6 @@ export class EventListener { this.unsubscribeHandle = null; } - this.pendingRedeemEvents = []; this.pendingXcmSentEvents = []; if (this.api) { diff --git a/packages/shared/src/substrateEvents/eventParsers.ts b/packages/shared/src/substrateEvents/eventParsers.ts deleted file mode 100644 index 1f6378b48..000000000 --- a/packages/shared/src/substrateEvents/eventParsers.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { Event } from "@polkadot/types/interfaces"; -import { encodeAddress } from "@polkadot/util-crypto"; -import Big from "big.js"; - -import { hexToString, stellarHexToPublic } from "../helpers/conversions"; - -export type SpacewalkRedeemRequestEvent = ReturnType; -export type XcmSentEvent = ReturnType; -export type XTokensEvent = ReturnType; - -export type TokenTransferEvent = ReturnType; - -export function parseEventRedeemRequest({ event }: { event: Event }) { - const rawEventData = JSON.parse(event.data.toString()); - const mappedData = { - amount: parseInt(rawEventData[3].toString(), 10), - asset: extractStellarAssetInfo(rawEventData[4]), - fee: parseInt(rawEventData[5].toString(), 10), - premium: parseInt(rawEventData[6].toString(), 10), - redeemer: rawEventData[1].toString(), - redeemId: rawEventData[0].toString(), - stellarAddress: stellarHexToPublic(rawEventData[7].toString()), - transferFee: parseInt(rawEventData[8].toString(), 10), - vaultId: { - accountId: rawEventData[2].accountId.toString(), - currencies: { - collateral: { - XCM: parseInt(rawEventData[2].currencies.collateral.xcm.toString(), 10) - }, - wrapped: extractStellarAssetInfo(rawEventData[2].currencies.wrapped) - } - } - }; - return mappedData; -} - -export function parseEventRedeemExecution({ event }: { event: Event }) { - const rawEventData = JSON.parse(event.data.toString()); - const mappedData = { - amount: parseInt(rawEventData[3].toString(), 10), - asset: extractStellarAssetInfo(rawEventData[4]), - fee: parseInt(rawEventData[5].toString(), 10), - redeemer: rawEventData[1].toString(), - redeemId: rawEventData[0].toString(), - transferFee: parseInt(rawEventData[6].toString(), 10), - vaultId: { - accountId: rawEventData[2].accountId.toString(), - currencies: { - collateral: { - XCM: parseInt(rawEventData[2].currencies.collateral.xcm.toString(), 10) - }, - wrapped: extractStellarAssetInfo(rawEventData[2].currencies.wrapped) - } - } - }; - return mappedData; -} - -export function parseEventXcmSent({ event }: { event: Event }) { - const rawEventData = JSON.parse(event.data.toString()); - const mappedData = { - originAddress: encodeAddress(rawEventData[0].interior.x1[0].accountId32.id.toString()) - }; - return mappedData; -} - -export function parseEventMoonbeamXcmSent({ event }: { event: Event }) { - const rawEventData = JSON.parse(event.data.toString()); - - const mappedData = { - originAddress: rawEventData[0].interior.x1[0].accountKey20.key - }; - return mappedData; -} - -export function parseEventXTokens({ event }: { event: Event }) { - const rawEventData = JSON.parse(event.data.toString()); - const mappedData = { - sender: rawEventData[0].toString() - }; - return mappedData; -} - -type StellarAssetData = { - stellar: - | { - stellarNative: string; - } - | { - alphaNum4: { - code: string; - issuer: string; - }; - } - | { - alphaNum12: { - code: string; - issuer: string; - }; - }; -}; - -function extractStellarAssetInfo(data: StellarAssetData) { - if ("stellarNative" in data.stellar) { - return { - Stellar: "StellarNative" - }; - } else if ("alphaNum4" in data.stellar) { - return { - Stellar: { - AlphaNum4: { - code: hexToString(data.stellar.alphaNum4.code.toString()), - issuer: stellarHexToPublic(data.stellar.alphaNum4.issuer.toString()) - } - } - }; - } else if ("alphaNum12" in data.stellar) { - return { - Stellar: { - AlphaNum12: { - code: hexToString(data.stellar.alphaNum12.code.toString()), - issuer: stellarHexToPublic(data.stellar.alphaNum12.issuer.toString()) - } - } - }; - } else { - throw new Error("Invalid Stellar type"); - } -} - -export function parseTokenDepositEvent({ event }: { event: Event }) { - const rawEventData = JSON.parse(event.data.toString()); - const mappedData = { - amountRaw: new Big(rawEventData[2].toString()) as Big, - currencyId: rawEventData[0], - to: rawEventData[1].toString() as string - }; - return mappedData; -} - -// Both functions used to compare betweem CurrencyId's -// where {XCM: x} == {xcm: x} -function normalizeObjectKeys(obj: Record): Record { - return Object.keys(obj).reduce((acc: Record, key) => { - acc[key.toLowerCase()] = obj[key]; - return acc; - }, {}); -} - -export function compareObjects(obj1: Record, obj2: Record): boolean { - const normalizedObj1 = normalizeObjectKeys(obj1); - const normalizedObj2 = normalizeObjectKeys(obj2); - - const keys1 = Object.keys(normalizedObj1); - const keys2 = Object.keys(normalizedObj2); - - if (keys1.length !== keys2.length) { - return false; - } - - for (const key of keys1) { - if (normalizedObj1[key] !== normalizedObj2[key]) { - return false; - } - } - - return true; -} diff --git a/packages/shared/src/substrateEvents/index.ts b/packages/shared/src/substrateEvents/index.ts index 21332f4d9..705a1b18f 100644 --- a/packages/shared/src/substrateEvents/index.ts +++ b/packages/shared/src/substrateEvents/index.ts @@ -1,2 +1,2 @@ export * from "./eventListener"; -export * from "./eventParsers"; +export * from "./xcmParsers"; diff --git a/packages/shared/src/substrateEvents/xcmParsers.ts b/packages/shared/src/substrateEvents/xcmParsers.ts new file mode 100644 index 000000000..4b8a6c469 --- /dev/null +++ b/packages/shared/src/substrateEvents/xcmParsers.ts @@ -0,0 +1,30 @@ +import { Event } from "@polkadot/types/interfaces"; +import { encodeAddress } from "@polkadot/util-crypto"; + +export type XcmSentEvent = ReturnType; +export type XTokensEvent = ReturnType; + +export function parseEventXcmSent({ event }: { event: Event }) { + const rawEventData = event.data.toJSON() as any[]; + const mappedData = { + originAddress: encodeAddress(rawEventData[0].interior.x1[0].accountId32.id.toString()) + }; + return mappedData; +} + +export function parseEventMoonbeamXcmSent({ event }: { event: Event }) { + const rawEventData = event.data.toJSON() as any[]; + + const mappedData = { + originAddress: rawEventData[0].interior.x1[0].accountKey20.key + }; + return mappedData; +} + +export function parseEventXTokens({ event }: { event: Event }) { + const rawEventData = event.data.toJSON() as any[]; + const mappedData = { + sender: rawEventData[0].toString() + }; + return mappedData; +} diff --git a/packages/shared/src/tokens/constants/misc.ts b/packages/shared/src/tokens/constants/misc.ts index 66aeb6dfa..6d9f7eaad 100644 --- a/packages/shared/src/tokens/constants/misc.ts +++ b/packages/shared/src/tokens/constants/misc.ts @@ -5,15 +5,44 @@ import { AlfredpayOnChainCurrency } from "../../services/alfredpay/types"; import { EvmToken } from "../types/evm"; -export const HORIZON_URL = "https://horizon.stellar.org"; -export const STELLAR_EPHEMERAL_STARTING_BALANCE_UNITS = "2.5"; // Amount to send to the new stellar ephemeral account created export const PENDULUM_WSS = "wss://rpc-pendulum.prd.pendulumchain.tech"; export const ASSETHUB_WSS = "wss://dot-rpc.stakeworld.io/assethub"; export const MOONBEAM_WSS = "wss://wss.api.moonbeam.network"; export const WALLETCONNECT_ASSETHUB_ID = "polkadot:68d56f15f85d3136970ec16946040bc1"; export const NABLA_ROUTER = "6gAVVw13mQgzzKk4yEwScMmWiCNyMAunXFJUZonbgKrym81N"; // AssetHub USDC instance -export const NABLA_ROUTER_BASE: `0x${string}` = "0x8EF01C38e3261901e382A66bEbFa35E8B96c750C"; -export const NABLA_QUOTER_BASE: `0x${string}` = "0x2A7989993335b31A3133CDA93bc1a095e7b178Ff"; + +// Nabla on Base has two pool instances. Pools are addressed by their router+quoter pair. +// BRLA pool: handles BRLA<>USDC swaps (BRL on/off-ramp flows). +// EURC pool: handles EURC<>USDC swaps (Mykobo EUR on/off-ramp flows). +export const NABLA_ROUTER_BASE_BRLA: `0x${string}` = "0x8EF01C38e3261901e382A66bEbFa35E8B96c750C"; +export const NABLA_QUOTER_BASE_BRLA: `0x${string}` = "0x2A7989993335b31A3133CDA93bc1a095e7b178Ff"; +export const NABLA_ROUTER_BASE_EURC: `0x${string}` = "0x58E5Cb2dA15f01CB8FAefef202aa25238efCBdcf"; +export const NABLA_QUOTER_BASE_EURC: `0x${string}` = "0x94C2F795358170a92271bF2490a56135E3fBA58A"; + +/** + * Selects the Nabla pool (router + quoter) on Base for a given Base ERC20 token pair. + * The token pair determines the pool unambiguously: any pair involving EURC uses the EURC pool; + * any pair involving BRLA uses the BRLA pool. USDC appears in both pools. + * + * Throws if the pair is not supported by either pool (caller bug — token pair was not validated upstream). + */ +export function getNablaBasePool( + inputTokenAddress: `0x${string}`, + outputTokenAddress: `0x${string}` +): { router: `0x${string}`; quoter: `0x${string}` } { + const lcInput = inputTokenAddress.toLowerCase(); + const lcOutput = outputTokenAddress.toLowerCase(); + const lcEurc = ERC20_EURC_BASE.toLowerCase(); + const lcBrla = ERC20_BRLA_BASE.toLowerCase(); + + if (lcInput === lcEurc || lcOutput === lcEurc) { + return { quoter: NABLA_QUOTER_BASE_EURC, router: NABLA_ROUTER_BASE_EURC }; + } + if (lcInput === lcBrla || lcOutput === lcBrla) { + return { quoter: NABLA_QUOTER_BASE_BRLA, router: NABLA_ROUTER_BASE_BRLA }; + } + throw new Error(`getNablaBasePool: no Nabla pool on Base supports the pair ${inputTokenAddress} -> ${outputTokenAddress}`); +} export const SPACEWALK_REDEEM_SAFETY_MARGIN = 0.05; export const AMM_MINIMUM_OUTPUT_SOFT_MARGIN = 0.02; @@ -22,20 +51,19 @@ export const AMM_MINIMUM_OUTPUT_HARD_MARGIN = 0.05; export const TRANSFER_WAITING_TIME_SECONDS = 6000; export const DEFAULT_LOGIN_EXPIRATION_TIME_HOURS = 7 * 24; -// Constants relevant for the Monerium ramps -export const ERC20_EURE_POLYGON_V1: `0x${string}` = "0x18ec0A6E18E5bc3784fDd3a3634b31245ab704F6"; // EUR.e on Polygon export const ERC20_USDC_POLYGON: `0x${string}` = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359"; // USDC on Polygon export const ERC20_USDT_POLYGON: `0x${string}` = "0xc2132d05d31c914a87c6611c10748aeb04b58e8f"; // USDT on Polygon -// We are currently using both V1 and V2 addresses for EUR.e on Polygon, as Squidrouter uses V1 (presumably, due to pools). -// V2 is used for the permit - transferFrom flow. -// The token balances are synced between both contracts. -export const ERC20_EURE_POLYGON_V2: `0x${string}` = "0xE0aEa583266584DafBB3f9C3211d5588c73fEa8d"; // EUR.e on Polygon V2 -export const ERC20_EURE_POLYGON_TOKEN_NAME = "Monerium EURe"; -export const ERC20_EURE_POLYGON_DECIMALS = 18; // EUR.e on Polygon has 18 decimals export const ERC20_USDC_POLYGON_DECIMALS = 6; // USDC on Polygon has 6 decimals export const ERC20_USDT_POLYGON_DECIMALS = 6; // USDT on Polygon has 6 decimals +export const ERC20_EURC_BASE: `0x${string}` = "0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42"; +export const ERC20_EURC_BASE_TOKEN_NAME = "EURC"; +export const ERC20_EURC_BASE_DECIMALS = 6; +export const ERC20_BRLA_BASE: `0x${string}` = "0xfCB34c47f850f452C15EA1B84d51231C38A61783"; +export const BASE_CHAIN_ID = 8453; +export const BASE_SEPOLIA_CHAIN_ID = 84532; + // ── AlfredPay on-chain token configuration ────────────────────────────────── // Change these constants to switch the stablecoin used in all AlfredPay flows. export const ALFREDPAY_ONCHAIN_CURRENCY = AlfredpayOnChainCurrency.USDT; diff --git a/packages/shared/src/tokens/evm/config.ts b/packages/shared/src/tokens/evm/config.ts index f2ea1c83a..3b6631e53 100644 --- a/packages/shared/src/tokens/evm/config.ts +++ b/packages/shared/src/tokens/evm/config.ts @@ -3,6 +3,7 @@ */ import { EvmNetworks, Networks } from "../../helpers"; +import { ERC20_EURC_BASE, ERC20_EURC_BASE_DECIMALS } from "../constants/misc"; import { PENDULUM_USDC_AXL } from "../pendulum/config"; import { TokenType } from "../types/base"; import { EvmToken, EvmTokenDetails } from "../types/evm"; @@ -216,6 +217,15 @@ export const evmTokenConfig: Record> = { + [FiatToken.EURC]: { + assetSymbol: "EURC", + decimals: 6, + fiat: { + assetIcon: "eur", + name: "Euro", + symbol: "EUR" + }, + maxBuyAmountRaw: "10000000000", + maxSellAmountRaw: "10000000000", + minBuyAmountRaw: "1000000", + minSellAmountRaw: "25000000", + type: TokenType.Fiat + }, [FiatToken.USD]: { alfredpayLimits: USD_LIMITS, assetSymbol: "USD", diff --git a/packages/shared/src/tokens/index.ts b/packages/shared/src/tokens/index.ts index addec39cb..d6cb2515d 100644 --- a/packages/shared/src/tokens/index.ts +++ b/packages/shared/src/tokens/index.ts @@ -13,7 +13,6 @@ export * from "./evm/dynamicEvmTokens"; export * from "./freeTokens/config"; export * from "./moonbeam/config"; export * from "./pendulum/config"; -export * from "./stellar/config"; // TokenConfig export * from "./tokenConfig"; export * from "./types/assethub"; @@ -22,7 +21,6 @@ export * from "./types/base"; export * from "./types/evm"; export * from "./types/moonbeam"; export * from "./types/pendulum"; -export * from "./types/stellar"; export * from "./utils/helpers"; export * from "./utils/normalization"; // Utils diff --git a/packages/shared/src/tokens/stellar/config.ts b/packages/shared/src/tokens/stellar/config.ts deleted file mode 100644 index c174e6726..000000000 --- a/packages/shared/src/tokens/stellar/config.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Stellar token configuration - */ - -import { getTomlFileUrl } from "../tokenConfig"; -import { FiatToken, TokenType } from "../types/base"; -import { StellarTokenDetails } from "../types/stellar"; - -export const stellarTokenConfig: Partial> = { - [FiatToken.EURC]: { - anchorHomepageUrl: "https://mykobo.co", - assetSymbol: "EURC", - decimals: 12, - fiat: { - assetIcon: "eur", - name: "Euro", - symbol: "EUR" - }, - maxBuyAmountRaw: "10000000000000000", - maxSellAmountRaw: "10000000000000000", - minBuyAmountRaw: "1000000000000", - minSellAmountRaw: "25000000000000", - pendulumRepresentative: { - assetSymbol: "EURC", - currency: FiatToken.EURC, - currencyId: { - Stellar: { - AlphaNum4: { - code: "0x45555243", - issuer: "0xcf4f5a26e2090bb3adcf02c7a9d73dbfe6659cc690461475b86437fa49c71136" - } - } - }, - decimals: 12, - erc20WrapperAddress: "6eNUvRWCKE3kejoyrJTXiSM7NxtWi37eRXTnKhGKPsJevAj5" - }, - stellarAsset: { - code: { - hex: "0x45555243", - string: "EURC" - }, - issuer: { - hex: "0xcf4f5a26e2090bb3adcf02c7a9d73dbfe6659cc690461475b86437fa49c71136", - stellarEncoding: "GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2" - } - }, - supportsClientDomain: true, - tomlFileUrl: getTomlFileUrl("EURC"), - type: TokenType.Stellar, - usesMemo: false, - vaultAccountId: "6dgJM1ijyHFEfzUokJ1AHq3z3R3Z8ouc8B5SL9YjMRUaLsjh" - }, - [FiatToken.ARS]: { - anchorHomepageUrl: "https://home.anclap.com", - assetSymbol: "ARS", - decimals: 12, - fiat: { - assetIcon: "ars", - name: "Argentine Peso", - symbol: "ARS" - }, - maxBuyAmountRaw: "500000000000000000", - maxSellAmountRaw: "500000000000000000", - minBuyAmountRaw: "11000000000000", - minSellAmountRaw: "11000000000000", - pendulumRepresentative: { - assetSymbol: "ARS", - currency: FiatToken.ARS, - currencyId: { - Stellar: { - AlphaNum4: { - code: "0x41525300", - issuer: "0xb04f8bff207a0b001aec7b7659a8d106e54e659cdf9533528f468e079628fba1" - } - } - }, - decimals: 12, - erc20WrapperAddress: "6f7VMG1ERxpZMvFE2CbdWb7phxDgnoXrdornbV3CCd51nFsj" - }, - stellarAsset: { - code: { - hex: "0x41525300", - string: "ARS" - }, - issuer: { - hex: "0xb04f8bff207a0b001aec7b7659a8d106e54e659cdf9533528f468e079628fba1", - stellarEncoding: "GCYE7C77EB5AWAA25R5XMWNI2EDOKTTFTTPZKM2SR5DI4B4WFD52DARS" - } - }, // 11 ARS - supportsClientDomain: true, // 500000 ARS - tomlFileUrl: getTomlFileUrl("ARS"), // 2% - type: TokenType.Stellar, // 10 ARS - usesMemo: true, - vaultAccountId: "6bE2vjpLRkRNoVDqDtzokxE34QdSJC2fz7c87R9yCVFFDNWs" - } -}; diff --git a/packages/shared/src/tokens/tokenConfig.ts b/packages/shared/src/tokens/tokenConfig.ts index b548f73dc..850181f85 100644 --- a/packages/shared/src/tokens/tokenConfig.ts +++ b/packages/shared/src/tokens/tokenConfig.ts @@ -1,26 +1,4 @@ -// TODO we may now de-duplicate this and use StellarTokenDetails from token configs. - -import { isSandboxEnabled } from "../helpers/environment"; - -export interface StellarTokenConfig { - assetCode: string; - assetIssuer: string; - clientDomainEnabled: boolean; - homeDomain: string; - maximumSubsidyAmountRaw: string; - memoEnabled: boolean; - minWithdrawalAmount: string; - pendulumCurrencyId: { - Stellar: { - AlphaNum4: { - code: string; - issuer: string; - }; - }; - }; - tomlFileUrl: string; - vaultAccountId: string; -} +// TODO we may now de-duplicate this and use token details from token configs. export interface XCMTokenConfig { decimals: number; @@ -30,82 +8,23 @@ export interface XCMTokenConfig { export type MoonbeamTokenConfig = XCMTokenConfig; -export function isStellarTokenConfig(config: StellarTokenConfig | XCMTokenConfig): config is StellarTokenConfig { - return ( - "assetCode" in config && - "assetIssuer" in config && - "clientDomainEnabled" in config && - "homeDomain" in config && - "maximumSubsidyAmountRaw" in config && - "memoEnabled" in config && - "minWithdrawalAmount" in config && - "pendulumCurrencyId" in config && - "tomlFileUrl" in config && - "vaultAccountId" in config - ); -} - -export function isXCMTokenConfig(config: StellarTokenConfig | XCMTokenConfig): config is XCMTokenConfig { +export function isXCMTokenConfig(config: XCMTokenConfig): config is XCMTokenConfig { return "decimals" in config && "maximumSubsidyAmountRaw" in config && "pendulumCurrencyId" in config; } export type TokenConfig = { - ARS: StellarTokenConfig; - EURC: StellarTokenConfig; BRL: MoonbeamTokenConfig; USDC: XCMTokenConfig; GLMR: XCMTokenConfig; "USDC.AXL": XCMTokenConfig; }; -const EURC: StellarTokenConfig = { - assetCode: "EURC", - assetIssuer: "GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2", - clientDomainEnabled: true, - homeDomain: getHomeDomain("EURC"), - maximumSubsidyAmountRaw: "15000000000000", - memoEnabled: false, // 15 units - minWithdrawalAmount: "25000000000000", - pendulumCurrencyId: { - Stellar: { - AlphaNum4: { - code: "0x45555243", - issuer: "0xcf4f5a26e2090bb3adcf02c7a9d73dbfe6659cc690461475b86437fa49c71136" - } - } - }, - tomlFileUrl: getTomlFileUrl("EURC"), - vaultAccountId: "6bsD97dS8ZyomMmp1DLCnCtx25oABtf19dypQKdZe6FBQXSm" -}; - -const ARS: StellarTokenConfig = { - assetCode: "ARS", - assetIssuer: "GCYE7C77EB5AWAA25R5XMWNI2EDOKTTFTTPZKM2SR5DI4B4WFD52DARS", - clientDomainEnabled: true, - homeDomain: "api.anclap.com", - maximumSubsidyAmountRaw: "6000000000000000", // 11 ARS. Anchor minimum limit. - memoEnabled: true, // Defined by us: 6000 unit ~ 6 USD @ Jan/2025 - minWithdrawalAmount: "11000000000000", - pendulumCurrencyId: { - Stellar: { - AlphaNum4: { - code: "0x41525300", - issuer: "0xb04f8bff207a0b001aec7b7659a8d106e54e659cdf9533528f468e079628fba1" - } - } - }, - tomlFileUrl: getTomlFileUrl("ARS"), - vaultAccountId: "6bE2vjpLRkRNoVDqDtzokxE34QdSJC2fz7c87R9yCVFFDNWs" -}; - export const TOKEN_CONFIG: TokenConfig = { - ARS, BRL: { decimals: 18, maximumSubsidyAmountRaw: "86000000000000000000", // 86 units = 15 usd @ Mar/2025 pendulumCurrencyId: { XCM: 13 } }, - EURC, GLMR: { decimals: 18, maximumSubsidyAmountRaw: "0", // This definition is not used to subsidize swaps. @@ -123,13 +42,10 @@ export const TOKEN_CONFIG: TokenConfig = { } }; -export function getTokenConfigByAssetCode( - config: TokenConfig, - assetCode: string -): StellarTokenConfig | XCMTokenConfig | undefined { +export function getTokenConfigByAssetCode(config: TokenConfig, assetCode: string): XCMTokenConfig | undefined { for (const key in config) { const token = config[key as keyof TokenConfig]; - if ("assetCode" in token && token.assetCode === assetCode) { + if (key === assetCode) { return token; } } @@ -139,27 +55,3 @@ export function getTokenConfigByAssetCode( export function getPaddedAssetCode(assetCode: string): string { return assetCode.padEnd(4, "\0"); } - -export function getHomeDomain(assetCode: string): string { - switch (assetCode) { - case "EURC": - return isSandboxEnabled() ? "dev.stellar.mykobo.co" : "stellar.mykobo.co"; - case "ARS": - return "api.anclap.com"; - default: - throw new Error(`Home domain not configured for asset: ${assetCode}`); - } -} - -export function getTomlFileUrl(assetCode: string): string { - switch (assetCode) { - case "EURC": - return isSandboxEnabled() - ? "https://dev.stellar.mykobo.co/.well-known/stellar.toml" - : "https://stellar.mykobo.co/.well-known/stellar.toml"; - case "ARS": - return "https://api.anclap.com/.well-known/stellar.toml"; - default: - throw new Error(`TOML file URL not configured for asset: ${assetCode}`); - } -} diff --git a/packages/shared/src/tokens/types/base.ts b/packages/shared/src/tokens/types/base.ts index 126e20322..8f10b03a6 100644 --- a/packages/shared/src/tokens/types/base.ts +++ b/packages/shared/src/tokens/types/base.ts @@ -3,7 +3,6 @@ import { EvmToken } from "./evm"; export enum TokenType { Evm = "evm", AssetHub = "assethub", - Stellar = "stellar", Moonbeam = "moonbeam", Fiat = "fiat" } @@ -31,7 +30,7 @@ export type NablaToken = OnChainToken; // Combines fiat currencies with tokens in one type export type RampCurrency = FiatToken | OnChainToken; -export type PendulumCurrencyId = { Stellar: { AlphaNum4: { code: string; issuer: string } } } | { XCM: number }; +export type PendulumCurrencyId = { XCM: number }; export interface BaseTokenDetails { type: TokenType; diff --git a/packages/shared/src/tokens/types/evm.ts b/packages/shared/src/tokens/types/evm.ts index e0f2fda4a..9218ca8b6 100644 --- a/packages/shared/src/tokens/types/evm.ts +++ b/packages/shared/src/tokens/types/evm.ts @@ -12,7 +12,8 @@ export enum EvmToken { USDT = "USDT", USDCE = "USDC.E", ETH = "ETH", - BRLA = "BRLA" + BRLA = "BRLA", + EURC = "EURC" } export enum UsdLikeEvmToken { diff --git a/packages/shared/src/tokens/types/stellar.ts b/packages/shared/src/tokens/types/stellar.ts deleted file mode 100644 index ca683e122..000000000 --- a/packages/shared/src/tokens/types/stellar.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Stellar token types - */ - -import { PendulumTokenDetails } from "../types/pendulum"; -import { BaseFiatTokenDetails, BaseTokenDetails, TokenType } from "./base"; - -export interface StellarTokenDetails extends BaseTokenDetails, BaseFiatTokenDetails { - type: TokenType.Stellar; - stellarAsset: { - code: { - hex: string; - string: string; // Stellar (3 or 4 letter) representation - }; - issuer: { - hex: string; - stellarEncoding: string; - }; - }; - vaultAccountId: string; - supportsClientDomain: boolean; - anchorHomepageUrl: string; - tomlFileUrl: string; - usesMemo: boolean; - pendulumRepresentative: PendulumTokenDetails; -} diff --git a/packages/shared/src/tokens/utils/helpers.test.ts b/packages/shared/src/tokens/utils/helpers.test.ts deleted file mode 100644 index e9ad191a7..000000000 --- a/packages/shared/src/tokens/utils/helpers.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { Networks } from "../../helpers"; -import { - ERC20_EURE_POLYGON_DECIMALS, - ERC20_EURE_POLYGON_TOKEN_NAME, - ERC20_EURE_POLYGON_V1, - ERC20_EURE_POLYGON_V2 -} from "../constants/misc"; -import { evmTokenConfig } from "../evm/config"; -import { EvmToken } from "../types/evm"; -import { getEvmTokenDetailsByAddress } from "./helpers"; - -describe("getEvmTokenDetailsByAddress", () => { - it("resolves configured EVM tokens by contract address", () => { - const polygonUsdc = evmTokenConfig[Networks.Polygon][EvmToken.USDC]; - expect(polygonUsdc).toBeDefined(); - if (!polygonUsdc) { - throw new Error("Polygon USDC test fixture is missing"); - } - - const tokenDetails = getEvmTokenDetailsByAddress(Networks.Polygon, polygonUsdc.erc20AddressSourceChain); - - expect(tokenDetails).toEqual(polygonUsdc); - }); - - it("resolves Monerium EUR.e Polygon contracts by address", () => { - for (const tokenAddress of [ERC20_EURE_POLYGON_V1, ERC20_EURE_POLYGON_V2]) { - const tokenDetails = getEvmTokenDetailsByAddress(Networks.Polygon, tokenAddress); - - expect(tokenDetails?.assetSymbol).toBe(ERC20_EURE_POLYGON_TOKEN_NAME); - expect(tokenDetails?.decimals).toBe(ERC20_EURE_POLYGON_DECIMALS); - expect(tokenDetails?.erc20AddressSourceChain).toBe(tokenAddress); - expect(tokenDetails?.network).toBe(Networks.Polygon); - } - }); -}); diff --git a/packages/shared/src/tokens/utils/helpers.ts b/packages/shared/src/tokens/utils/helpers.ts index 202e2759d..8cf75c120 100644 --- a/packages/shared/src/tokens/utils/helpers.ts +++ b/packages/shared/src/tokens/utils/helpers.ts @@ -5,28 +5,17 @@ import { EvmNetworks, isNetworkEVM, Networks } from "../../helpers"; import logger from "../../logger"; import { assetHubTokenConfig } from "../assethub/config"; -import { - ERC20_EURE_POLYGON_DECIMALS, - ERC20_EURE_POLYGON_TOKEN_NAME, - ERC20_EURE_POLYGON_V1, - ERC20_EURE_POLYGON_V2 -} from "../constants/misc"; import { evmTokenConfig } from "../evm/config"; import { getEvmTokenConfig } from "../evm/dynamicEvmTokens"; import { freeTokenConfig } from "../freeTokens/config"; import { moonbeamTokenConfig } from "../moonbeam/config"; -import { PENDULUM_USDC_AXL } from "../pendulum/config"; -import { stellarTokenConfig } from "../stellar/config"; import { AssetHubToken, FiatToken, OnChainToken, OnChainTokenSymbol, RampCurrency, TokenType } from "../types/base"; import { EvmToken, EvmTokenDetails } from "../types/evm"; import { MoonbeamTokenDetails } from "../types/moonbeam"; import { PendulumTokenDetails } from "../types/pendulum"; -import { StellarTokenDetails } from "../types/stellar"; import { normalizeTokenSymbol } from "./normalization"; import { FiatTokenDetails, OnChainTokenDetails } from "./typeGuards"; -const MONERIUM_EURE_POLYGON_ADDRESSES = new Set([ERC20_EURE_POLYGON_V1.toLowerCase(), ERC20_EURE_POLYGON_V2.toLowerCase()]); - /** * Get token details for a specific network and token */ @@ -56,40 +45,6 @@ export function getOnChainTokenDetails( } } -/** - * Resolve an EVM token by contract address on a specific network. - */ -export function getEvmTokenDetailsByAddress( - network: EvmNetworks, - tokenAddress: `0x${string}`, - dynamicEvmTokenConfig?: Record>> -): EvmTokenDetails | undefined { - const normalizedTokenAddress = tokenAddress.toLowerCase(); - const configToUse = dynamicEvmTokenConfig ?? getEvmTokenConfig(); - - const configuredToken = Object.values(configToUse[network] ?? {}).find( - (token): token is EvmTokenDetails => - token !== undefined && token.erc20AddressSourceChain.toLowerCase() === normalizedTokenAddress - ); - if (configuredToken) { - return configuredToken; - } - - if (network === Networks.Polygon && MONERIUM_EURE_POLYGON_ADDRESSES.has(normalizedTokenAddress)) { - return { - assetSymbol: ERC20_EURE_POLYGON_TOKEN_NAME, - decimals: ERC20_EURE_POLYGON_DECIMALS, - erc20AddressSourceChain: tokenAddress, - isNative: false, - network: Networks.Polygon, - pendulumRepresentative: PENDULUM_USDC_AXL, - type: TokenType.Evm - }; - } - - return undefined; -} - /** * Get token details for a specific network and token, with fallback to default */ @@ -128,18 +83,6 @@ export function getOnChainTokenDetailsOrDefault( } } -/** - * Get Stellar token details for a specific fiat token - */ -export function getTokenDetailsSpacewalk(fiatToken: FiatToken): StellarTokenDetails { - const maybeOutputTokenDetails = stellarTokenConfig[fiatToken]; - - if (maybeOutputTokenDetails) { - return maybeOutputTokenDetails; - } - throw new Error(`Invalid fiat token type: ${fiatToken}. Token type is not Stellar.`); -} - /** * Get Moonbeam token details for a specific fiat token */ @@ -153,12 +96,12 @@ export function getAnyFiatTokenDetailsMoonbeam(fiatToken: FiatToken): MoonbeamTo } /** - * Get any fiat token details (Stellar or Moonbeam) + * Get any fiat token details (Moonbeam or free token) */ export function getAnyFiatTokenDetails(fiatToken: FiatToken): FiatTokenDetails { - const tokenDetails = stellarTokenConfig[fiatToken] || moonbeamTokenConfig[fiatToken] || freeTokenConfig[fiatToken]; + const tokenDetails = moonbeamTokenConfig[fiatToken] || freeTokenConfig[fiatToken]; if (!tokenDetails) { - throw new Error(`Invalid fiat token type: ${fiatToken}. Token type is not Stellar or Moonbeam.`); + throw new Error(`Invalid fiat token type: ${fiatToken}. Token is not found in Moonbeam or free token configs.`); } return tokenDetails; } diff --git a/packages/shared/src/tokens/utils/typeGuards.ts b/packages/shared/src/tokens/utils/typeGuards.ts index 3f316fdc6..ed75e69e5 100644 --- a/packages/shared/src/tokens/utils/typeGuards.ts +++ b/packages/shared/src/tokens/utils/typeGuards.ts @@ -6,16 +6,10 @@ import { AssetHubTokenDetails } from "../types/assethub"; import { AssetHubToken, FiatCurrencyDetails, FiatToken, OnChainToken, TokenType } from "../types/base"; import { EvmToken, EvmTokenDetails } from "../types/evm"; import { MoonbeamTokenDetails } from "../types/moonbeam"; -import { StellarTokenDetails } from "../types/stellar"; import { normalizeTokenSymbol } from "./normalization"; -export type TokenDetails = - | EvmTokenDetails - | AssetHubTokenDetails - | StellarTokenDetails - | MoonbeamTokenDetails - | FiatCurrencyDetails; +export type TokenDetails = EvmTokenDetails | AssetHubTokenDetails | MoonbeamTokenDetails | FiatCurrencyDetails; export type OnChainTokenDetails = EvmTokenDetails | AssetHubTokenDetails; -export type FiatTokenDetails = StellarTokenDetails | MoonbeamTokenDetails | FiatCurrencyDetails; +export type FiatTokenDetails = MoonbeamTokenDetails | FiatCurrencyDetails; export type OnChainTokenDetailsWithBalance = OnChainTokenDetails & { balance: string; @@ -36,13 +30,6 @@ export function isAssetHubTokenDetails(token: TokenDetails): token is AssetHubTo return token.type === TokenType.AssetHub; } -/** - * Type guard for Stellar tokens - */ -export function isStellarTokenDetails(token: TokenDetails): token is StellarTokenDetails { - return token.type === TokenType.Stellar; -} - /** * Type guard for Moonbeam tokens */ @@ -65,21 +52,14 @@ export function isOnChainTokenDetails(token: TokenDetails): token is OnChainToke * Type guard for fiat tokens */ export function isFiatTokenDetails(token: TokenDetails): token is FiatTokenDetails { - return isStellarTokenDetails(token) || isMoonbeamTokenDetails(token) || isFiatCurrencyDetails(token); -} - -/** - * Type guard for Stellar output token details - */ -export function isStellarOutputTokenDetails(tokenDetails: Partial): tokenDetails is StellarTokenDetails { - return tokenDetails.type === TokenType.Stellar; + return isMoonbeamTokenDetails(token) || isFiatCurrencyDetails(token); } /** * Type guard for Moonbeam output token details */ export function isMoonbeamOutputTokenDetails( - outputTokenDetails: StellarTokenDetails | MoonbeamTokenDetails + outputTokenDetails: MoonbeamTokenDetails ): outputTokenDetails is MoonbeamTokenDetails { return outputTokenDetails.type === TokenType.Moonbeam; }