Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions apps/api/src/api/services/quote/engines/fee/offramp-mykobo.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 }
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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)) {
Expand Down
56 changes: 56 additions & 0 deletions apps/api/src/api/services/quote/engines/mykobo-fee.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
45 changes: 45 additions & 0 deletions apps/api/src/api/services/quote/engines/mykobo-fee.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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<string> {
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<string> {
return resolveFee("withdraw", () => MykoboApiService.getInstance().defaultWithdrawFee(value));
}

async function resolveFee(kind: "deposit" | "withdraw", lookup: () => Promise<{ total: string }>): Promise<string> {
try {
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) {
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 });
}
}
6 changes: 6 additions & 0 deletions apps/api/src/api/services/quote/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
Expand Down
41 changes: 41 additions & 0 deletions apps/api/src/config/vars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,41 @@ 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 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) {
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();
Expand Down Expand Up @@ -119,6 +154,9 @@ interface Config {
discountStateTimeoutMinutes: number;
deltaDBasisPoints: number;
};
mykobo: {
feeFallback: MykoboFeeFallback;
};
subscanApiKey: string | undefined;
vortexFeePenPercentage: number;

Expand Down Expand Up @@ -178,6 +216,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: {
Expand Down
3 changes: 2 additions & 1 deletion apps/frontend/src/stores/quote/useQuoteStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ const friendlyErrorMessages: Record<QuoteError, string> = {
[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) {
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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!",
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/src/translations/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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!",
Expand Down
1 change: 1 addition & 0 deletions docs/security-spec/03-ramp-engine/fee-integrity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading