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. 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..80d0eeca5 --- /dev/null +++ b/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.test.ts @@ -0,0 +1,227 @@ +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 {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, listProfilePartnerAssignments} from "./profilePartnerAssignments.controller"; + +interface AssignmentFindAllOptions { + where: { + isActive?: boolean; + [Op.or]?: unknown[]; + }; +} + +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 originalAssignmentFindAll = ProfilePartnerAssignment.findAll; + + 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; + 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: "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" + })); + + 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 unknown as Request, + res as unknown as Response + ); + + 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( + { + buyPartnerId: "buy-partner-1", + expiresAt: null, + isActive: true, + partnerName: "Acme", + sellPartnerId: "sell-partner-1", + 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 unknown as Request, + res as unknown as Response + ); + + 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 + } + }); + }); + + 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 (_options: AssignmentFindAllOptions) => []); + 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).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 (_options: AssignmentFindAllOptions) => []); + 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).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 new file mode 100644 index 000000000..78df9239c --- /dev/null +++ b/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.ts @@ -0,0 +1,262 @@ +import { RampDirection } from "@vortexfi/shared"; +import { Request, Response } from "express"; +import httpStatus from "http-status"; +import { Op, 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 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; + } + + 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 { + 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 + }; +} + +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 buyPartnerId = getUniquePartnerIdForRamp(partners, RampDirection.BUY); + const sellPartnerId = getUniquePartnerIdForRamp(partners, RampDirection.SELL); + + const expirationDate = parseExpiration(expiresAt); + + 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); + } + + await ProfilePartnerAssignment.update( + { isActive: false }, + { + transaction, + where: { + isActive: true, + userId + } + } + ); + + return ProfilePartnerAssignment.create( + { + buyPartnerId, + expiresAt: expirationDate, + isActive: true, + partnerName, + sellPartnerId, + userId + }, + { transaction } + ); + }); + + res.status(httpStatus.CREATED).json({ + assignment: serializeAssignment(assignment), + 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: { + code: "INVALID_EXPIRES_AT", + message: error.message, + status: httpStatus.BAD_REQUEST + } + }); + 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: { + 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 = { + ...(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"]], + 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 6b682c0c2..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; 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..a67bb9fc2 --- /dev/null +++ b/apps/api/src/api/middlewares/maintenanceGuard.test.ts @@ -0,0 +1,242 @@ +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.IM_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")) +})); + +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"); + +type MaintenanceStatus = Awaited>; +type MaintenanceHttpResponse = { + code: number; + errors?: Record[]; + statusCode?: number; + type?: string; +}; + +const maintenanceService = MaintenanceService.getInstance(); +const originalGetMaintenanceStatus = maintenanceService.getMaintenanceStatus; + +function buildResponse() { + const headers: Record = {}; + 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"]; + + return res as Response & { headers: Record }; +} + +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; + }); + + 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("quote_create")({} as Request, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(); + expect(observedEvents).toEqual([]); + 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("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; + + 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"); + expect(error.errors).toEqual([ + { + detail: "Scheduled database maintenance", + maintenance_end: end, + maintenance_start: 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" + } + ]); + 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" + }) + ]); + }); + + 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 new file mode 100644 index 000000000..3c1d8422d --- /dev/null +++ b/apps/api/src/api/middlewares/maintenanceGuard.ts @@ -0,0 +1,117 @@ +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"; + +const MAINTENANCE_PROBLEM_TYPE = "https://api.vortexfinance.co/problems/maintenance-window"; +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: BlockedMaintenanceOperation): 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; + } + + 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}`; + + const error = 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, + status: httpStatus.SERVICE_UNAVAILABLE, + type: MAINTENANCE_PROBLEM_TYPE + }); + + observeMaintenanceDenial(req, operation, error, status.maintenance_details); + next(error); + } catch (error) { + next(error); + } + }; +} + +function observeMaintenanceDenial( + req: Request, + operation: BlockedMaintenanceOperation, + 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/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 dcfd9a4e4..88c3203bc 100644 --- a/apps/api/src/api/routes/v1/index.ts +++ b/apps/api/src/api/routes/v1/index.ts @@ -3,6 +3,7 @@ import { sendStatusWithPk as sendMoonbeamStatusWithPk } from "../../controllers/ import { sendStatusWithPk as sendPendulumStatusWithPk } from "../../controllers/pendulum.controller"; import apiClientEventsRoutes from "./admin/api-client-events.route"; 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"; @@ -175,6 +176,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); + /** * Admin routes for API client observability dashboards * GET /v1/admin/api-client-events diff --git a/apps/api/src/api/routes/v1/quote.route.ts b/apps/api/src/api/routes/v1/quote.route.ts index 2387bed83..c8225e1dd 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"; @@ -44,6 +45,7 @@ const router: Router = Router({ mergeParams: true }); router .route("/") .post( + rejectDuringActiveMaintenance("quote_create"), validateCreateQuoteInput, optionalAuth, validatePublicKey(), @@ -105,6 +107,7 @@ router router .route("/best") .post( + 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 2ecae7553..aa8e589b3 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", + rejectDuringActiveMaintenance("ramp_register"), + optionalPartnerOrUserAuth(), + 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", + rejectDuringActiveMaintenance("ramp_update"), + optionalPartnerOrUserAuth(), + 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", + rejectDuringActiveMaintenance("ramp_start"), + optionalPartnerOrUserAuth(), + rampController.startRamp as unknown as RequestHandler +); /** * @api {get} v1/ramp/:id Get ramp status 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..8aca4d8bf --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/nabla-swap-handler.test.ts @@ -0,0 +1,196 @@ +// 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 UNEXPECTED_EVM_EPHEMERAL_ADDRESS = "0x1111111111111111111111111111111111111111"; +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, + EvmTokenDetails: {}, + evmTokenConfig: { + [Networks.Base]: { + [EvmToken.USDC]: { + assetSymbol: EvmToken.USDC, + decimals: 6, + erc20AddressSourceChain: "0x3333333333333333333333333333333333333333", + isNative: false, + network: Networks.Base + } + } + }, + NABLA_ROUTER: "0x4444444444444444444444444444444444444444", + Networks, + RampPhase: {}, + 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(overrides: Record = {}) { + 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; + }, + ...overrides + }; + + 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); + }); + + 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 d2b616138..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 @@ -14,10 +14,12 @@ import { RampPhase } from "@vortexfi/shared"; import Big from "big.js"; +import { parseTransaction, recoverTransactionAddress } from "viem"; 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"; @@ -193,6 +195,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}` }); @@ -208,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. @@ -219,6 +227,72 @@ export class NablaSwapPhaseHandler extends BasePhaseHandler { const nextPhase = state.type === RampDirection.BUY ? "distributeFees" : "subsidizePostSwap"; return this.transitionToNextPhase(state, nextPhase); } + + private async dryRunEvmSwap(serializedTransaction: `0x${string}`, expectedSenderAddress: `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 (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."); + } + + try { + const callParameters = { + account: transactionSender, + blockTag: "pending" as const, + data: transaction.data, + gas: transaction.gas, + to: transaction.to, + value: transaction.value + }; + + if (transaction.type === "legacy" || transaction.type === undefined) { + 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 if (transaction.type === "eip1559") { + await baseClient.call({ + ...callParameters, + accessList: transaction.accessList, + maxFeePerGas: transaction.maxFeePerGas, + 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) { + 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)}`); + } + } } export default new NablaSwapPhaseHandler(); 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..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,7 +18,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"; @@ -27,6 +27,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 +133,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 +214,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 +224,70 @@ 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 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 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)} (${discrepancySubsidyCapFraction} of quote output $${quoteOutputUsd}).` ); } + 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)} (${discountSubsidyCapFraction} 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/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/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/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..04d880341 --- /dev/null +++ b/apps/api/src/api/services/quote/core/partner-resolution.test.ts @@ -0,0 +1,152 @@ +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 { buyPartnerId: "assigned-buy-id", sellPartnerId: "assigned-sell-id" }; + }) 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 () => ({ + buyPartnerId: "assigned-buy-id", + sellPartnerId: "assigned-sell-id" + })) 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 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; + }) 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("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 { buyPartnerId: "assigned-buy-id", sellPartnerId: "assigned-sell-id" }; + }) 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..89b36c971 --- /dev/null +++ b/apps/api/src/api/services/quote/core/partner-resolution.ts @@ -0,0 +1,123 @@ +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 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: { + [Op.or]: [{ expiresAt: null }, { expiresAt: { [Op.gt]: now } }], + isActive: true, + userId + } + }); + + if (!assignment) { + return null; + } + + return rampType === RampDirection.BUY ? assignment.buyPartnerId : assignment.sellPartnerId; +} + +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 assignedPartnerId = await findAssignedPartnerId(request.userId, request.rampType, now); + if (assignedPartnerId) { + const partner = await findPartnerByIdForRamp(assignedPartnerId, request.rampType); + 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.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" + }); + }); +}); 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 ddfc9a31f..094354f82 100644 --- a/apps/api/src/api/services/quote/index.ts +++ b/apps/api/src/api/services/quote/index.ts @@ -16,11 +16,11 @@ 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 { createLowLiquidityQuoteError, isLowLiquidityQuoteError } from "./core/errors"; 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"; @@ -165,22 +165,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( @@ -205,6 +191,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 a2f250fc6..6bfe6c654 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -220,9 +220,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/config/vars.ts b/apps/api/src/config/vars.ts index 97495a979..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; @@ -95,6 +111,10 @@ interface Config { swap: { deadlineMinutes: number; }; + subsidy: { + evmPostSwapDiscountSubsidyQuoteFraction: number; + evmSwapSubsidyQuoteFraction: number; + }; quote: { discountStateTimeoutMinutes: number; deltaDBasisPoints: number; @@ -209,6 +229,11 @@ export const config: Config = { storageSheetId: process.env.GOOGLE_SPREADSHEET_ID }, subscanApiKey: process.env.SUBSCAN_API_KEY, + + subsidy: { + 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 || "", serviceRoleKey: process.env.SUPABASE_SERVICE_KEY || "", diff --git a/apps/api/src/constants/constants.ts b/apps/api/src/constants/constants.ts index 1e42e296c..f53e8f380 100644 --- a/apps/api/src/constants/constants.ts +++ b/apps/api/src/constants/constants.ts @@ -15,7 +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 WEBHOOKS_CACHE_URL = "https://webhooks-cache.pendulumchain.tech"; // EXAMPLE URL @@ -38,23 +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, - BASE_EPHEMERAL_STARTING_BALANCE_UNITS + STELLAR_FUNDING_AMOUNT_UNITS, + SUBSIDY_MINIMUM_RATIO_FUND_UNITS, + WEBHOOKS_CACHE_URL }; diff --git a/apps/api/src/database/migrations/033-profile-partner-assignments.ts b/apps/api/src/database/migrations/033-profile-partner-assignments.ts new file mode 100644 index 000000000..0d70ecd2b --- /dev/null +++ b/apps/api/src/database/migrations/033-profile-partner-assignments.ts @@ -0,0 +1,126 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +export async function up(queryInterface: QueryInterface): 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, + 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) + }, + 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, + 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", ["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" + }); + + 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_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 5658dc230..847719dc8 100644 --- a/apps/api/src/models/index.ts +++ b/apps/api/src/models/index.ts @@ -7,6 +7,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"; @@ -19,6 +20,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" }); @@ -41,6 +44,13 @@ 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" }); +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 = { AlfredPayCustomer, @@ -51,6 +61,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..e8ae91799 --- /dev/null +++ b/apps/api/src/models/profilePartnerAssignment.model.ts @@ -0,0 +1,143 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +export interface ProfilePartnerAssignmentAttributes { + id: string; + userId: string; + partnerName: string; + buyPartnerId: string | null; + sellPartnerId: string | null; + 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 buyPartnerId: string | null; + + declare sellPartnerId: string | null; + + declare isActive: boolean; + + declare expiresAt: Date | null; + + declare createdAt: Date; + + declare updatedAt: Date; +} + +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, + 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) + }, + 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, + 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: ["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" + } + ], + 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 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. @@ -30,13 +30,14 @@ 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. 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 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. ## Threat Vectors & Mitigations @@ -44,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. | @@ -60,12 +62,14 @@ 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 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`. - [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 new file mode 100644 index 000000000..e918ae3ee --- /dev/null +++ b/docs/security-spec/03-ramp-engine/profile-partner-pricing.md @@ -0,0 +1,74 @@ +# Profile Partner Pricing + +## 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, 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: + +1. Partner API clients remain authenticated by `X-API-Key: sk_*` and may create partner-owned quotes. +2. Frontend users remain authenticated by `Authorization: Bearer ` 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. + +`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 + +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. **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 + +| 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 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`, 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. +- [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, 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 b5ad81b88..e89e9ac12 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 (`BUY` for on-ramp or `SELL` for off-ramp). 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 (`BUY` for on-ramp or `SELL` for off-ramp). 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. - If live route/pool liquidity cannot serve the quote at the requested amount, the API returns a user-facing `500` quote error (`This route is temporarily unavailable due to low liquidity. Please try a smaller amount or check back soon.`). Clients should treat it as a user-correctable liquidity failure and ask for a smaller amount or to check back soon. This applies to Nabla pool coverage failures, Squid route low-liquidity responses, and `/v1/quotes/best` when every candidate route fails for liquidity. Unexpected provider or calculation failures still follow the global production error policy and are masked as internal errors. 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. @@ -39,7 +39,7 @@ The system maintains an **in-memory** `Map 0`. @@ -60,7 +60,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 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`. @@ -68,22 +68,26 @@ 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. +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 | 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 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 | -| **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. | +| **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 (`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. | +| **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 @@ -102,8 +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/03-ramp-engine/ramp-phase-flows.md b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md index 92a41f670..6f43a4489 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` @@ -178,7 +179,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 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. @@ -187,19 +188,22 @@ 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 | 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 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. | +| **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. | | **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 @@ -221,7 +225,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` 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. @@ -229,3 +233,5 @@ 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. +- [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. diff --git a/docs/security-spec/05-integrations/brla.md b/docs/security-spec/05-integrations/brla.md index e356d48e8..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 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 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 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 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/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..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 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 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/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 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 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 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. diff --git a/docs/security-spec/07-operations/api-surface.md b/docs/security-spec/07-operations/api-surface.md index 9bddae599..7f2667b9d 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 @@ -69,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. @@ -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. 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 e620b8893..e1d2be517 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 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 | diff --git a/docs/security-spec/SPEC-DELTA-2026-05.md b/docs/security-spec/SPEC-DELTA-2026-05.md index d8dcdfcbe..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/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 — 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 (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 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. |