From b91f4ca580015aa1bd0d81beacd26acce2a679c8 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 7 Jul 2026 10:25:37 +0200 Subject: [PATCH 1/5] feat(shared): add AnchorTemporarilyUnavailable quote error Distinct quote error for when a fiat anchor's fee lookup is unavailable, so an anchor outage is no longer collapsed into the generic FailedToCalculateQuote. --- packages/shared/src/endpoints/quote.endpoints.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/shared/src/endpoints/quote.endpoints.ts b/packages/shared/src/endpoints/quote.endpoints.ts index 616182c81..0c9e21cb8 100644 --- a/packages/shared/src/endpoints/quote.endpoints.ts +++ b/packages/shared/src/endpoints/quote.endpoints.ts @@ -115,6 +115,7 @@ export enum QuoteError { // Availability errors UnsupportedCurrency = "Currency not supported", + AnchorTemporarilyUnavailable = "This payment provider is temporarily unavailable. Please try again in a few minutes.", // Compatibility errors AssetHubNotSupportedForAlfredPay = "AssetHub is not supported for this currency. Please select a different network.", From def11404f92c832c2b73d427a5a43fbdaffe9fd6 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 7 Jul 2026 10:25:52 +0200 Subject: [PATCH 2/5] feat(api): Mykobo fee display fallback with distinct outage error Route EUR quote fee lookups through resolveMykobo{Deposit,Withdraw}Fee. On a /fees outage: if MYKOBO_FEE_FALLBACK_ENABLED, return the configured flat MYKOBO_FALLBACK_{DEPOSIT,WITHDRAW}_FEE so EUR quotes still render (display only); otherwise throw MykoboFeeUnavailableError, which QuoteService maps to AnchorTemporarilyUnavailable (503). Fallback values are validated at boot. The fallback is display-only and never prices a ramp execution: EUR ramp registration remains blocked by the existing kill-switch. --- .../quote/engines/fee/offramp-mykobo.ts | 7 ++- .../quote/engines/initialize/onramp-mykobo.ts | 15 ++--- .../services/quote/engines/mykobo-fee.test.ts | 56 +++++++++++++++++++ .../api/services/quote/engines/mykobo-fee.ts | 44 +++++++++++++++ apps/api/src/api/services/quote/index.ts | 6 ++ apps/api/src/config/vars.ts | 39 +++++++++++++ 6 files changed, 153 insertions(+), 14 deletions(-) create mode 100644 apps/api/src/api/services/quote/engines/mykobo-fee.test.ts create mode 100644 apps/api/src/api/services/quote/engines/mykobo-fee.ts 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 index 73f5aa709..ee4e5405a 100644 --- a/apps/api/src/api/services/quote/engines/fee/offramp-mykobo.ts +++ b/apps/api/src/api/services/quote/engines/fee/offramp-mykobo.ts @@ -1,5 +1,6 @@ -import { EvmToken, FiatToken, MykoboApiService, RampCurrency, RampDirection } from "@vortexfi/shared"; +import { EvmToken, FiatToken, RampCurrency, RampDirection } from "@vortexfi/shared"; import { QuoteContext } from "../../core/types"; +import { resolveMykoboWithdrawFee } from "../mykobo-fee"; import { BaseFeeEngine, FeeComputation, FeeConfig } from "./index"; export class OffRampFeeMykoboEngine extends BaseFeeEngine { @@ -18,11 +19,11 @@ export class OffRampFeeMykoboEngine extends BaseFeeEngine { // biome-ignore lint/style/noNonNullAssertion: validated above const swapOutputEurc = ctx.nablaSwapEvm!.outputAmountDecimal.toFixed(2, 0); - const mykoboFee = await MykoboApiService.getInstance().defaultWithdrawFee(swapOutputEurc); + const mykoboFeeTotal = await resolveMykoboWithdrawFee(swapOutputEurc); const anchorFeeCurrency = FiatToken.EURC as RampCurrency; return { - anchor: { amount: mykoboFee.total, currency: anchorFeeCurrency }, + anchor: { amount: mykoboFeeTotal, currency: anchorFeeCurrency }, network: { amount: "0", currency: EvmToken.USDC as RampCurrency } }; } 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 index e19f692ef..89d645654 100644 --- a/apps/api/src/api/services/quote/engines/initialize/onramp-mykobo.ts +++ b/apps/api/src/api/services/quote/engines/initialize/onramp-mykobo.ts @@ -1,14 +1,7 @@ -import { - EvmToken, - FiatToken, - getOnChainTokenDetails, - MykoboApiService, - multiplyByPowerOfTen, - Networks, - RampDirection -} from "@vortexfi/shared"; +import { EvmToken, FiatToken, getOnChainTokenDetails, multiplyByPowerOfTen, Networks, RampDirection } from "@vortexfi/shared"; import Big from "big.js"; import { QuoteContext } from "../../core/types"; +import { resolveMykoboDepositFee } from "../mykobo-fee"; import { BaseInitializeEngine } from "./index"; export class OnRampInitializeMykoboEngine extends BaseInitializeEngine { @@ -28,8 +21,8 @@ export class OnRampInitializeMykoboEngine extends BaseInitializeEngine { 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 mykoboFeeTotal = await resolveMykoboDepositFee(inputAmountDecimal.toFixed(2, 0)); + const mykoboFeeDecimal = new Big(mykoboFeeTotal); const deliveredEurcDecimal = inputAmountDecimal.minus(mykoboFeeDecimal); if (deliveredEurcDecimal.lte(0)) { diff --git a/apps/api/src/api/services/quote/engines/mykobo-fee.test.ts b/apps/api/src/api/services/quote/engines/mykobo-fee.test.ts new file mode 100644 index 000000000..c3f4d7b0f --- /dev/null +++ b/apps/api/src/api/services/quote/engines/mykobo-fee.test.ts @@ -0,0 +1,56 @@ +import { MykoboApiService } from "@vortexfi/shared"; +import { afterEach, describe, expect, it, mock } from "bun:test"; +import { config } from "../../../../config/vars"; +import { MykoboFeeUnavailableError, resolveMykoboDepositFee, resolveMykoboWithdrawFee } from "./mykobo-fee"; + +describe("resolveMykobo*Fee", () => { + const originalGetInstance = MykoboApiService.getInstance; + const originalFallback = { ...config.mykobo.feeFallback }; + + afterEach(() => { + MykoboApiService.getInstance = originalGetInstance; + config.mykobo.feeFallback = { ...originalFallback }; + }); + + function stubFees(overrides: { deposit?: () => Promise<{ total: string }>; withdraw?: () => Promise<{ total: string }> }) { + MykoboApiService.getInstance = mock(() => ({ + defaultDepositFee: overrides.deposit ?? (async () => ({ total: "0.06" })), + defaultWithdrawFee: overrides.withdraw ?? (async () => ({ total: "0.09" })) + })) as unknown as typeof MykoboApiService.getInstance; + } + + it("returns the live fee total when the lookup succeeds", async () => { + stubFees({ deposit: async () => ({ total: "0.06" }), withdraw: async () => ({ total: "0.09" }) }); + config.mykobo.feeFallback = { depositFee: "1.50", enabled: true, withdrawFee: "2.00" }; + + expect(await resolveMykoboDepositFee("100.00")).toBe("0.06"); + expect(await resolveMykoboWithdrawFee("100.00")).toBe("0.09"); + }); + + it("throws MykoboFeeUnavailableError when the lookup fails and the fallback is disabled", async () => { + const boom = async () => { + throw new Error("Mykobo GET /fees failed: 500"); + }; + stubFees({ deposit: boom, withdraw: boom }); + config.mykobo.feeFallback = { depositFee: undefined, enabled: false, withdrawFee: undefined }; + + const depositError = await resolveMykoboDepositFee("100.00").catch(error => error); + expect(depositError).toBeInstanceOf(MykoboFeeUnavailableError); + expect(depositError.kind).toBe("deposit"); + + const withdrawError = await resolveMykoboWithdrawFee("100.00").catch(error => error); + expect(withdrawError).toBeInstanceOf(MykoboFeeUnavailableError); + expect(withdrawError.kind).toBe("withdraw"); + }); + + it("returns the configured flat fallback fee when the lookup fails and the fallback is enabled", async () => { + const boom = async () => { + throw new Error("Unable to connect"); + }; + stubFees({ deposit: boom, withdraw: boom }); + config.mykobo.feeFallback = { depositFee: "1.50", enabled: true, withdrawFee: "2.00" }; + + expect(await resolveMykoboDepositFee("100.00")).toBe("1.50"); + expect(await resolveMykoboWithdrawFee("100.00")).toBe("2.00"); + }); +}); diff --git a/apps/api/src/api/services/quote/engines/mykobo-fee.ts b/apps/api/src/api/services/quote/engines/mykobo-fee.ts new file mode 100644 index 000000000..a591d0f00 --- /dev/null +++ b/apps/api/src/api/services/quote/engines/mykobo-fee.ts @@ -0,0 +1,44 @@ +import { MykoboApiService } from "@vortexfi/shared"; +import logger from "../../../../config/logger"; +import { config } from "../../../../config/vars"; + +// Thrown when a Mykobo fee lookup fails and no display fallback is configured. +// QuoteService maps this to QuoteError.AnchorTemporarilyUnavailable so the failure +// is distinguishable from a generic quote calculation error. +export class MykoboFeeUnavailableError extends Error { + constructor( + public readonly kind: "deposit" | "withdraw", + options?: { cause?: unknown } + ) { + super(`Mykobo ${kind} fee lookup unavailable`, options); + this.name = "MykoboFeeUnavailableError"; + } +} + +// Returns the flat Mykobo deposit fee (EUR) for the given value, or the configured +// display fallback if the live lookup fails and the fallback is enabled. +export function resolveMykoboDepositFee(value: string): Promise { + return resolveFee("deposit", () => MykoboApiService.getInstance().defaultDepositFee(value)); +} + +// Returns the flat Mykobo withdraw fee (EUR) for the given value, or the configured +// display fallback if the live lookup fails and the fallback is enabled. +export function resolveMykoboWithdrawFee(value: string): Promise { + return resolveFee("withdraw", () => MykoboApiService.getInstance().defaultWithdrawFee(value)); +} + +async function resolveFee(kind: "deposit" | "withdraw", lookup: () => Promise<{ total: string }>): Promise { + try { + const response = await lookup(); + return response.total; + } catch (error) { + const { enabled, depositFee, withdrawFee } = config.mykobo.feeFallback; + const fallback = kind === "deposit" ? depositFee : withdrawFee; + if (enabled && fallback !== undefined) { + const reason = error instanceof Error ? error.message : String(error); + logger.warn(`Mykobo ${kind} fee lookup failed (${reason}); using display fallback fee ${fallback} EUR`); + return fallback; + } + throw new MykoboFeeUnavailableError(kind, { cause: error }); + } +} diff --git a/apps/api/src/api/services/quote/index.ts b/apps/api/src/api/services/quote/index.ts index 094354f82..0c1aa466c 100644 --- a/apps/api/src/api/services/quote/index.ts +++ b/apps/api/src/api/services/quote/index.ts @@ -24,6 +24,7 @@ import { resolveQuotePartner } from "./core/partner-resolution"; import { createQuoteContext } from "./core/quote-context"; import { QuoteOrchestrator } from "./core/quote-orchestrator"; import { buildQuoteResponse } from "./engines/finalize"; +import { MykoboFeeUnavailableError } from "./engines/mykobo-fee"; import { RouteResolver } from "./routes/route-resolver"; type BestQuoteFailure = { @@ -221,6 +222,11 @@ export class QuoteService extends BaseRampService { throw createLowLiquidityQuoteError(); } + // Surface an anchor (Mykobo) outage distinctly instead of the generic quote failure + if (error instanceof MykoboFeeUnavailableError) { + throw new APIError({ message: QuoteError.AnchorTemporarilyUnavailable, status: httpStatus.SERVICE_UNAVAILABLE }); + } + // Detect Alfredpay trade limit error and surface it as a user-facing limit error if (error instanceof AlfredpayTradeLimitError) { throw mapAlfredpayLimitErrorToApiError(error, ctx.request.rampType === RampDirection.BUY); diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index 59c765252..53457ffb2 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -53,6 +53,39 @@ function readFlowVariant(): FlowVariant { return rawFlowVariant as FlowVariant; } +interface MykoboFeeFallback { + enabled: boolean; + depositFee: string | undefined; + withdrawFee: string | undefined; +} + +// Display-only fallback so EUR quotes still render when the Mykobo /fees endpoint is +// down. Never used to execute a ramp: EUR ramp start is guarded separately and re-checks +// the live integration. Both fees are flat EUR amounts and are required when enabled. +function readMykoboFeeFallback(): MykoboFeeFallback { + const enabled = process.env.MYKOBO_FEE_FALLBACK_ENABLED === "true"; + if (!enabled) { + return { depositFee: undefined, enabled: false, withdrawFee: undefined }; + } + return { + depositFee: readNonNegativeDecimalEnv("MYKOBO_FALLBACK_DEPOSIT_FEE"), + enabled: true, + withdrawFee: readNonNegativeDecimalEnv("MYKOBO_FALLBACK_WITHDRAW_FEE") + }; +} + +function readNonNegativeDecimalEnv(name: string): string { + const rawValue = process.env[name]?.trim(); + if (!rawValue) { + throw new Error(`${name} is required when MYKOBO_FEE_FALLBACK_ENABLED=true`); + } + const value = Number(rawValue); + if (!Number.isFinite(value) || value < 0) { + throw new Error(`${name} must be a non-negative number (got '${rawValue}')`); + } + return rawValue; +} + function readFractionEnv(name: string, defaultValue: string): number { const rawValue = process.env[name] ?? defaultValue; const trimmedValue = rawValue.trim(); @@ -119,6 +152,9 @@ interface Config { discountStateTimeoutMinutes: number; deltaDBasisPoints: number; }; + mykobo: { + feeFallback: MykoboFeeFallback; + }; subscanApiKey: string | undefined; vortexFeePenPercentage: number; @@ -178,6 +214,9 @@ export const config: Config = { }, logs: nodeEnv === "production" ? "combined" : "dev", metricsDashboardSecret: process.env.METRICS_DASHBOARD_SECRET || "", + mykobo: { + feeFallback: readMykoboFeeFallback() + }, pendulumWss: process.env.PENDULUM_WSS || "wss://rpc-pendulum.prd.pendulumchain.tech", port: process.env.PORT || 3000, priceProviders: { From ee55bdbe0b455e3137f93e5e9295950969862e32 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 7 Jul 2026 10:26:00 +0200 Subject: [PATCH 3/5] feat(frontend): surface anchor-unavailable quote error Map AnchorTemporarilyUnavailable to a friendly message and add the anchorUnavailable translation (en, pt). --- apps/frontend/src/stores/quote/useQuoteStore.ts | 3 ++- apps/frontend/src/translations/en.json | 1 + apps/frontend/src/translations/pt.json | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/stores/quote/useQuoteStore.ts b/apps/frontend/src/stores/quote/useQuoteStore.ts index 1ea1144ae..09362dc72 100644 --- a/apps/frontend/src/stores/quote/useQuoteStore.ts +++ b/apps/frontend/src/stores/quote/useQuoteStore.ts @@ -74,7 +74,8 @@ const friendlyErrorMessages: Record = { [QuoteError.FailedToCalculateQuote]: "pages.swap.error.tryDifferentAmount", [QuoteError.FailedToCalculatePreNablaDeductibleFees]: "pages.swap.error.tryDifferentAmount", [QuoteError.FailedToCalculateFeeComponents]: "pages.swap.error.tryDifferentAmount", - [QuoteError.UnsupportedCurrency]: "pages.swap.error.unsupportedCurrency" + [QuoteError.UnsupportedCurrency]: "pages.swap.error.unsupportedCurrency", + [QuoteError.AnchorTemporarilyUnavailable]: "pages.swap.error.anchorUnavailable" }; function getFriendlyErrorMessage(error: unknown) { diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index 0cf61d648..fa06c722b 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -1313,6 +1313,7 @@ "sellTooHigh": "{{assetSymbol}} orders must be less than {{maxAmountUnits}} {{assetSymbol}}", "sellTooLow": "{{assetSymbol}} orders must be more than {{minAmountUnits}} {{assetSymbol}}" }, + "anchorUnavailable": "This payment provider is temporarily unavailable. Please try again in a few minutes.", "assetHubNotSupportedForAlfredPay": "AssetHub is not supported for this currency. Please select a different network.", "BRL_tokenUnavailable": "Improving your BRL exit - back shortly! ", "COP_tokenUnavailable": "Building your COP rail - available soon!", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index a584e7279..b5a11d061 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -1318,6 +1318,7 @@ "sellTooHigh": "Pedidos em {{assetSymbol}} devem ser menos de {{maxAmountUnits}} {{assetSymbol}}", "sellTooLow": "Pedidos em {{assetSymbol}} devem ser mais de {{minAmountUnits}} {{assetSymbol}}" }, + "anchorUnavailable": "Este provedor de pagamento está temporariamente indisponível. Tente novamente em alguns minutos.", "assetHubNotSupportedForAlfredPay": "AssetHub não é suportado para esta moeda. Por favor, selecione uma rede diferente.", "BRL_tokenUnavailable": "Ajustando sua saída BRL - em breve!", "COP_tokenUnavailable": "Construa seu trilho COP - disponível em breve!", From 0257e4a0059171c86a3745246e41eb888d233584 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 7 Jul 2026 10:26:11 +0200 Subject: [PATCH 4/5] docs(security-spec): document Mykobo fee fallback; fix invariant numbering Add the display-only fee fallback and outage error distinction to the Mykobo and fee-integrity specs (behavior note, invariant, threat vector, checklist). Fix the duplicate invariant number in mykobo.md (two #23 -> renumber to #26). --- docs/security-spec/03-ramp-engine/fee-integrity.md | 1 + docs/security-spec/05-integrations/mykobo.md | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/security-spec/03-ramp-engine/fee-integrity.md b/docs/security-spec/03-ramp-engine/fee-integrity.md index 40daa7a63..67e222d07 100644 --- a/docs/security-spec/03-ramp-engine/fee-integrity.md +++ b/docs/security-spec/03-ramp-engine/fee-integrity.md @@ -80,6 +80,7 @@ The `distribute-fees-handler.ts` chooses the correct path at runtime based on th - [x] Partner markup payout uses the pricing partner when present. **PASS** — fee distribution resolves payout from `pricing_partner_id ?? partner_id`, preserving profile-assigned quote payouts while keeping older partner-owned quotes compatible. - [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). +- [ ] Mykobo `/fees` outage during quote creation surfaces as `QuoteError.AnchorTemporarilyUnavailable` (`503`), not a generic failure. The optional env-gated display fallback (`MYKOBO_FEE_FALLBACK_ENABLED` → flat `MYKOBO_FALLBACK_DEPOSIT_FEE` / `MYKOBO_FALLBACK_WITHDRAW_FEE`) is **display-only** and MUST NOT price a ramp execution; a fallback-priced quote MUST re-validate the live Mykobo fee before a rail runs (EUR registration is currently disabled). See `05-integrations/mykobo.md` (invariant 26). - [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/05-integrations/mykobo.md b/docs/security-spec/05-integrations/mykobo.md index 70fe946e6..6e12cc678 100644 --- a/docs/security-spec/05-integrations/mykobo.md +++ b/docs/security-spec/05-integrations/mykobo.md @@ -23,6 +23,8 @@ Mykobo replaces two earlier EUR rails: **`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. +**Fee lookup and quote-time display fallback:** EUR quote creation looks up the Mykobo fee live via `GET /fees` — on-ramp through `defaultDepositFee` (`OnRampInitializeMykoboEngine`), off-ramp through `defaultWithdrawFee` (`OffRampFeeMykoboEngine`). Both call sites go through `quote/engines/mykobo-fee.ts`. If the lookup fails or Mykobo is unreachable, the helper throws `MykoboFeeUnavailableError`, which `QuoteService` maps to `QuoteError.AnchorTemporarilyUnavailable` (`503`) instead of the generic `FailedToCalculateQuote` — so a Mykobo outage is distinguishable in logs and to the user. An **optional, env-gated display fallback** (`MYKOBO_FEE_FALLBACK_ENABLED=true` with flat, boot-validated `MYKOBO_FALLBACK_DEPOSIT_FEE` / `MYKOBO_FALLBACK_WITHDRAW_FEE` EUR values) lets EUR quotes still render during a `/fees` outage by returning the configured fee instead of throwing. It is **display-only** — it affects only the fee shown on the quote, never ramp execution — and is flat, so it does not model Mykobo's percentage component; quotes rendered during an outage are approximate. + ### 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). @@ -91,8 +93,9 @@ Unlike Monerium (`moneriumOnrampMint` + `moneriumOnrampSelfTransfer`), Vortex do 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. 23. **EUR ramp registration MUST derive the Mykobo email from the effective user and require approved Mykobo KYC** — Both the on-ramp (`prepareMykoboOnrampTransactions`) and off-ramp (`evm-to-mykobo.ts`) resolve the Mykobo `email_address` via `resolveMykoboCustomerForUser(userId, providedEmail?)`, which (a) reads the canonical email from the effective user's profile (`profiles.email` is unique and keyed by `userId`, so this works for both Supabase sessions and user-scoped secret keys), (b) accepts a client-supplied `additionalData.email` only when it matches the derived value and rejects mismatches with `400`, and (c) refreshes the Mykobo KYC mirror from the live profile and rejects with `400` unless the resulting `MykoboCustomer.status === APPROVED`. The client-supplied email is never passed to Mykobo directly, and the provider intent is created only after the gate passes. This mirrors the BRL/Avenia (`resolveAveniaAccountForUser`) and Alfredpay (`resolveAlfredpayCustomerId`) derivation+KYC pattern. Combined with the universal register-time effective-user requirement (`01-auth/api-keys.md` inv. 13), EUR ramps cannot be registered anonymously or with an unlinked key, and one user cannot drive a ramp against another user's Mykobo identity. -23. **Disabled EUR registration MUST fail before provider side effects** — While EUR ramps are disabled, `registerRamp` MUST reject any quote with `inputCurrency === FiatToken.EURC` or `outputCurrency === FiatToken.EURC` using `503 SERVICE_UNAVAILABLE` before Mykobo intent creation, presigned transaction preparation, quote consumption, or ramp-state creation. -24. **`mykoboPayoutOnBase` MUST verify the ephemeral's EURC balance before the first broadcast** — The presigned payout is single-use (its nonce is consumed even on revert), so the handler calls `ensurePresignedTransferFunded` before `sendRawTransaction`: sender/token/amount are decoded from the signed raw tx and the ephemeral balance is polled (3-minute timeout); a shortfall raises a recoverable error instead of burning the nonce. See `03-ramp-engine/ramp-phase-flows.md` invariant 12. +24. **Disabled EUR registration MUST fail before provider side effects** — While EUR ramps are disabled, `registerRamp` MUST reject any quote with `inputCurrency === FiatToken.EURC` or `outputCurrency === FiatToken.EURC` using `503 SERVICE_UNAVAILABLE` before Mykobo intent creation, presigned transaction preparation, quote consumption, or ramp-state creation. +25. **`mykoboPayoutOnBase` MUST verify the ephemeral's EURC balance before the first broadcast** — The presigned payout is single-use (its nonce is consumed even on revert), so the handler calls `ensurePresignedTransferFunded` before `sendRawTransaction`: sender/token/amount are decoded from the signed raw tx and the ephemeral balance is polled (3-minute timeout); a shortfall raises a recoverable error instead of burning the nonce. See `03-ramp-engine/ramp-phase-flows.md` invariant 12. +26. **The Mykobo fee display fallback MUST be display-only and MUST NOT price a ramp execution** — When `MYKOBO_FEE_FALLBACK_ENABLED=true`, a failed `defaultDepositFee` / `defaultWithdrawFee` lookup returns the configured flat `MYKOBO_FALLBACK_DEPOSIT_FEE` / `MYKOBO_FALLBACK_WITHDRAW_FEE` (validated non-negative at boot) so EUR quotes still render during a `/fees` outage. This value is used only for the quote's anchor-fee display. Ramp execution MUST NOT run on a fallback fee: the EUR registration kill-switch (`registerRamp` rejects EURC quotes with `503`) currently blocks all EUR execution, and when the rail is re-enabled, ramp start MUST re-validate the live Mykobo fee before executing. When the fallback is disabled (default) or a needed value is unset, the lookup failure MUST surface as `MykoboFeeUnavailableError` → `QuoteError.AnchorTemporarilyUnavailable` (`503`), never as an invented fee. ## Threat Vectors & Mitigations @@ -115,6 +118,7 @@ Unlike Monerium (`moneriumOnrampMint` + `moneriumOnrampSelfTransfer`), Vortex do | **Unverified EUR ramp / KYC bypass** | A registered-but-not-KYC'd profile (or one whose Mykobo review is pending/rejected) registers an EUR ramp and receives SEPA payment instructions | Registration refreshes the Mykobo KYC mirror live and rejects with `400` unless `MykoboCustomer.status === APPROVED`; the Mykobo intent is created only after the gate passes. | | **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`). | +| **Fabricated fee during Mykobo `/fees` outage** | Mykobo `/fees` is down; the display fallback lets a quote render, and if such a quote could be executed the user might be charged a fee that differs from Mykobo's real charge | The fallback is env-gated (`MYKOBO_FEE_FALLBACK_ENABLED`), display-only, and flat; it never reaches execution — EUR registration is disabled and, when re-enabled, ramp start MUST re-validate the live Mykobo fee. With the fallback off (default), the outage surfaces as `AnchorTemporarilyUnavailable` (`503`) rather than an invented fee. | | **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. | | **Underdelivery from Nabla-on-Base** | Nabla swap returns less USDC/EURC than quoted and the ramp reaches `subsidizePostSwap`. | `subsidizePostSwap` (EVM branch) tops up eligible shortfalls subject to split caps: actual-vs-quoted swap discrepancy uses `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION`; discount subsidy uses `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION`. Over-cap cases are recoverable waits with no transfer submitted. | @@ -150,3 +154,4 @@ Unlike Monerium (`moneriumOnrampMint` + `moneriumOnrampSelfTransfer`), Vortex do - [ ] 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 - [ ] While EUR ramps are disabled, `registerRamp` rejects EURC input/output quotes with `503 SERVICE_UNAVAILABLE` before any Mykobo or ramp-state side effects +- [ ] Mykobo `/fees` outage during quote creation surfaces as `QuoteError.AnchorTemporarilyUnavailable` (`503`), not generic `FailedToCalculateQuote`; the env-gated display fallback (`MYKOBO_FEE_FALLBACK_ENABLED`) is display-only and never prices a ramp execution From 4676a62bb353c153b30b64470a57ae3a13d01a90 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 7 Jul 2026 10:48:01 +0200 Subject: [PATCH 5/5] fix(api): clarify fallback safety comment and log fee-lookup outages Address PR review: - vars.ts: the fallback comment claimed EUR start 're-checks the live integration'; it does not. Reword to reflect the actual register-time kill-switch and the future need to re-validate live fees when EUR is re-enabled. - mykobo-fee.ts: when the lookup fails with no fallback configured, log the upstream reason (5xx vs unreachable) before throwing, so the actionable cause isn't lost (QuoteService logs only the wrapper message). --- apps/api/src/api/services/quote/engines/mykobo-fee.ts | 3 ++- apps/api/src/config/vars.ts | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/api/src/api/services/quote/engines/mykobo-fee.ts b/apps/api/src/api/services/quote/engines/mykobo-fee.ts index a591d0f00..304e98ff1 100644 --- a/apps/api/src/api/services/quote/engines/mykobo-fee.ts +++ b/apps/api/src/api/services/quote/engines/mykobo-fee.ts @@ -32,13 +32,14 @@ async function resolveFee(kind: "deposit" | "withdraw", lookup: () => Promise<{ const response = await lookup(); return response.total; } catch (error) { + const reason = error instanceof Error ? error.message : String(error); const { enabled, depositFee, withdrawFee } = config.mykobo.feeFallback; const fallback = kind === "deposit" ? depositFee : withdrawFee; if (enabled && fallback !== undefined) { - const reason = error instanceof Error ? error.message : String(error); logger.warn(`Mykobo ${kind} fee lookup failed (${reason}); using display fallback fee ${fallback} EUR`); return fallback; } + logger.error(`Mykobo ${kind} fee lookup failed (${reason}); no display fallback configured, failing quote`); throw new MykoboFeeUnavailableError(kind, { cause: error }); } } diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index 53457ffb2..0cb4eb31c 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -60,8 +60,10 @@ interface MykoboFeeFallback { } // Display-only fallback so EUR quotes still render when the Mykobo /fees endpoint is -// down. Never used to execute a ramp: EUR ramp start is guarded separately and re-checks -// the live integration. Both fees are flat EUR amounts and are required when enabled. +// down. Never prices a ramp execution: EUR ramp start is currently blocked entirely by +// the register-time kill-switch (registerRamp rejects EURC quotes with 503). When EUR is +// re-enabled, ramp start must re-validate the live Mykobo fee before executing — no such +// check exists today. Both fees are flat EUR amounts and are required when enabled. function readMykoboFeeFallback(): MykoboFeeFallback { const enabled = process.env.MYKOBO_FEE_FALLBACK_ENABLED === "true"; if (!enabled) {