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 index ad97c5de7..ad1862dd5 100644 --- 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 @@ -1,9 +1,39 @@ -import { describe, expect, it, mock } from "bun:test"; +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"; +import {Keyring} from "@polkadot/api"; +import {mnemonicGenerate} from "@polkadot/util-crypto"; +import httpStatus from "http-status"; +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 {Transaction, UpdateOptions} from "sequelize"; +import {config} from "../../../config/vars"; +import QuoteTicket, {QuoteTicketAttributes, QuoteTicketCreationAttributes} from "../../../models/quoteTicket.model"; +import RampState, {RampStateAttributes, RampStateCreationAttributes} from "../../../models/rampState.model"; +import {APIError} from "../../errors/api-error"; +import RampRecoveryWorker from "../../workers/ramp-recovery.worker"; +import {QuoteService} from "../quote"; +import {RampService} from "../ramp/ramp.service"; // 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. @@ -28,35 +58,6 @@ mock.module("../quote/core/nabla", () => { } }; }); -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"; @@ -265,21 +266,35 @@ describe("Mykobo EUR offramp contract test (real sandbox, no on-chain submission 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({ + it("rejects Base USDC->EUR offramp registration while EUR ramps are disabled", async () => { + const rampService = new RampService() as RampService & { + withTransaction: (callback: (transaction: Transaction) => Promise) => Promise; + }; + rampService.withTransaction = async callback => callback({} as Transaction); + + quoteTicket = { + apiKey: null, + countryCode: null, + createdAt: new Date(), + expiresAt: new Date(Date.now() + 10 * 60 * 1000), + flowVariant: config.flowVariant, from: Networks.Base as DestinationType, + id: "test-disabled-eur-offramp-quote-id", inputAmount: TEST_INPUT_AMOUNT, inputCurrency: EvmToken.USDC, + metadata: {}, network: Networks.Base, + outputAmount: "32.20", outputCurrency: FiatToken.EURC, + partnerId: null, + paymentMethod: EPaymentMethod.SEPA, + pricingPartnerId: null, rampType: RampDirection.SELL, - to: EPaymentMethod.SEPA as DestinationType - }); + status: "pending", + to: EPaymentMethod.SEPA as DestinationType, + updatedAt: new Date(), + userId: null + } as QuoteTicket; const additionalData: RegisterRampRequest["additionalData"] = { destinationAddress: EVM_DESTINATION_ADDRESS, @@ -288,44 +303,17 @@ describe("Mykobo EUR offramp contract test (real sandbox, no on-chain submission 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"); + try { + await rampService.registerRamp({ + additionalData, + quoteId: quoteTicket.id, + signingAccounts: testSigningAccountsMeta + }); + throw new Error("expected rejection"); + } catch (err) { + expect(err).toBeInstanceOf(APIError); + expect((err as APIError).status).toBe(httpStatus.SERVICE_UNAVAILABLE); + expect((err as APIError).message).toBe("EUR ramps are currently disabled"); } - - 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 index c8aa982b8..c0dc60ea8 100644 --- 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 @@ -1,9 +1,39 @@ -import { describe, expect, it, mock } from "bun:test"; +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"; +import {Keyring} from "@polkadot/api"; +import {mnemonicGenerate} from "@polkadot/util-crypto"; +import httpStatus from "http-status"; +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 {Transaction, UpdateOptions} from "sequelize"; +import {config} from "../../../config/vars"; +import QuoteTicket, {QuoteTicketAttributes, QuoteTicketCreationAttributes} from "../../../models/quoteTicket.model"; +import RampState, {RampStateAttributes, RampStateCreationAttributes} from "../../../models/rampState.model"; +import {APIError} from "../../errors/api-error"; +import RampRecoveryWorker from "../../workers/ramp-recovery.worker"; +import {QuoteService} from "../quote"; +import {RampService} from "../ramp/ramp.service"; // 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. @@ -28,36 +58,6 @@ mock.module("../quote/core/nabla", () => { } }; }); -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"; @@ -267,21 +267,35 @@ describe("Mykobo EUR onramp contract test (real sandbox, no on-chain submission) 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({ + it("rejects EUR->Base USDC onramp registration while EUR ramps are disabled", async () => { + const rampService = new RampService() as RampService & { + withTransaction: (callback: (transaction: Transaction) => Promise) => Promise; + }; + rampService.withTransaction = async callback => callback({} as Transaction); + + quoteTicket = { + apiKey: null, + countryCode: null, + createdAt: new Date(), + expiresAt: new Date(Date.now() + 10 * 60 * 1000), + flowVariant: config.flowVariant, from: EPaymentMethod.SEPA as DestinationType, + id: "test-disabled-eur-onramp-quote-id", inputAmount: TEST_INPUT_AMOUNT, inputCurrency: FiatToken.EURC, + metadata: {}, network: Networks.Base, + outputAmount: "36.75", outputCurrency: EvmToken.USDC, + partnerId: null, + paymentMethod: EPaymentMethod.SEPA, + pricingPartnerId: null, rampType: RampDirection.BUY, - to: Networks.Base as DestinationType - }); + status: "pending", + to: Networks.Base as DestinationType, + updatedAt: new Date(), + userId: null + } as QuoteTicket; const additionalData: RegisterRampRequest["additionalData"] = { destinationAddress: EVM_DESTINATION_ADDRESS, @@ -290,49 +304,17 @@ describe("Mykobo EUR onramp contract test (real sandbox, no on-chain submission) 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"); + try { + await rampService.registerRamp({ + additionalData, + quoteId: quoteTicket.id, + signingAccounts: testSigningAccountsMeta + }); + throw new Error("expected rejection"); + } catch (err) { + expect(err).toBeInstanceOf(APIError); + expect((err as APIError).status).toBe(httpStatus.SERVICE_UNAVAILABLE); + expect((err as APIError).message).toBe("EUR ramps are currently disabled"); } - - 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/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index fbdbfcb74..f87e61edd 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -199,6 +199,13 @@ export class RampService extends BaseRampService { }); } + if (quote.inputCurrency === FiatToken.EURC || quote.outputCurrency === FiatToken.EURC) { + throw new APIError({ + message: "EUR ramps are currently disabled", + status: httpStatus.SERVICE_UNAVAILABLE + }); + } + const { normalizedSigningAccounts, ephemerals } = normalizeAndValidateSigningAccounts(signingAccounts); await validateEphemeralAccountsFresh(ephemerals); diff --git a/apps/frontend/src/components/QuoteSubmitButtons/index.tsx b/apps/frontend/src/components/QuoteSubmitButtons/index.tsx index bc9e0b1ed..938d39ef6 100644 --- a/apps/frontend/src/components/QuoteSubmitButtons/index.tsx +++ b/apps/frontend/src/components/QuoteSubmitButtons/index.tsx @@ -1,6 +1,7 @@ +import { InformationCircleIcon } from "@heroicons/react/24/outline"; import { useAppKitAccount } from "@reown/appkit/react"; import { useParams, useRouter } from "@tanstack/react-router"; -import { isNetworkEVM, RampDirection } from "@vortexfi/shared"; +import { FiatToken, isNetworkEVM, RampDirection } from "@vortexfi/shared"; import Big from "big.js"; import { FC, useMemo } from "react"; import { useTranslation } from "react-i18next"; @@ -9,7 +10,7 @@ import { usePolkadotWalletState } from "../../contexts/polkadotWallet"; import { useRampActor } from "../../contexts/rampState"; import { useRampValidation } from "../../hooks/ramp/useRampValidation"; import { useMaintenanceAwareButton } from "../../hooks/useMaintenanceAware"; -import { useInputAmount } from "../../stores/quote/useQuoteFormStore"; +import { useFiatToken, useInputAmount } from "../../stores/quote/useQuoteFormStore"; import { useQuoteStore } from "../../stores/quote/useQuoteStore"; import { useRampDirection } from "../../stores/rampDirectionStore"; import { ConnectWalletButton } from "../buttons/ConnectWalletButton"; @@ -63,6 +64,7 @@ export const QuoteSubmitButton: FC = ({ className, disab const rampActor = useRampActor(); const router = useRouter(); const params = useParams({ strict: false }); + const fiatToken = useFiatToken(); const currentErrorMessage = getCurrentErrorMessage(); @@ -73,7 +75,8 @@ export const QuoteSubmitButton: FC = ({ className, disab const hasInputAmount = Boolean(inputAmount) && !Big(inputAmount).eq(0); const isQuoteOutdated = (!!quoteInputAmount && !!inputAmount && !Big(quoteInputAmount).eq(Big(inputAmount))) || quote?.rampType !== rampDirection; - const isSubmitButtonDisabled = disabled || Boolean(currentErrorMessage) || !quote || isQuoteOutdated; + const isEurDisabled = fiatToken === FiatToken.EURC; + const isSubmitButtonDisabled = disabled || Boolean(currentErrorMessage) || !quote || isQuoteOutdated || isEurDisabled; const showSpinner = hasInputAmount && (isQuoteOutdated || pending) && !currentErrorMessage; const { buttonProps, isMaintenanceDisabled } = useMaintenanceAwareButton(isSubmitButtonDisabled || pending); @@ -106,6 +109,14 @@ export const QuoteSubmitButton: FC = ({ className, disab return (
+ {isEurDisabled && ( +
+
+ +

{t("pages.widget.details.eurRampUnavailable")}

+
+
+ )}