From e5f057642626742d18139c93d9cfd52d2d8270d0 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 3 Jun 2026 18:44:37 +0200 Subject: [PATCH 01/29] Add profile partner pricing assignments --- .../profilePartnerAssignments.controller.ts | 195 ++++++++++++++++++ apps/api/src/api/middlewares/dualAuth.test.ts | 12 ++ .../profile-partner-assignments.route.ts | 31 +++ apps/api/src/api/routes/v1/index.ts | 9 + .../quote/core/partner-resolution.test.ts | 106 ++++++++++ .../services/quote/core/partner-resolution.ts | 101 +++++++++ .../api/services/quote/core/quote-context.ts | 10 +- apps/api/src/api/services/quote/core/types.ts | 10 +- .../services/quote/engines/finalize/index.ts | 3 +- apps/api/src/api/services/quote/index.ts | 23 +-- .../api/src/api/services/ramp/ramp.service.ts | 5 +- .../transactions/common/feeDistribution.ts | 16 +- .../032-profile-partner-assignments.ts | 94 +++++++++ apps/api/src/models/index.ts | 7 + .../models/profilePartnerAssignment.model.ts | 107 ++++++++++ apps/api/src/models/quoteTicket.model.ts | 18 ++ .../03-ramp-engine/profile-partner-pricing.md | 59 ++++++ docs/security-spec/README.md | 1 + 18 files changed, 779 insertions(+), 28 deletions(-) create mode 100644 apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.ts create mode 100644 apps/api/src/api/routes/v1/admin/profile-partner-assignments.route.ts create mode 100644 apps/api/src/api/services/quote/core/partner-resolution.test.ts create mode 100644 apps/api/src/api/services/quote/core/partner-resolution.ts create mode 100644 apps/api/src/database/migrations/032-profile-partner-assignments.ts create mode 100644 apps/api/src/models/profilePartnerAssignment.model.ts create mode 100644 docs/security-spec/03-ramp-engine/profile-partner-pricing.md diff --git a/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.ts b/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.ts new file mode 100644 index 000000000..2c330590b --- /dev/null +++ b/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.ts @@ -0,0 +1,195 @@ +import { Request, Response } from "express"; +import httpStatus from "http-status"; +import { WhereOptions } from "sequelize"; +import logger from "../../../config/logger"; +import Partner from "../../../models/partner.model"; +import ProfilePartnerAssignment, { ProfilePartnerAssignmentAttributes } from "../../../models/profilePartnerAssignment.model"; +import User from "../../../models/user.model"; + +function parseExpiration(expiresAt: unknown): Date | null { + if (!expiresAt) { + return null; + } + + if (typeof expiresAt !== "string") { + throw new Error("expiresAt must be an ISO date string"); + } + + const expirationDate = new Date(expiresAt); + if (Number.isNaN(expirationDate.getTime())) { + throw new Error("expiresAt must be a valid ISO date string"); + } + + return expirationDate; +} + +function serializeAssignment(assignment: ProfilePartnerAssignment) { + return { + createdAt: assignment.createdAt, + expiresAt: assignment.expiresAt, + id: assignment.id, + isActive: assignment.isActive, + partnerName: assignment.partnerName, + updatedAt: assignment.updatedAt, + userId: assignment.userId + }; +} + +export async function createProfilePartnerAssignment(req: Request, res: Response): Promise { + try { + const { userId, partnerName, expiresAt } = req.body; + + if (!userId || typeof userId !== "string" || !partnerName || typeof partnerName !== "string") { + res.status(httpStatus.BAD_REQUEST).json({ + error: { + code: "INVALID_ASSIGNMENT_INPUT", + message: "userId and partnerName are required string fields", + status: httpStatus.BAD_REQUEST + } + }); + return; + } + + const user = await User.findByPk(userId); + if (!user) { + res.status(httpStatus.NOT_FOUND).json({ + error: { + code: "USER_NOT_FOUND", + message: "Profile was not found", + status: httpStatus.NOT_FOUND + } + }); + return; + } + + const partners = await Partner.findAll({ + where: { + isActive: true, + name: partnerName + } + }); + + if (partners.length === 0) { + res.status(httpStatus.NOT_FOUND).json({ + error: { + code: "PARTNER_NOT_FOUND", + message: `No active partners found with name: ${partnerName}`, + status: httpStatus.NOT_FOUND + } + }); + return; + } + + const expirationDate = parseExpiration(expiresAt); + + await ProfilePartnerAssignment.update( + { isActive: false }, + { + where: { + isActive: true, + userId + } + } + ); + + const assignment = await ProfilePartnerAssignment.create({ + expiresAt: expirationDate, + isActive: true, + partnerName, + userId + }); + + res.status(httpStatus.CREATED).json({ + assignment: serializeAssignment(assignment), + partnerCount: partners.length + }); + } catch (error) { + if (error instanceof Error && error.message.startsWith("expiresAt")) { + res.status(httpStatus.BAD_REQUEST).json({ + error: { + code: "INVALID_EXPIRES_AT", + message: error.message, + status: httpStatus.BAD_REQUEST + } + }); + return; + } + + logger.error("Error creating profile partner assignment:", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + error: { + code: "INTERNAL_SERVER_ERROR", + message: "Failed to create profile partner assignment", + status: httpStatus.INTERNAL_SERVER_ERROR + } + }); + } +} + +export async function listProfilePartnerAssignments( + req: Request, + res: Response +): Promise { + try { + const { includeInactive, partnerName, userId } = req.query; + const where: WhereOptions = {}; + + if (includeInactive !== "true") { + where.isActive = true; + } + if (partnerName) { + where.partnerName = partnerName; + } + if (userId) { + where.userId = userId; + } + + const assignments = await ProfilePartnerAssignment.findAll({ + order: [["createdAt", "DESC"]], + where + }); + + res.status(httpStatus.OK).json({ + assignments: assignments.map(serializeAssignment) + }); + } catch (error) { + logger.error("Error listing profile partner assignments:", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + error: { + code: "INTERNAL_SERVER_ERROR", + message: "Failed to list profile partner assignments", + status: httpStatus.INTERNAL_SERVER_ERROR + } + }); + } +} + +export async function revokeProfilePartnerAssignment(req: Request<{ assignmentId: string }>, res: Response): Promise { + try { + const { assignmentId } = req.params; + const assignment = await ProfilePartnerAssignment.findByPk(assignmentId); + + if (!assignment) { + res.status(httpStatus.NOT_FOUND).json({ + error: { + code: "ASSIGNMENT_NOT_FOUND", + message: "Profile partner assignment was not found", + status: httpStatus.NOT_FOUND + } + }); + return; + } + + await assignment.update({ isActive: false }); + res.status(httpStatus.NO_CONTENT).send(); + } catch (error) { + logger.error("Error revoking profile partner assignment:", error); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + error: { + code: "INTERNAL_SERVER_ERROR", + message: "Failed to revoke profile partner assignment", + status: httpStatus.INTERNAL_SERVER_ERROR + } + }); + } +} diff --git a/apps/api/src/api/middlewares/dualAuth.test.ts b/apps/api/src/api/middlewares/dualAuth.test.ts index 97d2a31e5..41a40fcd0 100644 --- a/apps/api/src/api/middlewares/dualAuth.test.ts +++ b/apps/api/src/api/middlewares/dualAuth.test.ts @@ -27,6 +27,17 @@ describe("assertQuoteOwnership", () => { it("allows a Supabase user registering their own quote", async () => { QuoteTicket.findByPk = mock(async () => ({ partnerId: null, + pricingPartnerId: null, + userId: "user-1" + })) as typeof QuoteTicket.findByPk; + + await expect(assertQuoteOwnership({ userId: "user-1" }, "quote-1")).resolves.toBeUndefined(); + }); + + it("allows a Supabase user registering their own profile-priced quote", async () => { + QuoteTicket.findByPk = mock(async () => ({ + partnerId: null, + pricingPartnerId: "pricing-partner-id", userId: "user-1" })) as typeof QuoteTicket.findByPk; @@ -49,6 +60,7 @@ describe("assertQuoteOwnership", () => { })) as typeof QuoteTicket.findByPk; Partner.findByPk = mock(async () => ({ id: "quote-partner-id", + isActive: true, name: "Partner" })) as typeof Partner.findByPk; diff --git a/apps/api/src/api/routes/v1/admin/profile-partner-assignments.route.ts b/apps/api/src/api/routes/v1/admin/profile-partner-assignments.route.ts new file mode 100644 index 000000000..cad5181c1 --- /dev/null +++ b/apps/api/src/api/routes/v1/admin/profile-partner-assignments.route.ts @@ -0,0 +1,31 @@ +import { Router } from "express"; +import { + createProfilePartnerAssignment, + listProfilePartnerAssignments, + revokeProfilePartnerAssignment +} from "../../../controllers/admin/profilePartnerAssignments.controller"; +import { adminAuth } from "../../../middlewares/adminAuth"; + +const router: Router = Router({ mergeParams: true }); + +router.use(adminAuth); + +/** + * POST /v1/admin/profile-partner-assignments + * Assign a Supabase profile to a partner name for pricing-only quote behavior. + */ +router.post("/", createProfilePartnerAssignment); + +/** + * GET /v1/admin/profile-partner-assignments + * List active profile partner assignments by default. + */ +router.get("/", listProfilePartnerAssignments); + +/** + * DELETE /v1/admin/profile-partner-assignments/:assignmentId + * Revoke (soft delete) a profile partner assignment. + */ +router.delete("/:assignmentId", revokeProfilePartnerAssignment); + +export default router; diff --git a/apps/api/src/api/routes/v1/index.ts b/apps/api/src/api/routes/v1/index.ts index b7e6d6e0e..ac6130ba5 100644 --- a/apps/api/src/api/routes/v1/index.ts +++ b/apps/api/src/api/routes/v1/index.ts @@ -2,6 +2,7 @@ import { Request, Response, Router } from "express"; import { sendStatusWithPk as sendMoonbeamStatusWithPk } from "../../controllers/moonbeam.controller"; import { sendStatusWithPk as sendPendulumStatusWithPk } from "../../controllers/pendulum.controller"; import partnerApiKeysRoutes from "./admin/partner-api-keys.route"; +import profilePartnerAssignmentsRoutes from "./admin/profile-partner-assignments.route"; import alfredpayRoutes from "./alfredpay.route"; import authRoutes from "./auth.route"; import brlaRoutes from "./brla.route"; @@ -174,6 +175,14 @@ router.use("/metrics", metricsRoutes); */ router.use("/admin/partners/:partnerName/api-keys", partnerApiKeysRoutes); +/** + * Admin routes for profile partner pricing assignments + * POST /v1/admin/profile-partner-assignments + * GET /v1/admin/profile-partner-assignments + * DELETE /v1/admin/profile-partner-assignments/:assignmentId + */ +router.use("/admin/profile-partner-assignments", profilePartnerAssignmentsRoutes); + router.get("/ip", (request: Request, response: Response) => { response.send(request.ip); }); diff --git a/apps/api/src/api/services/quote/core/partner-resolution.test.ts b/apps/api/src/api/services/quote/core/partner-resolution.test.ts new file mode 100644 index 000000000..240a53a65 --- /dev/null +++ b/apps/api/src/api/services/quote/core/partner-resolution.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; +import { EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; +import Partner from "../../../../models/partner.model"; +import ProfilePartnerAssignment from "../../../../models/profilePartnerAssignment.model"; +import { resolveQuotePartner } from "./partner-resolution"; + +const baseRequest = { + from: EPaymentMethod.PIX, + inputAmount: "100", + inputCurrency: FiatToken.BRL, + network: Networks.Base, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.Base +}; + +describe("resolveQuotePartner", () => { + const originalPartnerFindOne = Partner.findOne; + const originalAssignmentFindOne = ProfilePartnerAssignment.findOne; + + afterEach(() => { + Partner.findOne = originalPartnerFindOne; + ProfilePartnerAssignment.findOne = originalAssignmentFindOne; + }); + + it("uses explicit partnerId before public keys or profile assignments", async () => { + let assignmentLookupCount = 0; + Partner.findOne = mock(async ({ where }: { where: { name?: string } }) => { + if (where.name === "ExplicitPartner") { + return { id: "explicit-buy-id", name: "ExplicitPartner" }; + } + return null; + }) as typeof Partner.findOne; + ProfilePartnerAssignment.findOne = mock(async () => { + assignmentLookupCount++; + return { partnerName: "AssignedPartner" }; + }) as typeof ProfilePartnerAssignment.findOne; + + const result = await resolveQuotePartner({ + ...baseRequest, + partnerId: "ExplicitPartner", + partnerName: "PublicKeyPartner", + userId: "user-1" + }); + + expect(result.source).toBe("request"); + expect(result.pricingPartnerId).toBe("explicit-buy-id"); + expect(result.ownerPartnerId).toBe("explicit-buy-id"); + expect(assignmentLookupCount).toBe(0); + }); + + it("keeps public-key partner quotes partner-owned for compatibility", async () => { + Partner.findOne = mock(async ({ where }: { where: { name?: string } }) => { + if (where.name === "PublicKeyPartner") { + return { id: "public-key-buy-id", name: "PublicKeyPartner" }; + } + return null; + }) as typeof Partner.findOne; + ProfilePartnerAssignment.findOne = mock(async () => ({ partnerName: "AssignedPartner" })) as typeof ProfilePartnerAssignment.findOne; + + const result = await resolveQuotePartner({ + ...baseRequest, + partnerName: "PublicKeyPartner", + userId: "user-1" + }); + + expect(result.source).toBe("publicKey"); + expect(result.pricingPartnerId).toBe("public-key-buy-id"); + expect(result.ownerPartnerId).toBe("public-key-buy-id"); + }); + + it("applies profile assignments as pricing-only for authenticated users", async () => { + ProfilePartnerAssignment.findOne = mock(async () => ({ partnerName: "AssignedPartner" })) as typeof ProfilePartnerAssignment.findOne; + Partner.findOne = mock(async ({ where }: { where: { name?: string } }) => { + if (where.name === "AssignedPartner") { + return { id: "assigned-buy-id", name: "AssignedPartner" }; + } + return null; + }) as typeof Partner.findOne; + + const result = await resolveQuotePartner({ + ...baseRequest, + userId: "user-1" + }); + + expect(result.source).toBe("profileAssignment"); + expect(result.pricingPartnerId).toBe("assigned-buy-id"); + expect(result.ownerPartnerId).toBeNull(); + }); + + it("does not apply profile assignments to anonymous requests", async () => { + let assignmentLookupCount = 0; + ProfilePartnerAssignment.findOne = mock(async () => { + assignmentLookupCount++; + return { partnerName: "AssignedPartner" }; + }) as typeof ProfilePartnerAssignment.findOne; + Partner.findOne = mock(async () => null) as typeof Partner.findOne; + + const result = await resolveQuotePartner(baseRequest); + + expect(result.source).toBe("none"); + expect(result.pricingPartnerId).toBeNull(); + expect(result.ownerPartnerId).toBeNull(); + expect(assignmentLookupCount).toBe(0); + }); +}); diff --git a/apps/api/src/api/services/quote/core/partner-resolution.ts b/apps/api/src/api/services/quote/core/partner-resolution.ts new file mode 100644 index 000000000..80fbae5a6 --- /dev/null +++ b/apps/api/src/api/services/quote/core/partner-resolution.ts @@ -0,0 +1,101 @@ +import { CreateQuoteRequest, RampDirection } from "@vortexfi/shared"; +import { Op } from "sequelize"; +import logger from "../../../../config/logger"; +import Partner from "../../../../models/partner.model"; +import ProfilePartnerAssignment from "../../../../models/profilePartnerAssignment.model"; +import type { PartnerPricingSource } from "./types"; + +type QuotePartnerResolutionRequest = CreateQuoteRequest & { + partnerName?: string | null; + userId?: string; +}; + +export interface ResolvedQuotePartner { + ownerPartnerId: string | null; + partner: Partner | null; + pricingPartnerId: string | null; + source: PartnerPricingSource; +} + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +async function findPartnerForRamp( + partnerRef: string, + rampType: RampDirection, + source: PartnerPricingSource +): Promise { + const isUuid = source === "request" && UUID_PATTERN.test(partnerRef); + const partner = await Partner.findOne({ + where: { + ...(isUuid ? { id: partnerRef } : { name: partnerRef }), + isActive: true, + rampType + } + }); + + if (!partner) { + logger.warn( + `Partner '${partnerRef}' from ${source} not found or not active for rampType=${rampType}. Proceeding with default fees.` + ); + } + + return partner; +} + +async function findAssignedPartnerName(userId: string, now: Date): Promise { + const assignment = await ProfilePartnerAssignment.findOne({ + order: [["createdAt", "DESC"]], + where: { + [Op.or]: [{ expiresAt: null }, { expiresAt: { [Op.gt]: now } }], + isActive: true, + userId + } + }); + + return assignment?.partnerName ?? null; +} + +export async function resolveQuotePartner( + request: QuotePartnerResolutionRequest, + now = new Date() +): Promise { + if (request.partnerId) { + const partner = await findPartnerForRamp(request.partnerId, request.rampType, "request"); + return { + ownerPartnerId: partner?.id ?? null, + partner, + pricingPartnerId: partner?.id ?? null, + source: "request" + }; + } + + if (request.partnerName) { + const partner = await findPartnerForRamp(request.partnerName, request.rampType, "publicKey"); + return { + ownerPartnerId: partner?.id ?? null, + partner, + pricingPartnerId: partner?.id ?? null, + source: "publicKey" + }; + } + + if (request.userId) { + const assignedPartnerName = await findAssignedPartnerName(request.userId, now); + if (assignedPartnerName) { + const partner = await findPartnerForRamp(assignedPartnerName, request.rampType, "profileAssignment"); + return { + ownerPartnerId: null, + partner, + pricingPartnerId: partner?.id ?? null, + source: "profileAssignment" + }; + } + } + + return { + ownerPartnerId: null, + partner: null, + pricingPartnerId: null, + source: "none" + }; +} diff --git a/apps/api/src/api/services/quote/core/quote-context.ts b/apps/api/src/api/services/quote/core/quote-context.ts index 05d7820d3..3e7097a4b 100644 --- a/apps/api/src/api/services/quote/core/quote-context.ts +++ b/apps/api/src/api/services/quote/core/quote-context.ts @@ -8,12 +8,15 @@ */ import { CreateQuoteRequest, RampCurrency, RampDirection } from "@vortexfi/shared"; -import type { QuoteContext as IQuoteContext, PartnerInfo } from "./types"; +import type { QuoteContext as IQuoteContext, PartnerInfo, PartnerPricingSource } from "./types"; export function createQuoteContext(args: { - request: CreateQuoteRequest; + request: CreateQuoteRequest & { partnerName?: string | null; userId?: string }; targetFeeFiatCurrency: RampCurrency; partner: PartnerInfo | null; + partnerOwnerId?: string | null; + partnerPricingSource?: PartnerPricingSource; + pricingPartnerId?: string | null; now?: Date; }): IQuoteContext { let notes: string[] | undefined; @@ -41,6 +44,9 @@ export function createQuoteContext(args: { }, now: args.now ?? new Date(), partner: args.partner, + partnerOwnerId: args.partnerOwnerId ?? null, + partnerPricingSource: args.partnerPricingSource ?? "none", + pricingPartnerId: args.pricingPartnerId ?? null, // Provide a default object to keep stage logic simple; optional by interface. request: args.request, diff --git a/apps/api/src/api/services/quote/core/types.ts b/apps/api/src/api/services/quote/core/types.ts index 523ed37d4..fd41f71bb 100644 --- a/apps/api/src/api/services/quote/core/types.ts +++ b/apps/api/src/api/services/quote/core/types.ts @@ -73,6 +73,8 @@ export interface PartnerInfo { minDynamicDifference?: number; } +export type PartnerPricingSource = "request" | "publicKey" | "profileAssignment" | "none"; + // Strategy for a specific route/path export interface IRouteStrategy { // Optional: human-friendly name for logging @@ -88,12 +90,18 @@ export interface IRouteStrategy { // Re-export here for convenience to avoid deep imports. export interface QuoteContext { // immutable request details - readonly request: CreateQuoteRequest & { userId?: string }; + readonly request: CreateQuoteRequest & { partnerName?: string | null; userId?: string }; readonly now: Date; // Partner info (if any) partner: PartnerInfo | null; + // Quote ownership and pricing provenance. `partnerOwnerId` is used for + // partner-principal ownership; `pricingPartnerId` is used for custom rates. + partnerOwnerId?: string | null; + pricingPartnerId?: string | null; + partnerPricingSource?: PartnerPricingSource; + // The fiat currency used for displaying fee breakdown (per helpers.getTargetFiatCurrency) targetFeeFiatCurrency: RampCurrency; diff --git a/apps/api/src/api/services/quote/engines/finalize/index.ts b/apps/api/src/api/services/quote/engines/finalize/index.ts index 811bb281c..a4134adde 100644 --- a/apps/api/src/api/services/quote/engines/finalize/index.ts +++ b/apps/api/src/api/services/quote/engines/finalize/index.ts @@ -158,8 +158,9 @@ export abstract class BaseFinalizeEngine implements Stage { network: request.network, outputAmount: outputAmountStr, outputCurrency: request.outputCurrency, - partnerId: ctx.partner?.id || null, + partnerId: ctx.partnerOwnerId || null, paymentMethod, + pricingPartnerId: ctx.pricingPartnerId || null, rampType: request.rampType, status: "pending", to: request.to, diff --git a/apps/api/src/api/services/quote/index.ts b/apps/api/src/api/services/quote/index.ts index 402a6a950..db4a371b5 100644 --- a/apps/api/src/api/services/quote/index.ts +++ b/apps/api/src/api/services/quote/index.ts @@ -16,10 +16,10 @@ import httpStatus from "http-status"; import pLimit from "p-limit"; import logger from "../../../config/logger"; import { config } from "../../../config/vars"; -import Partner from "../../../models/partner.model"; import { APIError } from "../../errors/api-error"; import { BaseRampService } from "../ramp/base.service"; import { getTargetFiatCurrency, SUPPORTED_CHAINS, validateChainSupport } from "./core/helpers"; +import { resolveQuotePartner } from "./core/partner-resolution"; import { createQuoteContext } from "./core/quote-context"; import { QuoteOrchestrator } from "./core/quote-orchestrator"; import { buildQuoteResponse } from "./engines/finalize"; @@ -152,22 +152,8 @@ export class QuoteService extends BaseRampService { throw new APIError({ message: QuoteError.FailedToCalculateQuote, status: httpStatus.INTERNAL_SERVER_ERROR }); } - let partner = null; - const partnerNameToUse = request.partnerId || request.partnerName; - - if (partnerNameToUse) { - partner = await Partner.findOne({ - where: { - isActive: true, - name: partnerNameToUse, - rampType: request.rampType - } - }); - - if (!partner) { - logger.warn(`Partner with name '${partnerNameToUse}' not found or not active. Proceeding with default fees.`); - } - } + const resolvedPartner = await resolveQuotePartner(request); + const partner = resolvedPartner.partner; if (partner && partner.markupType !== "none" && partner.payoutAddressEvm === null && requiresEvmPartnerPayout(request)) { logger.error( @@ -192,6 +178,9 @@ export class QuoteService extends BaseRampService { targetDiscount: partner.targetDiscount } : { id: null }, + partnerOwnerId: resolvedPartner.ownerPartnerId, + partnerPricingSource: resolvedPartner.source, + pricingPartnerId: resolvedPartner.pricingPartnerId, request: { ...request, apiKey: request.apiKey || undefined }, targetFeeFiatCurrency }); diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index fe0913e36..53bd04b40 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -218,9 +218,10 @@ export class RampService extends BaseRampService { }); } + const pricingPartnerId = quote.pricingPartnerId ?? quote.partnerId; let partner: ActivePartner = null; - if (quote.partnerId) { - partner = await Partner.findByPk(quote.partnerId); + if (pricingPartnerId) { + partner = await Partner.findByPk(pricingPartnerId); } handleQuoteConsumptionForDiscountState(partner); diff --git a/apps/api/src/api/services/transactions/common/feeDistribution.ts b/apps/api/src/api/services/transactions/common/feeDistribution.ts index 730ae2be0..57439cf90 100644 --- a/apps/api/src/api/services/transactions/common/feeDistribution.ts +++ b/apps/api/src/api/services/transactions/common/feeDistribution.ts @@ -24,6 +24,10 @@ import { QuoteTicketAttributes } from "../../../../models/quoteTicket.model"; import { multiplyByPowerOfTen } from "../../pendulum/helpers"; import { getZenlinkIdForAsset } from "../../zenlink"; +function getQuotePricingPartnerId(quote: QuoteTicketAttributes): string | null { + return quote.pricingPartnerId ?? quote.partnerId ?? null; +} + /** * Creates a pre-signed fee distribution transaction for the distribute-fees-handler phase. * This is shared between onramp and offramp flows. @@ -71,10 +75,11 @@ export async function createSubstrateFeeDistributionTransaction(quote: QuoteTick } const vortexPayoutAddress = vortexPartner.payoutAddressSubstrate; + const pricingPartnerId = getQuotePricingPartnerId(quote); let partnerPayoutAddress = null; - if (quote.partnerId) { + if (pricingPartnerId) { const quotePartner = await Partner.findOne({ - where: { id: quote.partnerId, isActive: true, rampType: quote.rampType } + where: { id: pricingPartnerId, isActive: true, rampType: quote.rampType } }); if (quotePartner && quotePartner.payoutAddressSubstrate) { partnerPayoutAddress = quotePartner.payoutAddressSubstrate; @@ -249,10 +254,11 @@ export async function createEvmFeeDistributionTransaction(quote: QuoteTicketAttr const vortexPayoutAddress = vortexPartner.payoutAddressEvm ?? (config.defaults.vortexEvmPayoutAddress as string); // Look up partner EVM payout address for markup split + const pricingPartnerId = getQuotePricingPartnerId(quote); let partnerPayoutAddressEvm: string | null = null; - if (quote.partnerId) { + if (pricingPartnerId) { const quotePartner = await Partner.findOne({ - where: { id: quote.partnerId, isActive: true, rampType: quote.rampType } + where: { id: pricingPartnerId, isActive: true, rampType: quote.rampType } }); if (quotePartner?.payoutAddressEvm) { partnerPayoutAddressEvm = quotePartner.payoutAddressEvm; @@ -261,7 +267,7 @@ export async function createEvmFeeDistributionTransaction(quote: QuoteTicketAttr if (Big(partnerMarkupFeeUSD).gt(0) && partnerPayoutAddressEvm === null) { logger.warn( - `EVM FEE DISTRIBUTION: partner markup of ${partnerMarkupFeeUSD.toString()} USD will be DROPPED for quote ${quote.id} (partnerId=${quote.partnerId ?? "none"}, rampType=${quote.rampType}); 'payout_address_evm' is not set on the partner row.` + `EVM FEE DISTRIBUTION: partner markup of ${partnerMarkupFeeUSD.toString()} USD will be DROPPED for quote ${quote.id} (pricingPartnerId=${pricingPartnerId ?? "none"}, ownerPartnerId=${quote.partnerId ?? "none"}, rampType=${quote.rampType}); 'payout_address_evm' is not set on the partner row.` ); } diff --git a/apps/api/src/database/migrations/032-profile-partner-assignments.ts b/apps/api/src/database/migrations/032-profile-partner-assignments.ts new file mode 100644 index 000000000..0a14195f7 --- /dev/null +++ b/apps/api/src/database/migrations/032-profile-partner-assignments.ts @@ -0,0 +1,94 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.createTable("profile_partner_assignments", { + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + expiresAt: { + allowNull: true, + field: "expires_at", + type: DataTypes.DATE + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + isActive: { + allowNull: false, + defaultValue: true, + field: "is_active", + type: DataTypes.BOOLEAN + }, + partnerName: { + allowNull: false, + field: "partner_name", + type: DataTypes.STRING(100) + }, + 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 + } + }); + + await queryInterface.addIndex("profile_partner_assignments", ["user_id"], { + name: "idx_profile_partner_assignments_user_id" + }); + + await queryInterface.addIndex("profile_partner_assignments", ["partner_name"], { + name: "idx_profile_partner_assignments_partner_name" + }); + + await queryInterface.addIndex("profile_partner_assignments", ["user_id", "is_active", "expires_at"], { + name: "idx_profile_partner_assignments_active_lookup" + }); + + await queryInterface.addIndex("profile_partner_assignments", ["user_id"], { + name: "uniq_profile_partner_assignments_active_user", + unique: true, + where: { is_active: true } + }); + + await queryInterface.addColumn("quote_tickets", "pricing_partner_id", { + allowNull: true, + onDelete: "SET NULL", + onUpdate: "CASCADE", + references: { + key: "id", + model: "partners" + }, + type: DataTypes.UUID + }); + + await queryInterface.addIndex("quote_tickets", ["pricing_partner_id"], { + name: "idx_quote_tickets_pricing_partner" + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeIndex("quote_tickets", "idx_quote_tickets_pricing_partner"); + await queryInterface.removeColumn("quote_tickets", "pricing_partner_id"); + + await queryInterface.removeIndex("profile_partner_assignments", "uniq_profile_partner_assignments_active_user"); + await queryInterface.removeIndex("profile_partner_assignments", "idx_profile_partner_assignments_active_lookup"); + await queryInterface.removeIndex("profile_partner_assignments", "idx_profile_partner_assignments_partner_name"); + await queryInterface.removeIndex("profile_partner_assignments", "idx_profile_partner_assignments_user_id"); + await queryInterface.dropTable("profile_partner_assignments"); +} diff --git a/apps/api/src/models/index.ts b/apps/api/src/models/index.ts index 73ea4998d..a459d803f 100644 --- a/apps/api/src/models/index.ts +++ b/apps/api/src/models/index.ts @@ -6,6 +6,7 @@ import KycLevel2 from "./kycLevel2.model"; import MaintenanceSchedule from "./maintenanceSchedule.model"; import MykoboCustomer from "./mykoboCustomer.model"; import Partner from "./partner.model"; +import ProfilePartnerAssignment from "./profilePartnerAssignment.model"; import QuoteTicket from "./quoteTicket.model"; import RampState from "./rampState.model"; import Subsidy from "./subsidy.model"; @@ -18,6 +19,8 @@ RampState.belongsTo(QuoteTicket, { as: "quote", foreignKey: "quoteId" }); QuoteTicket.hasOne(RampState, { as: "rampState", foreignKey: "quoteId" }); QuoteTicket.belongsTo(Partner, { as: "partner", foreignKey: "partnerId" }); Partner.hasMany(QuoteTicket, { as: "quotes", foreignKey: "partnerId" }); +QuoteTicket.belongsTo(Partner, { as: "pricingPartner", foreignKey: "pricingPartnerId" }); +Partner.hasMany(QuoteTicket, { as: "pricedQuotes", foreignKey: "pricingPartnerId" }); RampState.hasMany(Subsidy, { as: "subsidies", foreignKey: "rampId" }); Subsidy.belongsTo(RampState, { as: "rampState", foreignKey: "rampId" }); @@ -40,6 +43,9 @@ AlfredPayCustomer.belongsTo(User, { as: "user", foreignKey: "userId" }); User.hasOne(MykoboCustomer, { as: "mykoboCustomer", foreignKey: "userId" }); MykoboCustomer.belongsTo(User, { as: "user", foreignKey: "userId" }); +User.hasMany(ProfilePartnerAssignment, { as: "partnerAssignments", foreignKey: "userId" }); +ProfilePartnerAssignment.belongsTo(User, { as: "user", foreignKey: "userId" }); + // Initialize models const models = { AlfredPayCustomer, @@ -49,6 +55,7 @@ const models = { MaintenanceSchedule, MykoboCustomer, Partner, + ProfilePartnerAssignment, QuoteTicket, RampState, Subsidy, diff --git a/apps/api/src/models/profilePartnerAssignment.model.ts b/apps/api/src/models/profilePartnerAssignment.model.ts new file mode 100644 index 000000000..a20066b9c --- /dev/null +++ b/apps/api/src/models/profilePartnerAssignment.model.ts @@ -0,0 +1,107 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +export interface ProfilePartnerAssignmentAttributes { + id: string; + userId: string; + partnerName: string; + isActive: boolean; + expiresAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +type ProfilePartnerAssignmentCreationAttributes = Optional< + ProfilePartnerAssignmentAttributes, + "id" | "createdAt" | "updatedAt" | "isActive" | "expiresAt" +>; + +class ProfilePartnerAssignment + extends Model + implements ProfilePartnerAssignmentAttributes +{ + declare id: string; + + declare userId: string; + + declare partnerName: string; + + declare isActive: boolean; + + declare expiresAt: Date | null; + + declare createdAt: Date; + + declare updatedAt: Date; +} + +ProfilePartnerAssignment.init( + { + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + expiresAt: { + allowNull: true, + field: "expires_at", + type: DataTypes.DATE + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + isActive: { + allowNull: false, + defaultValue: true, + field: "is_active", + type: DataTypes.BOOLEAN + }, + partnerName: { + allowNull: false, + field: "partner_name", + type: DataTypes.STRING(100) + }, + 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 + } + }, + { + indexes: [ + { + fields: ["user_id"], + name: "idx_profile_partner_assignments_user_id" + }, + { + fields: ["partner_name"], + name: "idx_profile_partner_assignments_partner_name" + }, + { + fields: ["user_id", "is_active", "expires_at"], + name: "idx_profile_partner_assignments_active_lookup" + } + ], + modelName: "ProfilePartnerAssignment", + sequelize, + tableName: "profile_partner_assignments", + timestamps: true + } +); + +export default ProfilePartnerAssignment; diff --git a/apps/api/src/models/quoteTicket.model.ts b/apps/api/src/models/quoteTicket.model.ts index df07a5ce1..a6a81a960 100644 --- a/apps/api/src/models/quoteTicket.model.ts +++ b/apps/api/src/models/quoteTicket.model.ts @@ -16,6 +16,7 @@ export interface QuoteTicketAttributes { outputAmount: string; outputCurrency: RampCurrency; partnerId: string | null; + pricingPartnerId: string | null; apiKey: string | null; expiresAt: Date; status: "pending" | "consumed" | "expired"; @@ -53,6 +54,8 @@ class QuoteTicket extends Model` and may create user-owned quotes. +3. A profile assignment grants pricing eligibility only. It does not grant partner ownership, API access, webhook permissions, or the ability to act as the partner. + +The intended data model separates two concepts that were historically collapsed into `quote_tickets.partner_id`: + +- `partner_id` remains the partner owner of a quote for API-key integrations. +- `pricing_partner_id` records which partner rate configuration was used for quote pricing, fee calculation, subsidy calculation, fee distribution, and dynamic discount state. + +For profile-assigned frontend quotes, `quote_tickets.user_id` is set to the authenticated profile, `quote_tickets.partner_id` stays `NULL`, and `quote_tickets.pricing_partner_id` is set to the resolved partner row. This lets the user consume their own quote through the existing Supabase ownership path while still preserving which partner pricing was applied. + +## Security Invariants + +1. **Profile assignments MUST be server-side only** - The client MUST NOT be able to choose its assigned partner by passing a request body field, URL parameter, or local storage value. The backend resolves assignments only from the authenticated `req.userId`. +2. **Profile assignments MUST NOT authenticate partner ownership** - A Supabase profile assigned to a partner MUST NOT populate `req.authenticatedPartner`, MUST NOT satisfy `enforcePartnerAuth()`, and MUST NOT access partner-owned quotes or ramps. +3. **Explicit partner API-key integrations MUST keep their existing behavior** - Requests that include `partnerId` still require a matching partner secret key. Existing SDK/API clients using partner keys must continue to create partner-owned quotes. +4. **Partner pricing source precedence MUST be deterministic** - Explicit `partnerId` has highest precedence, then validated public API key partner name, then profile assignment, then default `"vortex"` pricing. +5. **Profile-assigned quotes MUST be user-owned** - A quote priced through a profile assignment MUST persist `user_id = req.userId` and `partner_id = NULL`. Register/update/start/status access for the resulting ramp is authorized through the Supabase user path. +6. **The pricing partner MUST be persisted separately** - Any quote that applies non-default partner pricing MUST persist `pricing_partner_id` so downstream fee distribution and dynamic discount state use the same partner row that quote calculation used. +7. **Inactive or expired assignments MUST be ignored** - The assignment resolver must require `is_active = true` and either `expires_at IS NULL` or `expires_at > now()`. +8. **Invalid assignments MUST fail closed to default pricing** - If an assignment points to a partner name with no active row for the requested ramp type, quote creation proceeds without that partner's pricing instead of accepting untrusted client input or fabricating a partner. +9. **Fee distribution MUST use the pricing partner, not only the owner partner** - Partner markup payout uses `pricing_partner_id` when present, with `partner_id` as a backward-compatible fallback for older quotes. +10. **Dynamic discount state MUST use the pricing partner** - Quote consumption adjusts the dynamic discount state for the partner whose pricing was used, not for the quote owner. +11. **Assignment administration MUST require admin auth** - Create, list, and revoke assignment endpoints MUST be protected by `adminAuth`; partner API keys and Supabase user tokens MUST NOT manage assignments. + +## Threat Vectors & Mitigations + +| Threat | Attack Scenario | Mitigation | +|---|---|---| +| **User spoofs partner discount** | A frontend user passes another partner's `partnerId` in the quote request body to claim better rates. | Existing `enforcePartnerAuth()` rejects `partnerId` unless a matching `sk_*` is present. Profile assignment resolution ignores client-supplied partner fields. | +| **Assigned user becomes partner principal** | A profile assigned to a partner tries to read or mutate partner-owned quotes, ramps, webhooks, or history. | Assignment affects quote pricing only. It does not set `req.authenticatedPartner`; ownership guards still separate user-owned and partner-owned resources. | +| **Broken API-client compatibility** | Splitting pricing and owner fields accidentally makes SDK quotes user-owned or anonymous. | Existing partner-key and public-key request paths continue to populate `partner_id` as before. The new user-owned behavior is only used for server-resolved profile assignments. | +| **Dropped partner markup payout** | A profile-assigned quote computes a partner markup but downstream fee distribution looks only at `quote.partnerId`, sees `NULL`, and skips partner payout. | Fee distribution resolves payout from `pricing_partner_id ?? partner_id`. | +| **Dynamic state drift for the wrong principal** | A profile-assigned quote is consumed but dynamic discount state is decremented for no partner or the wrong partner. | Ramp registration resolves the partner from `pricing_partner_id ?? partner_id` before calling `handleQuoteConsumptionForDiscountState`. | +| **Stale assignment remains usable** | A profile's temporary partner entitlement expires but quote creation still applies custom rates. | Resolver filters out assignments with `expires_at <= now()`. | +| **Assignment to missing ramp-type config** | A profile is assigned to partner `Acme`, but `partners` has only a BUY row and the user requests SELL. | Resolver requires active partner row with matching `rampType`; otherwise it logs and falls back to default pricing. | +| **Unauthorized assignment management** | A partner or normal frontend user assigns themselves or another profile to a discounted partner. | Assignment management routes live under `/v1/admin/profile-partner-assignments` and require `adminAuth`. | + +## Audit Checklist + +- [ ] `profile_partner_assignments` exists with `user_id`, `partner_name`, `is_active`, optional `expires_at`, timestamps, and indexes for active user lookups. +- [ ] Admin assignment endpoints are protected by `adminAuth` and reject non-admin credentials. +- [ ] Quote creation resolves profile assignments only from `req.userId`; unauthenticated quotes never use profile assignment pricing. +- [ ] `POST /v1/quotes` and `POST /v1/quotes/best` still reject explicit `partnerId` without matching secret-key authentication. +- [ ] Profile-assigned quotes persist `user_id` and `pricing_partner_id`, while leaving `partner_id` `NULL`. +- [ ] Existing partner API-key and public-key quote paths preserve their previous `partner_id` behavior. +- [ ] Fee distribution uses `pricing_partner_id ?? partner_id` for partner markup payout. +- [ ] Ramp registration updates discount state using `pricing_partner_id ?? partner_id`. +- [ ] User ownership checks continue to authorize profile-assigned quotes through `user_id`. +- [ ] Partner ownership checks continue to authorize API-client quotes through `partner_id`. +- [ ] Tests cover assigned user quote ownership and the non-regression path for partner-owned quotes. diff --git a/docs/security-spec/README.md b/docs/security-spec/README.md index cd7f4437e..171a93e19 100644 --- a/docs/security-spec/README.md +++ b/docs/security-spec/README.md @@ -28,6 +28,7 @@ This directory contains the security specification for the Vortex cross-border p | Quote Lifecycle | `03-ramp-engine/quote-lifecycle.md` | Creation, expiry, binding to ramp | | Fee Integrity | `03-ramp-engine/fee-integrity.md` | Fee calculation, dual-system discrepancy | | Discount Mechanism | `03-ramp-engine/discount-mechanism.md` | Partner discounts, subsidies, dynamic adjustment | +| Profile Partner Pricing | `03-ramp-engine/profile-partner-pricing.md` | Supabase profile assignments to partner pricing | | Transaction Validation | `03-ramp-engine/transaction-validation.md` | Presigned tx verification, content validation, signing model | | Ephemeral Account Lifecycle | `03-ramp-engine/ephemeral-accounts.md` | Funding, cleanup, stuck fund prevention | | Ramp Phase Flows | `03-ramp-engine/ramp-phase-flows.md` | Per-corridor token flow, phase handler map, subsidy bounds | From ef163f0b07c07a6e9f2b821c4ce872089977953a Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 3 Jun 2026 18:57:39 +0200 Subject: [PATCH 02/29] Make profile assignment replacement atomic --- ...ofilePartnerAssignments.controller.test.ts | 144 ++++++++++++++++++ .../profilePartnerAssignments.controller.ts | 68 +++++++-- .../03-ramp-engine/profile-partner-pricing.md | 4 + 3 files changed, 202 insertions(+), 14 deletions(-) create mode 100644 apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.test.ts diff --git a/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.test.ts b/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.test.ts new file mode 100644 index 000000000..a4ece0bfb --- /dev/null +++ b/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; +import httpStatus from "http-status"; +import { Transaction, UniqueConstraintError } from "sequelize"; +import sequelize from "../../../config/database"; +import Partner from "../../../models/partner.model"; +import ProfilePartnerAssignment from "../../../models/profilePartnerAssignment.model"; +import User from "../../../models/user.model"; +import { createProfilePartnerAssignment } from "./profilePartnerAssignments.controller"; + +function createResponse() { + const res = { + body: undefined as unknown, + send: mock(() => res), + statusCode: Number(httpStatus.OK), + json: mock((body: unknown) => { + res.body = body; + return res; + }), + status: mock((statusCode: number) => { + res.statusCode = statusCode; + return res; + }) + }; + + return res; +} + +describe("createProfilePartnerAssignment", () => { + const originalTransaction = sequelize.transaction; + const originalUserFindByPk = User.findByPk; + const originalPartnerFindAll = Partner.findAll; + const originalAssignmentUpdate = ProfilePartnerAssignment.update; + const originalAssignmentCreate = ProfilePartnerAssignment.create; + + const transaction = { id: "profile-assignment-tx" }; + const createdAt = new Date("2026-06-03T12:00:00.000Z"); + const updatedAt = new Date("2026-06-03T12:00:01.000Z"); + + afterEach(() => { + sequelize.transaction = originalTransaction; + User.findByPk = originalUserFindByPk; + Partner.findAll = originalPartnerFindAll; + ProfilePartnerAssignment.update = originalAssignmentUpdate; + ProfilePartnerAssignment.create = originalAssignmentCreate; + }); + + function mockValidAssignmentDependencies() { + const transactionMock = mock(async (callback: (tx: unknown) => Promise) => callback(transaction)); + const userFindByPkMock = mock(async () => ({ id: "user-1" })); + const partnerFindAllMock = mock(async () => [{ id: "partner-1", name: "Acme" }]); + const assignmentUpdateMock = mock(async () => [1]); + const assignmentCreateMock = mock(async () => ({ + createdAt, + expiresAt: null, + id: "assignment-2", + isActive: true, + partnerName: "Acme", + updatedAt, + userId: "user-1" + })); + + sequelize.transaction = transactionMock as unknown as typeof sequelize.transaction; + User.findByPk = userFindByPkMock as unknown as typeof User.findByPk; + Partner.findAll = partnerFindAllMock as unknown as typeof Partner.findAll; + ProfilePartnerAssignment.update = assignmentUpdateMock as unknown as typeof ProfilePartnerAssignment.update; + ProfilePartnerAssignment.create = assignmentCreateMock as unknown as typeof ProfilePartnerAssignment.create; + + return { + assignmentCreateMock, + assignmentUpdateMock, + partnerFindAllMock, + transactionMock, + userFindByPkMock + }; + } + + it("replaces the active assignment inside a transaction after locking the profile row", async () => { + const { assignmentCreateMock, assignmentUpdateMock, transactionMock, userFindByPkMock } = mockValidAssignmentDependencies(); + const res = createResponse(); + + await createProfilePartnerAssignment( + { + body: { + partnerName: "Acme", + userId: "user-1" + } + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.CREATED); + expect(transactionMock).toHaveBeenCalledTimes(1); + expect(userFindByPkMock).toHaveBeenCalledWith("user-1", { + lock: Transaction.LOCK.UPDATE, + transaction + }); + expect(assignmentUpdateMock).toHaveBeenCalledWith( + { isActive: false }, + { + transaction, + where: { + isActive: true, + userId: "user-1" + } + } + ); + expect(assignmentCreateMock).toHaveBeenCalledWith( + { + expiresAt: null, + isActive: true, + partnerName: "Acme", + userId: "user-1" + }, + { transaction } + ); + }); + + it("returns 409 when the active-assignment unique index rejects a concurrent replacement", async () => { + const { assignmentCreateMock } = mockValidAssignmentDependencies(); + assignmentCreateMock.mockImplementation(async () => { + throw new UniqueConstraintError({ message: "active assignment already exists" }); + }); + const res = createResponse(); + + await createProfilePartnerAssignment( + { + body: { + partnerName: "Acme", + userId: "user-1" + } + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.CONFLICT); + expect(res.body).toEqual({ + error: { + code: "ASSIGNMENT_CONFLICT", + message: "An active assignment already exists for this profile. Please retry the request.", + status: httpStatus.CONFLICT + } + }); + }); +}); diff --git a/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.ts b/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.ts index 2c330590b..076ef5494 100644 --- a/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.ts +++ b/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.ts @@ -1,11 +1,14 @@ import { Request, Response } from "express"; import httpStatus from "http-status"; -import { WhereOptions } from "sequelize"; +import { Transaction, UniqueConstraintError, WhereOptions } from "sequelize"; +import sequelize from "../../../config/database"; import logger from "../../../config/logger"; import Partner from "../../../models/partner.model"; import ProfilePartnerAssignment, { ProfilePartnerAssignmentAttributes } from "../../../models/profilePartnerAssignment.model"; import User from "../../../models/user.model"; +const PROFILE_NOT_FOUND_AFTER_LOCK = "PROFILE_NOT_FOUND_AFTER_LOCK"; + function parseExpiration(expiresAt: unknown): Date | null { if (!expiresAt) { return null; @@ -82,21 +85,36 @@ export async function createProfilePartnerAssignment(req: Request, res: Response const expirationDate = parseExpiration(expiresAt); - await ProfilePartnerAssignment.update( - { isActive: false }, - { - where: { - isActive: true, - userId - } + const assignment = await sequelize.transaction(async transaction => { + const lockedUser = await User.findByPk(userId, { + lock: Transaction.LOCK.UPDATE, + transaction + }); + + if (!lockedUser) { + throw new Error(PROFILE_NOT_FOUND_AFTER_LOCK); } - ); - const assignment = await ProfilePartnerAssignment.create({ - expiresAt: expirationDate, - isActive: true, - partnerName, - userId + await ProfilePartnerAssignment.update( + { isActive: false }, + { + transaction, + where: { + isActive: true, + userId + } + } + ); + + return ProfilePartnerAssignment.create( + { + expiresAt: expirationDate, + isActive: true, + partnerName, + userId + }, + { transaction } + ); }); res.status(httpStatus.CREATED).json({ @@ -115,6 +133,28 @@ export async function createProfilePartnerAssignment(req: Request, res: Response return; } + if (error instanceof Error && error.message === PROFILE_NOT_FOUND_AFTER_LOCK) { + res.status(httpStatus.NOT_FOUND).json({ + error: { + code: "USER_NOT_FOUND", + message: "Profile was not found", + status: httpStatus.NOT_FOUND + } + }); + return; + } + + if (error instanceof UniqueConstraintError) { + res.status(httpStatus.CONFLICT).json({ + error: { + code: "ASSIGNMENT_CONFLICT", + message: "An active assignment already exists for this profile. Please retry the request.", + status: httpStatus.CONFLICT + } + }); + return; + } + logger.error("Error creating profile partner assignment:", error); res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ error: { diff --git a/docs/security-spec/03-ramp-engine/profile-partner-pricing.md b/docs/security-spec/03-ramp-engine/profile-partner-pricing.md index ca88d9c27..c75718a7d 100644 --- a/docs/security-spec/03-ramp-engine/profile-partner-pricing.md +++ b/docs/security-spec/03-ramp-engine/profile-partner-pricing.md @@ -30,6 +30,7 @@ For profile-assigned frontend quotes, `quote_tickets.user_id` is set to the auth 9. **Fee distribution MUST use the pricing partner, not only the owner partner** - Partner markup payout uses `pricing_partner_id` when present, with `partner_id` as a backward-compatible fallback for older quotes. 10. **Dynamic discount state MUST use the pricing partner** - Quote consumption adjusts the dynamic discount state for the partner whose pricing was used, not for the quote owner. 11. **Assignment administration MUST require admin auth** - Create, list, and revoke assignment endpoints MUST be protected by `adminAuth`; partner API keys and Supabase user tokens MUST NOT manage assignments. +12. **Assignment replacement MUST be atomic per profile** - Creating a new active assignment MUST deactivate the previous active row and insert the replacement in one database transaction. The transaction MUST lock the profile row so concurrent admin writes for the same user serialize, and any residual active-assignment unique-index conflict MUST fail with a retryable `409`. ## Threat Vectors & Mitigations @@ -43,11 +44,14 @@ For profile-assigned frontend quotes, `quote_tickets.user_id` is set to the auth | **Stale assignment remains usable** | A profile's temporary partner entitlement expires but quote creation still applies custom rates. | Resolver filters out assignments with `expires_at <= now()`. | | **Assignment to missing ramp-type config** | A profile is assigned to partner `Acme`, but `partners` has only a BUY row and the user requests SELL. | Resolver requires active partner row with matching `rampType`; otherwise it logs and falls back to default pricing. | | **Unauthorized assignment management** | A partner or normal frontend user assigns themselves or another profile to a discounted partner. | Assignment management routes live under `/v1/admin/profile-partner-assignments` and require `adminAuth`. | +| **Partial assignment replacement** | Admin assignment creation deactivates the current row and then fails before inserting the replacement, leaving the profile with no active pricing assignment. Concurrent creates can also race against the active-user partial unique index. | Replacement runs in one transaction after locking the profile row. Rollback preserves the prior active assignment, and residual unique-index conflicts return `409 ASSIGNMENT_CONFLICT` so the admin can retry. | ## Audit Checklist - [ ] `profile_partner_assignments` exists with `user_id`, `partner_name`, `is_active`, optional `expires_at`, timestamps, and indexes for active user lookups. - [ ] Admin assignment endpoints are protected by `adminAuth` and reject non-admin credentials. +- [ ] Admin assignment replacement deactivates the old active row and creates the new row in one transaction after taking a row lock for the target profile. +- [ ] Active-assignment unique-index collisions return `409 ASSIGNMENT_CONFLICT` instead of a generic server error. - [ ] Quote creation resolves profile assignments only from `req.userId`; unauthenticated quotes never use profile assignment pricing. - [ ] `POST /v1/quotes` and `POST /v1/quotes/best` still reject explicit `partnerId` without matching secret-key authentication. - [ ] Profile-assigned quotes persist `user_id` and `pricing_partner_id`, while leaving `partner_id` `NULL`. From ae137aa345cd7558fa5e6c7599cdb10fc2c9bc59 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 3 Jun 2026 19:22:21 +0200 Subject: [PATCH 03/29] Run audit/spec check --- .../03-ramp-engine/profile-partner-pricing.md | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/security-spec/03-ramp-engine/profile-partner-pricing.md b/docs/security-spec/03-ramp-engine/profile-partner-pricing.md index c75718a7d..0f0d640e9 100644 --- a/docs/security-spec/03-ramp-engine/profile-partner-pricing.md +++ b/docs/security-spec/03-ramp-engine/profile-partner-pricing.md @@ -48,16 +48,16 @@ For profile-assigned frontend quotes, `quote_tickets.user_id` is set to the auth ## Audit Checklist -- [ ] `profile_partner_assignments` exists with `user_id`, `partner_name`, `is_active`, optional `expires_at`, timestamps, and indexes for active user lookups. -- [ ] Admin assignment endpoints are protected by `adminAuth` and reject non-admin credentials. -- [ ] Admin assignment replacement deactivates the old active row and creates the new row in one transaction after taking a row lock for the target profile. -- [ ] Active-assignment unique-index collisions return `409 ASSIGNMENT_CONFLICT` instead of a generic server error. -- [ ] Quote creation resolves profile assignments only from `req.userId`; unauthenticated quotes never use profile assignment pricing. -- [ ] `POST /v1/quotes` and `POST /v1/quotes/best` still reject explicit `partnerId` without matching secret-key authentication. -- [ ] Profile-assigned quotes persist `user_id` and `pricing_partner_id`, while leaving `partner_id` `NULL`. -- [ ] Existing partner API-key and public-key quote paths preserve their previous `partner_id` behavior. -- [ ] Fee distribution uses `pricing_partner_id ?? partner_id` for partner markup payout. -- [ ] Ramp registration updates discount state using `pricing_partner_id ?? partner_id`. -- [ ] User ownership checks continue to authorize profile-assigned quotes through `user_id`. -- [ ] Partner ownership checks continue to authorize API-client quotes through `partner_id`. -- [ ] Tests cover assigned user quote ownership and the non-regression path for partner-owned quotes. +- [x] `profile_partner_assignments` exists with `user_id`, `partner_name`, `is_active`, optional `expires_at`, timestamps, and indexes for active user lookups. +- [x] Admin assignment endpoints are protected by `adminAuth` and reject non-admin credentials. +- [x] Admin assignment replacement deactivates the old active row and creates the new row in one transaction after taking a row lock for the target profile. +- [x] Active-assignment unique-index collisions return `409 ASSIGNMENT_CONFLICT` instead of a generic server error. +- [x] Quote creation resolves profile assignments only from `req.userId`; unauthenticated quotes never use profile assignment pricing. +- [x] `POST /v1/quotes` and `POST /v1/quotes/best` still reject explicit `partnerId` without matching secret-key authentication. +- [x] Profile-assigned quotes persist `user_id` and `pricing_partner_id`, while leaving `partner_id` `NULL`. +- [x] Existing partner API-key and public-key quote paths preserve their previous `partner_id` behavior. +- [x] Fee distribution uses `pricing_partner_id ?? partner_id` for partner markup payout. +- [x] Ramp registration updates discount state using `pricing_partner_id ?? partner_id`. +- [x] User ownership checks continue to authorize profile-assigned quotes through `user_id`. +- [x] Partner ownership checks continue to authorize API-client quotes through `partner_id`. +- [x] Tests cover assigned user quote ownership and the non-regression path for partner-owned quotes. From 6111f9f598fc98dd4866ba2ad9e3e14e6b2505da Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 9 Jun 2026 18:14:29 +0200 Subject: [PATCH 04/29] Introduce separate cap for 'discount' post-swap subsidy --- .../handlers/subsidize-post-swap-handler.ts | 70 +++++++++++++++---- .../post-swap-subsidy-breakdown.test.ts | 56 +++++++++++++++ .../helpers/post-swap-subsidy-breakdown.ts | 49 +++++++++++++ apps/api/src/constants/constants.ts | 2 + 4 files changed, 162 insertions(+), 15 deletions(-) create mode 100644 apps/api/src/api/services/phases/helpers/post-swap-subsidy-breakdown.test.ts create mode 100644 apps/api/src/api/services/phases/helpers/post-swap-subsidy-breakdown.ts 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 08928a49d..3fc33d629 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 @@ -18,7 +18,10 @@ import { import Big from "big.js"; import { encodeFunctionData, erc20Abi } from "viem"; import logger from "../../../../config/logger"; -import { MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION } from "../../../../constants/constants"; +import { + MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION, + MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION +} from "../../../../constants/constants"; import QuoteTicket from "../../../../models/quoteTicket.model"; import RampState from "../../../../models/rampState.model"; import { SubsidyToken } from "../../../../models/subsidy.model"; @@ -27,6 +30,7 @@ import { PhaseError } from "../../../errors/phase-error"; import { priceFeedService } from "../../priceFeed.service"; import { BasePhaseHandler } from "../base-phase-handler"; import { getEvmFundingAccount } from "../evm-funding"; +import { calculatePostSwapSubsidyComponents } from "../helpers/post-swap-subsidy-breakdown"; import { StateMetadata } from "../meta-state-types"; export class SubsidizePostSwapPhaseHandler extends BasePhaseHandler { @@ -132,7 +136,7 @@ export class SubsidizePostSwapPhaseHandler extends BasePhaseHandler { } logger.info( - `Subsidizing post-swap with ${requiredAmount.toFixed()} to reach target value of ${expectedSwapOutputAmountRaw}` + `Subsidizing post-swap with ${requiredAmount.toFixed()} to reach target value of ${expectedSwapOutputAmountRaw.toFixed(0, 0)}` ); const result = await apiManager.executeApiCall( api => @@ -213,7 +217,7 @@ export class SubsidizePostSwapPhaseHandler extends BasePhaseHandler { quote.metadata.subsidy.subsidyAmountInOutputTokenRaw ); - logger.debug(`SubsidizePostSwapHandler (EVM): expectedSwapOutputAmountRaw ${expectedSwapOutputAmountRaw.toString()}`); + logger.debug(`SubsidizePostSwapHandler (EVM): expectedSwapOutputAmountRaw ${expectedSwapOutputAmountRaw.toFixed(0, 0)}`); // Try to find the required amount to subsidize on the quote metadata if (state.type === RampDirection.BUY) { @@ -223,32 +227,68 @@ export class SubsidizePostSwapPhaseHandler extends BasePhaseHandler { expectedSwapOutputAmountRaw = Big(quote.metadata.nablaSwapEvm.outputAmountRaw); } - const requiredAmount = Big(expectedSwapOutputAmountRaw).sub(currentBalance); - logger.debug(`SubsidizePostSwapHandler (EVM): requiredAmount ${requiredAmount.toString()}`); + const subsidyComponents = calculatePostSwapSubsidyComponents({ + currentBalanceRaw: currentBalance, + discountSubsidyAmountRaw: quote.metadata.subsidy.subsidyAmountInOutputTokenRaw, + expectedOutputAmountRaw: expectedSwapOutputAmountRaw, + quotedActualOutputAmountRaw: quote.metadata.subsidy.actualOutputAmountRaw + }); + const requiredAmount = subsidyComponents.requiredAmountRaw; + logger.debug( + `SubsidizePostSwapHandler (EVM): requiredAmount ${requiredAmount.toFixed(0, 0)}, ` + + `discrepancyAmount ${subsidyComponents.discrepancyAmountRaw.toFixed(0, 0)}, ` + + `discountAmount ${subsidyComponents.discountAmountRaw.toFixed(0, 0)}` + ); if (requiredAmount.gt(Big(0))) { - const subsidyDecimal = nativeToDecimal(requiredAmount, quote.metadata.nablaSwapEvm.outputDecimals).toString(); - const subsidyUsd = await priceFeedService.convertCurrency( - subsidyDecimal, - outputToken as RampCurrency, - EvmToken.USDC as RampCurrency - ); + const discrepancySubsidyDecimal = nativeToDecimal( + subsidyComponents.discrepancyAmountRaw, + quote.metadata.nablaSwapEvm.outputDecimals + ).toFixed(); + const discountSubsidyDecimal = nativeToDecimal( + subsidyComponents.discountAmountRaw, + quote.metadata.nablaSwapEvm.outputDecimals + ).toFixed(); + const discrepancySubsidyUsd = subsidyComponents.discrepancyAmountRaw.gt(0) + ? await priceFeedService.convertCurrency( + discrepancySubsidyDecimal, + outputToken as RampCurrency, + EvmToken.USDC as RampCurrency + ) + : "0"; + const discountSubsidyUsd = subsidyComponents.discountAmountRaw.gt(0) + ? await priceFeedService.convertCurrency( + discountSubsidyDecimal, + outputToken as RampCurrency, + EvmToken.USDC as RampCurrency + ) + : "0"; const quoteOutputUsd = await priceFeedService.convertCurrency( quote.outputAmount, quote.outputCurrency as RampCurrency, EvmToken.USDC as RampCurrency ); - const subsidyCapUsd = Big(quoteOutputUsd).mul(MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION); - if (Big(subsidyUsd).gt(subsidyCapUsd)) { + const discrepancySubsidyCapUsd = Big(quoteOutputUsd).mul(MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION); + if (Big(discrepancySubsidyUsd).gt(discrepancySubsidyCapUsd)) { // Pause for operator intervention without moving the ramp to failed. throw this.createRecoverableError( - `SubsidizePostSwapPhaseHandler: Required subsidy $${subsidyUsd} exceeds cap $${subsidyCapUsd.toFixed(2)} (${MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION} of quote output $${quoteOutputUsd}).` + `SubsidizePostSwapPhaseHandler: Required swap discrepancy subsidy $${discrepancySubsidyUsd} exceeds cap $${discrepancySubsidyCapUsd.toFixed(2)} (${MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION} of quote output $${quoteOutputUsd}).` ); } + const discountSubsidyCapUsd = Big(quoteOutputUsd).mul(MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION); + if (Big(discountSubsidyUsd).gt(discountSubsidyCapUsd)) { + // Pause for operator intervention without moving the ramp to failed. + throw this.createRecoverableError( + `SubsidizePostSwapPhaseHandler: Required discount subsidy $${discountSubsidyUsd} exceeds cap $${discountSubsidyCapUsd.toFixed(2)} (${MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION} of quote output $${quoteOutputUsd}).` + ); + } + + const subsidyUsd = Big(discrepancySubsidyUsd).plus(discountSubsidyUsd).toFixed(); + // Do the actual subsidizing on EVM logger.info( - `Subsidizing post-swap EVM with ${requiredAmount.toFixed()} to reach target value of ${expectedSwapOutputAmountRaw}` + `Subsidizing post-swap EVM with ${requiredAmount.toFixed()} ($${subsidyUsd}) to reach target value of ${expectedSwapOutputAmountRaw.toFixed(0, 0)}` ); const evmClientManager = EvmClientManager.getInstance(); diff --git a/apps/api/src/api/services/phases/helpers/post-swap-subsidy-breakdown.test.ts b/apps/api/src/api/services/phases/helpers/post-swap-subsidy-breakdown.test.ts new file mode 100644 index 000000000..e74a7f6f7 --- /dev/null +++ b/apps/api/src/api/services/phases/helpers/post-swap-subsidy-breakdown.test.ts @@ -0,0 +1,56 @@ +// eslint-disable-next-line import/no-unresolved +import {describe, expect, it} from "bun:test"; +import {calculatePostSwapSubsidyComponents} from "./post-swap-subsidy-breakdown"; + +describe("calculatePostSwapSubsidyComponents", () => { + it("splits live shortfall below the quoted actual output into discrepancy plus discount", () => { + const result = calculatePostSwapSubsidyComponents({ + currentBalanceRaw: "90", + discountSubsidyAmountRaw: "5", + expectedOutputAmountRaw: "105", + quotedActualOutputAmountRaw: "100" + }); + + expect(result.requiredAmountRaw.toFixed(0, 0)).toBe("15"); + expect(result.discrepancyAmountRaw.toFixed(0, 0)).toBe("10"); + expect(result.discountAmountRaw.toFixed(0, 0)).toBe("5"); + }); + + it("treats shortfall above the quoted actual output as discount only", () => { + const result = calculatePostSwapSubsidyComponents({ + currentBalanceRaw: "102", + discountSubsidyAmountRaw: "5", + expectedOutputAmountRaw: "105", + quotedActualOutputAmountRaw: "100" + }); + + expect(result.requiredAmountRaw.toFixed(0, 0)).toBe("3"); + expect(result.discrepancyAmountRaw.toFixed(0, 0)).toBe("0"); + expect(result.discountAmountRaw.toFixed(0, 0)).toBe("3"); + }); + + it("falls back to expected output minus discount when quoted actual output is unavailable", () => { + const result = calculatePostSwapSubsidyComponents({ + currentBalanceRaw: "90", + discountSubsidyAmountRaw: "5", + expectedOutputAmountRaw: "105" + }); + + expect(result.requiredAmountRaw.toFixed(0, 0)).toBe("15"); + expect(result.discrepancyAmountRaw.toFixed(0, 0)).toBe("10"); + expect(result.discountAmountRaw.toFixed(0, 0)).toBe("5"); + }); + + it("returns zero components when the live balance already reaches the target", () => { + const result = calculatePostSwapSubsidyComponents({ + currentBalanceRaw: "106", + discountSubsidyAmountRaw: "5", + expectedOutputAmountRaw: "105", + quotedActualOutputAmountRaw: "100" + }); + + expect(result.requiredAmountRaw.toFixed(0, 0)).toBe("0"); + expect(result.discrepancyAmountRaw.toFixed(0, 0)).toBe("0"); + expect(result.discountAmountRaw.toFixed(0, 0)).toBe("0"); + }); +}); diff --git a/apps/api/src/api/services/phases/helpers/post-swap-subsidy-breakdown.ts b/apps/api/src/api/services/phases/helpers/post-swap-subsidy-breakdown.ts new file mode 100644 index 000000000..0f3d282a8 --- /dev/null +++ b/apps/api/src/api/services/phases/helpers/post-swap-subsidy-breakdown.ts @@ -0,0 +1,49 @@ +import Big from "big.js"; + +type BigSource = string | number | Big; + +interface PostSwapSubsidyComponentsInput { + currentBalanceRaw: BigSource; + discountSubsidyAmountRaw: BigSource; + expectedOutputAmountRaw: BigSource; + quotedActualOutputAmountRaw?: BigSource; +} + +export interface PostSwapSubsidyComponents { + discountAmountRaw: Big; + discrepancyAmountRaw: Big; + requiredAmountRaw: Big; +} + +function positive(value: Big): Big { + return value.gt(0) ? value : Big(0); +} + +function minBig(a: Big, b: Big): Big { + return a.lt(b) ? a : b; +} + +export function calculatePostSwapSubsidyComponents({ + currentBalanceRaw, + discountSubsidyAmountRaw, + expectedOutputAmountRaw, + quotedActualOutputAmountRaw +}: PostSwapSubsidyComponentsInput): PostSwapSubsidyComponents { + const currentBalance = Big(currentBalanceRaw); + const expectedOutputAmount = Big(expectedOutputAmountRaw); + const discountSubsidyAmount = positive(Big(discountSubsidyAmountRaw)); + const quotedActualOutputAmount = + quotedActualOutputAmountRaw === undefined + ? expectedOutputAmount.minus(discountSubsidyAmount) + : Big(quotedActualOutputAmountRaw); + + const requiredAmountRaw = positive(expectedOutputAmount.minus(currentBalance)); + const discrepancyBaselineRaw = minBig(positive(quotedActualOutputAmount), expectedOutputAmount); + const discrepancyAmountRaw = positive(discrepancyBaselineRaw.minus(currentBalance)); + + return { + discountAmountRaw: positive(requiredAmountRaw.minus(discrepancyAmountRaw)), + discrepancyAmountRaw, + requiredAmountRaw + }; +} diff --git a/apps/api/src/constants/constants.ts b/apps/api/src/constants/constants.ts index 1e42e296c..03d9bb6f7 100644 --- a/apps/api/src/constants/constants.ts +++ b/apps/api/src/constants/constants.ts @@ -16,6 +16,7 @@ const GLMR_FUNDING_AMOUNT_RAW = "50000000000000000"; const ASSETHUB_XCM_FEE_USDC_UNITS = 0.013124; const MAX_FINAL_SETTLEMENT_SUBSIDY_USD = "10"; // 10 USD const MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION = "0.05"; // 5% of quote.outputAmount in USD +const MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION = "0.05"; // 5% of quote.outputAmount in USD const WEBHOOKS_CACHE_URL = "https://webhooks-cache.pendulumchain.tech"; // EXAMPLE URL @@ -56,5 +57,6 @@ export { STELLAR_BASE_FEE, MAX_FINAL_SETTLEMENT_SUBSIDY_USD, MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION, + MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION, BASE_EPHEMERAL_STARTING_BALANCE_UNITS }; From dd2bc11aed24af989d8e8bf6e0ee584415cf2f42 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 9 Jun 2026 18:14:36 +0200 Subject: [PATCH 05/29] Adjust spec files --- docs/security-spec/03-ramp-engine/discount-mechanism.md | 8 +++++--- docs/security-spec/03-ramp-engine/quote-lifecycle.md | 4 ++-- docs/security-spec/03-ramp-engine/ramp-phase-flows.md | 7 ++++--- docs/security-spec/05-integrations/brla.md | 4 ++-- docs/security-spec/05-integrations/mykobo.md | 3 ++- docs/security-spec/06-cross-chain/fund-routing.md | 6 +++--- docs/security-spec/SPEC-DELTA-2026-05.md | 4 ++-- 7 files changed, 20 insertions(+), 16 deletions(-) diff --git a/docs/security-spec/03-ramp-engine/discount-mechanism.md b/docs/security-spec/03-ramp-engine/discount-mechanism.md index b1fc5e0b2..29bd59f61 100644 --- a/docs/security-spec/03-ramp-engine/discount-mechanism.md +++ b/docs/security-spec/03-ramp-engine/discount-mechanism.md @@ -14,7 +14,7 @@ For each quote, the discount engine: 4. Calculates `expectedOutput = inputAmount × oraclePrice × (1 + targetDiscount + adjustedDifference)`. For offramps the oracle price is inverted first. 5. Calculates `actualOutput` as what the user would receive without subsidy (Nabla output minus post-swap fees on onramp, anchor fee added back on offramp). 6. Calculates `idealSubsidy = max(0, expectedOutput − actualOutput)` and `actualSubsidy = min(idealSubsidy, maxSubsidy × expectedOutput)` (only when `targetDiscount > 0`). -7. Writes a `ctx.subsidy` record consumed by downstream merge-subsidy and finalize stages and ultimately by the subsidy phase handlers. +7. Writes a `ctx.subsidy` record consumed by downstream merge-subsidy and finalize stages and ultimately by the subsidy phase handlers. On EVM post-swap routes this record represents the discount-derived subsidy component only; the runtime handler may additionally cover actual-vs-quoted swap-output discrepancy, which is capped separately. The engine is wired by strategy configuration. Of the 10 route strategies in `apps/api/src/api/services/quote/routes/strategies/`, 9 register a discount engine and 1 does **not**: `onramp-monerium-to-evm`. On that single route, no subsidy is computed regardless of partner configuration. @@ -35,8 +35,9 @@ For onramps to EVM destinations other than AssetHub, the engine also probes Squi 6. **Subsidy MUST NOT bypass fee collection.** For onramps, `actualOutput = nablaOutput − (network + vortex + partnerMarkup)`. The subsidy then covers the shortfall against `expectedOutput` *after* those fees, so fees still flow to fee accounts. 7. **For offramps, the anchor fee MUST be added back to `expectedOutput`** before computing the shortfall (`adjustedExpectedOutputDecimal = oracleExpected + anchorFeeInBrl`). Otherwise the user would receive `expectedOutput − anchorFee`, which is short of the advertised rate by the anchor's cut. 8. **Subsidy amounts written to `ctx.subsidy` MUST be deterministic for a given input.** With `targetDiscount=0` the actual subsidy is forced to zero (`actualSubsidyAmountDecimal = Big(0)`), even when `idealSubsidy > 0`. This is the contract the merge-subsidy and subsidy phase handlers rely on. -9. **The dynamic difference MUST NOT be incremented within `discountStateTimeoutMinutes` of the last quote** — `getAdjustedDifference` only adds `deltaD` when `isWithinStateTimeout` is **false**. Otherwise repeated quotes from the same partner would inflate the difference faster than intended. -10. **Squid Router probe failures MUST fall back to a 1:1 assumption, never block the quote.** Both `getSquidRouterUSDCConversionRate` and `getSquidRouterAxlUSDCConversionRate` return `null` on error and the engine proceeds with `adjustedExpectedOutputDecimal = oracleExpectedOutputDecimal`. A network failure on the probe MUST NOT cause the entire quote stage to throw. +9. **Discount subsidy MUST remain distinct from runtime swap discrepancy subsidy.** `ctx.subsidy.subsidyAmountInOutputTokenRaw` is the quote-time discount component, bounded by partner `maxSubsidy`. On EVM `subsidizePostSwap`, any actual-vs-quoted swap-output discrepancy is calculated against the live post-swap balance and capped by `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION`; the discount component is capped separately by `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION`. +10. **The dynamic difference MUST NOT be incremented within `discountStateTimeoutMinutes` of the last quote** — `getAdjustedDifference` only adds `deltaD` when `isWithinStateTimeout` is **false**. Otherwise repeated quotes from the same partner would inflate the difference faster than intended. +11. **Squid Router probe failures MUST fall back to a 1:1 assumption, never block the quote.** Both `getSquidRouterUSDCConversionRate` and `getSquidRouterAxlUSDCConversionRate` return `null` on error and the engine proceeds with `adjustedExpectedOutputDecimal = oracleExpectedOutputDecimal`. A network failure on the probe MUST NOT cause the entire quote stage to throw. ## Threat Vectors & Mitigations @@ -60,6 +61,7 @@ For onramps to EVM destinations other than AssetHub, the engine also probes Squi - [x] `OffRampDiscountEngine` only fires for `RampDirection.SELL`; `OnRampDiscountEngine` only fires for `RampDirection.BUY`. **PASS** — `offramp.ts:10`, `onramp.ts:18`. - [x] Discount parameters (`targetDiscount`, `maxSubsidy`, `minDynamicDifference`, `maxDynamicDifference`) are read exclusively from the `Partner` Sequelize model and never accepted from request fields. **PASS** — `resolveDiscountPartner` uses `Partner.findOne`; no request field is read. - [x] Subsidy cap `maxSubsidy × expectedOutput` is enforced in `calculateSubsidyAmount` (`helpers.ts:152-167`). **PASS**. +- [x] EVM post-swap runtime cap logic treats discount subsidy separately from swap discrepancy subsidy. **PASS** — the discount component comes from `ctx.subsidy.subsidyAmountInOutputTokenRaw`; the live discrepancy component is computed from the post-swap balance and quoted actual output. Each component must pass its own runtime cap before transfer. - [x] `targetDiscount=0` forces `actualSubsidyAmountDecimal = Big(0)` in both engines. **PASS** — `offramp.ts:76-79`, `onramp.ts:209-212`. - [x] Offramp `expectedOutput` adds back the anchor fee (`adjustedExpectedOutputDecimal = oracleExpectedOutput + anchorFeeInBrl`). **PASS** — `offramp.ts:50-51`. - [x] Onramp `actualOutput` subtracts post-swap fees (`network + vortex + partnerMarkup`). **PASS** — `onramp.ts:198-199`. diff --git a/docs/security-spec/03-ramp-engine/quote-lifecycle.md b/docs/security-spec/03-ramp-engine/quote-lifecycle.md index 067d739bd..b2602dab6 100644 --- a/docs/security-spec/03-ramp-engine/quote-lifecycle.md +++ b/docs/security-spec/03-ramp-engine/quote-lifecycle.md @@ -59,7 +59,7 @@ The refresh policy is intentionally strict (byte-identical `toAmount` and `fee` 1. **Quotes MUST expire** — A quote older than 10 minutes MUST be rejected when a ramp attempts to bind to it. The expiry is checked via `quote.expiresAt < new Date()` at registration time. Exchange rates change; stale quotes expose the platform to unfavorable rates. 2. **Each quote MUST be consumable exactly once** — After a quote is bound to a ramp, it MUST NOT be reusable for another ramp. This prevents a single favorable quote from being exploited multiple times. 3. **Quote amounts MUST be immutable after creation** — Once a quote is stored, its `inputAmount`, `outputAmount`, fee breakdown, and exchange rate MUST NOT be modifiable. The ramp uses these exact values. -4. **The quoted output amount MUST be the guaranteed minimum the user receives** — The platform subsidizes any shortfall between the actual swap result and the quoted amount (up to the subsidy cap). The user MUST NOT receive less than the quoted output (after fees). +4. **The quoted output amount MUST be the guaranteed minimum the user receives** — The platform subsidizes eligible shortfalls between the actual swap result and the quoted amount (up to the applicable subsidy caps). On EVM post-swap routes, the top-up may be split into an actual-vs-quoted swap-output discrepancy component and a quote-time discount component; each is bounded independently. The user MUST NOT receive less than the quoted output (after fees) unless a cap breach leaves the ramp waiting for operator intervention. 5. **Fee calculations MUST be deterministic for the same inputs** — Given the same input amount, currencies, ramp direction, and fee configuration, the quote MUST produce the same fee breakdown. Non-deterministic fees create audit and reconciliation gaps. Note: the dynamic pricing adjustment (`difference`) adds intentional variability to the *rate*, not the *fees*. 6. **Quote validation MUST occur at ramp registration time** — When binding a quote to a ramp, the API MUST verify: quote exists, quote is not expired, quote is not already consumed, and the requesting user/partner is authorized to use it. 7. **Dynamic pricing `difference` MUST be clamped to partner bounds** — The `difference` value must never exceed `maxDynamicDifference` or fall below `minDynamicDifference`. Both bounds are enforced in `getAdjustedDifference` and `handleQuoteConsumptionForDiscountState`. @@ -72,7 +72,7 @@ The refresh policy is intentionally strict (byte-identical `toAmount` and `fee` | Threat | Attack Scenario | Mitigation | |---|---|---| -| **Stale quote exploitation** | Attacker creates a quote when rates are favorable, waits for rates to move against the platform, then registers a ramp at the old rate | Quote expiry (10 minutes hardcoded); platform subsidizes the difference but bounds it via subsidy cap (`maxSubsidy`) | +| **Stale quote exploitation** | Attacker creates a quote when rates are favorable, waits for rates to move against the platform, then registers a ramp at the old rate | Quote expiry (10 minutes hardcoded); quote-time discount subsidy is bounded by partner `maxSubsidy`, and EVM post-swap runtime subsidy components are bounded by their own caps before funds move. | | **Quote replay** | Attacker uses the same favorable quote ID for multiple ramps | One-time consumption: quote status is set to `"consumed"` on ramp registration; second attempt is rejected (`quote.status !== "pending"`) | | **Quote manipulation** | Attacker modifies quote amounts in transit or in database | Quotes stored server-side; amounts calculated server-side from authoritative sources; client cannot override amounts | | **Price oracle manipulation** | Attacker manipulates the DEX price before requesting a quote to get an artificially favorable rate | Use TWAP or multi-source pricing; bound acceptable deviation from reference rates; monitor for unusual quote patterns | 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 ec3a62f09..279789429 100644 --- a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md +++ b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md @@ -34,6 +34,7 @@ There are 29+ phase handlers in `apps/api/src/api/services/phases/handlers/`. Th - **Temporary disablement:** AssetHub→BRL quotes are currently not returned by the quote engine. The active BRL off-ramp corridor is source EVM → Base → PIX only; any legacy AssetHub→BRL route code should be treated as unreachable until the corridor is re-enabled. - Note: `distributeFees` runs **before** `nablaSwap` on offramp because fees are denominated in USDC and must be deducted before swapping to BRLA. - Naming: `nablaApprove`, `nablaSwap`, `distributeFees`, `subsidizePreSwap`, and `subsidizePostSwap` are polymorphic runtime phases that dispatch to the EVM (Base) branch when the ephemeral involved is on Base (BRL input or output corridor) and to the Substrate (Pendulum) branch otherwise. +- The EVM `subsidizePostSwap` branch may fund two bounded components in one transfer: the actual-vs-quoted Nabla output discrepancy and the quote-time discount subsidy. The discrepancy component uses `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION`; the discount component uses `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION`. **BRL On-ramp (Avenia/BRLA on Base):** PIX payment → Avenia mints BRLA on Base ephemeral → Nabla-on-Base swap (BRLA→USDC) → optional Squid → user destination - Runtime backend phases: `initial` → `brlaOnrampMint` (poll Base RPC, 30min outer / 5min inner) → `fundEphemeral` → `subsidizePreSwap` → `nablaApprove` → `nablaSwap` → `distributeFees` → `subsidizePostSwap` → `squidRouterSwap` → `destinationTransfer` → `complete` @@ -176,7 +177,7 @@ graph TD ## Security Invariants 1. **Phase ordering MUST match the expected corridor flow** — Each corridor has a fixed phase sequence. The phase processor MUST NOT allow out-of-order transitions. The phase handler's return value determines the next phase, and it MUST match the expected sequence for the ramp's corridor. -2. **Subsidy amounts MUST be bounded** — Every subsidization handler (`subsidizePreSwap`, `subsidizePostSwap`, `fundEphemeral`, `finalSettlementSubsidy`) must enforce a maximum USD-equivalent cap to prevent draining the funding account on a single ramp. +2. **Subsidy amounts MUST be bounded** — Every subsidization handler (`subsidizePreSwap`, `subsidizePostSwap`, `fundEphemeral`, `finalSettlementSubsidy`) must enforce a maximum USD-equivalent cap to prevent draining the funding account on a single ramp. EVM `subsidizePostSwap` must not treat the top-up as one undifferentiated bucket: the actual-vs-quoted swap-output discrepancy and the discount-derived subsidy must each pass their own cap before any transfer is submitted. 3. **Presigned transactions MUST be used in the correct phase** — `getPresignedTransaction(state, phase)` retrieves the transaction for a specific phase. A phase handler MUST NOT access presigned transactions for a different phase. 4. **Token amounts at each phase MUST be traceable to the original quote** — The quote defines input/output amounts. Each phase should operate on amounts derived from the quote, not from untrusted runtime state. 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. @@ -191,7 +192,7 @@ graph TD | Threat | Attack Scenario | Mitigation | |---|---|---| | **Phase skip / injection** | Attacker with DB access modifies `currentPhase` to skip subsidization or jump to `complete`. | Phase transitions are controlled by handler return values, not external input. DB access is a prerequisite (see `state-machine.md`, Threat: "Phase skip attack"). No DB-level constraints on valid transitions exist. | -| **Subsidy drain** | A crafted ramp triggers multiple subsidization phases, each at the maximum allowed amount, draining the funding account. | Per-ramp subsidy caps (`MAX_FINAL_SETTLEMENT_SUBSIDY_USD`, balance pre-checks in pre/post-swap handlers). No aggregate cross-ramp cap exists — many concurrent ramps could still drain funds. | +| **Subsidy drain** | A crafted ramp triggers multiple subsidization phases, each at the maximum allowed amount, draining the funding account. | Per-ramp subsidy caps (`MAX_FINAL_SETTLEMENT_SUBSIDY_USD`, balance pre-checks in pre/post-swap handlers). EVM post-swap subsidy is split into discrepancy and discount components with independent caps. No aggregate cross-ramp cap exists — many concurrent ramps could still drain funds. | | **Double-execution on retry** | Phase processor retries after timeout. Handler re-executes a swap or transfer that already completed. Funds are consumed twice. | Nonce guards in Spacewalk and Hydration handlers detect prior execution. Other handlers rely on transaction nonce uniqueness at the chain level. Not all handlers have explicit re-execution guards. | | **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). | @@ -219,7 +220,7 @@ graph TD - [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] EVM subsidy phases enforce USD-equivalent caps. **PASS** — `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION="0.05"` clamps pre-swap subsidy and the post-swap actual-vs-quoted swap-output discrepancy component. `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION="0.05"` separately clamps the post-swap discount-derived component. 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. diff --git a/docs/security-spec/05-integrations/brla.md b/docs/security-spec/05-integrations/brla.md index e356d48e8..de7ec5204 100644 --- a/docs/security-spec/05-integrations/brla.md +++ b/docs/security-spec/05-integrations/brla.md @@ -86,7 +86,7 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou | **Amount manipulation between quote and payout** | Attacker modifies the payout amount between quote and execution | `quote.outputAmount` read from DB at execution time; quote is immutable post-creation. | | **Avenia service outage** | Avenia API is unreachable mid-ramp | `RecoverablePhaseError` → phase processor retries; off-ramp fails to payout but BRLA is held on the Avenia subaccount, not lost. | | **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`. | +| **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 split quote-relative EVM subsidy caps documented in `fund-routing.md`. The actual-vs-quoted swap discrepancy uses `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION`; the discount component uses `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION`. | | **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. | | **Destination-token decimal under-delivery** | A BRL on-ramp targets an 18-decimal token such as BSC USDT, but the quote output is truncated to 6 decimals before `destinationTransfer` raw amount construction. | On-ramp finalization uses destination-token decimals for BRL EVM outputs; Squid metadata preserves destination raw output from `route.estimate.toAmount`. | @@ -118,4 +118,4 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou ## Remediation Notes - **Hardcoded BRL offramp validation amount:** Resolved in the remediation pass; BRL offramp validation now derives the pre-anchor amount from quote metadata instead of a literal placeholder. -- **EVM subsidy USD cap:** Resolved for the Base EVM subsidy handlers via `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION`. Over-cap cases are intentionally recoverable retries: no subsidy transfer is submitted, and the ramp remains waiting for operator action rather than becoming unrecoverably failed. +- **EVM subsidy USD caps:** Resolved for the Base EVM subsidy handlers via `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` and, for post-swap discount top-ups, `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION`. Over-cap cases are intentionally recoverable retries: no subsidy transfer is submitted, and the ramp remains waiting for operator action rather than becoming unrecoverably failed. diff --git a/docs/security-spec/05-integrations/mykobo.md b/docs/security-spec/05-integrations/mykobo.md index 2d36c3d66..886540cc0 100644 --- a/docs/security-spec/05-integrations/mykobo.md +++ b/docs/security-spec/05-integrations/mykobo.md @@ -32,7 +32,7 @@ Mykobo replaces two earlier EUR rails: - **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`). +6. `subsidizePostSwap` (if needed) → `distributeFees` (Multicall3 batch on Base, see `fee-integrity.md`). The EVM post-swap branch uses the split subsidy caps documented in `fund-routing.md`: swap-output discrepancy and discount subsidy are bounded separately before any transfer is submitted. 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) @@ -110,6 +110,7 @@ Unlike Monerium (`moneriumOnrampMint` + `moneriumOnrampSelfTransfer`), Vortex do | **`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. | +| **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. | ## Audit Checklist diff --git a/docs/security-spec/06-cross-chain/fund-routing.md b/docs/security-spec/06-cross-chain/fund-routing.md index 3eeb1a56e..10ab021d1 100644 --- a/docs/security-spec/06-cross-chain/fund-routing.md +++ b/docs/security-spec/06-cross-chain/fund-routing.md @@ -12,7 +12,7 @@ There are now **five** subsidization-related phase handlers and one settlement p - `final-settlement-subsidy.ts` — Tops up an EVM ephemeral by SquidRouter-swapping native → ERC-20 (legacy / cross-chain settlement). Has a USD cap (`MAX_FINAL_SETTLEMENT_SUBSIDY_USD`). - `destination-transfer-handler.ts` — Sends the presigned EVM transfer from the ephemeral to the user's destination address -**Phase handlers (EVM):** The Substrate handlers above are polymorphic: `subsidize-pre-swap-handler.ts` and `subsidize-post-swap-handler.ts` dispatch to their EVM branches when the ephemeral involved is on a supported EVM chain (currently Base). The EVM branches top the ephemeral up before/after `nablaSwap` (EVM branch) and enforce the quote-relative USD cap `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION`. +**Phase handlers (EVM):** The Substrate handlers above are polymorphic: `subsidize-pre-swap-handler.ts` and `subsidize-post-swap-handler.ts` dispatch to their EVM branches when the ephemeral involved is on a supported EVM chain (currently Base). The EVM pre-swap branch tops the ephemeral up before `nablaSwap` and enforces the quote-relative USD cap `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION`. The EVM post-swap branch splits the required top-up into a swap-discrepancy component and a discount component: the existing `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` applies only to the actual-vs-quoted swap-output discrepancy, while `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION` caps the discount-derived top-up separately. **How subsidization works:** 1. Read the ephemeral account's current balance @@ -41,7 +41,7 @@ The current code resolves this through `EVM_FUNDING_PRIVATE_KEY` and the `getEvm 6. **Destination transfer MUST verify balance before submission** — The handler checks that the ephemeral has sufficient balance for the transfer. If insufficient, the phase fails rather than submitting a transaction that would revert. 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. +9. **EVM subsidy caps MUST stop transfers without forcing manual phase repair** — If an EVM pre-swap subsidy exceeds the quote-relative cap, the handler must not submit a transfer. For EVM post-swap subsidy, the handler must split the top-up into (a) actual-vs-quoted swap-output discrepancy and (b) discount-derived subsidy, then enforce each component's cap independently before submitting a single transfer. A 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. @@ -76,7 +76,7 @@ The current code resolves this through `EVM_FUNDING_PRIVATE_KEY` and the `getEvm - [N/A] Check whether there is any monitoring or alerting on funding account balance depletion. **N/A** — no monitoring infrastructure audited. - [x] Verify `MAX_FINAL_SETTLEMENT_SUBSIDY_USD` value is reasonable for the expected settlement amounts (check the constant's actual value). **PASS** — value reviewed and reasonable for expected settlement sizes. - [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] **EVM subsidy handlers (`subsidize-pre-swap-evm-handler.ts`, `subsidize-post-swap-evm-handler.ts`) enforce USD caps**. Pre-swap subsidy uses `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION`. Post-swap subsidy uses split caps: `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` for the actual-vs-quoted swap-output discrepancy, and `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION` for the discount-derived component. 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/SPEC-DELTA-2026-05.md b/docs/security-spec/SPEC-DELTA-2026-05.md index d8dcdfcbe..a570221f5 100644 --- a/docs/security-spec/SPEC-DELTA-2026-05.md +++ b/docs/security-spec/SPEC-DELTA-2026-05.md @@ -266,7 +266,7 @@ Priority order for the next audit/dev cycle, based on severity × likelihood. Re | # | Finding | Status | |---|---|---| -| 1 | **F-NEW-02** (HIGH if cap matters in practice) — Add EVM subsidy USD cap. Mirror F-001 fix. | RESOLVED — `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION="0.05"` enforced in pre/post-swap EVM handlers; over-cap cases are recoverable waits with no transfer submitted. | +| 1 | **F-NEW-02** (HIGH if cap matters in practice) — Add EVM subsidy USD cap. Mirror F-001 fix. | RESOLVED — `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION="0.05"` enforced in pre-swap EVM handlers and on the post-swap actual-vs-quoted swap-output discrepancy component. Post-swap discount-derived subsidy is capped separately via `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION="0.05"`; over-cap cases are recoverable waits with no transfer submitted. | | 2 | **F-NEW-01** (HIGH) — Replace hardcoded `validateBRLOfframp` amount. | RESOLVED — `validateBRLOfframpMetadata(quote)` reads `quote.metadata.pendulumToMoonbeamXcm.outputAmountRaw`. Dead `evm-to-brl.ts` route deleted. | | 3 | **F-NEW-06b** (MEDIUM) — Surface or fail-fast on partner `payout_address_evm` NULL (silent markup loss). | RESOLVED — quote-time rejection (`APIError 400`) when partner has markup AND `payout_address_evm` NULL on EVM-payout routes; runtime WARN if it slips through. | | 4 | **F-NEW-04** (MEDIUM) — Harden no-permit fallback receipt validation. | RESOLVED — `waitForUserHash` now verifies receipt `to` and tx `input` against the presigned `EvmTransactionData`. | @@ -276,7 +276,7 @@ Priority order for the next audit/dev cycle, based on severity × likelihood. Re | 8 | **F-NEW-03** (LOW) — Tighten `backupApprove` allowance from `maxUint256` to a calculated bound. | RESOLVED — `avenia-to-evm-base.ts` `backupApprove` now uses `inputAmountRawFinalBridge × 1.05`. | | 9 | **F-NEW-08** — Investigate skip-Squid passthrough divergence. | NO BUG — same-chain same-token passthrough has no Squid fee; `networkFeeUSD="0"` and 1:1 rate are correct. | | 10 | **F-NEW-09** — Investigate BRLA payout recovery branches. | NO BUG — once `payOutTicketId` exists, BRLA acknowledged the EVM payout; on-chain receipt is no longer authoritative. | -| 11 | **F-NEW-10** — Avenia anchor-fee assumption in three-amount model. | NO BUG — `OffRampMergeSubsidyEvmEngine` adds the projected subsidy into `nablaSwapEvm.outputAmountRaw`, and `OffRampFinalizeEngine` then sets `quote.outputAmount = nablaSwapEvm.outputAmountDecimal − anchorFee`. The relationship `nablaSwapEvm.outputAmountRaw ≥ quote.outputAmount × 10^brlaDecimals` is therefore tautological at quote-build time. The actual safety net is the EVM branch of `subsidize-post-swap-handler.ts`, which tops the ephemeral up to `nablaSwapEvm.outputAmountRaw` at runtime (capped by F-NEW-02's 5% USD subsidy bound). No build-time assertion needed. | +| 11 | **F-NEW-10** — Avenia anchor-fee assumption in three-amount model. | NO BUG — `OffRampMergeSubsidyEvmEngine` adds the projected subsidy into `nablaSwapEvm.outputAmountRaw`, and `OffRampFinalizeEngine` then sets `quote.outputAmount = nablaSwapEvm.outputAmountDecimal − anchorFee`. The relationship `nablaSwapEvm.outputAmountRaw ≥ quote.outputAmount × 10^brlaDecimals` is therefore tautological at quote-build time. The actual safety net is the EVM branch of `subsidize-post-swap-handler.ts`, which tops the ephemeral up to `nablaSwapEvm.outputAmountRaw` at runtime using split caps for swap discrepancy and discount subsidy. No build-time assertion needed. | | 12 | **F-NEW-05** — Add Base ephemeral cleanup. | RESOLVED — `BaseChainPostProcessHandler` sweeps BRLA and USDC residuals after `currentPhase === "complete"` via presigned `approve` + funding-key `transferFrom`. Wired into both `evm-to-brl-base.ts` (offramp) and `avenia-to-evm-base.ts` (onramp). New phase keys `baseCleanupBrla` and `baseCleanupUsdc`. ETH gas dust on EVM ephemerals remains unswept (intentional). | | 13 | **F-013** — Multiple security-sensitive endpoints have no authentication. | RESOLVED — dual-track auth wired across all `/v1/ramp/*` and `/v1/ramp/quotes(/best)` endpoints. Each request carrying credentials must present **either** `X-API-Key: sk_*` (partner SDK) **or** `Authorization: Bearer ` (Supabase frontend); invalid credentials are always rejected. Per-principal ownership guards (`assertRampOwnership`, `assertQuoteOwnership`) prevent cross-tenant access: partners are scoped via `RampState.quoteId → QuoteTicket.partnerId`, Supabase users via `RampState.userId`. Anonymous access is permitted only on register/update/start/status/errors and only when the underlying resource is fully anonymous (no partner, no user owner); `getRampHistory` always requires credentials. `enforcePartnerAuth()` is active on `/quotes` and `/quotes/best`, closing the partner-spoofing vector. | From 9a8e089b45fe69abcfa14069ac540cedd203dfd9 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 9 Jun 2026 18:37:04 +0200 Subject: [PATCH 06/29] Enforce maintenance windows on backend ramp operations --- .../api/middlewares/maintenanceGuard.test.ts | 92 +++++++++++++++++++ .../src/api/middlewares/maintenanceGuard.ts | 45 +++++++++ apps/api/src/api/routes/v1/quote.route.ts | 3 + apps/api/src/api/routes/v1/ramp.route.ts | 22 ++++- 4 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/api/middlewares/maintenanceGuard.test.ts create mode 100644 apps/api/src/api/middlewares/maintenanceGuard.ts diff --git a/apps/api/src/api/middlewares/maintenanceGuard.test.ts b/apps/api/src/api/middlewares/maintenanceGuard.test.ts new file mode 100644 index 000000000..fb9234850 --- /dev/null +++ b/apps/api/src/api/middlewares/maintenanceGuard.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; +import type { NextFunction, Request, Response } from "express"; +import { APIError } from "../errors/api-error"; +import { MaintenanceService } from "../services/maintenance.service"; +import { rejectDuringActiveMaintenance } from "./maintenanceGuard"; + +type MaintenanceStatus = Awaited>; + +const maintenanceService = MaintenanceService.getInstance(); +const originalGetMaintenanceStatus = maintenanceService.getMaintenanceStatus; + +function buildResponse() { + const headers: Record = {}; + const res: Partial & { headers: Record; sentContentType?: string } = { headers }; + + res.setHeader = mock((name: string, value: number | string | readonly string[]) => { + headers[name] = Array.isArray(value) ? value.join(", ") : String(value); + return res as Response; + }) as Response["setHeader"]; + + res.type = mock((contentType: string) => { + res.sentContentType = contentType; + return res as Response; + }) as Response["type"]; + + return res as Response & { headers: Record; sentContentType?: string }; +} + +function mockMaintenanceStatus(status: MaintenanceStatus) { + maintenanceService.getMaintenanceStatus = mock(async () => status) as unknown as MaintenanceService["getMaintenanceStatus"]; +} + +describe("rejectDuringActiveMaintenance", () => { + afterEach(() => { + maintenanceService.getMaintenanceStatus = originalGetMaintenanceStatus; + }); + + it("passes through when there is no active maintenance window", async () => { + mockMaintenanceStatus({ is_maintenance_active: false, maintenance_details: null }); + + const res = buildResponse(); + const next: NextFunction = mock(() => undefined) as unknown as NextFunction; + + await rejectDuringActiveMaintenance({} as Request, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(); + expect(res.headers["Retry-After"]).toBeUndefined(); + }); + + it("rejects with 503 and downtime metadata during active maintenance", async () => { + const start = new Date(Date.now() - 60_000).toISOString(); + const end = new Date(Date.now() + 30 * 60_000).toISOString(); + + mockMaintenanceStatus({ + is_maintenance_active: true, + maintenance_details: { + end_datetime: end, + estimated_time_remaining_seconds: 1800, + message: "Scheduled database maintenance", + start_datetime: start, + title: "Database upgrade" + } + }); + + const res = buildResponse(); + const next: NextFunction = mock(() => undefined) as unknown as NextFunction; + + await rejectDuringActiveMaintenance({} as Request, res, next); + + expect(next).toHaveBeenCalledTimes(1); + const error = (next as ReturnType).mock.calls[0][0] as APIError; + + expect(error).toBeInstanceOf(APIError); + expect(error.status).toBe(503); + expect(error.message).toContain("scheduled maintenance"); + expect(error.errors).toEqual([ + { + detail: "Scheduled database maintenance", + maintenance_end: end, + maintenance_start: start, + operations: ["create_quote", "ramp_register", "ramp_update", "ramp_start"], + retry_after_seconds: expect.any(Number), + title: "Database upgrade", + type: "https://api.vortexfinance.co/problems/maintenance-window" + } + ]); + expect(res.headers["Retry-After"]).toBe(new Date(end).toUTCString()); + expect(res.headers["Cache-Control"]).toBe("no-store"); + expect(res.sentContentType).toBe("application/problem+json"); + }); +}); diff --git a/apps/api/src/api/middlewares/maintenanceGuard.ts b/apps/api/src/api/middlewares/maintenanceGuard.ts new file mode 100644 index 000000000..1181c7ffd --- /dev/null +++ b/apps/api/src/api/middlewares/maintenanceGuard.ts @@ -0,0 +1,45 @@ +import type { NextFunction, Request, Response } from "express"; +import httpStatus from "http-status"; +import { APIError } from "../errors/api-error"; +import { MaintenanceService } from "../services/maintenance.service"; + +const MAINTENANCE_PROBLEM_TYPE = "https://api.vortexfinance.co/problems/maintenance-window"; +const BLOCKED_OPERATIONS = ["create_quote", "ramp_register", "ramp_update", "ramp_start"]; + +export async function rejectDuringActiveMaintenance(_req: Request, res: Response, next: NextFunction): Promise { + try { + const status = await MaintenanceService.getInstance().getMaintenanceStatus(); + + if (!status.is_maintenance_active || !status.maintenance_details) { + next(); + return; + } + + const maintenanceEnd = new Date(status.maintenance_details.end_datetime); + const retryAfterSeconds = Math.max(0, Math.ceil((maintenanceEnd.getTime() - Date.now()) / 1000)); + + res.setHeader("Retry-After", maintenanceEnd.toUTCString()); + res.setHeader("Cache-Control", "no-store"); + res.type("application/problem+json"); + + next( + new APIError({ + errors: [ + { + detail: status.maintenance_details.message, + maintenance_end: status.maintenance_details.end_datetime, + maintenance_start: status.maintenance_details.start_datetime, + operations: BLOCKED_OPERATIONS, + retry_after_seconds: retryAfterSeconds, + title: status.maintenance_details.title, + type: MAINTENANCE_PROBLEM_TYPE + } + ], + message: "Vortex services are temporarily unavailable during scheduled maintenance", + status: httpStatus.SERVICE_UNAVAILABLE + }) + ); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/api/routes/v1/quote.route.ts b/apps/api/src/api/routes/v1/quote.route.ts index 2387bed83..fcc14010b 100644 --- a/apps/api/src/api/routes/v1/quote.route.ts +++ b/apps/api/src/api/routes/v1/quote.route.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import { createBestQuote, createQuote, getQuote } from "../../controllers/quote.controller"; import { apiKeyAuth, enforcePartnerAuth } from "../../middlewares/apiKeyAuth"; +import { rejectDuringActiveMaintenance } from "../../middlewares/maintenanceGuard"; import { validatePublicKey } from "../../middlewares/publicKeyAuth"; import { optionalAuth } from "../../middlewares/supabaseAuth"; import { validateCreateBestQuoteInput, validateCreateQuoteInput } from "../../middlewares/validators"; @@ -49,6 +50,7 @@ router validatePublicKey(), apiKeyAuth({ required: false }), enforcePartnerAuth(), + rejectDuringActiveMaintenance, createQuote ); @@ -110,6 +112,7 @@ router validatePublicKey(), apiKeyAuth({ required: false }), enforcePartnerAuth(), + rejectDuringActiveMaintenance, createBestQuote ); diff --git a/apps/api/src/api/routes/v1/ramp.route.ts b/apps/api/src/api/routes/v1/ramp.route.ts index 2ecae7553..653649431 100644 --- a/apps/api/src/api/routes/v1/ramp.route.ts +++ b/apps/api/src/api/routes/v1/ramp.route.ts @@ -1,6 +1,7 @@ import { RequestHandler, Router } from "express"; import * as rampController from "../../controllers/ramp.controller"; import { optionalPartnerOrUserAuth, requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; +import { rejectDuringActiveMaintenance } from "../../middlewares/maintenanceGuard"; const router = Router(); @@ -30,7 +31,12 @@ const router = Router(); * @apiError (Not Found 404) NotFound Quote does not exist */ -router.post("/register", optionalPartnerOrUserAuth(), rampController.registerRamp as unknown as RequestHandler); +router.post( + "/register", + optionalPartnerOrUserAuth(), + rejectDuringActiveMaintenance, + rampController.registerRamp as unknown as RequestHandler +); /** * @api {post} v1/ramp/update Update ramping process @@ -57,7 +63,12 @@ router.post("/register", optionalPartnerOrUserAuth(), rampController.registerRam * @apiError (Not Found 404) NotFound Ramp does not exist * @apiError (Conflict 409) ConflictError Ramp is not in a state that allows updates */ -router.post("/update", optionalPartnerOrUserAuth(), rampController.updateRamp as unknown as RequestHandler); +router.post( + "/update", + optionalPartnerOrUserAuth(), + rejectDuringActiveMaintenance, + rampController.updateRamp as unknown as RequestHandler +); /** * @api {post} v1/ramp/start Start ramping process @@ -83,7 +94,12 @@ router.post("/update", optionalPartnerOrUserAuth(), rampController.updateRamp as * @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values * @apiError (Not Found 404) NotFound Quote does not exist */ -router.post("/start", optionalPartnerOrUserAuth(), rampController.startRamp as unknown as RequestHandler); +router.post( + "/start", + optionalPartnerOrUserAuth(), + rejectDuringActiveMaintenance, + rampController.startRamp as unknown as RequestHandler +); /** * @api {get} v1/ramp/:id Get ramp status From 19676d088c6ac139ef2f6894f8452dc3b9cc0237 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 9 Jun 2026 18:37:58 +0200 Subject: [PATCH 07/29] Document maintenance handling in quote and ramp specs --- docs/security-spec/03-ramp-engine/quote-lifecycle.md | 5 ++++- docs/security-spec/03-ramp-engine/ramp-phase-flows.md | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/security-spec/03-ramp-engine/quote-lifecycle.md b/docs/security-spec/03-ramp-engine/quote-lifecycle.md index 067d739bd..80d4531f5 100644 --- a/docs/security-spec/03-ramp-engine/quote-lifecycle.md +++ b/docs/security-spec/03-ramp-engine/quote-lifecycle.md @@ -4,7 +4,7 @@ Quotes are the entry point for every ramp. A quote calculates the expected output amount for a given input, factoring in exchange rates, fees, and dynamic pricing adjustments. The lifecycle: -1. **Creation** — Client requests a quote via `POST /v1/quotes` with input currency, output currency, amount, and ramp direction (on/off). The API calculates fees, fetches live exchange rates (Nabla DEX, price providers), applies the dynamic pricing adjustment, and returns a `QuoteResponse` including the expected output amount, fee breakdown, and a quote ID. +1. **Creation** — Client requests a quote via `POST /v1/quotes` with input currency, output currency, amount, and ramp direction (on/off). If an active maintenance window exists, the backend rejects quote creation with `503 Service Unavailable`, `Retry-After`, and downtime start/end metadata before fetching rates or writing a quote. Otherwise, the API calculates fees, fetches live exchange rates (Nabla DEX, price providers), applies the dynamic pricing adjustment, and returns a `QuoteResponse` including the expected output amount, fee breakdown, and a quote ID. 2. **Expiry** — Quotes expire **10 minutes** after creation (hardcoded in `QuoteTicket.create()` and the model default: `new Date(Date.now() + 10 * 60 * 1000)`). After expiry, the quote cannot be used to start a ramp. Note: this is a separate timeout from `discountStateTimeoutMinutes` (see Dynamic Pricing below). 3. **Binding** — When a ramp is registered (`POST /v1/ramp/register`), it binds to a specific quote ID. The quote's amounts become the committed values for the ramp. 4. **Consumption** — A quote can only be bound to one ramp. Once consumed, it cannot be reused. @@ -67,6 +67,7 @@ The refresh policy is intentionally strict (byte-identical `toAmount` and `fee` 9. **Exchange rates MUST be sourced from authoritative on-chain data** — Swap rates should come from the actual DEX (Nabla) or routing protocol (Squid), not from stale caches or third-party price feeds that could be manipulated. 10. **Subsidy MUST only be applied when `targetDiscount > 0`** — If a partner has no target discount configured, the subsidy amount is always `0`, regardless of the shortfall. 11. **Quote output precision MUST match the final settlement token** — For EVM onramps whose final output comes from Squid, the stored `quote.outputAmount` must retain the destination token's precision, not a fixed source-token precision. This includes BRL/EURC Base→EVM routes and routed Alfredpay USD/MXN/COP Polygon→EVM routes. Direct same-chain same-token passthrough keeps the minted/source token's precision. +12. **Quote creation MUST honor active maintenance windows server-side** — `POST /v1/quotes` and `POST /v1/quotes/best` must reject during active maintenance before quote calculation/persistence, including enough downtime metadata for direct API clients to retry after the window. ## Threat Vectors & Mitigations @@ -83,6 +84,7 @@ The refresh policy is intentionally strict (byte-identical `toAmount` and `fee` | **Negative `minDynamicDifference`** | If `minDynamicDifference` is set to a large negative value in the partner DB record, consuming quotes could push the rate below the base `targetDiscount`, potentially making the effective discount negative (user receives less than the oracle rate) | DB constraint: `minDynamicDifference` defaults to `0`. However, there is no DB-level CHECK constraint preventing negative values. If set manually, the clamping logic would allow `difference` to go negative. | | **Concurrent quote and consumption** | Two simultaneous requests — one quoting, one consuming — for the same partner could read stale `difference` values from the in-memory Map | JavaScript's single-threaded event loop prevents true concurrency for synchronous Map operations. However, the `async` functions in `compute()` could interleave if there are `await` points between reading and writing the Map. In practice, the read and write of `partnerDiscountState` in `getAdjustedDifference` are synchronous, so this is safe within a single process. | | **Quote-output precision loss** | A quote targets an 18-decimal destination token but stores only 6 decimal places. The user-visible amount looks close, but final raw transfer construction under-delivers by the truncated dust amount. | Finalize EVM onramp quotes with destination token decimals when the final amount comes from Squid; tests should cover 6-decimal source → 18-decimal destination routes. | +| **Direct API quote creation during planned downtime** | A partner bypasses the UI maintenance banner and requests quotes directly while operators expect Vortex services to be unavailable. | Quote creation routes run the backend maintenance guard and return `503` with `Retry-After`, `maintenance_start`, and `maintenance_end` before any quote is persisted. | ## Audit Checklist @@ -106,3 +108,4 @@ The refresh policy is intentionally strict (byte-identical `toAmount` and `fee` - [x] The `resolveDiscountPartner` fallback to the `"vortex"` default partner is intentional — verify the default partner exists and has appropriate discount/subsidy settings. **PASS** — fallback to "vortex" partner confirmed in code. - [N/A] Monitoring exists for quotes with unusually high subsidization requirements. **N/A** — no monitoring infrastructure audited. - [x] **FINDING F-059 (HIGH)**: Verify `registerRamp` acquires `SELECT FOR UPDATE` lock on the quote, checks `consumeQuote` affected rows, and has a unique constraint on `rampState.quoteId` to prevent double-binding. **PASS (FIXED)** — lock added, affected rows checked, unique constraint migration `026` created. +- [x] Verify active maintenance windows block `POST /v1/quotes` and `POST /v1/quotes/best` server-side with client-actionable downtime metadata. 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 ec3a62f09..c1493ba29 100644 --- a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md +++ b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md @@ -194,6 +194,7 @@ graph TD | **Subsidy drain** | A crafted ramp triggers multiple subsidization phases, each at the maximum allowed amount, draining the funding account. | Per-ramp subsidy caps (`MAX_FINAL_SETTLEMENT_SUBSIDY_USD`, balance pre-checks in pre/post-swap handlers). No aggregate cross-ramp cap exists — many concurrent ramps could still drain funds. | | **Double-execution on retry** | Phase processor retries after timeout. Handler re-executes a swap or transfer that already completed. Funds are consumed twice. | Nonce guards in Spacewalk and Hydration handlers detect prior execution. Other handlers rely on transaction nonce uniqueness at the chain level. Not all handlers have explicit re-execution guards. | | **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. | +| **Direct API ramp mutation during planned downtime** | A partner bypasses the UI maintenance state and calls register/update/start while operators expect Vortex services to be paused. | Ramp mutation routes run the backend maintenance guard and return `503` with `Retry-After`, `maintenance_start`, and `maintenance_end` before registration, presigned transaction updates, or phase processing begins. | | **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. | @@ -227,3 +228,4 @@ graph TD - [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. +- [x] Active maintenance windows block `POST /v1/ramp/register`, `POST /v1/ramp/update`, and `POST /v1/ramp/start` before ramp state mutation or phase processing. From 00b36e35248d7030136aa2cf7df832a3bef0de88 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 9 Jun 2026 18:38:18 +0200 Subject: [PATCH 08/29] Document maintenance handling on API surface --- docs/security-spec/07-operations/api-surface.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/security-spec/07-operations/api-surface.md b/docs/security-spec/07-operations/api-surface.md index 9bddae599..032539bd5 100644 --- a/docs/security-spec/07-operations/api-surface.md +++ b/docs/security-spec/07-operations/api-surface.md @@ -27,6 +27,11 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api - The API returns `X-Request-ID` so clients can include it in support/debug reports. - Partner-facing quote/ramp/auth outcomes are recorded as sanitized operational events; see `07-operations/client-observability.md`. +**Maintenance-window enforcement** (`middlewares/maintenanceGuard.ts`): +- Active maintenance windows are sourced from the `maintenance_schedules` table via `MaintenanceService`. +- During an active window, mutable quote/ramp operations return HTTP `503 Service Unavailable` before controller/service work starts. +- Rejections include `Retry-After`, `Cache-Control: no-store`, and downtime metadata (`maintenance_start`, `maintenance_end`, affected operations) in the error payload so direct API clients can pause and retry after the window. + **Route structure:** 27 TypeScript route files under `api/routes/v1/` including `index.ts`, each mounting controllers with appropriate auth middleware. ## Security Invariants @@ -42,6 +47,7 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api 9. **No endpoint MUST accept and process fields not explicitly validated** — Hand-written validators check specific fields but don't reject unknown fields. Extra fields pass through to controllers, which could lead to mass assignment or unexpected behavior. 10. **Request IDs MUST be correlation-only** — Request IDs may be accepted from clients or generated by the API, but they must not grant access, alter authorization, or be treated as trusted identity. 11. **API observability MUST NOT change request outcomes** — Client event persistence/logging must be best-effort and must not change controller response bodies, status codes, or ramp/quote state. +12. **Maintenance windows MUST be backend-enforced on mutable ramp entrypoints** — `POST /v1/quotes`, `POST /v1/quotes/best`, `POST /v1/ramp/register`, `POST /v1/ramp/update`, and `POST /v1/ramp/start` must reject during active maintenance with `503`, `Retry-After`, and explicit downtime start/end metadata. UI disabling is not sufficient because partners may call the API directly. ## Threat Vectors & Mitigations @@ -57,6 +63,7 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api | **No per-endpoint rate limiting** — Sensitive endpoints (ramp creation, admin operations) have the same rate limit as public read endpoints | An attacker can create 100 ramps per minute per IP. For endpoints that trigger expensive operations (XCM, SquidRouter), this could amplify costs. | | **Cookie-based auth without CSRF protection** — Cookie parser is enabled for Supabase auth tokens | If auth tokens are stored in cookies (not just headers), cross-site requests from CORS-allowed origins could carry auth cookies automatically. Verify whether CSRF tokens or `SameSite` cookie attributes are used. | | **Observability side effects** — Event persistence failure breaks a partner-facing API call | Observability helpers must catch persistence/logging errors and run best-effort only. See `client-observability.md`. | +| **Direct API bypass of UI maintenance mode** — Partner SDK or custom API clients ignore the frontend and continue creating quotes or mutating ramps during planned downtime | Mutable quote/ramp routes run the maintenance guard server-side and fail closed with `503 Service Unavailable`, `Retry-After`, and the active window's start/end timestamps. | ## Audit Checklist @@ -78,3 +85,4 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api - [x] Check all 27 route files for endpoints that accept file uploads — verify file size limits and type validation if present. **PASS** — no file upload endpoints found. - [ ] Verify request ID middleware runs before routes and returns `X-Request-ID` without using request IDs for authorization. - [ ] Verify partner-facing API observability writes are best-effort and cannot alter response status, response body, or quote/ramp state. +- [x] Verify active maintenance windows are enforced by the backend on quote creation and ramp register/update/start, not only by frontend UI state. From dad786deb8c7a58a6339a93b36b6b0c3dff75a3d Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 9 Jun 2026 18:48:50 +0200 Subject: [PATCH 09/29] Make EVM subsidy caps configurable --- .../handlers/subsidize-post-swap-handler.ts | 15 +++++----- .../handlers/subsidize-pre-swap-handler.ts | 7 +++-- apps/api/src/config/vars.ts | 11 ++++++++ apps/api/src/constants/constants.ts | 28 ++++++++----------- 4 files changed, 34 insertions(+), 27 deletions(-) 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 3fc33d629..032e56e2e 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 @@ -18,10 +18,7 @@ import { import Big from "big.js"; import { encodeFunctionData, erc20Abi } from "viem"; import logger from "../../../../config/logger"; -import { - MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION, - MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION -} from "../../../../constants/constants"; +import { config } from "../../../../config/vars"; import QuoteTicket from "../../../../models/quoteTicket.model"; import RampState from "../../../../models/rampState.model"; import { SubsidyToken } from "../../../../models/subsidy.model"; @@ -268,19 +265,21 @@ export class SubsidizePostSwapPhaseHandler extends BasePhaseHandler { quote.outputCurrency as RampCurrency, EvmToken.USDC as RampCurrency ); - const discrepancySubsidyCapUsd = Big(quoteOutputUsd).mul(MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION); + const discrepancySubsidyCapFraction = config.subsidy.evmSwapSubsidyQuoteFraction; + const discrepancySubsidyCapUsd = Big(quoteOutputUsd).mul(discrepancySubsidyCapFraction); if (Big(discrepancySubsidyUsd).gt(discrepancySubsidyCapUsd)) { // Pause for operator intervention without moving the ramp to failed. throw this.createRecoverableError( - `SubsidizePostSwapPhaseHandler: Required swap discrepancy subsidy $${discrepancySubsidyUsd} exceeds cap $${discrepancySubsidyCapUsd.toFixed(2)} (${MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION} of quote output $${quoteOutputUsd}).` + `SubsidizePostSwapPhaseHandler: Required swap discrepancy subsidy $${discrepancySubsidyUsd} exceeds cap $${discrepancySubsidyCapUsd.toFixed(2)} (${discrepancySubsidyCapFraction} of quote output $${quoteOutputUsd}).` ); } - const discountSubsidyCapUsd = Big(quoteOutputUsd).mul(MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION); + const discountSubsidyCapFraction = config.subsidy.evmPostSwapDiscountSubsidyQuoteFraction; + const discountSubsidyCapUsd = Big(quoteOutputUsd).mul(discountSubsidyCapFraction); if (Big(discountSubsidyUsd).gt(discountSubsidyCapUsd)) { // Pause for operator intervention without moving the ramp to failed. throw this.createRecoverableError( - `SubsidizePostSwapPhaseHandler: Required discount subsidy $${discountSubsidyUsd} exceeds cap $${discountSubsidyCapUsd.toFixed(2)} (${MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION} of quote output $${quoteOutputUsd}).` + `SubsidizePostSwapPhaseHandler: Required discount subsidy $${discountSubsidyUsd} exceeds cap $${discountSubsidyCapUsd.toFixed(2)} (${discountSubsidyCapFraction} of quote output $${quoteOutputUsd}).` ); } 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 3d7ff6875..ce51a4db4 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 @@ -21,7 +21,7 @@ import { import Big from "big.js"; import { encodeFunctionData, erc20Abi } from "viem"; import logger from "../../../../config/logger"; -import { MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION } from "../../../../constants/constants"; +import { config } from "../../../../config/vars"; import QuoteTicket from "../../../../models/quoteTicket.model"; import RampState from "../../../../models/rampState.model"; import { SubsidyToken } from "../../../../models/subsidy.model"; @@ -243,11 +243,12 @@ export class SubsidizePreSwapPhaseHandler extends BasePhaseHandler { quote.outputCurrency as RampCurrency, EvmToken.USDC as RampCurrency ); - const subsidyCapUsd = Big(quoteOutputUsd).mul(MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION); + const subsidyCapFraction = config.subsidy.evmSwapSubsidyQuoteFraction; + const subsidyCapUsd = Big(quoteOutputUsd).mul(subsidyCapFraction); if (Big(subsidyUsd).gt(subsidyCapUsd)) { // Pause for operator intervention without moving the ramp to failed. throw this.createRecoverableError( - `SubsidizePreSwapPhaseHandler: Required subsidy $${subsidyUsd} exceeds cap $${subsidyCapUsd.toFixed(2)} (${MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION} of quote output $${quoteOutputUsd}).` + `SubsidizePreSwapPhaseHandler: Required subsidy $${subsidyUsd} exceeds cap $${subsidyCapUsd.toFixed(2)} (${subsidyCapFraction} of quote output $${quoteOutputUsd}).` ); } diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index 97495a979..d54b8f88c 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -95,6 +95,10 @@ interface Config { swap: { deadlineMinutes: number; }; + subsidy: { + evmPostSwapDiscountSubsidyQuoteFraction: number; + evmSwapSubsidyQuoteFraction: number; + }; quote: { discountStateTimeoutMinutes: number; deltaDBasisPoints: number; @@ -209,6 +213,13 @@ export const config: Config = { storageSheetId: process.env.GOOGLE_SPREADSHEET_ID }, subscanApiKey: process.env.SUBSCAN_API_KEY, + + subsidy: { + evmPostSwapDiscountSubsidyQuoteFraction: parseFloat( + process.env.MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION || "0.05" + ), + evmSwapSubsidyQuoteFraction: parseFloat(process.env.MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION || "0.05") + }, supabase: { anonKey: process.env.SUPABASE_ANON_KEY || "", serviceRoleKey: process.env.SUPABASE_SERVICE_KEY || "", diff --git a/apps/api/src/constants/constants.ts b/apps/api/src/constants/constants.ts index 03d9bb6f7..f53e8f380 100644 --- a/apps/api/src/constants/constants.ts +++ b/apps/api/src/constants/constants.ts @@ -15,8 +15,6 @@ const DEFAULT_POLLING_INTERVAL = 3000; const GLMR_FUNDING_AMOUNT_RAW = "50000000000000000"; const ASSETHUB_XCM_FEE_USDC_UNITS = 0.013124; const MAX_FINAL_SETTLEMENT_SUBSIDY_USD = "10"; // 10 USD -const MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION = "0.05"; // 5% of quote.outputAmount in USD -const MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION = "0.05"; // 5% of quote.outputAmount in USD const WEBHOOKS_CACHE_URL = "https://webhooks-cache.pendulumchain.tech"; // EXAMPLE URL @@ -39,24 +37,22 @@ const SEQUENCE_TIME_WINDOWS = { }; export { - POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS, ASSETHUB_XCM_FEE_USDC_UNITS, - SEQUENCE_TIME_WINDOWS, + BASE_EPHEMERAL_STARTING_BALANCE_UNITS, + DEFAULT_LOGIN_EXPIRATION_TIME_HOURS, + DEFAULT_POLLING_INTERVAL, GLMR_FUNDING_AMOUNT_RAW, - PENDULUM_GLMR_FUNDING_AMOUNT_UNITS, - PENDULUM_FUNDING_AMOUNT_UNITS, - STELLAR_FUNDING_AMOUNT_UNITS, + MAX_FINAL_SETTLEMENT_SUBSIDY_USD, + MOONBEAM_EPHEMERAL_STARTING_BALANCE_UNITS, MOONBEAM_FUNDING_AMOUNT_UNITS, MOONBEAM_RECEIVER_CONTRACT_ADDRESS, - SUBSIDY_MINIMUM_RATIO_FUND_UNITS, PENDULUM_EPHEMERAL_STARTING_BALANCE_UNITS, - MOONBEAM_EPHEMERAL_STARTING_BALANCE_UNITS, - DEFAULT_LOGIN_EXPIRATION_TIME_HOURS, - WEBHOOKS_CACHE_URL, - DEFAULT_POLLING_INTERVAL, + PENDULUM_FUNDING_AMOUNT_UNITS, + PENDULUM_GLMR_FUNDING_AMOUNT_UNITS, + POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS, + SEQUENCE_TIME_WINDOWS, STELLAR_BASE_FEE, - MAX_FINAL_SETTLEMENT_SUBSIDY_USD, - MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION, - MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION, - BASE_EPHEMERAL_STARTING_BALANCE_UNITS + STELLAR_FUNDING_AMOUNT_UNITS, + SUBSIDY_MINIMUM_RATIO_FUND_UNITS, + WEBHOOKS_CACHE_URL }; From 7f504d336d8429f6bb19f8992b451c37567cc22b Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 9 Jun 2026 18:49:21 +0200 Subject: [PATCH 10/29] Document configurable EVM subsidy cap mechanics --- docs/security-spec/03-ramp-engine/discount-mechanism.md | 4 ++-- docs/security-spec/06-cross-chain/fund-routing.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/security-spec/03-ramp-engine/discount-mechanism.md b/docs/security-spec/03-ramp-engine/discount-mechanism.md index 29bd59f61..fa3a00033 100644 --- a/docs/security-spec/03-ramp-engine/discount-mechanism.md +++ b/docs/security-spec/03-ramp-engine/discount-mechanism.md @@ -35,7 +35,7 @@ For onramps to EVM destinations other than AssetHub, the engine also probes Squi 6. **Subsidy MUST NOT bypass fee collection.** For onramps, `actualOutput = nablaOutput − (network + vortex + partnerMarkup)`. The subsidy then covers the shortfall against `expectedOutput` *after* those fees, so fees still flow to fee accounts. 7. **For offramps, the anchor fee MUST be added back to `expectedOutput`** before computing the shortfall (`adjustedExpectedOutputDecimal = oracleExpected + anchorFeeInBrl`). Otherwise the user would receive `expectedOutput − anchorFee`, which is short of the advertised rate by the anchor's cut. 8. **Subsidy amounts written to `ctx.subsidy` MUST be deterministic for a given input.** With `targetDiscount=0` the actual subsidy is forced to zero (`actualSubsidyAmountDecimal = Big(0)`), even when `idealSubsidy > 0`. This is the contract the merge-subsidy and subsidy phase handlers rely on. -9. **Discount subsidy MUST remain distinct from runtime swap discrepancy subsidy.** `ctx.subsidy.subsidyAmountInOutputTokenRaw` is the quote-time discount component, bounded by partner `maxSubsidy`. On EVM `subsidizePostSwap`, any actual-vs-quoted swap-output discrepancy is calculated against the live post-swap balance and capped by `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION`; the discount component is capped separately by `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION`. +9. **Discount subsidy MUST remain distinct from runtime swap discrepancy subsidy.** `ctx.subsidy.subsidyAmountInOutputTokenRaw` is the quote-time discount component, bounded by partner `maxSubsidy`. On EVM `subsidizePostSwap`, any actual-vs-quoted swap-output discrepancy is calculated against the live post-swap balance and capped by env-configured `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION`; the discount component is capped separately by env-configured `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION`. Both runtime fractions default to `0.05`. 10. **The dynamic difference MUST NOT be incremented within `discountStateTimeoutMinutes` of the last quote** — `getAdjustedDifference` only adds `deltaD` when `isWithinStateTimeout` is **false**. Otherwise repeated quotes from the same partner would inflate the difference faster than intended. 11. **Squid Router probe failures MUST fall back to a 1:1 assumption, never block the quote.** Both `getSquidRouterUSDCConversionRate` and `getSquidRouterAxlUSDCConversionRate` return `null` on error and the engine proceeds with `adjustedExpectedOutputDecimal = oracleExpectedOutputDecimal`. A network failure on the probe MUST NOT cause the entire quote stage to throw. @@ -61,7 +61,7 @@ For onramps to EVM destinations other than AssetHub, the engine also probes Squi - [x] `OffRampDiscountEngine` only fires for `RampDirection.SELL`; `OnRampDiscountEngine` only fires for `RampDirection.BUY`. **PASS** — `offramp.ts:10`, `onramp.ts:18`. - [x] Discount parameters (`targetDiscount`, `maxSubsidy`, `minDynamicDifference`, `maxDynamicDifference`) are read exclusively from the `Partner` Sequelize model and never accepted from request fields. **PASS** — `resolveDiscountPartner` uses `Partner.findOne`; no request field is read. - [x] Subsidy cap `maxSubsidy × expectedOutput` is enforced in `calculateSubsidyAmount` (`helpers.ts:152-167`). **PASS**. -- [x] EVM post-swap runtime cap logic treats discount subsidy separately from swap discrepancy subsidy. **PASS** — the discount component comes from `ctx.subsidy.subsidyAmountInOutputTokenRaw`; the live discrepancy component is computed from the post-swap balance and quoted actual output. Each component must pass its own runtime cap before transfer. +- [x] EVM post-swap runtime cap logic treats discount subsidy separately from swap discrepancy subsidy. **PASS** — the discount component comes from `ctx.subsidy.subsidyAmountInOutputTokenRaw`; the live discrepancy component is computed from the post-swap balance and quoted actual output. Each component must pass its own env-configured runtime cap before transfer. - [x] `targetDiscount=0` forces `actualSubsidyAmountDecimal = Big(0)` in both engines. **PASS** — `offramp.ts:76-79`, `onramp.ts:209-212`. - [x] Offramp `expectedOutput` adds back the anchor fee (`adjustedExpectedOutputDecimal = oracleExpectedOutput + anchorFeeInBrl`). **PASS** — `offramp.ts:50-51`. - [x] Onramp `actualOutput` subtracts post-swap fees (`network + vortex + partnerMarkup`). **PASS** — `onramp.ts:198-199`. diff --git a/docs/security-spec/06-cross-chain/fund-routing.md b/docs/security-spec/06-cross-chain/fund-routing.md index 10ab021d1..d3d7f0db2 100644 --- a/docs/security-spec/06-cross-chain/fund-routing.md +++ b/docs/security-spec/06-cross-chain/fund-routing.md @@ -12,7 +12,7 @@ There are now **five** subsidization-related phase handlers and one settlement p - `final-settlement-subsidy.ts` — Tops up an EVM ephemeral by SquidRouter-swapping native → ERC-20 (legacy / cross-chain settlement). Has a USD cap (`MAX_FINAL_SETTLEMENT_SUBSIDY_USD`). - `destination-transfer-handler.ts` — Sends the presigned EVM transfer from the ephemeral to the user's destination address -**Phase handlers (EVM):** The Substrate handlers above are polymorphic: `subsidize-pre-swap-handler.ts` and `subsidize-post-swap-handler.ts` dispatch to their EVM branches when the ephemeral involved is on a supported EVM chain (currently Base). The EVM pre-swap branch tops the ephemeral up before `nablaSwap` and enforces the quote-relative USD cap `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION`. The EVM post-swap branch splits the required top-up into a swap-discrepancy component and a discount component: the existing `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` applies only to the actual-vs-quoted swap-output discrepancy, while `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION` caps the discount-derived top-up separately. +**Phase handlers (EVM):** The Substrate handlers above are polymorphic: `subsidize-pre-swap-handler.ts` and `subsidize-post-swap-handler.ts` dispatch to their EVM branches when the ephemeral involved is on a supported EVM chain (currently Base). The EVM pre-swap branch tops the ephemeral up before `nablaSwap` and enforces the quote-relative cap fraction from `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` (default `0.05`). The EVM post-swap branch splits the required top-up into a swap-discrepancy component and a discount component: `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` applies to the actual-vs-quoted swap-output discrepancy, while `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION` (default `0.05`) caps the discount-derived top-up separately. **How subsidization works:** 1. Read the ephemeral account's current balance @@ -41,7 +41,7 @@ The current code resolves this through `EVM_FUNDING_PRIVATE_KEY` and the `getEvm 6. **Destination transfer MUST verify balance before submission** — The handler checks that the ephemeral has sufficient balance for the transfer. If insufficient, the phase fails rather than submitting a transaction that would revert. 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-swap subsidy exceeds the quote-relative cap, the handler must not submit a transfer. For EVM post-swap subsidy, the handler must split the top-up into (a) actual-vs-quoted swap-output discrepancy and (b) discount-derived subsidy, then enforce each component's cap independently before submitting a single transfer. A cap breach is intentionally recoverable so operators can investigate, top up, or cancel the ramp without repairing an unrecoverably failed phase. +9. **EVM subsidy caps MUST stop transfers without forcing manual phase repair** — If an EVM pre-swap subsidy exceeds its configured quote-relative cap, the handler must not submit a transfer. For EVM post-swap subsidy, the handler must split the top-up into (a) actual-vs-quoted swap-output discrepancy and (b) discount-derived subsidy, then enforce each component's configured cap independently before submitting a single transfer. Both post-swap cap fractions are env-overridable and default to `0.05`. A 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. @@ -76,7 +76,7 @@ The current code resolves this through `EVM_FUNDING_PRIVATE_KEY` and the `getEvm - [N/A] Check whether there is any monitoring or alerting on funding account balance depletion. **N/A** — no monitoring infrastructure audited. - [x] Verify `MAX_FINAL_SETTLEMENT_SUBSIDY_USD` value is reasonable for the expected settlement amounts (check the constant's actual value). **PASS** — value reviewed and reasonable for expected settlement sizes. - [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 USD caps**. Pre-swap subsidy uses `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION`. Post-swap subsidy uses split caps: `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` for the actual-vs-quoted swap-output discrepancy, and `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION` for the discount-derived component. Over-cap subsidies throw `RecoverablePhaseError` before any transfer is submitted, leaving the ramp waiting for operator action instead of moving to `failed`. +- [x] **EVM subsidy handlers (`subsidize-pre-swap-evm-handler.ts`, `subsidize-post-swap-evm-handler.ts`) enforce env-configured USD caps**. Pre-swap subsidy uses `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` (default `0.05`). Post-swap subsidy uses split caps: `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` for actual-vs-quoted swap-output discrepancy and `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION` (default `0.05`) for the discount-derived component. 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. From 2dfc4d6a9652172989ea1a1d75ceb4ece22ce1bf Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 9 Jun 2026 18:49:56 +0200 Subject: [PATCH 11/29] Update ramp phase cap documentation --- docs/security-spec/03-ramp-engine/ramp-phase-flows.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 279789429..b0d967ea9 100644 --- a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md +++ b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md @@ -177,7 +177,7 @@ graph TD ## Security Invariants 1. **Phase ordering MUST match the expected corridor flow** — Each corridor has a fixed phase sequence. The phase processor MUST NOT allow out-of-order transitions. The phase handler's return value determines the next phase, and it MUST match the expected sequence for the ramp's corridor. -2. **Subsidy amounts MUST be bounded** — Every subsidization handler (`subsidizePreSwap`, `subsidizePostSwap`, `fundEphemeral`, `finalSettlementSubsidy`) must enforce a maximum USD-equivalent cap to prevent draining the funding account on a single ramp. EVM `subsidizePostSwap` must not treat the top-up as one undifferentiated bucket: the actual-vs-quoted swap-output discrepancy and the discount-derived subsidy must each pass their own cap before any transfer is submitted. +2. **Subsidy amounts MUST be bounded** — Every subsidization handler (`subsidizePreSwap`, `subsidizePostSwap`, `fundEphemeral`, `finalSettlementSubsidy`) must enforce a maximum USD-equivalent cap to prevent draining the funding account on a single ramp. EVM pre/post-swap cap fractions are loaded from environment configuration and default to `0.05`. EVM `subsidizePostSwap` must not treat the top-up as one undifferentiated bucket: the actual-vs-quoted swap-output discrepancy and the discount-derived subsidy must each pass their own configured cap before any transfer is submitted. 3. **Presigned transactions MUST be used in the correct phase** — `getPresignedTransaction(state, phase)` retrieves the transaction for a specific phase. A phase handler MUST NOT access presigned transactions for a different phase. 4. **Token amounts at each phase MUST be traceable to the original quote** — The quote defines input/output amounts. Each phase should operate on amounts derived from the quote, not from untrusted runtime state. 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. @@ -192,7 +192,7 @@ graph TD | Threat | Attack Scenario | Mitigation | |---|---|---| | **Phase skip / injection** | Attacker with DB access modifies `currentPhase` to skip subsidization or jump to `complete`. | Phase transitions are controlled by handler return values, not external input. DB access is a prerequisite (see `state-machine.md`, Threat: "Phase skip attack"). No DB-level constraints on valid transitions exist. | -| **Subsidy drain** | A crafted ramp triggers multiple subsidization phases, each at the maximum allowed amount, draining the funding account. | Per-ramp subsidy caps (`MAX_FINAL_SETTLEMENT_SUBSIDY_USD`, balance pre-checks in pre/post-swap handlers). EVM post-swap subsidy is split into discrepancy and discount components with independent caps. No aggregate cross-ramp cap exists — many concurrent ramps could still drain funds. | +| **Subsidy drain** | A crafted ramp triggers multiple subsidization phases, each at the maximum allowed amount, draining the funding account. | Per-ramp subsidy caps (`MAX_FINAL_SETTLEMENT_SUBSIDY_USD`, balance pre-checks in pre/post-swap handlers). EVM pre/post-swap caps are env-configured quote-relative fractions, and EVM post-swap subsidy is split into discrepancy and discount components with independent caps. No aggregate cross-ramp cap exists — many concurrent ramps could still drain funds. | | **Double-execution on retry** | Phase processor retries after timeout. Handler re-executes a swap or transfer that already completed. Funds are consumed twice. | Nonce guards in Spacewalk and Hydration handlers detect prior execution. Other handlers rely on transaction nonce uniqueness at the chain level. Not all handlers have explicit re-execution guards. | | **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). | @@ -220,7 +220,7 @@ graph TD - [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 USD-equivalent caps. **PASS** — `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION="0.05"` clamps pre-swap subsidy and the post-swap actual-vs-quoted swap-output discrepancy component. `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION="0.05"` separately clamps the post-swap discount-derived component. Over-cap cases are intentionally recoverable retries: no transfer is submitted, and the ramp waits for operator intervention instead of moving to `failed`. +- [x] EVM subsidy phases enforce USD-equivalent caps. **PASS** — `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` defaults to `0.05` and clamps pre-swap subsidy plus the post-swap actual-vs-quoted swap-output discrepancy component. `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION` defaults to `0.05` and separately clamps the post-swap discount-derived component. Both values are env-overridable. 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. From 81189a38f2b2eaafbcfef7398902912275edde6d Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 9 Jun 2026 18:50:26 +0200 Subject: [PATCH 12/29] Clarify quote lifecycle subsidy cap boundaries --- docs/security-spec/03-ramp-engine/quote-lifecycle.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/security-spec/03-ramp-engine/quote-lifecycle.md b/docs/security-spec/03-ramp-engine/quote-lifecycle.md index b2602dab6..d486890bc 100644 --- a/docs/security-spec/03-ramp-engine/quote-lifecycle.md +++ b/docs/security-spec/03-ramp-engine/quote-lifecycle.md @@ -59,7 +59,7 @@ The refresh policy is intentionally strict (byte-identical `toAmount` and `fee` 1. **Quotes MUST expire** — A quote older than 10 minutes MUST be rejected when a ramp attempts to bind to it. The expiry is checked via `quote.expiresAt < new Date()` at registration time. Exchange rates change; stale quotes expose the platform to unfavorable rates. 2. **Each quote MUST be consumable exactly once** — After a quote is bound to a ramp, it MUST NOT be reusable for another ramp. This prevents a single favorable quote from being exploited multiple times. 3. **Quote amounts MUST be immutable after creation** — Once a quote is stored, its `inputAmount`, `outputAmount`, fee breakdown, and exchange rate MUST NOT be modifiable. The ramp uses these exact values. -4. **The quoted output amount MUST be the guaranteed minimum the user receives** — The platform subsidizes eligible shortfalls between the actual swap result and the quoted amount (up to the applicable subsidy caps). On EVM post-swap routes, the top-up may be split into an actual-vs-quoted swap-output discrepancy component and a quote-time discount component; each is bounded independently. The user MUST NOT receive less than the quoted output (after fees) unless a cap breach leaves the ramp waiting for operator intervention. +4. **The quoted output amount MUST be the guaranteed minimum the user receives** — The platform subsidizes eligible shortfalls between the actual swap result and the quoted amount (up to the applicable subsidy caps). On EVM post-swap routes, the top-up may be split into an actual-vs-quoted swap-output discrepancy component and a quote-time discount component; each is bounded independently by env-configured runtime caps. The user MUST NOT receive less than the quoted output (after fees) unless a cap breach leaves the ramp waiting for operator intervention. 5. **Fee calculations MUST be deterministic for the same inputs** — Given the same input amount, currencies, ramp direction, and fee configuration, the quote MUST produce the same fee breakdown. Non-deterministic fees create audit and reconciliation gaps. Note: the dynamic pricing adjustment (`difference`) adds intentional variability to the *rate*, not the *fees*. 6. **Quote validation MUST occur at ramp registration time** — When binding a quote to a ramp, the API MUST verify: quote exists, quote is not expired, quote is not already consumed, and the requesting user/partner is authorized to use it. 7. **Dynamic pricing `difference` MUST be clamped to partner bounds** — The `difference` value must never exceed `maxDynamicDifference` or fall below `minDynamicDifference`. Both bounds are enforced in `getAdjustedDifference` and `handleQuoteConsumptionForDiscountState`. @@ -72,13 +72,13 @@ The refresh policy is intentionally strict (byte-identical `toAmount` and `fee` | Threat | Attack Scenario | Mitigation | |---|---|---| -| **Stale quote exploitation** | Attacker creates a quote when rates are favorable, waits for rates to move against the platform, then registers a ramp at the old rate | Quote expiry (10 minutes hardcoded); quote-time discount subsidy is bounded by partner `maxSubsidy`, and EVM post-swap runtime subsidy components are bounded by their own caps before funds move. | +| **Stale quote exploitation** | Attacker creates a quote when rates are favorable, waits for rates to move against the platform, then registers a ramp at the old rate | Quote expiry (10 minutes hardcoded); quote-time discount subsidy is bounded by partner `maxSubsidy`, and EVM post-swap runtime subsidy components are bounded by their own env-configured caps before funds move. | | **Quote replay** | Attacker uses the same favorable quote ID for multiple ramps | One-time consumption: quote status is set to `"consumed"` on ramp registration; second attempt is rejected (`quote.status !== "pending"`) | | **Quote manipulation** | Attacker modifies quote amounts in transit or in database | Quotes stored server-side; amounts calculated server-side from authoritative sources; client cannot override amounts | | **Price oracle manipulation** | Attacker manipulates the DEX price before requesting a quote to get an artificially favorable rate | Use TWAP or multi-source pricing; bound acceptable deviation from reference rates; monitor for unusual quote patterns | | **Dynamic pricing farming** | Attacker rapidly requests quotes without consuming them to push `difference` toward `maxDynamicDifference`, then consumes at the best possible rate | Each quote request within the timeout window does NOT change the difference — only quotes **after** the timeout increase it. So the attacker would need to wait `discountStateTimeoutMinutes` between each step increase. With default `deltaD = 0.00003` and a 10-minute timeout, farming is slow. However, the `maxDynamicDifference` cap is the hard limit. | | **⚠️ In-memory state loss** | Server restart resets all partner discount states to `difference = 0`. Partners lose their accumulated rate adjustments. | **NO MITIGATION.** State is in-memory only. After restart, all partners start fresh. This could cause abrupt rate changes if a partner had a significant accumulated difference. | -| **Subsidization abuse** | Attacker creates quotes during high volatility, forcing the platform to cover large subsidization amounts | Subsidy capped by `maxSubsidy` per partner; dynamic pricing adjusts rates over time; `maxDynamicDifference` bounds the maximum rate improvement | +| **Subsidization abuse** | Attacker creates quotes during high volatility, forcing the platform to cover large subsidization amounts | Quote-time discount subsidy is capped by `maxSubsidy` per partner; EVM runtime top-ups are separately bounded by the pre/post-swap cap fractions; dynamic pricing adjusts rates over time; `maxDynamicDifference` bounds the maximum rate improvement | | **Unauthorized quote consumption** | Attacker binds someone else's quote to their own ramp | Quotes carrying an owner (`partnerId` or `userId`) are bound to that owner; ownership is verified at ramp registration via `assertQuoteOwnership`. Fully-anonymous quotes (no `partnerId` and no `userId`) are intentionally consumable by any caller — they cannot leak privileged data because they were created without a principal in the first place. | | **Negative `minDynamicDifference`** | If `minDynamicDifference` is set to a large negative value in the partner DB record, consuming quotes could push the rate below the base `targetDiscount`, potentially making the effective discount negative (user receives less than the oracle rate) | DB constraint: `minDynamicDifference` defaults to `0`. However, there is no DB-level CHECK constraint preventing negative values. If set manually, the clamping logic would allow `difference` to go negative. | | **Concurrent quote and consumption** | Two simultaneous requests — one quoting, one consuming — for the same partner could read stale `difference` values from the in-memory Map | JavaScript's single-threaded event loop prevents true concurrency for synchronous Map operations. However, the `async` functions in `compute()` could interleave if there are `await` points between reading and writing the Map. In practice, the read and write of `partnerDiscountState` in `getAdjustedDifference` are synchronous, so this is safe within a single process. | From fc158b2f20c3e939b8e513934455a39b82ab365d Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 9 Jun 2026 18:50:54 +0200 Subject: [PATCH 13/29] Update BRLA subsidy cap audit notes --- docs/security-spec/05-integrations/brla.md | 4 ++-- docs/security-spec/SPEC-DELTA-2026-05.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/security-spec/05-integrations/brla.md b/docs/security-spec/05-integrations/brla.md index de7ec5204..7b0d3622e 100644 --- a/docs/security-spec/05-integrations/brla.md +++ b/docs/security-spec/05-integrations/brla.md @@ -86,7 +86,7 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou | **Amount manipulation between quote and payout** | Attacker modifies the payout amount between quote and execution | `quote.outputAmount` read from DB at execution time; quote is immutable post-creation. | | **Avenia service outage** | Avenia API is unreachable mid-ramp | `RecoverablePhaseError` → phase processor retries; off-ramp fails to payout but BRLA is held on the Avenia subaccount, not lost. | | **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 split quote-relative EVM subsidy caps documented in `fund-routing.md`. The actual-vs-quoted swap discrepancy uses `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION`; the discount component uses `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION`. | +| **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 eligible shortfalls subject to the env-configured split quote-relative EVM subsidy caps documented in `fund-routing.md`. The actual-vs-quoted swap discrepancy uses `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION`; the discount component uses `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION`. Both default to `0.05`. | | **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. | | **Destination-token decimal under-delivery** | A BRL on-ramp targets an 18-decimal token such as BSC USDT, but the quote output is truncated to 6 decimals before `destinationTransfer` raw amount construction. | On-ramp finalization uses destination-token decimals for BRL EVM outputs; Squid metadata preserves destination raw output from `route.estimate.toAmount`. | @@ -118,4 +118,4 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou ## Remediation Notes - **Hardcoded BRL offramp validation amount:** Resolved in the remediation pass; BRL offramp validation now derives the pre-anchor amount from quote metadata instead of a literal placeholder. -- **EVM subsidy USD caps:** Resolved for the Base EVM subsidy handlers via `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` and, for post-swap discount top-ups, `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION`. Over-cap cases are intentionally recoverable retries: no subsidy transfer is submitted, and the ramp remains waiting for operator action rather than becoming unrecoverably failed. +- **EVM subsidy USD caps:** Resolved for the Base EVM subsidy handlers via env-configured quote-relative cap fractions. `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` and `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION` both default to `0.05` and can be overridden through environment variables. Over-cap cases are intentionally recoverable retries: no subsidy transfer is submitted, and the ramp remains waiting for operator action rather than becoming unrecoverably failed. diff --git a/docs/security-spec/SPEC-DELTA-2026-05.md b/docs/security-spec/SPEC-DELTA-2026-05.md index a570221f5..192da7631 100644 --- a/docs/security-spec/SPEC-DELTA-2026-05.md +++ b/docs/security-spec/SPEC-DELTA-2026-05.md @@ -169,7 +169,7 @@ These are findings **the user has confirmed direction on** during the spec rewri **Issue:** The BRL on-ramp runtime phase chain transitioned `fundEphemeral → nablaApprove` directly, skipping `subsidizePreSwap`. The handler was registered and wired downstream (`subsidizePreSwap → nablaApprove`), but no upstream handler returned `"subsidizePreSwap"` as its next phase for BRL onramps. The symmetric `subsidizePostSwap` phase was reached normally via `nablaSwap`'s nextPhase logic, producing an asymmetric flow where pre-swap subsidization was unreachable. -**Risk:** If the Avenia BRLA mint underdelivers (e.g. anchor fee not pre-deducted, transient rounding, or mint amount slightly below `inputAmountForSwapRaw`), the on-ramp would fail at `nablaSwap` with insufficient input balance instead of being topped up by the funding key (capped at 5% of `outputAmount` via `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION`). User funds remained on the Base ephemeral until manual recovery. +**Risk:** If the Avenia BRLA mint underdelivers (e.g. anchor fee not pre-deducted, transient rounding, or mint amount slightly below `inputAmountForSwapRaw`), the on-ramp would fail at `nablaSwap` with insufficient input balance instead of being topped up by the funding key (capped by the configured EVM subsidy fraction for that handler; default `0.05`). User funds remained on the Base ephemeral until manual recovery. **Resolution:** Changed the BRL onramp branch of `FundEphemeralHandler.nextPhaseSelector` to return `"subsidizePreSwap"`. The phase chain is now `fundEphemeral → subsidizePreSwap → nablaApprove → nablaSwap → ...`, symmetric with the BRL off-ramp pre-swap subsidization path. @@ -266,7 +266,7 @@ Priority order for the next audit/dev cycle, based on severity × likelihood. Re | # | Finding | Status | |---|---|---| -| 1 | **F-NEW-02** (HIGH if cap matters in practice) — Add EVM subsidy USD cap. Mirror F-001 fix. | RESOLVED — `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION="0.05"` enforced in pre-swap EVM handlers and on the post-swap actual-vs-quoted swap-output discrepancy component. Post-swap discount-derived subsidy is capped separately via `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION="0.05"`; over-cap cases are recoverable waits with no transfer submitted. | +| 1 | **F-NEW-02** (HIGH if cap matters in practice) — Add EVM subsidy USD cap. Mirror F-001 fix. | RESOLVED — env-configured EVM subsidy cap fractions are enforced in the pre/post-swap EVM handlers. `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` defaults to `0.05`; post-swap discount-derived subsidy is capped separately via `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION`, also defaulting to `0.05`. Over-cap cases are recoverable waits with no transfer submitted. | | 2 | **F-NEW-01** (HIGH) — Replace hardcoded `validateBRLOfframp` amount. | RESOLVED — `validateBRLOfframpMetadata(quote)` reads `quote.metadata.pendulumToMoonbeamXcm.outputAmountRaw`. Dead `evm-to-brl.ts` route deleted. | | 3 | **F-NEW-06b** (MEDIUM) — Surface or fail-fast on partner `payout_address_evm` NULL (silent markup loss). | RESOLVED — quote-time rejection (`APIError 400`) when partner has markup AND `payout_address_evm` NULL on EVM-payout routes; runtime WARN if it slips through. | | 4 | **F-NEW-04** (MEDIUM) — Harden no-permit fallback receipt validation. | RESOLVED — `waitForUserHash` now verifies receipt `to` and tx `input` against the presigned `EvmTransactionData`. | @@ -276,7 +276,7 @@ Priority order for the next audit/dev cycle, based on severity × likelihood. Re | 8 | **F-NEW-03** (LOW) — Tighten `backupApprove` allowance from `maxUint256` to a calculated bound. | RESOLVED — `avenia-to-evm-base.ts` `backupApprove` now uses `inputAmountRawFinalBridge × 1.05`. | | 9 | **F-NEW-08** — Investigate skip-Squid passthrough divergence. | NO BUG — same-chain same-token passthrough has no Squid fee; `networkFeeUSD="0"` and 1:1 rate are correct. | | 10 | **F-NEW-09** — Investigate BRLA payout recovery branches. | NO BUG — once `payOutTicketId` exists, BRLA acknowledged the EVM payout; on-chain receipt is no longer authoritative. | -| 11 | **F-NEW-10** — Avenia anchor-fee assumption in three-amount model. | NO BUG — `OffRampMergeSubsidyEvmEngine` adds the projected subsidy into `nablaSwapEvm.outputAmountRaw`, and `OffRampFinalizeEngine` then sets `quote.outputAmount = nablaSwapEvm.outputAmountDecimal − anchorFee`. The relationship `nablaSwapEvm.outputAmountRaw ≥ quote.outputAmount × 10^brlaDecimals` is therefore tautological at quote-build time. The actual safety net is the EVM branch of `subsidize-post-swap-handler.ts`, which tops the ephemeral up to `nablaSwapEvm.outputAmountRaw` at runtime using split caps for swap discrepancy and discount subsidy. No build-time assertion needed. | +| 11 | **F-NEW-10** — Avenia anchor-fee assumption in three-amount model. | NO BUG — `OffRampMergeSubsidyEvmEngine` adds the projected subsidy into `nablaSwapEvm.outputAmountRaw`, and `OffRampFinalizeEngine` then sets `quote.outputAmount = nablaSwapEvm.outputAmountDecimal − anchorFee`. The relationship `nablaSwapEvm.outputAmountRaw ≥ quote.outputAmount × 10^brlaDecimals` is therefore tautological at quote-build time. The actual safety net is the EVM branch of `subsidize-post-swap-handler.ts`, which tops the ephemeral up to `nablaSwapEvm.outputAmountRaw` at runtime using env-configured split caps for swap discrepancy and discount subsidy. No build-time assertion needed. | | 12 | **F-NEW-05** — Add Base ephemeral cleanup. | RESOLVED — `BaseChainPostProcessHandler` sweeps BRLA and USDC residuals after `currentPhase === "complete"` via presigned `approve` + funding-key `transferFrom`. Wired into both `evm-to-brl-base.ts` (offramp) and `avenia-to-evm-base.ts` (onramp). New phase keys `baseCleanupBrla` and `baseCleanupUsdc`. ETH gas dust on EVM ephemerals remains unswept (intentional). | | 13 | **F-013** — Multiple security-sensitive endpoints have no authentication. | RESOLVED — dual-track auth wired across all `/v1/ramp/*` and `/v1/ramp/quotes(/best)` endpoints. Each request carrying credentials must present **either** `X-API-Key: sk_*` (partner SDK) **or** `Authorization: Bearer ` (Supabase frontend); invalid credentials are always rejected. Per-principal ownership guards (`assertRampOwnership`, `assertQuoteOwnership`) prevent cross-tenant access: partners are scoped via `RampState.quoteId → QuoteTicket.partnerId`, Supabase users via `RampState.userId`. Anonymous access is permitted only on register/update/start/status/errors and only when the underlying resource is fully anonymous (no partner, no user owner); `getRampHistory` always requires credentials. `enforcePartnerAuth()` is active on `/quotes` and `/quotes/best`, closing the partner-spoofing vector. | From 1d5889fbddda21fa4f4f8591f8fc04eec2a13a4d Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 9 Jun 2026 20:11:46 +0200 Subject: [PATCH 14/29] Address pr review comments --- .../src/api/middlewares/maintenanceGuard.test.ts | 14 +++++--------- apps/api/src/api/middlewares/maintenanceGuard.ts | 13 ++++++++++--- apps/api/src/api/routes/v1/quote.route.ts | 4 ++-- apps/api/src/api/routes/v1/ramp.route.ts | 6 +++--- 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/apps/api/src/api/middlewares/maintenanceGuard.test.ts b/apps/api/src/api/middlewares/maintenanceGuard.test.ts index fb9234850..7c182ed66 100644 --- a/apps/api/src/api/middlewares/maintenanceGuard.test.ts +++ b/apps/api/src/api/middlewares/maintenanceGuard.test.ts @@ -11,19 +11,14 @@ const originalGetMaintenanceStatus = maintenanceService.getMaintenanceStatus; function buildResponse() { const headers: Record = {}; - const res: Partial & { headers: Record; sentContentType?: string } = { headers }; + const res: Partial & { headers: Record } = { headers }; res.setHeader = mock((name: string, value: number | string | readonly string[]) => { headers[name] = Array.isArray(value) ? value.join(", ") : String(value); return res as Response; }) as Response["setHeader"]; - res.type = mock((contentType: string) => { - res.sentContentType = contentType; - return res as Response; - }) as Response["type"]; - - return res as Response & { headers: Record; sentContentType?: string }; + return res as Response & { headers: Record }; } function mockMaintenanceStatus(status: MaintenanceStatus) { @@ -74,12 +69,14 @@ describe("rejectDuringActiveMaintenance", () => { expect(error).toBeInstanceOf(APIError); expect(error.status).toBe(503); expect(error.message).toContain("scheduled maintenance"); + expect(error.message).toContain("Database upgrade"); + expect(error.message).toContain("Scheduled database maintenance"); expect(error.errors).toEqual([ { detail: "Scheduled database maintenance", maintenance_end: end, maintenance_start: start, - operations: ["create_quote", "ramp_register", "ramp_update", "ramp_start"], + operations: ["quote_create", "quote_create_best", "ramp_register", "ramp_update", "ramp_start"], retry_after_seconds: expect.any(Number), title: "Database upgrade", type: "https://api.vortexfinance.co/problems/maintenance-window" @@ -87,6 +84,5 @@ describe("rejectDuringActiveMaintenance", () => { ]); expect(res.headers["Retry-After"]).toBe(new Date(end).toUTCString()); expect(res.headers["Cache-Control"]).toBe("no-store"); - expect(res.sentContentType).toBe("application/problem+json"); }); }); diff --git a/apps/api/src/api/middlewares/maintenanceGuard.ts b/apps/api/src/api/middlewares/maintenanceGuard.ts index 1181c7ffd..d4942936a 100644 --- a/apps/api/src/api/middlewares/maintenanceGuard.ts +++ b/apps/api/src/api/middlewares/maintenanceGuard.ts @@ -1,10 +1,17 @@ import type { NextFunction, Request, Response } from "express"; import httpStatus from "http-status"; import { APIError } from "../errors/api-error"; +import type { ApiClientOperation } from "../observability/types"; import { MaintenanceService } from "../services/maintenance.service"; const MAINTENANCE_PROBLEM_TYPE = "https://api.vortexfinance.co/problems/maintenance-window"; -const BLOCKED_OPERATIONS = ["create_quote", "ramp_register", "ramp_update", "ramp_start"]; +const BLOCKED_OPERATIONS: ApiClientOperation[] = [ + "quote_create", + "quote_create_best", + "ramp_register", + "ramp_update", + "ramp_start" +]; export async function rejectDuringActiveMaintenance(_req: Request, res: Response, next: NextFunction): Promise { try { @@ -20,7 +27,7 @@ export async function rejectDuringActiveMaintenance(_req: Request, res: Response res.setHeader("Retry-After", maintenanceEnd.toUTCString()); res.setHeader("Cache-Control", "no-store"); - res.type("application/problem+json"); + const message = `Vortex services are temporarily unavailable during scheduled maintenance: ${status.maintenance_details.title} - ${status.maintenance_details.message}`; next( new APIError({ @@ -35,7 +42,7 @@ export async function rejectDuringActiveMaintenance(_req: Request, res: Response type: MAINTENANCE_PROBLEM_TYPE } ], - message: "Vortex services are temporarily unavailable during scheduled maintenance", + message, status: httpStatus.SERVICE_UNAVAILABLE }) ); diff --git a/apps/api/src/api/routes/v1/quote.route.ts b/apps/api/src/api/routes/v1/quote.route.ts index fcc14010b..56b6a1883 100644 --- a/apps/api/src/api/routes/v1/quote.route.ts +++ b/apps/api/src/api/routes/v1/quote.route.ts @@ -45,12 +45,12 @@ const router: Router = Router({ mergeParams: true }); router .route("/") .post( + rejectDuringActiveMaintenance, validateCreateQuoteInput, optionalAuth, validatePublicKey(), apiKeyAuth({ required: false }), enforcePartnerAuth(), - rejectDuringActiveMaintenance, createQuote ); @@ -107,12 +107,12 @@ router router .route("/best") .post( + rejectDuringActiveMaintenance, validateCreateBestQuoteInput, optionalAuth, validatePublicKey(), apiKeyAuth({ required: false }), enforcePartnerAuth(), - rejectDuringActiveMaintenance, createBestQuote ); diff --git a/apps/api/src/api/routes/v1/ramp.route.ts b/apps/api/src/api/routes/v1/ramp.route.ts index 653649431..925f20c7a 100644 --- a/apps/api/src/api/routes/v1/ramp.route.ts +++ b/apps/api/src/api/routes/v1/ramp.route.ts @@ -33,8 +33,8 @@ const router = Router(); router.post( "/register", - optionalPartnerOrUserAuth(), rejectDuringActiveMaintenance, + optionalPartnerOrUserAuth(), rampController.registerRamp as unknown as RequestHandler ); @@ -65,8 +65,8 @@ router.post( */ router.post( "/update", - optionalPartnerOrUserAuth(), rejectDuringActiveMaintenance, + optionalPartnerOrUserAuth(), rampController.updateRamp as unknown as RequestHandler ); @@ -96,8 +96,8 @@ router.post( */ router.post( "/start", - optionalPartnerOrUserAuth(), rejectDuringActiveMaintenance, + optionalPartnerOrUserAuth(), rampController.startRamp as unknown as RequestHandler ); From 998051f1f10dbc0ff8ab40b6d11026fd7f4b8610 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 10 Jun 2026 10:09:42 +0200 Subject: [PATCH 15/29] Address pr review comments --- .../api/middlewares/maintenanceGuard.test.ts | 49 ++++++++- .../src/api/middlewares/maintenanceGuard.ts | 101 ++++++++++++++---- apps/api/src/api/routes/v1/quote.route.ts | 4 +- apps/api/src/api/routes/v1/ramp.route.ts | 6 +- 4 files changed, 133 insertions(+), 27 deletions(-) diff --git a/apps/api/src/api/middlewares/maintenanceGuard.test.ts b/apps/api/src/api/middlewares/maintenanceGuard.test.ts index 7c182ed66..5d1b14ca6 100644 --- a/apps/api/src/api/middlewares/maintenanceGuard.test.ts +++ b/apps/api/src/api/middlewares/maintenanceGuard.test.ts @@ -2,7 +2,17 @@ import { afterEach, describe, expect, it, mock } from "bun:test"; import type { NextFunction, Request, Response } from "express"; import { APIError } from "../errors/api-error"; import { MaintenanceService } from "../services/maintenance.service"; -import { rejectDuringActiveMaintenance } from "./maintenanceGuard"; +import type { ApiClientEventInput } from "../observability/types"; + +const observedEvents: ApiClientEventInput[] = []; + +mock.module("../observability/apiClientEvent.service", () => ({ + observeApiClientEvent: mock((event: ApiClientEventInput) => { + observedEvents.push(event); + }) +})); + +const { rejectDuringActiveMaintenance } = await import("./maintenanceGuard"); type MaintenanceStatus = Awaited>; @@ -27,6 +37,7 @@ function mockMaintenanceStatus(status: MaintenanceStatus) { describe("rejectDuringActiveMaintenance", () => { afterEach(() => { + observedEvents.length = 0; maintenanceService.getMaintenanceStatus = originalGetMaintenanceStatus; }); @@ -36,10 +47,11 @@ describe("rejectDuringActiveMaintenance", () => { const res = buildResponse(); const next: NextFunction = mock(() => undefined) as unknown as NextFunction; - await rejectDuringActiveMaintenance({} as Request, res, next); + await rejectDuringActiveMaintenance("quote_create")({} as Request, res, next); expect(next).toHaveBeenCalledTimes(1); expect(next).toHaveBeenCalledWith(); + expect(observedEvents).toEqual([]); expect(res.headers["Retry-After"]).toBeUndefined(); }); @@ -61,7 +73,20 @@ describe("rejectDuringActiveMaintenance", () => { const res = buildResponse(); const next: NextFunction = mock(() => undefined) as unknown as NextFunction; - await rejectDuringActiveMaintenance({} as Request, res, next); + await rejectDuringActiveMaintenance("quote_create")( + { + body: { + apiKey: "pk_live_1234567890abcdef", + paymentMethod: "pix", + quoteId: "quote-1", + rampType: "BUY" + }, + requestId: "request-1", + requestStartedAt: Date.now() - 50 + } as Request, + res, + next + ); expect(next).toHaveBeenCalledTimes(1); const error = (next as ReturnType).mock.calls[0][0] as APIError; @@ -84,5 +109,23 @@ describe("rejectDuringActiveMaintenance", () => { ]); expect(res.headers["Retry-After"]).toBe(new Date(end).toUTCString()); expect(res.headers["Cache-Control"]).toBe("no-store"); + expect(observedEvents).toEqual([ + expect.objectContaining({ + apiKeyPrefix: "pk_live_", + errorType: "service_unavailable", + httpStatus: 503, + metadata: { + maintenance_end: end, + maintenance_start: start, + maintenance_title: "Database upgrade" + }, + operation: "quote_create", + paymentMethod: "pix", + quoteId: "quote-1", + rampType: "BUY", + requestId: "request-1", + status: "failure" + }) + ]); }); }); diff --git a/apps/api/src/api/middlewares/maintenanceGuard.ts b/apps/api/src/api/middlewares/maintenanceGuard.ts index d4942936a..53652eb2e 100644 --- a/apps/api/src/api/middlewares/maintenanceGuard.ts +++ b/apps/api/src/api/middlewares/maintenanceGuard.ts @@ -1,6 +1,9 @@ -import type { NextFunction, Request, Response } from "express"; +import type { NextFunction, Request, RequestHandler, Response } from "express"; import httpStatus from "http-status"; import { APIError } from "../errors/api-error"; +import { observeApiClientEvent } from "../observability/apiClientEvent.service"; +import { classifyApiClientError, getErrorMessage } from "../observability/errorClassifier"; +import { getRequestDurationMs } from "../observability/requestContext"; import type { ApiClientOperation } from "../observability/types"; import { MaintenanceService } from "../services/maintenance.service"; @@ -13,24 +16,24 @@ const BLOCKED_OPERATIONS: ApiClientOperation[] = [ "ramp_start" ]; -export async function rejectDuringActiveMaintenance(_req: Request, res: Response, next: NextFunction): Promise { - try { - const status = await MaintenanceService.getInstance().getMaintenanceStatus(); +export function rejectDuringActiveMaintenance(operation: ApiClientOperation): RequestHandler { + return async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const status = await MaintenanceService.getInstance().getMaintenanceStatus(); - if (!status.is_maintenance_active || !status.maintenance_details) { - next(); - return; - } + if (!status.is_maintenance_active || !status.maintenance_details) { + next(); + return; + } - const maintenanceEnd = new Date(status.maintenance_details.end_datetime); - const retryAfterSeconds = Math.max(0, Math.ceil((maintenanceEnd.getTime() - Date.now()) / 1000)); + const maintenanceEnd = new Date(status.maintenance_details.end_datetime); + const retryAfterSeconds = Math.max(0, Math.ceil((maintenanceEnd.getTime() - Date.now()) / 1000)); - res.setHeader("Retry-After", maintenanceEnd.toUTCString()); - res.setHeader("Cache-Control", "no-store"); - const message = `Vortex services are temporarily unavailable during scheduled maintenance: ${status.maintenance_details.title} - ${status.maintenance_details.message}`; + res.setHeader("Retry-After", maintenanceEnd.toUTCString()); + res.setHeader("Cache-Control", "no-store"); + const message = `Vortex services are temporarily unavailable during scheduled maintenance: ${status.maintenance_details.title} - ${status.maintenance_details.message}`; - next( - new APIError({ + const error = new APIError({ errors: [ { detail: status.maintenance_details.message, @@ -44,9 +47,69 @@ export async function rejectDuringActiveMaintenance(_req: Request, res: Response ], message, status: httpStatus.SERVICE_UNAVAILABLE - }) - ); - } catch (error) { - next(error); + }); + + observeMaintenanceDenial(req, operation, error, status.maintenance_details); + next(error); + } catch (error) { + next(error); + } + }; +} + +function observeMaintenanceDenial( + req: Request, + operation: ApiClientOperation, + error: APIError, + maintenanceDetails: { + end_datetime: string; + message: string; + start_datetime: string; + title: string; } +): void { + const body = getRequestBody(req); + const publicApiKey = getString(body.apiKey) || req.validatedPublicKey?.apiKey; + const secretApiKey = getHeaderValue(req.headers?.["x-api-key"]); + + observeApiClientEvent({ + apiKeyPrefix: getSafeApiKeyPrefix(secretApiKey || publicApiKey), + durationMs: getRequestDurationMs(req), + errorMessage: getErrorMessage(error), + errorType: classifyApiClientError(error, httpStatus.SERVICE_UNAVAILABLE), + httpStatus: httpStatus.SERVICE_UNAVAILABLE, + metadata: { + maintenance_end: maintenanceDetails.end_datetime, + maintenance_start: maintenanceDetails.start_datetime, + maintenance_title: maintenanceDetails.title + }, + operation, + partnerId: req.authenticatedPartner?.id || getString(body.partnerId), + partnerName: req.authenticatedPartner?.name || req.validatedPublicKey?.partnerName || null, + paymentMethod: getString(body.paymentMethod), + quoteId: getString(body.quoteId), + rampId: getString(body.rampId), + rampType: getString(body.rampType), + requestId: req.requestId, + status: "failure", + userId: req.userId || null + }); +} + +function getRequestBody(req: Request): Record { + return typeof req.body === "object" && req.body !== null ? (req.body as Record) : {}; +} + +function getString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function getHeaderValue(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) return value[0] || null; + return value || null; +} + +function getSafeApiKeyPrefix(apiKey: string | null | undefined): string | null { + if (!apiKey?.startsWith("pk_") && !apiKey?.startsWith("sk_")) return null; + return apiKey.slice(0, 8); } diff --git a/apps/api/src/api/routes/v1/quote.route.ts b/apps/api/src/api/routes/v1/quote.route.ts index 56b6a1883..c8225e1dd 100644 --- a/apps/api/src/api/routes/v1/quote.route.ts +++ b/apps/api/src/api/routes/v1/quote.route.ts @@ -45,7 +45,7 @@ const router: Router = Router({ mergeParams: true }); router .route("/") .post( - rejectDuringActiveMaintenance, + rejectDuringActiveMaintenance("quote_create"), validateCreateQuoteInput, optionalAuth, validatePublicKey(), @@ -107,7 +107,7 @@ router router .route("/best") .post( - rejectDuringActiveMaintenance, + rejectDuringActiveMaintenance("quote_create_best"), validateCreateBestQuoteInput, optionalAuth, validatePublicKey(), diff --git a/apps/api/src/api/routes/v1/ramp.route.ts b/apps/api/src/api/routes/v1/ramp.route.ts index 925f20c7a..aa8e589b3 100644 --- a/apps/api/src/api/routes/v1/ramp.route.ts +++ b/apps/api/src/api/routes/v1/ramp.route.ts @@ -33,7 +33,7 @@ const router = Router(); router.post( "/register", - rejectDuringActiveMaintenance, + rejectDuringActiveMaintenance("ramp_register"), optionalPartnerOrUserAuth(), rampController.registerRamp as unknown as RequestHandler ); @@ -65,7 +65,7 @@ router.post( */ router.post( "/update", - rejectDuringActiveMaintenance, + rejectDuringActiveMaintenance("ramp_update"), optionalPartnerOrUserAuth(), rampController.updateRamp as unknown as RequestHandler ); @@ -96,7 +96,7 @@ router.post( */ router.post( "/start", - rejectDuringActiveMaintenance, + rejectDuringActiveMaintenance("ramp_start"), optionalPartnerOrUserAuth(), rampController.startRamp as unknown as RequestHandler ); From 1f4147d17d78d398821c71e1aa623243c2c49b56 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 10 Jun 2026 10:26:16 +0200 Subject: [PATCH 16/29] Add function to read and validate subsidy quote fractions from environment variables --- apps/api/src/config/vars.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index d54b8f88c..59c765252 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -53,6 +53,22 @@ function readFlowVariant(): FlowVariant { return rawFlowVariant as FlowVariant; } +function readFractionEnv(name: string, defaultValue: string): number { + const rawValue = process.env[name] ?? defaultValue; + const trimmedValue = rawValue.trim(); + + if (trimmedValue === "") { + throw new Error(`${name} must be a finite number between 0 and 1`); + } + + const value = Number(trimmedValue); + if (!Number.isFinite(value) || value < 0 || value > 1) { + throw new Error(`${name} must be a finite number between 0 and 1`); + } + + return value; +} + interface Config { env: string; deploymentEnv: DeploymentEnv; @@ -215,10 +231,8 @@ export const config: Config = { subscanApiKey: process.env.SUBSCAN_API_KEY, subsidy: { - evmPostSwapDiscountSubsidyQuoteFraction: parseFloat( - process.env.MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION || "0.05" - ), - evmSwapSubsidyQuoteFraction: parseFloat(process.env.MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION || "0.05") + evmPostSwapDiscountSubsidyQuoteFraction: readFractionEnv("MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION", "0.05"), + evmSwapSubsidyQuoteFraction: readFractionEnv("MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION", "0.05") }, supabase: { anonKey: process.env.SUPABASE_ANON_KEY || "", From 071eda97bec7e0911bbb983c71ae5d61a9978e1b Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 10 Jun 2026 13:56:16 +0200 Subject: [PATCH 17/29] Improve type for maintenance guard --- apps/api/src/api/middlewares/maintenanceGuard.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/api/src/api/middlewares/maintenanceGuard.ts b/apps/api/src/api/middlewares/maintenanceGuard.ts index 53652eb2e..449dfcc04 100644 --- a/apps/api/src/api/middlewares/maintenanceGuard.ts +++ b/apps/api/src/api/middlewares/maintenanceGuard.ts @@ -8,15 +8,16 @@ import type { ApiClientOperation } from "../observability/types"; import { MaintenanceService } from "../services/maintenance.service"; const MAINTENANCE_PROBLEM_TYPE = "https://api.vortexfinance.co/problems/maintenance-window"; -const BLOCKED_OPERATIONS: ApiClientOperation[] = [ +const BLOCKED_OPERATIONS = [ "quote_create", "quote_create_best", "ramp_register", "ramp_update", "ramp_start" -]; +] as const satisfies readonly ApiClientOperation[]; +type BlockedMaintenanceOperation = (typeof BLOCKED_OPERATIONS)[number]; -export function rejectDuringActiveMaintenance(operation: ApiClientOperation): RequestHandler { +export function rejectDuringActiveMaintenance(operation: BlockedMaintenanceOperation): RequestHandler { return async (req: Request, res: Response, next: NextFunction): Promise => { try { const status = await MaintenanceService.getInstance().getMaintenanceStatus(); @@ -59,7 +60,7 @@ export function rejectDuringActiveMaintenance(operation: ApiClientOperation): Re function observeMaintenanceDenial( req: Request, - operation: ApiClientOperation, + operation: BlockedMaintenanceOperation, error: APIError, maintenanceDetails: { end_datetime: string; From b633f4f4bacfe3a0684c8dc4ffe37c7bd8833865 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 12 Jun 2026 18:01:36 +0200 Subject: [PATCH 18/29] Add type to maintenanceGuard.ts --- .../api/middlewares/maintenanceGuard.test.ts | 115 +++++++++++++++++- .../src/api/middlewares/maintenanceGuard.ts | 3 +- 2 files changed, 112 insertions(+), 6 deletions(-) diff --git a/apps/api/src/api/middlewares/maintenanceGuard.test.ts b/apps/api/src/api/middlewares/maintenanceGuard.test.ts index 5d1b14ca6..3f844d037 100644 --- a/apps/api/src/api/middlewares/maintenanceGuard.test.ts +++ b/apps/api/src/api/middlewares/maintenanceGuard.test.ts @@ -1,20 +1,56 @@ -import { afterEach, describe, expect, it, mock } from "bun:test"; -import type { NextFunction, Request, Response } from "express"; -import { APIError } from "../errors/api-error"; -import { MaintenanceService } from "../services/maintenance.service"; -import type { ApiClientEventInput } from "../observability/types"; +import {afterEach, describe, expect, it, mock} from "bun:test"; +import type {NextFunction, Request, Response} from "express"; +import express from "express"; +import httpStatus from "http-status"; +import {APIError} from "../errors/api-error"; +import type {ApiClientEventInput} from "../observability/types"; +import {MaintenanceService} from "../services/maintenance.service"; +import {handler as errorHandler} from "./error"; const observedEvents: ApiClientEventInput[] = []; +const controllerCalls: string[] = []; mock.module("../observability/apiClientEvent.service", () => ({ + buildApiClientRequestMetadata: mock(() => ({})), + getSafeApiKeyPrefix: mock((apiKey: string | null | undefined) => apiKey?.slice(0, 16) || null), observeApiClientEvent: mock((event: ApiClientEventInput) => { observedEvents.push(event); }) })); +function controllerHandler(name: string) { + return (_req: Request, res: Response) => { + controllerCalls.push(name); + res.status(httpStatus.I_AM_A_TEAPOT).json({ reached: name }); + }; +} + +mock.module("../controllers/quote.controller", () => ({ + createBestQuote: mock(controllerHandler("quote_create_best_controller")), + createQuote: mock(controllerHandler("quote_create_controller")), + getQuote: mock(controllerHandler("quote_get_controller")) +})); + +mock.module("../controllers/ramp.controller", () => ({ + getErrorLogs: mock(controllerHandler("ramp_errors_controller")), + getRampHistory: mock(controllerHandler("ramp_history_controller")), + getRampStatus: mock(controllerHandler("ramp_status_controller")), + registerRamp: mock(controllerHandler("ramp_register_controller")), + startRamp: mock(controllerHandler("ramp_start_controller")), + updateRamp: mock(controllerHandler("ramp_update_controller")) +})); + const { rejectDuringActiveMaintenance } = await import("./maintenanceGuard"); +const { default: quoteRoutes } = await import("../routes/v1/quote.route"); +const { default: rampRoutes } = await import("../routes/v1/ramp.route"); type MaintenanceStatus = Awaited>; +type MaintenanceHttpResponse = { + code: number; + errors?: Record[]; + statusCode?: number; + type?: string; +}; const maintenanceService = MaintenanceService.getInstance(); const originalGetMaintenanceStatus = maintenanceService.getMaintenanceStatus; @@ -35,8 +71,35 @@ function mockMaintenanceStatus(status: MaintenanceStatus) { maintenanceService.getMaintenanceStatus = mock(async () => status) as unknown as MaintenanceService["getMaintenanceStatus"]; } +async function fetchFromGuardedRoutes(path: string) { + const app = express(); + app.use(express.json()); + app.use("/v1/quotes", quoteRoutes); + app.use("/v1/ramp", rampRoutes); + app.use(errorHandler); + + const server = app.listen(0); + const address = server.address(); + + if (!address || typeof address === "string") { + server.close(); + throw new Error("Could not bind test server"); + } + + try { + return await fetch(`http://127.0.0.1:${address.port}${path}`, { + body: JSON.stringify({ malformed: true }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + } finally { + server.close(); + } +} + describe("rejectDuringActiveMaintenance", () => { afterEach(() => { + controllerCalls.length = 0; observedEvents.length = 0; maintenanceService.getMaintenanceStatus = originalGetMaintenanceStatus; }); @@ -93,6 +156,7 @@ describe("rejectDuringActiveMaintenance", () => { expect(error).toBeInstanceOf(APIError); expect(error.status).toBe(503); + expect(error.type).toBe("https://api.vortexfinance.co/problems/maintenance-window"); expect(error.message).toContain("scheduled maintenance"); expect(error.message).toContain("Database upgrade"); expect(error.message).toContain("Scheduled database maintenance"); @@ -128,4 +192,45 @@ describe("rejectDuringActiveMaintenance", () => { }) ]); }); + + it("rejects every guarded quote and ramp route before controllers run", async () => { + const start = new Date(Date.now() - 60_000).toISOString(); + const end = new Date(Date.now() + 30 * 60_000).toISOString(); + + mockMaintenanceStatus({ + is_maintenance_active: true, + maintenance_details: { + end_datetime: end, + estimated_time_remaining_seconds: 1800, + message: "Scheduled database maintenance", + start_datetime: start, + title: "Database upgrade" + } + }); + + const guardedPaths = ["/v1/quotes", "/v1/quotes/best", "/v1/ramp/register", "/v1/ramp/update", "/v1/ramp/start"]; + + for (const path of guardedPaths) { + const response = await fetchFromGuardedRoutes(path); + const body = (await response.json()) as MaintenanceHttpResponse; + + expect(response.status).toBe(httpStatus.SERVICE_UNAVAILABLE); + expect(response.headers.get("Retry-After")).toBe(new Date(end).toUTCString()); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(body.code).toBe(httpStatus.SERVICE_UNAVAILABLE); + expect(body.statusCode).toBe(httpStatus.SERVICE_UNAVAILABLE); + expect(body.type).toBe("https://api.vortexfinance.co/problems/maintenance-window"); + expect(body.errors?.[0]).toEqual( + expect.objectContaining({ + maintenance_end: end, + maintenance_start: start, + operations: ["quote_create", "quote_create_best", "ramp_register", "ramp_update", "ramp_start"], + retry_after_seconds: expect.any(Number), + type: "https://api.vortexfinance.co/problems/maintenance-window" + }) + ); + } + + expect(controllerCalls).toEqual([]); + }); }); diff --git a/apps/api/src/api/middlewares/maintenanceGuard.ts b/apps/api/src/api/middlewares/maintenanceGuard.ts index 449dfcc04..3c1d8422d 100644 --- a/apps/api/src/api/middlewares/maintenanceGuard.ts +++ b/apps/api/src/api/middlewares/maintenanceGuard.ts @@ -47,7 +47,8 @@ export function rejectDuringActiveMaintenance(operation: BlockedMaintenanceOpera } ], message, - status: httpStatus.SERVICE_UNAVAILABLE + status: httpStatus.SERVICE_UNAVAILABLE, + type: MAINTENANCE_PROBLEM_TYPE }); observeMaintenanceDenial(req, operation, error, status.maintenance_details); From 287bb1b78ac757fde48897103640c578e8740567 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 15 Jun 2026 10:38:03 +0200 Subject: [PATCH 19/29] Rename migration --- ...-partner-assignments.ts => 033-profile-partner-assignments.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/api/src/database/migrations/{032-profile-partner-assignments.ts => 033-profile-partner-assignments.ts} (100%) diff --git a/apps/api/src/database/migrations/032-profile-partner-assignments.ts b/apps/api/src/database/migrations/033-profile-partner-assignments.ts similarity index 100% rename from apps/api/src/database/migrations/032-profile-partner-assignments.ts rename to apps/api/src/database/migrations/033-profile-partner-assignments.ts From 4db2865c3341a05ebc0a89847582a763fcab3983 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 15 Jun 2026 11:10:45 +0200 Subject: [PATCH 20/29] Add instruction to keep security-spec in sync to CLAUDE.md --- CLAUDE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 71371528c..79d7bfd4e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,6 +158,12 @@ cd apps/frontend bun test ``` +## Security Spec Sync + +`docs/security-spec/` is the audit-facing source of truth for security-sensitive behavior. When changing auth, admin routes, quote/ramp state, signing, fees, partner pricing, integrations, migrations/schema that affect invariants, or cross-chain fund flow, do a quick targeted check for the matching spec file and update it in the same change if behavior changed. + +Keep this lightweight: grep/read only the relevant spec path from `docs/security-spec/README.md`; skip this for cosmetic refactors, test-only changes, or implementation changes that do not alter security-relevant behavior. + ## Type Issues If IDE doesn't detect `@pendulum-chain/types` properly, ensure all `@polkadot/*` packages match versions in the types package. The root `package.json` uses `catalog:` for version management. From 0e59536cd4ffeb29ead266f7c323f66131243da3 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 15 Jun 2026 11:11:57 +0200 Subject: [PATCH 21/29] Allow assigning partner to profile buy vs sell separately --- ...ofilePartnerAssignments.controller.test.ts | 84 +++++++++++++++++-- .../profilePartnerAssignments.controller.ts | 31 ++++++- .../quote/core/partner-resolution.test.ts | 66 ++++++++++++--- .../services/quote/core/partner-resolution.ts | 32 +++++-- .../033-profile-partner-assignments.ts | 32 +++++++ apps/api/src/models/index.ts | 4 + .../models/profilePartnerAssignment.model.ts | 36 ++++++++ 7 files changed, 261 insertions(+), 24 deletions(-) diff --git a/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.test.ts b/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.test.ts index a4ece0bfb..6cf21bc9e 100644 --- a/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.test.ts +++ b/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.test.ts @@ -1,11 +1,13 @@ -import { afterEach, describe, expect, it, mock } from "bun:test"; +import {afterEach, describe, expect, it, mock} from "bun:test"; +import {RampDirection} from "@vortexfi/shared"; +import {Request, Response} from "express"; import httpStatus from "http-status"; -import { Transaction, UniqueConstraintError } from "sequelize"; +import {Op, Transaction, UniqueConstraintError} from "sequelize"; import sequelize from "../../../config/database"; import Partner from "../../../models/partner.model"; import ProfilePartnerAssignment from "../../../models/profilePartnerAssignment.model"; import User from "../../../models/user.model"; -import { createProfilePartnerAssignment } from "./profilePartnerAssignments.controller"; +import {createProfilePartnerAssignment, listProfilePartnerAssignments} from "./profilePartnerAssignments.controller"; function createResponse() { const res = { @@ -31,6 +33,7 @@ describe("createProfilePartnerAssignment", () => { const originalPartnerFindAll = Partner.findAll; const originalAssignmentUpdate = ProfilePartnerAssignment.update; const originalAssignmentCreate = ProfilePartnerAssignment.create; + const originalAssignmentFindAll = ProfilePartnerAssignment.findAll; const transaction = { id: "profile-assignment-tx" }; const createdAt = new Date("2026-06-03T12:00:00.000Z"); @@ -42,19 +45,25 @@ describe("createProfilePartnerAssignment", () => { Partner.findAll = originalPartnerFindAll; ProfilePartnerAssignment.update = originalAssignmentUpdate; ProfilePartnerAssignment.create = originalAssignmentCreate; + ProfilePartnerAssignment.findAll = originalAssignmentFindAll; }); function mockValidAssignmentDependencies() { const transactionMock = mock(async (callback: (tx: unknown) => Promise) => callback(transaction)); const userFindByPkMock = mock(async () => ({ id: "user-1" })); - const partnerFindAllMock = mock(async () => [{ id: "partner-1", name: "Acme" }]); + const partnerFindAllMock = mock(async () => [ + { id: "buy-partner-1", name: "Acme", rampType: RampDirection.BUY }, + { id: "sell-partner-1", name: "Acme", rampType: RampDirection.SELL } + ]); const assignmentUpdateMock = mock(async () => [1]); const assignmentCreateMock = mock(async () => ({ createdAt, expiresAt: null, + buyPartnerId: "buy-partner-1", id: "assignment-2", isActive: true, partnerName: "Acme", + sellPartnerId: "sell-partner-1", updatedAt, userId: "user-1" })); @@ -84,8 +93,8 @@ describe("createProfilePartnerAssignment", () => { partnerName: "Acme", userId: "user-1" } - } as any, - res as any + } as unknown as Request, + res as unknown as Response ); expect(res.statusCode).toBe(httpStatus.CREATED); @@ -106,9 +115,11 @@ describe("createProfilePartnerAssignment", () => { ); expect(assignmentCreateMock).toHaveBeenCalledWith( { + buyPartnerId: "buy-partner-1", expiresAt: null, isActive: true, partnerName: "Acme", + sellPartnerId: "sell-partner-1", userId: "user-1" }, { transaction } @@ -128,8 +139,8 @@ describe("createProfilePartnerAssignment", () => { partnerName: "Acme", userId: "user-1" } - } as any, - res as any + } as unknown as Request, + res as unknown as Response ); expect(res.statusCode).toBe(httpStatus.CONFLICT); @@ -141,4 +152,61 @@ describe("createProfilePartnerAssignment", () => { } }); }); + + it("rejects ambiguous active partners for the same ramp type", async () => { + mockValidAssignmentDependencies(); + Partner.findAll = mock(async () => [ + { id: "buy-partner-1", name: "Acme", rampType: RampDirection.BUY }, + { id: "buy-partner-2", name: "Acme", rampType: RampDirection.BUY } + ]) as unknown as typeof Partner.findAll; + const res = createResponse(); + + await createProfilePartnerAssignment( + { + body: { + partnerName: "Acme", + userId: "user-1" + } + } as unknown as Request, + res as unknown as Response + ); + + expect(res.statusCode).toBe(httpStatus.CONFLICT); + expect(res.body).toEqual({ + error: { + code: "AMBIGUOUS_PARTNER_ASSIGNMENT", + message: `Multiple active ${RampDirection.BUY} partners found with this name`, + status: httpStatus.CONFLICT + } + }); + }); + + it("excludes expired assignments from the default list", async () => { + const assignmentFindAllMock = mock(async () => []); + ProfilePartnerAssignment.findAll = assignmentFindAllMock as unknown as typeof ProfilePartnerAssignment.findAll; + const res = createResponse(); + + await listProfilePartnerAssignments({ query: {} } as unknown as Request, res as unknown as Response); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(assignmentFindAllMock).toHaveBeenCalledTimes(1); + const findOptions = assignmentFindAllMock.mock.calls[0][0]; + expect(findOptions.where.isActive).toBe(true); + expect(findOptions.where[Op.or]).toHaveLength(2); + }); + + it("includes expired assignments when includeInactive is true", async () => { + const assignmentFindAllMock = mock(async () => []); + ProfilePartnerAssignment.findAll = assignmentFindAllMock as unknown as typeof ProfilePartnerAssignment.findAll; + const res = createResponse(); + + await listProfilePartnerAssignments( + { query: { includeInactive: "true" } } as unknown as Request, + res as unknown as Response + ); + + const findOptions = assignmentFindAllMock.mock.calls[0][0]; + expect(findOptions.where).not.toHaveProperty("isActive"); + expect(findOptions.where[Op.or]).toBeUndefined(); + }); }); diff --git a/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.ts b/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.ts index 076ef5494..71142409e 100644 --- a/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.ts +++ b/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.ts @@ -1,6 +1,7 @@ +import { RampDirection } from "@vortexfi/shared"; import { Request, Response } from "express"; import httpStatus from "http-status"; -import { Transaction, UniqueConstraintError, WhereOptions } from "sequelize"; +import { Op, Transaction, UniqueConstraintError, WhereOptions } from "sequelize"; import sequelize from "../../../config/database"; import logger from "../../../config/logger"; import Partner from "../../../models/partner.model"; @@ -9,6 +10,15 @@ import User from "../../../models/user.model"; const PROFILE_NOT_FOUND_AFTER_LOCK = "PROFILE_NOT_FOUND_AFTER_LOCK"; +function getUniquePartnerIdForRamp(partners: Partner[], rampType: RampDirection): string | null { + const rampPartners = partners.filter(partner => partner.rampType === rampType); + if (rampPartners.length > 1) { + throw new Error(`Multiple active ${rampType} partners found with this name`); + } + + return rampPartners[0]?.id ?? null; +} + function parseExpiration(expiresAt: unknown): Date | null { if (!expiresAt) { return null; @@ -28,11 +38,13 @@ function parseExpiration(expiresAt: unknown): Date | null { function serializeAssignment(assignment: ProfilePartnerAssignment) { return { + buyPartnerId: assignment.buyPartnerId, createdAt: assignment.createdAt, expiresAt: assignment.expiresAt, id: assignment.id, isActive: assignment.isActive, partnerName: assignment.partnerName, + sellPartnerId: assignment.sellPartnerId, updatedAt: assignment.updatedAt, userId: assignment.userId }; @@ -83,6 +95,9 @@ export async function createProfilePartnerAssignment(req: Request, res: Response return; } + const buyPartnerId = getUniquePartnerIdForRamp(partners, RampDirection.BUY); + const sellPartnerId = getUniquePartnerIdForRamp(partners, RampDirection.SELL); + const expirationDate = parseExpiration(expiresAt); const assignment = await sequelize.transaction(async transaction => { @@ -108,9 +123,11 @@ export async function createProfilePartnerAssignment(req: Request, res: Response return ProfilePartnerAssignment.create( { + buyPartnerId, expiresAt: expirationDate, isActive: true, partnerName, + sellPartnerId, userId }, { transaction } @@ -122,6 +139,17 @@ export async function createProfilePartnerAssignment(req: Request, res: Response partnerCount: partners.length }); } catch (error) { + if (error instanceof Error && error.message.startsWith("Multiple active")) { + res.status(httpStatus.CONFLICT).json({ + error: { + code: "AMBIGUOUS_PARTNER_ASSIGNMENT", + message: error.message, + status: httpStatus.CONFLICT + } + }); + return; + } + if (error instanceof Error && error.message.startsWith("expiresAt")) { res.status(httpStatus.BAD_REQUEST).json({ error: { @@ -176,6 +204,7 @@ export async function listProfilePartnerAssignments( if (includeInactive !== "true") { where.isActive = true; + where[Op.or] = [{ expiresAt: null }, { expiresAt: { [Op.gt]: new Date() } }]; } if (partnerName) { where.partnerName = partnerName; diff --git a/apps/api/src/api/services/quote/core/partner-resolution.test.ts b/apps/api/src/api/services/quote/core/partner-resolution.test.ts index 240a53a65..04d880341 100644 --- a/apps/api/src/api/services/quote/core/partner-resolution.test.ts +++ b/apps/api/src/api/services/quote/core/partner-resolution.test.ts @@ -1,8 +1,8 @@ -import { afterEach, describe, expect, it, mock } from "bun:test"; -import { EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; +import {afterEach, describe, expect, it, mock} from "bun:test"; +import {EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection} from "@vortexfi/shared"; import Partner from "../../../../models/partner.model"; import ProfilePartnerAssignment from "../../../../models/profilePartnerAssignment.model"; -import { resolveQuotePartner } from "./partner-resolution"; +import {resolveQuotePartner} from "./partner-resolution"; const baseRequest = { from: EPaymentMethod.PIX, @@ -33,7 +33,7 @@ describe("resolveQuotePartner", () => { }) as typeof Partner.findOne; ProfilePartnerAssignment.findOne = mock(async () => { assignmentLookupCount++; - return { partnerName: "AssignedPartner" }; + return { buyPartnerId: "assigned-buy-id", sellPartnerId: "assigned-sell-id" }; }) as typeof ProfilePartnerAssignment.findOne; const result = await resolveQuotePartner({ @@ -56,7 +56,10 @@ describe("resolveQuotePartner", () => { } return null; }) as typeof Partner.findOne; - ProfilePartnerAssignment.findOne = mock(async () => ({ partnerName: "AssignedPartner" })) as typeof ProfilePartnerAssignment.findOne; + ProfilePartnerAssignment.findOne = mock(async () => ({ + buyPartnerId: "assigned-buy-id", + sellPartnerId: "assigned-sell-id" + })) as typeof ProfilePartnerAssignment.findOne; const result = await resolveQuotePartner({ ...baseRequest, @@ -69,10 +72,13 @@ describe("resolveQuotePartner", () => { expect(result.ownerPartnerId).toBe("public-key-buy-id"); }); - it("applies profile assignments as pricing-only for authenticated users", async () => { - ProfilePartnerAssignment.findOne = mock(async () => ({ partnerName: "AssignedPartner" })) as typeof ProfilePartnerAssignment.findOne; - Partner.findOne = mock(async ({ where }: { where: { name?: string } }) => { - if (where.name === "AssignedPartner") { + it("applies the buy profile assignment as pricing-only for authenticated buy users", async () => { + ProfilePartnerAssignment.findOne = mock(async () => ({ + buyPartnerId: "assigned-buy-id", + sellPartnerId: "assigned-sell-id" + })) as typeof ProfilePartnerAssignment.findOne; + Partner.findOne = mock(async ({ where }: { where: { id?: string; rampType?: RampDirection } }) => { + if (where.id === "assigned-buy-id" && where.rampType === RampDirection.BUY) { return { id: "assigned-buy-id", name: "AssignedPartner" }; } return null; @@ -88,11 +94,51 @@ describe("resolveQuotePartner", () => { expect(result.ownerPartnerId).toBeNull(); }); + it("applies the sell profile assignment as pricing-only for authenticated sell users", async () => { + ProfilePartnerAssignment.findOne = mock(async () => ({ + buyPartnerId: "assigned-buy-id", + sellPartnerId: "assigned-sell-id" + })) as typeof ProfilePartnerAssignment.findOne; + Partner.findOne = mock(async ({ where }: { where: { id?: string; rampType?: RampDirection } }) => { + if (where.id === "assigned-sell-id" && where.rampType === RampDirection.SELL) { + return { id: "assigned-sell-id", name: "AssignedPartner" }; + } + return null; + }) as typeof Partner.findOne; + + const result = await resolveQuotePartner({ + ...baseRequest, + rampType: RampDirection.SELL, + userId: "user-1" + }); + + expect(result.source).toBe("profileAssignment"); + expect(result.pricingPartnerId).toBe("assigned-sell-id"); + expect(result.ownerPartnerId).toBeNull(); + }); + + it("falls back to default pricing when no partner id is assigned for the ramp type", async () => { + ProfilePartnerAssignment.findOne = mock(async () => ({ + buyPartnerId: null, + sellPartnerId: "assigned-sell-id" + })) as typeof ProfilePartnerAssignment.findOne; + Partner.findOne = mock(async () => ({ id: "unexpected" })) as typeof Partner.findOne; + + const result = await resolveQuotePartner({ + ...baseRequest, + userId: "user-1" + }); + + expect(result.source).toBe("none"); + expect(result.pricingPartnerId).toBeNull(); + expect(result.ownerPartnerId).toBeNull(); + }); + it("does not apply profile assignments to anonymous requests", async () => { let assignmentLookupCount = 0; ProfilePartnerAssignment.findOne = mock(async () => { assignmentLookupCount++; - return { partnerName: "AssignedPartner" }; + return { buyPartnerId: "assigned-buy-id", sellPartnerId: "assigned-sell-id" }; }) as typeof ProfilePartnerAssignment.findOne; Partner.findOne = mock(async () => null) as typeof Partner.findOne; diff --git a/apps/api/src/api/services/quote/core/partner-resolution.ts b/apps/api/src/api/services/quote/core/partner-resolution.ts index 80fbae5a6..89b36c971 100644 --- a/apps/api/src/api/services/quote/core/partner-resolution.ts +++ b/apps/api/src/api/services/quote/core/partner-resolution.ts @@ -42,7 +42,25 @@ async function findPartnerForRamp( return partner; } -async function findAssignedPartnerName(userId: string, now: Date): Promise { +async function findPartnerByIdForRamp(partnerId: string, rampType: RampDirection): Promise { + const partner = await Partner.findOne({ + where: { + id: partnerId, + isActive: true, + rampType + } + }); + + if (!partner) { + logger.warn( + `Assigned partner '${partnerId}' not found or not active for rampType=${rampType}. Proceeding with default fees.` + ); + } + + return partner; +} + +async function findAssignedPartnerId(userId: string, rampType: RampDirection, now: Date): Promise { const assignment = await ProfilePartnerAssignment.findOne({ order: [["createdAt", "DESC"]], where: { @@ -52,7 +70,11 @@ async function findAssignedPartnerName(userId: string, now: Date): Promise { await queryInterface.createTable("profile_partner_assignments", { + buyPartnerId: { + allowNull: true, + field: "buy_partner_id", + onDelete: "SET NULL", + onUpdate: "CASCADE", + references: { + key: "id", + model: "partners" + }, + type: DataTypes.UUID + }, createdAt: { allowNull: false, defaultValue: DataTypes.NOW, @@ -29,6 +40,17 @@ export async function up(queryInterface: QueryInterface): Promise { field: "partner_name", type: DataTypes.STRING(100) }, + sellPartnerId: { + allowNull: true, + field: "sell_partner_id", + onDelete: "SET NULL", + onUpdate: "CASCADE", + references: { + key: "id", + model: "partners" + }, + type: DataTypes.UUID + }, updatedAt: { allowNull: false, defaultValue: DataTypes.NOW, @@ -56,6 +78,14 @@ export async function up(queryInterface: QueryInterface): Promise { name: "idx_profile_partner_assignments_partner_name" }); + await queryInterface.addIndex("profile_partner_assignments", ["buy_partner_id"], { + name: "idx_profile_partner_assignments_buy_partner" + }); + + await queryInterface.addIndex("profile_partner_assignments", ["sell_partner_id"], { + name: "idx_profile_partner_assignments_sell_partner" + }); + await queryInterface.addIndex("profile_partner_assignments", ["user_id", "is_active", "expires_at"], { name: "idx_profile_partner_assignments_active_lookup" }); @@ -88,6 +118,8 @@ export async function down(queryInterface: QueryInterface): Promise { await queryInterface.removeIndex("profile_partner_assignments", "uniq_profile_partner_assignments_active_user"); await queryInterface.removeIndex("profile_partner_assignments", "idx_profile_partner_assignments_active_lookup"); + await queryInterface.removeIndex("profile_partner_assignments", "idx_profile_partner_assignments_sell_partner"); + await queryInterface.removeIndex("profile_partner_assignments", "idx_profile_partner_assignments_buy_partner"); await queryInterface.removeIndex("profile_partner_assignments", "idx_profile_partner_assignments_partner_name"); await queryInterface.removeIndex("profile_partner_assignments", "idx_profile_partner_assignments_user_id"); await queryInterface.dropTable("profile_partner_assignments"); diff --git a/apps/api/src/models/index.ts b/apps/api/src/models/index.ts index 9d0fec10f..847719dc8 100644 --- a/apps/api/src/models/index.ts +++ b/apps/api/src/models/index.ts @@ -46,6 +46,10 @@ MykoboCustomer.belongsTo(User, { as: "user", foreignKey: "userId" }); User.hasMany(ProfilePartnerAssignment, { as: "partnerAssignments", foreignKey: "userId" }); ProfilePartnerAssignment.belongsTo(User, { as: "user", foreignKey: "userId" }); +ProfilePartnerAssignment.belongsTo(Partner, { as: "buyPartner", foreignKey: "buyPartnerId" }); +ProfilePartnerAssignment.belongsTo(Partner, { as: "sellPartner", foreignKey: "sellPartnerId" }); +Partner.hasMany(ProfilePartnerAssignment, { as: "buyProfileAssignments", foreignKey: "buyPartnerId" }); +Partner.hasMany(ProfilePartnerAssignment, { as: "sellProfileAssignments", foreignKey: "sellPartnerId" }); // Initialize models const models = { diff --git a/apps/api/src/models/profilePartnerAssignment.model.ts b/apps/api/src/models/profilePartnerAssignment.model.ts index a20066b9c..e8ae91799 100644 --- a/apps/api/src/models/profilePartnerAssignment.model.ts +++ b/apps/api/src/models/profilePartnerAssignment.model.ts @@ -5,6 +5,8 @@ export interface ProfilePartnerAssignmentAttributes { id: string; userId: string; partnerName: string; + buyPartnerId: string | null; + sellPartnerId: string | null; isActive: boolean; expiresAt: Date | null; createdAt: Date; @@ -26,6 +28,10 @@ class ProfilePartnerAssignment declare partnerName: string; + declare buyPartnerId: string | null; + + declare sellPartnerId: string | null; + declare isActive: boolean; declare expiresAt: Date | null; @@ -37,6 +43,17 @@ class ProfilePartnerAssignment ProfilePartnerAssignment.init( { + buyPartnerId: { + allowNull: true, + field: "buy_partner_id", + onDelete: "SET NULL", + onUpdate: "CASCADE", + references: { + key: "id", + model: "partners" + }, + type: DataTypes.UUID + }, createdAt: { allowNull: false, defaultValue: DataTypes.NOW, @@ -64,6 +81,17 @@ ProfilePartnerAssignment.init( field: "partner_name", type: DataTypes.STRING(100) }, + sellPartnerId: { + allowNull: true, + field: "sell_partner_id", + onDelete: "SET NULL", + onUpdate: "CASCADE", + references: { + key: "id", + model: "partners" + }, + type: DataTypes.UUID + }, updatedAt: { allowNull: false, defaultValue: DataTypes.NOW, @@ -92,6 +120,14 @@ ProfilePartnerAssignment.init( fields: ["partner_name"], name: "idx_profile_partner_assignments_partner_name" }, + { + fields: ["buy_partner_id"], + name: "idx_profile_partner_assignments_buy_partner" + }, + { + fields: ["sell_partner_id"], + name: "idx_profile_partner_assignments_sell_partner" + }, { fields: ["user_id", "is_active", "expires_at"], name: "idx_profile_partner_assignments_active_lookup" From f5dc85834ed27e87975b2db2ea8726844553868b Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 15 Jun 2026 11:12:18 +0200 Subject: [PATCH 22/29] Update security-spec --- .../03-ramp-engine/discount-mechanism.md | 10 ++++--- .../03-ramp-engine/fee-integrity.md | 9 ++++-- .../03-ramp-engine/profile-partner-pricing.md | 29 +++++++++++++------ .../03-ramp-engine/quote-lifecycle.md | 9 ++++-- .../07-operations/api-surface.md | 2 +- .../07-operations/client-observability.md | 4 ++- docs/security-spec/README.md | 2 +- 7 files changed, 43 insertions(+), 22 deletions(-) diff --git a/docs/security-spec/03-ramp-engine/discount-mechanism.md b/docs/security-spec/03-ramp-engine/discount-mechanism.md index fa3a00033..95125c150 100644 --- a/docs/security-spec/03-ramp-engine/discount-mechanism.md +++ b/docs/security-spec/03-ramp-engine/discount-mechanism.md @@ -6,11 +6,11 @@ The discount stage decides whether the platform tops up a swap result so the use For each quote, the discount engine: -1. Resolves an `ActivePartner` row (the request's `partnerId`, or the system default `vortex`). +1. Resolves an `ActivePartner` row for pricing. The source can be an explicit partner-owned request, a validated public-key partner, a profile assignment's ramp-specific partner ID, or the system default `vortex`. 2. Reads two partner-scoped parameters: - `targetDiscount` — the discount to advertise. A positive `targetDiscount` means the user receives **more** than the oracle implies (e.g. `targetDiscount=0.005` means the rate offered is 0.5% better than the oracle rate). - `maxSubsidy` — a fractional cap on the subsidy as a share of expected output. -3. Reads dynamic state `partnerDiscountState[partner.id]`, which holds a `difference` value that drifts up while no quote is consumed and back down once a quote is consumed, bounded by `[minDynamicDifference, maxDynamicDifference]`. +3. Reads dynamic state `partnerDiscountState[partner.id]`, keyed by the pricing partner, which holds a `difference` value that drifts up while no quote is consumed and back down once a quote is consumed, bounded by `[minDynamicDifference, maxDynamicDifference]`. 4. Calculates `expectedOutput = inputAmount × oraclePrice × (1 + targetDiscount + adjustedDifference)`. For offramps the oracle price is inverted first. 5. Calculates `actualOutput` as what the user would receive without subsidy (Nabla output minus post-swap fees on onramp, anchor fee added back on offramp). 6. Calculates `idealSubsidy = max(0, expectedOutput − actualOutput)` and `actualSubsidy = min(idealSubsidy, maxSubsidy × expectedOutput)` (only when `targetDiscount > 0`). @@ -30,7 +30,7 @@ For onramps to EVM destinations other than AssetHub, the engine also probes Squi 1. **Subsidy amount MUST be bounded by `maxSubsidy × expectedOutput`** — when `maxSubsidy > 0`, `calculateSubsidyAmount` clamps the shortfall to `expectedOutput × maxSubsidy`; for higher caps, the full shortfall is paid. The cap MUST always be enforced from the partner row, never from the request. 2. **Discount parameters MUST come from the database**, never from the API request. The engine reads `targetDiscount`, `maxSubsidy`, `minDynamicDifference`, `maxDynamicDifference` from a Sequelize `Partner` row. No request field overrides them. 3. **Dynamic-difference clamping MUST hold both ends.** `getAdjustedDifference` enforces `≤ maxDynamicDifference`; `handleQuoteConsumptionForDiscountState` enforces `≥ minDynamicDifference`. A partner with no caps configured behaves as if both caps were `0` (no dynamic adjustment). -4. **The default partner row (`name = "vortex"`) MUST exist and MUST be `isActive`.** `resolveDiscountPartner` falls back to it when the request supplies no partner or the partner row is inactive. Without an active default, discount computation produces a `null` partner and `targetDiscount=0`, silently disabling subsidies platform-wide. +4. **The default partner row (`name = "vortex"`) MUST exist and MUST be `isActive`.** Discount partner resolution falls back to it when no non-default pricing partner applies or the referenced pricing partner row is inactive. Without an active default, discount computation produces a `null` partner and `targetDiscount=0`, silently disabling subsidies platform-wide. 5. **`targetDiscount` MUST be expressed as a fractional rate (not basis points).** It is added directly to `1` in `calculateExpectedOutput`: `effectivePrice × (1 + targetDiscount + adjustedDifference)`. A value of `0.005` means 0.5%. 6. **Subsidy MUST NOT bypass fee collection.** For onramps, `actualOutput = nablaOutput − (network + vortex + partnerMarkup)`. The subsidy then covers the shortfall against `expectedOutput` *after* those fees, so fees still flow to fee accounts. 7. **For offramps, the anchor fee MUST be added back to `expectedOutput`** before computing the shortfall (`adjustedExpectedOutputDecimal = oracleExpected + anchorFeeInBrl`). Otherwise the user would receive `expectedOutput − anchorFee`, which is short of the advertised rate by the anchor's cut. @@ -45,6 +45,7 @@ For onramps to EVM destinations other than AssetHub, the engine also probes Squi |---|---|---| | **Subsidy drain via partner row manipulation** | An attacker (or compromised admin endpoint) sets `targetDiscount` or `maxSubsidy` to large values on a partner row, causing the platform to over-subsidize every quote routed through that partner. | Admin endpoints that mutate `Partner` rows MUST be protected by `adminAuth`. Operational monitoring SHOULD alert on `cumulative subsidy per partner per day > threshold`. The `maxSubsidy` field is a hard per-quote ceiling; an organization-level cap is not currently enforced in code. | | **Quote bursting against dynamic difference** | A client issues many quotes in rapid succession to consume `partnerDiscountState.difference` down to `minDynamicDifference`, then waits for it to drift back up before placing a real ramp at a better rate. | `handleQuoteConsumptionForDiscountState` decreases `difference` by `deltaD` on each *consumed* quote (clamped at `minDynamicDifference`); `getAdjustedDifference` only increases it when `isWithinStateTimeout` is false. Rate limiting at the API layer is the primary defense; the discount state itself only provides eventual mean-reversion. | +| **Owner/pricing partner drift** | A profile-assigned quote is user-owned (`partner_id = NULL`) but priced by a partner row. If discount state follows quote ownership instead of pricing attribution, dynamic adjustment is applied to no partner or the wrong partner. | Quote persistence records `pricing_partner_id`; ramp registration consumes dynamic discount state using `pricing_partner_id ?? partner_id`, so state follows the partner whose pricing was used. | | **State loss on process restart re-grants discount** | The `partnerDiscountState` map lives in process memory (`apps/api/src/api/services/quote/engines/discount/helpers.ts:15`). A restart resets every partner's `difference` to `0` and `lastQuoteTimestamp` to `null`, effectively forgiving any in-progress quote consumption. | **Operational risk only — accepted for now.** Until the state is persisted to PostgreSQL, restarts will reset partner positions. Operators MUST treat planned restarts during high-volume periods as a known subsidy leakage vector. | | **Multi-replica state divergence** | Running the API behind multiple replicas with no sticky routing causes each replica to maintain its own `partnerDiscountState`. The total subsidy paid can exceed the intended cap because each replica enforces its own ceiling independently. | **OPEN (F-DISC-01).** The current deployment topology MUST run a single replica, or the discount state MUST be persisted/centralised (e.g. Redis or a `partner_discount_state` table with row-level locking) before horizontal scaling. | | **Side-effect on read (cache-poisoning analogue)** | `getAdjustedDifference` mutates `partnerDiscountState` whenever it's called (`partnerDiscountState.set` on lines 106, 111, 120). If a quote pipeline retries the discount stage, the dynamic difference is incremented twice for one logical quote, charging the platform more than intended. | **OPEN (F-DISC-02).** `getAdjustedDifference` MUST be split into a pure reader and an explicit `recordQuoteIssued()` mutator, invoked once per quote at a well-defined point. As long as the discount engine is called exactly once per quote (the current stage pipeline guarantees this), the practical impact is bounded. | @@ -67,7 +68,8 @@ For onramps to EVM destinations other than AssetHub, the engine also probes Squi - [x] Onramp `actualOutput` subtracts post-swap fees (`network + vortex + partnerMarkup`). **PASS** — `onramp.ts:198-199`. - [x] Squid probe failures return `null` and the engine falls back to `adjustedExpected = oracleExpected`. **PASS** — `onramp.ts:88-93`, `148-153`, `185-192`. - [x] Squid probe short-circuits to `1` when the destination is Base USDC same-token same-chain. **PASS** — `onramp.ts:123-125`. -- [x] `resolveDiscountPartner` falls back to `name = "vortex"` only when the requested partner is missing or inactive. **PASS** — `helpers.ts:44-62`. An audit MUST verify a database seed exists that creates an active `vortex` partner row for every supported `rampType`. +- [x] `resolveDiscountPartner` falls back to `name = "vortex"` only when no active pricing partner applies. **PASS** — `helpers.ts:44-62`. An audit MUST verify a database seed exists that creates an active `vortex` partner row for every supported `rampType`. +- [x] Consumed quote dynamic state uses the pricing partner rather than only the quote owner. **PASS** — ramp registration resolves `pricing_partner_id ?? partner_id` before calling `handleQuoteConsumptionForDiscountState`. - [x] Dynamic difference is bounded by `partner.minDynamicDifference` and `partner.maxDynamicDifference` (defaulting to `0` when null). **PASS** — `helpers.ts:103, 119, 144, 147`. - [x] `deltaD` is derived from `config.quote.deltaDBasisPoints / 10000`, server-side only. **PASS** — `helpers.ts:17-19`. - [x] `isWithinStateTimeout` gates the increment branch in `getAdjustedDifference`. **PASS** — `helpers.ts:115-125`. diff --git a/docs/security-spec/03-ramp-engine/fee-integrity.md b/docs/security-spec/03-ramp-engine/fee-integrity.md index f73feb608..55acd47cf 100644 --- a/docs/security-spec/03-ramp-engine/fee-integrity.md +++ b/docs/security-spec/03-ramp-engine/fee-integrity.md @@ -26,7 +26,7 @@ This means the fees shown to the user (from the database system) may differ from Two parallel implementations live in `apps/api/src/api/services/transactions/common/feeDistribution.ts`: 1. **Substrate (Pendulum)** — Single batch extrinsic that transfers each fee component to the corresponding partner address read from `Partner.payout_address_substrate`. -2. **EVM (Base)** — `Multicall3.aggregate3` batch (`MULTICALL3_ADDRESS = 0xcA11bde05977b3631167028862bE2a173976CA11`) executes one ERC-20 transfer per fee recipient atomically. Recipient addresses come from `Partner.payout_address_evm`. The handler pre-checks the active `vortex` partner row has a non-NULL `payout_address_evm` and aborts the phase otherwise; partner-markup recipients fall through silently when the quote partner's `payout_address_evm` is NULL. +2. **EVM (Base)** — `Multicall3.aggregate3` batch (`MULTICALL3_ADDRESS = 0xcA11bde05977b3631167028862bE2a173976CA11`) executes one ERC-20 transfer per fee recipient atomically. Recipient addresses come from `Partner.payout_address_evm`. The handler pre-checks the active `vortex` partner row has a non-NULL `payout_address_evm` and aborts the phase otherwise; partner-markup recipients resolve through the quote's pricing partner (`pricing_partner_id ?? partner_id`) and fall through with a warning when that partner's `payout_address_evm` is NULL. The `distribute-fees-handler.ts` chooses the correct path at runtime based on the ephemeral network (Pendulum vs. Base). For EVM, the handler pre-checks that the ephemeral has sufficient ERC-20 balance via `checkEvmBalanceForToken` with a 60-second poll timeout (`FEE_BALANCE_POLL_TIMEOUT_MS`). @@ -45,8 +45,9 @@ The `distribute-fees-handler.ts` chooses the correct path at runtime based on th 6. **Anchor fees MUST be accounted for in the quoted amount** — When BRLA or Stellar anchors deduct their fee, the system's quoted output must have already factored this in. The user should receive exactly the quoted net amount. 7. **Subsidization MUST NOT bypass fee collection** — When the platform subsidizes a shortfall (swap returned less than quoted), the subsidization covers the difference AFTER fees, not before. The platform should not subsidize to offset its own fees. 8. **Fee distribution (`distributeFees` phase) MUST transfer exact calculated amounts** — The amounts sent to vortex, network, and partner fee accounts must match the fee breakdown calculated during quoting. -9. **Rounding MUST be consistent and favor the platform** — On-ramp fees are rounded to 6 decimal places (round half up). Off-ramp fees are rounded to 2 decimal places (round half down). Rounding mode should never create a scenario where the user receives more than entitled. -10. **Fee configuration changes MUST NOT affect in-flight ramps** — Once a quote is created with specific fees, those fees are locked. Changing fee configuration should only apply to new quotes. +9. **Partner markup distribution MUST use pricing attribution** — When `pricing_partner_id` is present, partner markup payout MUST use that partner row instead of relying only on the quote owner `partner_id`; `partner_id` is only the backward-compatible fallback. +10. **Rounding MUST be consistent and favor the platform** — On-ramp fees are rounded to 6 decimal places (round half up). Off-ramp fees are rounded to 2 decimal places (round half down). Rounding mode should never create a scenario where the user receives more than entitled. +11. **Fee configuration changes MUST NOT affect in-flight ramps** — Once a quote is created with specific fees, those fees are locked. Changing fee configuration should only apply to new quotes. ## Threat Vectors & Mitigations @@ -58,6 +59,7 @@ The `distribute-fees-handler.ts` chooses the correct path at runtime based on th | **Fee parameter injection** | Attacker passes custom fee rates in the API request | Fee rates come exclusively from `getAnyFiatTokenDetails()` (token config) or database; never from request body | | **Subsidization drain** | Attacker manipulates conditions so the platform always subsidizes the maximum amount | Slippage bounds limit subsidization; monitoring for excessive subsidization; circuit breaker on total subsidization per period | | **Partner markup theft** | Partner sets unreasonably high markup to extract value | Partner markup bounds should be enforced; review partner configuration for reasonable limits | +| **Profile-priced markup not paid** | A profile-assigned quote is user-owned (`partner_id = NULL`) but has partner markup from custom pricing; fee distribution looks only at `partner_id` and drops the partner payout. | Fee distribution resolves the payout partner from `pricing_partner_id ?? partner_id`, so profile-assigned pricing still pays the partner whose rate was used. | ## Audit Checklist @@ -72,6 +74,7 @@ The `distribute-fees-handler.ts` chooses the correct path at runtime based on th - [x] Fee configuration from token configs (`shared/src/tokens/*/config.ts`) matches what's intended for each currency. **PASS** — token configs reviewed; basis points and fixed components present for all supported tokens. - [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] 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). - [x] Fee changes in token config or database don't retroactively affect already-created quotes. **PASS** — quotes store immutable fee snapshots at creation time. diff --git a/docs/security-spec/03-ramp-engine/profile-partner-pricing.md b/docs/security-spec/03-ramp-engine/profile-partner-pricing.md index 0f0d640e9..e918ae3ee 100644 --- a/docs/security-spec/03-ramp-engine/profile-partner-pricing.md +++ b/docs/security-spec/03-ramp-engine/profile-partner-pricing.md @@ -2,7 +2,7 @@ ## What This Does -Profile partner pricing lets an authenticated first-party user receive the custom quote behavior of a partner without exposing partner API credentials in the frontend. An administrator assigns a Supabase profile to a partner name. When that user creates a quote with a valid Supabase Bearer token, the backend resolves the active assignment and applies the matching partner row for the requested ramp type. +Profile partner pricing lets an authenticated first-party user receive the custom quote behavior of a partner without exposing partner API credentials in the frontend. An administrator assigns a Supabase profile to a partner name, and the backend resolves that name once into stable ramp-specific partner IDs. When that user creates a quote with a valid Supabase Bearer token, the backend reads the active assignment and applies `buy_partner_id` or `sell_partner_id` for the requested ramp type. This feature is intentionally different from partner API-key authentication: @@ -15,6 +15,8 @@ The intended data model separates two concepts that were historically collapsed - `partner_id` remains the partner owner of a quote for API-key integrations. - `pricing_partner_id` records which partner rate configuration was used for quote pricing, fee calculation, subsidy calculation, fee distribution, and dynamic discount state. +`profile_partner_assignments.partner_name` is a display/audit snapshot only. Runtime pricing resolution MUST use the assignment's stored `buy_partner_id` / `sell_partner_id` foreign keys, so partner renames or duplicate partner names cannot silently change an existing profile assignment. + For profile-assigned frontend quotes, `quote_tickets.user_id` is set to the authenticated profile, `quote_tickets.partner_id` stays `NULL`, and `quote_tickets.pricing_partner_id` is set to the resolved partner row. This lets the user consume their own quote through the existing Supabase ownership path while still preserving which partner pricing was applied. ## Security Invariants @@ -26,11 +28,14 @@ For profile-assigned frontend quotes, `quote_tickets.user_id` is set to the auth 5. **Profile-assigned quotes MUST be user-owned** - A quote priced through a profile assignment MUST persist `user_id = req.userId` and `partner_id = NULL`. Register/update/start/status access for the resulting ramp is authorized through the Supabase user path. 6. **The pricing partner MUST be persisted separately** - Any quote that applies non-default partner pricing MUST persist `pricing_partner_id` so downstream fee distribution and dynamic discount state use the same partner row that quote calculation used. 7. **Inactive or expired assignments MUST be ignored** - The assignment resolver must require `is_active = true` and either `expires_at IS NULL` or `expires_at > now()`. -8. **Invalid assignments MUST fail closed to default pricing** - If an assignment points to a partner name with no active row for the requested ramp type, quote creation proceeds without that partner's pricing instead of accepting untrusted client input or fabricating a partner. -9. **Fee distribution MUST use the pricing partner, not only the owner partner** - Partner markup payout uses `pricing_partner_id` when present, with `partner_id` as a backward-compatible fallback for older quotes. -10. **Dynamic discount state MUST use the pricing partner** - Quote consumption adjusts the dynamic discount state for the partner whose pricing was used, not for the quote owner. -11. **Assignment administration MUST require admin auth** - Create, list, and revoke assignment endpoints MUST be protected by `adminAuth`; partner API keys and Supabase user tokens MUST NOT manage assignments. -12. **Assignment replacement MUST be atomic per profile** - Creating a new active assignment MUST deactivate the previous active row and insert the replacement in one database transaction. The transaction MUST lock the profile row so concurrent admin writes for the same user serialize, and any residual active-assignment unique-index conflict MUST fail with a retryable `409`. +8. **Assignment partner IDs MUST be stable and ramp-specific** - Assignment creation may accept a logical partner name for admin convenience, but it MUST persist resolved `buy_partner_id` and/or `sell_partner_id` values and quote resolution MUST use those IDs, not a fresh name lookup. +9. **Ambiguous partner-name assignments MUST be rejected** - If assignment creation finds multiple active partner rows with the same name for the same ramp type, the API MUST fail instead of choosing an arbitrary partner row. +10. **Invalid assignments MUST fail closed to default pricing** - If an assignment points to no active partner row for the requested ramp type, quote creation proceeds without that partner's pricing instead of accepting untrusted client input or fabricating a partner. +11. **Admin active-list semantics MUST match quote-time semantics** - Default assignment listing MUST exclude rows that are inactive or expired; historical listing may include them only when explicitly requested. +12. **Fee distribution MUST use the pricing partner, not only the owner partner** - Partner markup payout uses `pricing_partner_id` when present, with `partner_id` as a backward-compatible fallback for older quotes. +13. **Dynamic discount state MUST use the pricing partner** - Quote consumption adjusts the dynamic discount state for the partner whose pricing was used, not for the quote owner. +14. **Assignment administration MUST require admin auth** - Create, list, and revoke assignment endpoints MUST be protected by `adminAuth`; partner API keys and Supabase user tokens MUST NOT manage assignments. +15. **Assignment replacement MUST be atomic per profile** - Creating a new active assignment MUST deactivate the previous active row and insert the replacement in one database transaction. The transaction MUST lock the profile row so concurrent admin writes for the same user serialize, and any residual active-assignment unique-index conflict MUST fail with a retryable `409`. ## Threat Vectors & Mitigations @@ -42,17 +47,23 @@ For profile-assigned frontend quotes, `quote_tickets.user_id` is set to the auth | **Dropped partner markup payout** | A profile-assigned quote computes a partner markup but downstream fee distribution looks only at `quote.partnerId`, sees `NULL`, and skips partner payout. | Fee distribution resolves payout from `pricing_partner_id ?? partner_id`. | | **Dynamic state drift for the wrong principal** | A profile-assigned quote is consumed but dynamic discount state is decremented for no partner or the wrong partner. | Ramp registration resolves the partner from `pricing_partner_id ?? partner_id` before calling `handleQuoteConsumptionForDiscountState`. | | **Stale assignment remains usable** | A profile's temporary partner entitlement expires but quote creation still applies custom rates. | Resolver filters out assignments with `expires_at <= now()`. | -| **Assignment to missing ramp-type config** | A profile is assigned to partner `Acme`, but `partners` has only a BUY row and the user requests SELL. | Resolver requires active partner row with matching `rampType`; otherwise it logs and falls back to default pricing. | +| **Assignment changes after partner rename** | A partner row is renamed after assignment creation, and future quotes unexpectedly lose or change pricing. | Assignments persist ramp-specific partner IDs; `partner_name` is display/audit only. | +| **Ambiguous same-name partner row** | Two active BUY partner rows share `name = Acme`, so a name lookup could choose the wrong pricing/payout configuration. | Assignment creation rejects same-name ambiguity per ramp type instead of storing a nondeterministic assignment. | +| **Assignment to missing ramp-type config** | A profile is assigned to partner `Acme`, but `partners` has only a BUY row and the user requests SELL. | The assignment has no `sell_partner_id`; resolver falls back to default pricing for SELL. | +| **Expired assignment shown as active** | Admin tooling lists an expired row as active, leading support to assume custom rates still apply. | Default list filtering uses the same active + unexpired predicate as quote resolution; `includeInactive=true` is the historical view. | | **Unauthorized assignment management** | A partner or normal frontend user assigns themselves or another profile to a discounted partner. | Assignment management routes live under `/v1/admin/profile-partner-assignments` and require `adminAuth`. | | **Partial assignment replacement** | Admin assignment creation deactivates the current row and then fails before inserting the replacement, leaving the profile with no active pricing assignment. Concurrent creates can also race against the active-user partial unique index. | Replacement runs in one transaction after locking the profile row. Rollback preserves the prior active assignment, and residual unique-index conflicts return `409 ASSIGNMENT_CONFLICT` so the admin can retry. | ## Audit Checklist -- [x] `profile_partner_assignments` exists with `user_id`, `partner_name`, `is_active`, optional `expires_at`, timestamps, and indexes for active user lookups. +- [x] `profile_partner_assignments` exists with `user_id`, display/audit `partner_name`, ramp-specific `buy_partner_id` / `sell_partner_id`, `is_active`, optional `expires_at`, timestamps, and indexes for active user lookups. - [x] Admin assignment endpoints are protected by `adminAuth` and reject non-admin credentials. +- [x] Admin assignment creation rejects ambiguous same-name active partner rows for the same ramp type. +- [x] Default admin assignment listing excludes expired rows; `includeInactive=true` is required for historical rows. - [x] Admin assignment replacement deactivates the old active row and creates the new row in one transaction after taking a row lock for the target profile. - [x] Active-assignment unique-index collisions return `409 ASSIGNMENT_CONFLICT` instead of a generic server error. - [x] Quote creation resolves profile assignments only from `req.userId`; unauthenticated quotes never use profile assignment pricing. +- [x] Profile assignment quote resolution uses stored `buy_partner_id` / `sell_partner_id`, not a fresh runtime `partner_name` lookup. - [x] `POST /v1/quotes` and `POST /v1/quotes/best` still reject explicit `partnerId` without matching secret-key authentication. - [x] Profile-assigned quotes persist `user_id` and `pricing_partner_id`, while leaving `partner_id` `NULL`. - [x] Existing partner API-key and public-key quote paths preserve their previous `partner_id` behavior. @@ -60,4 +71,4 @@ For profile-assigned frontend quotes, `quote_tickets.user_id` is set to the auth - [x] Ramp registration updates discount state using `pricing_partner_id ?? partner_id`. - [x] User ownership checks continue to authorize profile-assigned quotes through `user_id`. - [x] Partner ownership checks continue to authorize API-client quotes through `partner_id`. -- [x] Tests cover assigned user quote ownership and the non-regression path for partner-owned quotes. +- [x] Tests cover assigned user quote ownership, ramp-specific partner-ID resolution, quote persistence of `pricing_partner_id`, expired list filtering, and the non-regression path for partner-owned quotes. diff --git a/docs/security-spec/03-ramp-engine/quote-lifecycle.md b/docs/security-spec/03-ramp-engine/quote-lifecycle.md index f574586b1..e89e9ac12 100644 --- a/docs/security-spec/03-ramp-engine/quote-lifecycle.md +++ b/docs/security-spec/03-ramp-engine/quote-lifecycle.md @@ -39,7 +39,7 @@ The system maintains an **in-memory** `Map 0`. @@ -69,6 +69,7 @@ The refresh policy is intentionally strict (byte-identical `toAmount` and `fee` 10. **Subsidy MUST only be applied when `targetDiscount > 0`** — If a partner has no target discount configured, the subsidy amount is always `0`, regardless of the shortfall. 11. **Quote output precision MUST match the final settlement token** — For EVM onramps whose final output comes from Squid, the stored `quote.outputAmount` must retain the destination token's precision, not a fixed source-token precision. This includes BRL/EURC Base→EVM routes and routed Alfredpay USD/MXN/COP Polygon→EVM routes. Direct same-chain same-token passthrough keeps the minted/source token's precision. 12. **Quote creation MUST honor active maintenance windows server-side** — `POST /v1/quotes` and `POST /v1/quotes/best` must reject during active maintenance before quote calculation/persistence, including enough downtime metadata for direct API clients to retry after the window. +13. **Quote ownership MUST stay separate from pricing attribution** — Profile-assigned quotes MUST remain user-owned (`user_id = req.userId`, `partner_id = NULL`) while storing the applied partner pricing row in `pricing_partner_id`. ## Threat Vectors & Mitigations @@ -81,7 +82,8 @@ The refresh policy is intentionally strict (byte-identical `toAmount` and `fee` | **Dynamic pricing farming** | Attacker rapidly requests quotes without consuming them to push `difference` toward `maxDynamicDifference`, then consumes at the best possible rate | Each quote request within the timeout window does NOT change the difference — only quotes **after** the timeout increase it. So the attacker would need to wait `discountStateTimeoutMinutes` between each step increase. With default `deltaD = 0.00003` and a 10-minute timeout, farming is slow. However, the `maxDynamicDifference` cap is the hard limit. | | **⚠️ In-memory state loss** | Server restart resets all partner discount states to `difference = 0`. Partners lose their accumulated rate adjustments. | **NO MITIGATION.** State is in-memory only. After restart, all partners start fresh. This could cause abrupt rate changes if a partner had a significant accumulated difference. | | **Subsidization abuse** | Attacker creates quotes during high volatility, forcing the platform to cover large subsidization amounts | Quote-time discount subsidy is capped by `maxSubsidy` per partner; EVM runtime top-ups are separately bounded by the pre/post-swap cap fractions; dynamic pricing adjusts rates over time; `maxDynamicDifference` bounds the maximum rate improvement | -| **Unauthorized quote consumption** | Attacker binds someone else's quote to their own ramp | Quotes carrying an owner (`partnerId` or `userId`) are bound to that owner; ownership is verified at ramp registration via `assertQuoteOwnership`. Fully-anonymous quotes (no `partnerId` and no `userId`) are intentionally consumable by any caller — they cannot leak privileged data because they were created without a principal in the first place. | +| **Unauthorized quote consumption** | Attacker binds someone else's quote to their own ramp | Quotes carrying an owner (`partner_id` or `user_id`) are bound to that owner; ownership is verified at ramp registration via `assertQuoteOwnership`. `pricing_partner_id` is not an ownership credential. Fully-anonymous quotes (no `partner_id` and no `user_id`) are intentionally consumable by any caller — they cannot leak privileged data because they were created without a principal in the first place. | +| **Pricing partner treated as owner** | A profile-assigned user receives partner pricing, then tries to access partner-owned quotes or ramps. | Profile assignments populate `pricing_partner_id` only; `partner_id` stays `NULL`, so ownership guards continue to authorize through the Supabase `user_id` path. | | **Negative `minDynamicDifference`** | If `minDynamicDifference` is set to a large negative value in the partner DB record, consuming quotes could push the rate below the base `targetDiscount`, potentially making the effective discount negative (user receives less than the oracle rate) | DB constraint: `minDynamicDifference` defaults to `0`. However, there is no DB-level CHECK constraint preventing negative values. If set manually, the clamping logic would allow `difference` to go negative. | | **Concurrent quote and consumption** | Two simultaneous requests — one quoting, one consuming — for the same partner could read stale `difference` values from the in-memory Map | JavaScript's single-threaded event loop prevents true concurrency for synchronous Map operations. However, the `async` functions in `compute()` could interleave if there are `await` points between reading and writing the Map. In practice, the read and write of `partnerDiscountState` in `getAdjustedDifference` are synchronous, so this is safe within a single process. | | **Quote-output precision loss** | A quote targets an 18-decimal destination token but stores only 6 decimal places. The user-visible amount looks close, but final raw transfer construction under-delivers by the truncated dust amount. | Finalize EVM onramp quotes with destination token decimals when the final amount comes from Squid; tests should cover 6-decimal source → 18-decimal destination routes. | @@ -104,9 +106,10 @@ The refresh policy is intentionally strict (byte-identical `toAmount` and `fee` - [x] EVM onramp output precision follows destination token decimals where the quote output comes from Squid. **PASS** — BRL/EURC Base→EVM and routed Alfredpay USD/MXN/COP Polygon→EVM finalization preserve destination token precision before downstream raw transfer construction. Direct same-chain same-token passthrough remains at minted/source-token precision. - [PARTIAL] Authentication is enforced on quote creation (verify which auth mechanisms protect `POST /v1/ramp/quotes`). **PARTIAL** — quote creation is accessible via API key auth or Supabase auth; the endpoint is optional-auth by design (public quotes allowed for some partners). - [PARTIAL] Quote ownership is verified at ramp registration — the user/partner creating the ramp must match the quote creator. **PARTIAL** — no strict user-to-quote binding; mitigated by UUID unpredictability and 10-minute expiry. +- [x] Profile-assigned quote pricing persists `pricing_partner_id` without granting partner ownership. **PASS** — profile-assigned quotes store `user_id`, leave `partner_id` `NULL`, and authorize through the user ownership path. - [x] Subsidy is only calculated when `targetDiscount > 0` — partners with no discount get `0` subsidy regardless of shortfall. **PASS** — verified in `calculateSubsidyAmount()`. - [x] `calculateSubsidyAmount` correctly caps at `maxSubsidy × expectedOutput` — verify the multiplication is the right semantic (fraction of expected, not absolute). **PASS** — confirmed: `maxSubsidy` is a fraction (0-1) multiplied by `expectedOutput`. -- [x] The `resolveDiscountPartner` fallback to the `"vortex"` default partner is intentional — verify the default partner exists and has appropriate discount/subsidy settings. **PASS** — fallback to "vortex" partner confirmed in code. +- [x] The `resolveDiscountPartner` fallback to the `"vortex"` default partner is intentional — verify the default partner exists and has appropriate discount/subsidy settings. **PASS** — fallback to "vortex" partner confirmed in code when no active pricing partner applies. - [N/A] Monitoring exists for quotes with unusually high subsidization requirements. **N/A** — no monitoring infrastructure audited. - [x] **FINDING F-059 (HIGH)**: Verify `registerRamp` acquires `SELECT FOR UPDATE` lock on the quote, checks `consumeQuote` affected rows, and has a unique constraint on `rampState.quoteId` to prevent double-binding. **PASS (FIXED)** — lock added, affected rows checked, unique constraint migration `026` created. - [x] Verify active maintenance windows block `POST /v1/quotes` and `POST /v1/quotes/best` server-side with client-actionable downtime metadata. diff --git a/docs/security-spec/07-operations/api-surface.md b/docs/security-spec/07-operations/api-surface.md index 032539bd5..7f2667b9d 100644 --- a/docs/security-spec/07-operations/api-surface.md +++ b/docs/security-spec/07-operations/api-surface.md @@ -76,7 +76,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. -- [x] Map all 27 TypeScript route files and verify each has appropriate auth middleware (Supabase, API key, admin, metrics dashboard, 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/*` and `/v1/admin/partners/:partnerName/api-keys` use `adminAuth`; `/v1/admin/api-client-events` uses `metricsDashboardAuth`; `/v1/webhook/*` uses `apiKeyAuth`. +- [x] Map all 28 TypeScript route files and verify each has appropriate auth middleware (Supabase, API key, admin, metrics dashboard, 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/*`, `/v1/admin/partners/:partnerName/api-keys`, and `/v1/admin/profile-partner-assignments` use `adminAuth`; `/v1/admin/api-client-events` uses `metricsDashboardAuth`; `/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/client-observability.md b/docs/security-spec/07-operations/client-observability.md index d58c3ff21..a3afb9f61 100644 --- a/docs/security-spec/07-operations/client-observability.md +++ b/docs/security-spec/07-operations/client-observability.md @@ -22,7 +22,7 @@ Internal operators can inspect these events through `GET /v1/admin/api-client-ev 3. **Secrets MUST NOT be logged or persisted** — `X-API-Key`, bearer tokens, secret API keys, provider credentials, private keys, seeds, ephemeral private material, and signed transaction payloads must not appear in logs or observability events. 4. **Sensitive user/payment data MUST NOT be logged or persisted** — Tax IDs, PIX destinations, QR codes, KYC data, bank details, and raw payment credentials must be excluded from observability metadata. 5. **Request correlation MUST be non-secret** — `requestId`, `quoteId`, and `rampId` may be stored for debugging, but they must not be used as high-cardinality metric labels. They are correlation identifiers, not authentication material. -6. **Partner attribution MUST use safe identifiers** — Events may store `partnerId`, `partnerName`, and short API key prefixes capped at 16 characters. Full secret keys and raw auth headers are forbidden. +6. **Partner attribution MUST use safe identifiers** — Events may store `partnerId`, `partnerName`, and short API key prefixes capped at 16 characters. Full secret keys and raw auth headers are forbidden. `partnerName` is a display/audit label only; it must not be treated as an authorization credential or runtime pricing key. 7. **Operational metrics MUST remain low-cardinality** — Future metric exporters must group by bounded labels such as operation, partner, status, HTTP status, and error type. They must not label by user ID, wallet address, request ID, quote ID, ramp ID, tax ID, PIX key, or free-form request values. 8. **Event persistence SHOULD have automated retention before production operational use** — Raw operational events are useful for investigation but must not be retained indefinitely without aggregation or cleanup. The backend retention worker keeps the current UTC calendar day plus the previous six full UTC calendar days and removes older `api_client_events` rows on startup and daily. 9. **Client observability access MUST go through metrics-dashboard-authenticated backend APIs** — Internal consumers must call protected backend endpoints and must not ship database credentials, Supabase service-role keys, Metabase embed secrets, or other server-only credentials to client-side code. @@ -36,6 +36,7 @@ Internal operators can inspect these events through `GET /v1/admin/api-client-ev | **PII leakage through metadata** — Client-provided `additionalData` or error messages include tax IDs, PIX keys, or bank details | Do not persist nested metadata objects. Keep metadata scalar-only and sanitized. Pass only allowlisted request-derived fields to observability helpers; use counts or presence flags for arrays/objects such as presigned transactions, signing accounts, and `additionalData`. Truncate error messages and prefer stable `errorType` categories. | | **Business flow disruption** — Database/logging outage causes quote/ramp requests to fail | Observability writes are fire-and-forget/best-effort and catch their own errors. The request path must proceed exactly as it would without observability. | | **Missing correlation during incidents** — Operators cannot connect a partner report to backend logs | Generate or propagate `requestId` for all requests and return it via `X-Request-ID`. Persist request IDs alongside quote/ramp IDs when available. | +| **Misread partner attribution** — Operators interpret a display `partnerName` as proof of partner ownership or pricing authority. | Observability labels are non-authoritative. Authorization comes from `partner_id`/`user_id` ownership checks, and pricing attribution comes from quote-time `pricing_partner_id` when present. | | **High-cardinality metric explosion** — Future observability metrics use ramp IDs or user IDs as labels | Keep high-cardinality identifiers in logs/event rows only. Export aggregate metrics using bounded labels. | | **Unbounded telemetry retention** — Raw event rows grow indefinitely | Use the backend retention worker to delete `api_client_events` older than the 7-day UTC calendar retention window. The cleanup runs on startup and daily, uses advisory locking, and deletes in bounded batches. | | **Internal metrics client exposure** — An internal metrics consumer is reachable by outsiders | Require the dedicated backend metrics dashboard bearer token for all event data. Do not rely on obscurity of client URLs. | @@ -46,6 +47,7 @@ Internal operators can inspect these events through `GET /v1/admin/api-client-ev - [ ] Verify `requestContext` assigns `requestId` and `requestStartedAt` before request logging and route handling. - [ ] Verify `X-Request-ID` is returned on API responses and incoming `X-Request-ID` / `X-Correlation-ID` values are treated only as correlation IDs. - [ ] Verify `api_client_events` stores only the approved fields: operation, status, HTTP status, error type/message, safe partner attribution, quote/ramp IDs, duration, and sanitized metadata. +- [ ] Verify partner attribution fields in events are used only for debugging/display, not authorization, pricing, or payout decisions. - [ ] Verify event persistence helpers catch their own errors and cannot throw into controller or middleware responses. - [ ] Verify auth, quote, and ramp request instrumentation does not alter existing response bodies or HTTP status codes. - [ ] Verify failure-event metadata contains only allowlisted scalar request summaries and no `X-API-Key`, bearer tokens, raw headers, raw request bodies, tax IDs, PIX destinations, QR codes, KYC data, private keys, seeds, ephemeral secrets, or signed transaction payloads. diff --git a/docs/security-spec/README.md b/docs/security-spec/README.md index 184372af3..e1d2be517 100644 --- a/docs/security-spec/README.md +++ b/docs/security-spec/README.md @@ -28,7 +28,7 @@ This directory contains the security specification for the Vortex cross-border p | Quote Lifecycle | `03-ramp-engine/quote-lifecycle.md` | Creation, expiry, binding to ramp | | Fee Integrity | `03-ramp-engine/fee-integrity.md` | Fee calculation, dual-system discrepancy | | Discount Mechanism | `03-ramp-engine/discount-mechanism.md` | Partner discounts, subsidies, dynamic adjustment | -| Profile Partner Pricing | `03-ramp-engine/profile-partner-pricing.md` | Supabase profile assignments to partner pricing | +| Profile Partner Pricing | `03-ramp-engine/profile-partner-pricing.md` | Supabase profile assignments to ramp-specific partner pricing IDs | | Transaction Validation | `03-ramp-engine/transaction-validation.md` | Presigned tx verification, content validation, signing model | | Ephemeral Account Lifecycle | `03-ramp-engine/ephemeral-accounts.md` | Funding, cleanup, stuck fund prevention | | Ramp Phase Flows | `03-ramp-engine/ramp-phase-flows.md` | Per-corridor token flow, phase handler map, subsidy bounds | From bbd724d1a87799f90aa6af1a7c48628a4380f854 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 15 Jun 2026 11:34:33 +0200 Subject: [PATCH 23/29] Fix type issues --- ...ofilePartnerAssignments.controller.test.ts | 23 +++++++++++++++---- .../profilePartnerAssignments.controller.ts | 22 ++++++++---------- .../api/middlewares/maintenanceGuard.test.ts | 8 ++++++- 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.test.ts b/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.test.ts index 6cf21bc9e..80d0eeca5 100644 --- a/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.test.ts +++ b/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.test.ts @@ -9,6 +9,13 @@ import ProfilePartnerAssignment from "../../../models/profilePartnerAssignment.m import User from "../../../models/user.model"; import {createProfilePartnerAssignment, listProfilePartnerAssignments} from "./profilePartnerAssignments.controller"; +interface AssignmentFindAllOptions { + where: { + isActive?: boolean; + [Op.or]?: unknown[]; + }; +} + function createResponse() { const res = { body: undefined as unknown, @@ -182,7 +189,7 @@ describe("createProfilePartnerAssignment", () => { }); it("excludes expired assignments from the default list", async () => { - const assignmentFindAllMock = mock(async () => []); + const assignmentFindAllMock = mock(async (_options: AssignmentFindAllOptions) => []); ProfilePartnerAssignment.findAll = assignmentFindAllMock as unknown as typeof ProfilePartnerAssignment.findAll; const res = createResponse(); @@ -190,13 +197,17 @@ describe("createProfilePartnerAssignment", () => { expect(res.statusCode).toBe(httpStatus.OK); expect(assignmentFindAllMock).toHaveBeenCalledTimes(1); - const findOptions = assignmentFindAllMock.mock.calls[0][0]; + const findOptions = assignmentFindAllMock.mock.calls[0]?.[0]; + expect(findOptions).toBeDefined(); + if (!findOptions) { + throw new Error("ProfilePartnerAssignment.findAll was not called with options"); + } expect(findOptions.where.isActive).toBe(true); expect(findOptions.where[Op.or]).toHaveLength(2); }); it("includes expired assignments when includeInactive is true", async () => { - const assignmentFindAllMock = mock(async () => []); + const assignmentFindAllMock = mock(async (_options: AssignmentFindAllOptions) => []); ProfilePartnerAssignment.findAll = assignmentFindAllMock as unknown as typeof ProfilePartnerAssignment.findAll; const res = createResponse(); @@ -205,7 +216,11 @@ describe("createProfilePartnerAssignment", () => { res as unknown as Response ); - const findOptions = assignmentFindAllMock.mock.calls[0][0]; + const findOptions = assignmentFindAllMock.mock.calls[0]?.[0]; + expect(findOptions).toBeDefined(); + if (!findOptions) { + throw new Error("ProfilePartnerAssignment.findAll was not called with options"); + } expect(findOptions.where).not.toHaveProperty("isActive"); expect(findOptions.where[Op.or]).toBeUndefined(); }); diff --git a/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.ts b/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.ts index 71142409e..78df9239c 100644 --- a/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.ts +++ b/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.ts @@ -200,18 +200,16 @@ export async function listProfilePartnerAssignments( ): Promise { try { const { includeInactive, partnerName, userId } = req.query; - const where: WhereOptions = {}; - - if (includeInactive !== "true") { - where.isActive = true; - where[Op.or] = [{ expiresAt: null }, { expiresAt: { [Op.gt]: new Date() } }]; - } - if (partnerName) { - where.partnerName = partnerName; - } - if (userId) { - where.userId = userId; - } + const where: WhereOptions = { + ...(includeInactive === "true" + ? {} + : { + [Op.or]: [{ expiresAt: null }, { expiresAt: { [Op.gt]: new Date() } }], + isActive: true + }), + ...(partnerName ? { partnerName } : {}), + ...(userId ? { userId } : {}) + }; const assignments = await ProfilePartnerAssignment.findAll({ order: [["createdAt", "DESC"]], diff --git a/apps/api/src/api/middlewares/maintenanceGuard.test.ts b/apps/api/src/api/middlewares/maintenanceGuard.test.ts index 3f844d037..a67bb9fc2 100644 --- a/apps/api/src/api/middlewares/maintenanceGuard.test.ts +++ b/apps/api/src/api/middlewares/maintenanceGuard.test.ts @@ -21,7 +21,7 @@ mock.module("../observability/apiClientEvent.service", () => ({ function controllerHandler(name: string) { return (_req: Request, res: Response) => { controllerCalls.push(name); - res.status(httpStatus.I_AM_A_TEAPOT).json({ reached: name }); + res.status(httpStatus.IM_A_TEAPOT).json({ reached: name }); }; } @@ -40,6 +40,12 @@ mock.module("../controllers/ramp.controller", () => ({ updateRamp: mock(controllerHandler("ramp_update_controller")) })); +mock.module("../services/auth", () => ({ + SupabaseAuthService: { + verifyToken: mock(async () => ({ valid: false })) + } +})); + const { rejectDuringActiveMaintenance } = await import("./maintenanceGuard"); const { default: quoteRoutes } = await import("../routes/v1/quote.route"); const { default: rampRoutes } = await import("../routes/v1/ramp.route"); From 3a4a8bee8baf30f0ed64847f7edc500dd97eceb5 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 15 Jun 2026 16:16:33 +0200 Subject: [PATCH 24/29] Add dry run for nabla swap transaction --- .../phases/handlers/nabla-swap-handler.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) 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 d2b616138..dfd5ab4b7 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 @@ -14,6 +14,7 @@ import { RampPhase } from "@vortexfi/shared"; import Big from "big.js"; +import { parseTransaction } from "viem"; import logger from "../../../../config/logger"; import { routerAbi } from "../../../../contracts/Router"; import QuoteTicket from "../../../../models/quoteTicket.model"; @@ -193,6 +194,8 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { throw new Error("NablaSwapPhaseHandler: Invalid EVM transaction data. This is a bug."); } + await this.dryRunEvmSwap(nablaSwapTransaction as `0x${string}`, evmEphemeralAddress as `0x${string}`); + const txHash = await baseClient.sendRawTransaction({ serializedTransaction: nablaSwapTransaction as `0x${string}` }); @@ -219,6 +222,32 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { const nextPhase = state.type === RampDirection.BUY ? "distributeFees" : "subsidizePostSwap"; return this.transitionToNextPhase(state, nextPhase); } + + private async dryRunEvmSwap(serializedTransaction: `0x${string}`, senderAddress: `0x${string}`): Promise { + const evmClientManager = EvmClientManager.getInstance(); + const baseClient = evmClientManager.getClient(Networks.Base); + const transaction = parseTransaction(serializedTransaction); + + if (!transaction.to) { + throw new Error("NablaSwapPhaseHandler: Cannot dry-run EVM swap transaction without a recipient address."); + } + + try { + await baseClient.call({ + account: senderAddress, + blockTag: "pending", + data: transaction.data, + gas: transaction.gas, + maxFeePerGas: transaction.maxFeePerGas, + maxPriorityFeePerGas: transaction.maxPriorityFeePerGas, + to: transaction.to, + value: transaction.value + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`NablaSwapPhaseHandler: EVM swap dry-run failed: ${errorMessage}`); + } + } } export default new NablaSwapPhaseHandler(); From f8298132c8e2c3c9203f449b908060d3ecac3e20 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 15 Jun 2026 16:19:05 +0200 Subject: [PATCH 25/29] Adjust security-spec --- docs/security-spec/03-ramp-engine/ramp-phase-flows.md | 3 +++ 1 file changed, 3 insertions(+) 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 64f994f11..6f43a4489 100644 --- a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md +++ b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md @@ -188,6 +188,7 @@ graph TD 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. +11. **Base EVM `nablaSwap` MUST be dry-run before broadcast** — `nabla-swap-handler.ts` must simulate the exact presigned raw swap transaction with `eth_call` from the Base ephemeral account before calling `sendRawTransaction`. The dry-run MUST use the decoded transaction's actual recipient, calldata, value, gas, and fee fields, and should use `blockTag: "pending"` so liquidity-sensitive failures are checked against the freshest available Base state. If the simulation reverts (for example `SP:quoteSwapInto:EXCEEDS_MAX_COVERAGE_RATIO`), the handler MUST fail before submitting the transaction so the ephemeral does not spend gas on a predictably reverting swap. ## Threat Vectors & Mitigations @@ -202,6 +203,7 @@ graph TD | **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. | +| **Predictable EVM Nabla swap revert** | Base Nabla pool liquidity or coverage-ratio constraints make the presigned swap impossible before it is broadcast. Without preflight, the ephemeral submits a transaction that reverts on-chain, wastes gas, and only fails after receipt polling. | The EVM branch of `nabla-swap-handler.ts` runs `eth_call` on the exact decoded presigned raw transaction from the ephemeral account with `blockTag: "pending"`. A simulation revert aborts before `sendRawTransaction`, preserving the revert reason in the phase error log. | ## Audit Checklist @@ -232,3 +234,4 @@ graph TD - [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. - [x] Active maintenance windows block `POST /v1/ramp/register`, `POST /v1/ramp/update`, and `POST /v1/ramp/start` before ramp state mutation or phase processing. +- [x] Base EVM `nablaSwap` dry-runs the exact presigned swap before broadcast. **PASS** — `nabla-swap-handler.ts` decodes the serialized transaction with `parseTransaction`, calls Base `eth_call` from the ephemeral sender using the decoded transaction fields and `blockTag: "pending"`, and only then calls `sendRawTransaction` if the call succeeds. From 7fa12c8583895073ddaedfebf5b1aa88dd9516f6 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 15 Jun 2026 16:27:40 +0200 Subject: [PATCH 26/29] Address PR comments --- .../phases/handlers/nabla-swap-handler.ts | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) 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 dfd5ab4b7..d03ea06d0 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 @@ -233,16 +233,37 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { } try { - await baseClient.call({ + const callParameters = { account: senderAddress, - blockTag: "pending", + blockTag: "pending" as const, data: transaction.data, gas: transaction.gas, - maxFeePerGas: transaction.maxFeePerGas, - maxPriorityFeePerGas: transaction.maxPriorityFeePerGas, to: transaction.to, value: transaction.value - }); + }; + + if (transaction.type === "legacy") { + await baseClient.call({ + ...callParameters, + gasPrice: transaction.gasPrice, + type: "legacy" + }); + } else if (transaction.type === "eip2930") { + await baseClient.call({ + ...callParameters, + accessList: transaction.accessList, + gasPrice: transaction.gasPrice, + type: "eip2930" + }); + } else { + await baseClient.call({ + ...callParameters, + accessList: transaction.accessList, + maxFeePerGas: transaction.maxFeePerGas, + maxPriorityFeePerGas: transaction.maxPriorityFeePerGas, + type: "eip1559" + }); + } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`NablaSwapPhaseHandler: EVM swap dry-run failed: ${errorMessage}`); From e7aff84b217fe3d9c7f88b63ea370e78eee1e10f Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 15 Jun 2026 16:41:55 +0200 Subject: [PATCH 27/29] Make error recoverable --- .../handlers/nabla-swap-handler.test.ts | 173 ++++++++++++++++++ .../phases/handlers/nabla-swap-handler.ts | 16 +- .../quote/engines/finalize/index.test.ts | 84 +++++++++ 3 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/api/services/phases/handlers/nabla-swap-handler.test.ts create mode 100644 apps/api/src/api/services/quote/engines/finalize/index.test.ts diff --git a/apps/api/src/api/services/phases/handlers/nabla-swap-handler.test.ts b/apps/api/src/api/services/phases/handlers/nabla-swap-handler.test.ts new file mode 100644 index 000000000..1aca46ab4 --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/nabla-swap-handler.test.ts @@ -0,0 +1,173 @@ +// eslint-disable-next-line import/no-unresolved +import {beforeEach, describe, expect, it, mock} from "bun:test"; +import {privateKeyToAccount} from "viem/accounts"; +import {parseTransaction} from "viem"; + +const Networks = { + Base: "base" +} as const; + +const RampDirection = { + SELL: "SELL" +} as const; + +const EvmToken = { + USDC: "USDC" +} as const; + +const EVM_EPHEMERAL_ACCOUNT = privateKeyToAccount( + "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +); +const NABLA_ROUTER_ADDRESS = "0x2222222222222222222222222222222222222222"; +const SWAP_TX_HASH = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const SWAP_TX = await EVM_EPHEMERAL_ACCOUNT.signTransaction({ + chainId: 8453, + data: "0x12345678", + gas: 500000n, + maxFeePerGas: 2000000000n, + maxPriorityFeePerGas: 1000000n, + nonce: 0, + to: NABLA_ROUTER_ADDRESS, + type: "eip1559", + value: 0n +}); + +const call = mock(async () => ({ data: "0x" })); +const sendRawTransaction = mock(async () => SWAP_TX_HASH); +const waitForTransactionReceipt = mock(async () => ({ status: "success" })); +const checkEvmBalanceForToken = mock(async () => undefined); +const appendErrorLog = mock(async (_rampId: string, _errorLog: { error: string; recoverable: boolean }) => undefined); + +mock.module("@vortexfi/shared", () => ({ + ApiManager: { + getInstance: () => ({}) + }, + checkEvmBalanceForToken, + decodeSubmittableExtrinsic: mock(), + defaultReadLimits: {}, + EvmClientManager: { + getInstance: () => ({ + getClient: () => ({ + call, + sendRawTransaction, + waitForTransactionReceipt + }) + }) + }, + EvmToken, + evmTokenConfig: { + [Networks.Base]: { + [EvmToken.USDC]: { + assetSymbol: EvmToken.USDC, + decimals: 6, + erc20AddressSourceChain: "0x3333333333333333333333333333333333333333", + isNative: false, + network: Networks.Base + } + } + }, + NABLA_ROUTER: "0x4444444444444444444444444444444444444444", + Networks, + RampDirection +})); + +mock.module("../../ramp/ramp.service", () => ({ + default: { + appendErrorLog + } +})); + +const { default: QuoteTicket } = await import("../../../../models/quoteTicket.model"); +const { NablaSwapPhaseHandler } = await import("./nabla-swap-handler"); + +type NablaSwapState = Parameters["execute"]>[0]; + +QuoteTicket.findByPk = mock(async () => ({ + metadata: { + nablaSwapEvm: { + inputAmountForSwapRaw: "1000000", + inputCurrency: EvmToken.USDC + } + } +})) as typeof QuoteTicket.findByPk; + +function makeState() { + const state = { + currentPhase: "nablaSwap", + errorLogs: [], + get() { + const { get: _get, update: _update, ...data } = this; + return data; + }, + id: "ramp-1", + phaseHistory: [], + presignedTxs: [ + { + meta: {}, + network: Networks.Base, + nonce: 0, + phase: "nablaSwap", + signer: EVM_EPHEMERAL_ACCOUNT.address, + txData: SWAP_TX + } + ], + quoteId: "quote-1", + state: { + evmEphemeralAddress: EVM_EPHEMERAL_ACCOUNT.address + }, + type: RampDirection.SELL, + async update(updateData: Record) { + Object.assign(this, updateData); + return this; + } + }; + + return state as unknown as NablaSwapState; +} + +describe("NablaSwapPhaseHandler", () => { + beforeEach(() => { + call.mockClear(); + sendRawTransaction.mockClear(); + waitForTransactionReceipt.mockClear(); + checkEvmBalanceForToken.mockClear(); + appendErrorLog.mockClear(); + }); + + it("dry-runs the decoded EVM swap transaction before broadcasting", async () => { + const decodedSwapTx = parseTransaction(SWAP_TX); + const handler = new NablaSwapPhaseHandler(); + const updatedState = await handler.execute(makeState()); + + expect(call).toHaveBeenCalledTimes(1); + expect(call).toHaveBeenCalledWith({ + accessList: decodedSwapTx.accessList, + account: EVM_EPHEMERAL_ACCOUNT.address, + blockTag: "pending", + data: decodedSwapTx.data, + gas: decodedSwapTx.gas, + maxFeePerGas: decodedSwapTx.maxFeePerGas, + maxPriorityFeePerGas: decodedSwapTx.maxPriorityFeePerGas, + to: decodedSwapTx.to, + type: "eip1559", + value: decodedSwapTx.value + }); + expect(sendRawTransaction).toHaveBeenCalledTimes(1); + expect(sendRawTransaction).toHaveBeenCalledWith({ serializedTransaction: SWAP_TX }); + expect(updatedState.currentPhase).toBe("subsidizePostSwap"); + }); + + it("does not broadcast when the EVM swap dry-run reverts", async () => { + call.mockRejectedValueOnce(new Error("SP:quoteSwapInto:EXCEEDS_MAX_COVERAGE_RATIO")); + + const handler = new NablaSwapPhaseHandler(); + + await expect(handler.execute(makeState())).rejects.toThrow("SP:quoteSwapInto:EXCEEDS_MAX_COVERAGE_RATIO"); + + expect(call).toHaveBeenCalledTimes(1); + expect(sendRawTransaction).not.toHaveBeenCalled(); + expect(appendErrorLog).toHaveBeenCalledTimes(1); + expect(appendErrorLog.mock.calls[0][1].error).toContain("SP:quoteSwapInto:EXCEEDS_MAX_COVERAGE_RATIO"); + expect(appendErrorLog.mock.calls[0][1].recoverable).toBe(true); + }); +}); 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 d03ea06d0..768cd6c79 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 @@ -19,6 +19,7 @@ import logger from "../../../../config/logger"; import { routerAbi } from "../../../../contracts/Router"; import QuoteTicket from "../../../../models/quoteTicket.model"; import RampState from "../../../../models/rampState.model"; +import { PhaseError } from "../../../errors/phase-error"; import { BasePhaseHandler } from "../base-phase-handler"; import { StateMetadata } from "../meta-state-types"; @@ -211,6 +212,10 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { logger.info(`NablaSwapPhaseHandler: EVM swap transaction successful: ${txHash}`); } catch (e) { logger.error(`Could not swap token on EVM: ${(e as Error).message}`); + if (e instanceof PhaseError) { + throw e; + } + // unrecoverable by default. // TODO do we want to add automatic recovery? Issue is, invalid swaps now revert. // We can add a retry with up to 1 or 2 backups. Or try to differentiate based on the revert message. @@ -265,8 +270,15 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { }); } } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - throw new Error(`NablaSwapPhaseHandler: EVM swap dry-run failed: ${errorMessage}`); + if (error instanceof Error) { + const recoverableError = this.createRecoverableError( + `NablaSwapPhaseHandler: EVM swap dry-run failed: ${error.message}` + ); + recoverableError.stack = error.stack; + throw recoverableError; + } + + throw this.createRecoverableError(`NablaSwapPhaseHandler: EVM swap dry-run failed: ${String(error)}`); } } } diff --git a/apps/api/src/api/services/quote/engines/finalize/index.test.ts b/apps/api/src/api/services/quote/engines/finalize/index.test.ts new file mode 100644 index 000000000..3ad8001ea --- /dev/null +++ b/apps/api/src/api/services/quote/engines/finalize/index.test.ts @@ -0,0 +1,84 @@ +import {afterEach, describe, expect, it, mock} from "bun:test"; +import {EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection} from "@vortexfi/shared"; +import Big from "big.js"; +import QuoteTicket from "../../../../../models/quoteTicket.model"; +import {QuoteContext} from "../../core/types"; +import {BaseFinalizeEngine, FinalizeComputation} from "."; + +class TestFinalizeEngine extends BaseFinalizeEngine { + readonly config = { + direction: RampDirection.BUY, + missingFeesMessage: "Missing test fees", + skipNote: "Skip sell quotes" + }; + + protected async computeOutput(_ctx: QuoteContext): Promise { + return { + amount: new Big(99), + decimals: 2 + }; + } +} + +describe("BaseFinalizeEngine", () => { + const originalQuoteTicketCreate = QuoteTicket.create; + + afterEach(() => { + QuoteTicket.create = originalQuoteTicketCreate; + }); + + it("persists profile-priced quotes as user-owned with a separate pricing partner", async () => { + const createdAt = new Date("2026-06-03T12:00:00.000Z"); + const expiresAt = new Date("2026-06-03T12:10:00.000Z"); + const quoteCreateMock = mock(async data => ({ + ...data, + createdAt, + expiresAt, + id: "quote-1" + })); + QuoteTicket.create = quoteCreateMock as unknown as typeof QuoteTicket.create; + + const ctx = { + addNote: mock(() => undefined), + fees: { + displayFiat: { + anchor: "1", + currency: FiatToken.BRL, + network: "0", + partnerMarkup: "2", + total: "13", + vortex: "10" + }, + usd: { + anchor: "0.2", + network: "0", + partnerMarkup: "0.4", + total: "2.6", + vortex: "2" + } + }, + partnerOwnerId: null, + pricingPartnerId: "pricing-partner-id", + request: { + from: EPaymentMethod.PIX, + inputAmount: "100", + inputCurrency: FiatToken.BRL, + network: Networks.Base, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY, + to: Networks.Base, + userId: "user-1" + } + } as unknown as QuoteContext; + + await new TestFinalizeEngine().execute(ctx); + + expect(quoteCreateMock).toHaveBeenCalledTimes(1); + expect(quoteCreateMock.mock.calls[0][0]).toMatchObject({ + partnerId: null, + pricingPartnerId: "pricing-partner-id", + status: "pending", + userId: "user-1" + }); + }); +}); From 7b59edbd882c651d85778afe4629fb656b1631d0 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 15 Jun 2026 16:54:30 +0200 Subject: [PATCH 28/29] Address PR review comments --- .../phases/handlers/nabla-swap-handler.test.ts | 5 ++++- .../phases/handlers/nabla-swap-handler.ts | 18 ++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/nabla-swap-handler.test.ts b/apps/api/src/api/services/phases/handlers/nabla-swap-handler.test.ts index 1aca46ab4..70daa78bf 100644 --- a/apps/api/src/api/services/phases/handlers/nabla-swap-handler.test.ts +++ b/apps/api/src/api/services/phases/handlers/nabla-swap-handler.test.ts @@ -18,6 +18,7 @@ const EvmToken = { const EVM_EPHEMERAL_ACCOUNT = privateKeyToAccount( "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" ); +const STATE_EVM_EPHEMERAL_ADDRESS = "0x1111111111111111111111111111111111111111"; const NABLA_ROUTER_ADDRESS = "0x2222222222222222222222222222222222222222"; const SWAP_TX_HASH = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const SWAP_TX = await EVM_EPHEMERAL_ACCOUNT.signTransaction({ @@ -55,6 +56,7 @@ mock.module("@vortexfi/shared", () => ({ }) }, EvmToken, + EvmTokenDetails: {}, evmTokenConfig: { [Networks.Base]: { [EvmToken.USDC]: { @@ -68,6 +70,7 @@ mock.module("@vortexfi/shared", () => ({ }, NABLA_ROUTER: "0x4444444444444444444444444444444444444444", Networks, + RampPhase: {}, RampDirection })); @@ -113,7 +116,7 @@ function makeState() { ], quoteId: "quote-1", state: { - evmEphemeralAddress: EVM_EPHEMERAL_ACCOUNT.address + evmEphemeralAddress: STATE_EVM_EPHEMERAL_ADDRESS }, type: RampDirection.SELL, async update(updateData: Record) { 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 768cd6c79..9b004f423 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 @@ -14,7 +14,7 @@ import { RampPhase } from "@vortexfi/shared"; import Big from "big.js"; -import { parseTransaction } from "viem"; +import { parseTransaction, recoverTransactionAddress } from "viem"; import logger from "../../../../config/logger"; import { routerAbi } from "../../../../contracts/Router"; import QuoteTicket from "../../../../models/quoteTicket.model"; @@ -195,7 +195,7 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { throw new Error("NablaSwapPhaseHandler: Invalid EVM transaction data. This is a bug."); } - await this.dryRunEvmSwap(nablaSwapTransaction as `0x${string}`, evmEphemeralAddress as `0x${string}`); + await this.dryRunEvmSwap(nablaSwapTransaction as `0x${string}`); const txHash = await baseClient.sendRawTransaction({ serializedTransaction: nablaSwapTransaction as `0x${string}` @@ -228,10 +228,14 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { return this.transitionToNextPhase(state, nextPhase); } - private async dryRunEvmSwap(serializedTransaction: `0x${string}`, senderAddress: `0x${string}`): Promise { + private async dryRunEvmSwap(serializedTransaction: `0x${string}`): Promise { const evmClientManager = EvmClientManager.getInstance(); const baseClient = evmClientManager.getClient(Networks.Base); const transaction = parseTransaction(serializedTransaction); + type RecoverTransactionAddressParams = Parameters[0]; + const transactionSender = await recoverTransactionAddress({ + serializedTransaction: serializedTransaction as RecoverTransactionAddressParams["serializedTransaction"] + }); if (!transaction.to) { throw new Error("NablaSwapPhaseHandler: Cannot dry-run EVM swap transaction without a recipient address."); @@ -239,7 +243,7 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { try { const callParameters = { - account: senderAddress, + account: transactionSender, blockTag: "pending" as const, data: transaction.data, gas: transaction.gas, @@ -247,7 +251,7 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { value: transaction.value }; - if (transaction.type === "legacy") { + if (transaction.type === "legacy" || transaction.type === undefined) { await baseClient.call({ ...callParameters, gasPrice: transaction.gasPrice, @@ -260,7 +264,7 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { gasPrice: transaction.gasPrice, type: "eip2930" }); - } else { + } else if (transaction.type === "eip1559") { await baseClient.call({ ...callParameters, accessList: transaction.accessList, @@ -268,6 +272,8 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { maxPriorityFeePerGas: transaction.maxPriorityFeePerGas, type: "eip1559" }); + } else { + throw new Error(`Unsupported EVM swap transaction type for dry-run: ${transaction.type}`); } } catch (error) { if (error instanceof Error) { From 70b91abf09f5743a5e84726d301b4c913331a285 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 15 Jun 2026 17:02:28 +0200 Subject: [PATCH 29/29] Address PR review comments --- .../handlers/nabla-swap-handler.test.ts | 28 ++++++++++++++++--- .../phases/handlers/nabla-swap-handler.ts | 10 +++++-- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/nabla-swap-handler.test.ts b/apps/api/src/api/services/phases/handlers/nabla-swap-handler.test.ts index 70daa78bf..8aca4d8bf 100644 --- a/apps/api/src/api/services/phases/handlers/nabla-swap-handler.test.ts +++ b/apps/api/src/api/services/phases/handlers/nabla-swap-handler.test.ts @@ -18,7 +18,7 @@ const EvmToken = { const EVM_EPHEMERAL_ACCOUNT = privateKeyToAccount( "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" ); -const STATE_EVM_EPHEMERAL_ADDRESS = "0x1111111111111111111111111111111111111111"; +const UNEXPECTED_EVM_EPHEMERAL_ADDRESS = "0x1111111111111111111111111111111111111111"; const NABLA_ROUTER_ADDRESS = "0x2222222222222222222222222222222222222222"; const SWAP_TX_HASH = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const SWAP_TX = await EVM_EPHEMERAL_ACCOUNT.signTransaction({ @@ -94,7 +94,7 @@ QuoteTicket.findByPk = mock(async () => ({ } })) as typeof QuoteTicket.findByPk; -function makeState() { +function makeState(overrides: Record = {}) { const state = { currentPhase: "nablaSwap", errorLogs: [], @@ -116,13 +116,14 @@ function makeState() { ], quoteId: "quote-1", state: { - evmEphemeralAddress: STATE_EVM_EPHEMERAL_ADDRESS + evmEphemeralAddress: EVM_EPHEMERAL_ACCOUNT.address }, type: RampDirection.SELL, async update(updateData: Record) { Object.assign(this, updateData); return this; - } + }, + ...overrides }; return state as unknown as NablaSwapState; @@ -173,4 +174,23 @@ describe("NablaSwapPhaseHandler", () => { expect(appendErrorLog.mock.calls[0][1].error).toContain("SP:quoteSwapInto:EXCEEDS_MAX_COVERAGE_RATIO"); expect(appendErrorLog.mock.calls[0][1].recoverable).toBe(true); }); + + it("rejects EVM swap transactions signed by an unexpected sender", async () => { + const handler = new NablaSwapPhaseHandler(); + + await expect( + handler.execute( + makeState({ + state: { + evmEphemeralAddress: UNEXPECTED_EVM_EPHEMERAL_ADDRESS + } + }) + ) + ).rejects.toThrow("EVM swap transaction sender mismatch"); + + expect(call).not.toHaveBeenCalled(); + expect(sendRawTransaction).not.toHaveBeenCalled(); + expect(appendErrorLog).toHaveBeenCalledTimes(1); + expect(appendErrorLog.mock.calls[0][1].recoverable).toBe(false); + }); }); 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 9b004f423..62ac1c1f6 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 @@ -195,7 +195,7 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { throw new Error("NablaSwapPhaseHandler: Invalid EVM transaction data. This is a bug."); } - await this.dryRunEvmSwap(nablaSwapTransaction as `0x${string}`); + await this.dryRunEvmSwap(nablaSwapTransaction as `0x${string}`, evmEphemeralAddress as `0x${string}`); const txHash = await baseClient.sendRawTransaction({ serializedTransaction: nablaSwapTransaction as `0x${string}` @@ -228,7 +228,7 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { return this.transitionToNextPhase(state, nextPhase); } - private async dryRunEvmSwap(serializedTransaction: `0x${string}`): Promise { + private async dryRunEvmSwap(serializedTransaction: `0x${string}`, expectedSenderAddress: `0x${string}`): Promise { const evmClientManager = EvmClientManager.getInstance(); const baseClient = evmClientManager.getClient(Networks.Base); const transaction = parseTransaction(serializedTransaction); @@ -237,6 +237,12 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { serializedTransaction: serializedTransaction as RecoverTransactionAddressParams["serializedTransaction"] }); + if (transactionSender.toLowerCase() !== expectedSenderAddress.toLowerCase()) { + throw new Error( + `NablaSwapPhaseHandler: EVM swap transaction sender mismatch. Expected ${expectedSenderAddress}, got ${transactionSender}` + ); + } + if (!transaction.to) { throw new Error("NablaSwapPhaseHandler: Cannot dry-run EVM swap transaction without a recipient address."); }