From b4d66ec795fe438cbcfa490c871cd95c03d8035f Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 11 Aug 2026 20:10:50 +0200 Subject: [PATCH 1/9] fix(shared): validate AlfredPay and Squid quote terms --- .../src/services/alfredpay/schemas.test.ts | 35 +++++++++++++++++++ .../shared/src/services/alfredpay/schemas.ts | 33 ++++++++++++++--- .../src/services/squidrouter/offramp.ts | 14 +++++--- .../src/services/squidrouter/schemas.test.ts | 18 ++++++++++ .../src/services/squidrouter/schemas.ts | 26 ++++++++++---- 5 files changed, 110 insertions(+), 16 deletions(-) diff --git a/packages/shared/src/services/alfredpay/schemas.test.ts b/packages/shared/src/services/alfredpay/schemas.test.ts index 8cd414b5d..1686729e6 100644 --- a/packages/shared/src/services/alfredpay/schemas.test.ts +++ b/packages/shared/src/services/alfredpay/schemas.test.ts @@ -94,6 +94,10 @@ describe("alfredpayQuoteResponseSchema", () => { const missingFeeType = validQuoteBody(); delete (missingFeeType.fees[0] as Record).type; expect(() => alfredpayQuoteResponseSchema.parse(missingFeeType)).toThrow(); + + const missingCurrency = validQuoteBody(); + delete (missingCurrency as Record).fromCurrency; + expect(() => alfredpayQuoteResponseSchema.parse(missingCurrency)).toThrow(); }); test("rejects a non-decimal toAmount", () => { @@ -138,10 +142,15 @@ describe("alfredpayOnrampTransactionSchema", () => { describe("alfredpayOfframpTransactionSchema", () => { test("rejects a non-EVM depositAddress", () => { const body = { + chain: "MATIC", + customerId: "cust-1", depositAddress: "not-an-address", expiration: "2026-07-07T12:00:00.000Z", fromAmount: "25", + fromCurrency: "USDT", + fiatAccountId: "fa-1", status: "ON_CHAIN_DEPOSIT_RECEIVED", + toAmount: "425", toCurrency: "MXN", transactionId: "tx-2" }; @@ -152,15 +161,41 @@ describe("alfredpayOfframpTransactionSchema", () => { test("accepts the pre-deposit CREATED status of a fresh offramp", () => { const body = { + chain: "MATIC", + customerId: "cust-1", depositAddress: "0x5afe00000000000000000000000000000000d0e5", expiration: "2026-07-07T12:00:00.000Z", fromAmount: "30", + fromCurrency: "USDT", + fiatAccountId: "fa-1", status: "CREATED", + toAmount: "510", toCurrency: "MXN", transactionId: "tx-3" }; expect(() => alfredpayOfframpTransactionSchema.parse(body)).not.toThrow(); }); + + test("rejects missing terms consumed by execution reconciliation", () => { + const body = { + chain: "MATIC", + customerId: "cust-1", + depositAddress: "0x5afe00000000000000000000000000000000d0e5", + expiration: "2026-07-07T12:00:00.000Z", + fromAmount: "30", + fromCurrency: "USDT", + fiatAccountId: "fa-1", + status: "CREATED", + toAmount: "510", + toCurrency: "MXN", + transactionId: "tx-3" + }; + for (const field of ["chain", "customerId", "fiatAccountId", "fromCurrency", "toAmount"] as const) { + const missing = { ...body } as Record; + delete missing[field]; + expect(() => alfredpayOfframpTransactionSchema.parse(missing)).toThrow(); + } + }); }); describe("alfredpayFiatAccountsResponseSchema", () => { diff --git a/packages/shared/src/services/alfredpay/schemas.ts b/packages/shared/src/services/alfredpay/schemas.ts index 9ea5aaa44..731b937d5 100644 --- a/packages/shared/src/services/alfredpay/schemas.ts +++ b/packages/shared/src/services/alfredpay/schemas.ts @@ -1,17 +1,20 @@ import { z } from "zod"; import { AlfredpayCustomerType } from "../../tokens/types/base"; import { + AlfredpayChain, AlfredpayConfigPair, AlfredpayFee, AlfredpayFeeType, AlfredpayFiatAccount, AlfredpayFiatAccountType, + AlfredpayFiatCurrency, AlfredpayFiatPaymentInstructions, AlfredpayKybCustomerAndBusiness, AlfredpayKybRelatedPersonDetails, AlfredpayKycStatus, AlfredpayOfframpStatus, AlfredpayOfframpTransaction, + AlfredpayOnChainCurrency, AlfredpayOnrampQuote, AlfredpayOnrampStatus, AlfredpayOnrampTransaction, @@ -36,14 +39,27 @@ type ConsumedConfigPair = Pick< >; type ConsumedFee = Pick; type ConsumedQuote = Pick & { + chain?: AlfredpayChain; fees: ConsumedFee[]; + fromCurrency: AlfredpayFiatCurrency | AlfredpayOnChainCurrency; + toCurrency: AlfredpayFiatCurrency | AlfredpayOnChainCurrency; }; type ConsumedOnrampTransaction = Pick & { metadata?: { txHash?: string; failureReason?: string } | null; }; type ConsumedOfframpTransaction = Pick< AlfredpayOfframpTransaction, - "transactionId" | "status" | "depositAddress" | "expiration" | "toCurrency" | "fromAmount" + | "transactionId" + | "status" + | "chain" + | "customerId" + | "depositAddress" + | "expiration" + | "fromAmount" + | "fromCurrency" + | "fiatAccountId" + | "toAmount" + | "toCurrency" >; type ConsumedFiatAccount = Pick & { metadata?: { accountHolderName?: string }; @@ -84,11 +100,11 @@ export const alfredpayConfigsResponseSchema = z.looseObject({ }) satisfies z.ZodType<{ supportedPairs: ConsumedConfigPair[] }>; /** - * The body of a POST …/quotes response, BUY and SELL alike — the consumed fields are - * direction-independent (`fromCurrency`/`toCurrency` are never read back; Vortex trusts - * its own request there). + * The body of a POST …/quotes response, BUY and SELL alike. SELL pricing and + * registration validate the returned currency pair before accepting amounts. */ export const alfredpayQuoteResponseSchema = z.looseObject({ + chain: z.enum(AlfredpayChain).optional(), expiration: parseableTimestamp, fees: z.array( z.looseObject({ @@ -98,9 +114,11 @@ export const alfredpayQuoteResponseSchema = z.looseObject({ }) ), fromAmount: z.string().regex(DECIMAL_STRING), + fromCurrency: z.union([z.enum(AlfredpayFiatCurrency), z.enum(AlfredpayOnChainCurrency)]), quoteId: z.string().min(1), rate: z.string().regex(DECIMAL_STRING), - toAmount: z.string().regex(DECIMAL_STRING) + toAmount: z.string().regex(DECIMAL_STRING), + toCurrency: z.union([z.enum(AlfredpayFiatCurrency), z.enum(AlfredpayOnChainCurrency)]) }) satisfies z.ZodType; /** @@ -131,10 +149,15 @@ export const alfredpayOnrampTransactionSchema = z.looseObject({ /** The body of a POST …/offramp and GET …/offramp/{id} response (same transaction shape). */ export const alfredpayOfframpTransactionSchema = z.looseObject({ + chain: z.enum(AlfredpayChain), + customerId: z.string().min(1), depositAddress: z.string().regex(EVM_ADDRESS), expiration: parseableTimestamp, + fiatAccountId: z.string().min(1), fromAmount: z.string().regex(DECIMAL_STRING), + fromCurrency: z.string().min(1), status: z.enum(AlfredpayOfframpStatus), + toAmount: z.string().regex(DECIMAL_STRING), toCurrency: z.string().min(1), transactionId: z.string().min(1) }) satisfies z.ZodType; diff --git a/packages/shared/src/services/squidrouter/offramp.ts b/packages/shared/src/services/squidrouter/offramp.ts index 30e45719b..87a418c8a 100644 --- a/packages/shared/src/services/squidrouter/offramp.ts +++ b/packages/shared/src/services/squidrouter/offramp.ts @@ -39,6 +39,7 @@ export interface OfframpTransactionData { export interface OfframpTransactionDataToEvm { approveData: EvmTransactionData; + route: SquidrouterRoute; swapData: EvmTransactionData; squidRouterQuoteId?: string; } @@ -104,10 +105,13 @@ export async function createOfframpSquidrouterTransactionsToEvm( const routeResult = await getRoute(routeParams); const { route } = routeResult.data; - return createTransactionDataFromRoute({ - inputTokenErc20Address: params.fromToken, - publicClient: fromNetworkClient, - rawAmount: params.rawAmount, + return { + ...(await createTransactionDataFromRoute({ + inputTokenErc20Address: params.fromToken, + publicClient: fromNetworkClient, + rawAmount: params.rawAmount, + route + })), route - }); + }; } diff --git a/packages/shared/src/services/squidrouter/schemas.test.ts b/packages/shared/src/services/squidrouter/schemas.test.ts index 30caa1550..b8b61f8a1 100644 --- a/packages/shared/src/services/squidrouter/schemas.test.ts +++ b/packages/shared/src/services/squidrouter/schemas.test.ts @@ -43,6 +43,24 @@ describe("squidrouterRouteResponseSchema", () => { test("rejects a non-raw-units toAmount", () => { const body = validRouteBody(); body.route.estimate.toAmount = "9.95"; + expect(() => squidrouterRouteResponseSchema.safeParse(body)).not.toThrow(); + expect(squidrouterRouteResponseSchema.safeParse(body).success).toBe(false); + }); + + test("requires a raw-units toAmountMin", () => { + const missing = validRouteBody(); + delete (missing.route.estimate as Record).toAmountMin; + expect(() => squidrouterRouteResponseSchema.parse(missing)).toThrow(); + + const malformed = validRouteBody(); + malformed.route.estimate.toAmountMin = "9.90"; + expect(() => squidrouterRouteResponseSchema.safeParse(malformed)).not.toThrow(); + expect(squidrouterRouteResponseSchema.safeParse(malformed).success).toBe(false); + }); + + test("rejects a guaranteed minimum above the estimated output", () => { + const body = validRouteBody(); + body.route.estimate.toAmountMin = "9950001"; expect(() => squidrouterRouteResponseSchema.parse(body)).toThrow(); }); diff --git a/packages/shared/src/services/squidrouter/schemas.ts b/packages/shared/src/services/squidrouter/schemas.ts index 9965421ec..2adcdaa81 100644 --- a/packages/shared/src/services/squidrouter/schemas.ts +++ b/packages/shared/src/services/squidrouter/schemas.ts @@ -13,7 +13,7 @@ import type { SquidRouterPayResponse, SquidrouterRoute, SquidrouterRouteEstimate // Consumed subsets of the full shared types. Deriving them via Pick ties the schemas to // the types: renaming a consumed field in route.ts breaks compilation here. // aggregateSlippage is optional because getRoute reads it defensively (`estimate?.aggregateSlippage !== undefined`). -type ConsumedRouteEstimate = Pick & +type ConsumedRouteEstimate = Pick & Partial>; type ConsumedRoute = Pick & { estimate: ConsumedRouteEstimate }; type ConsumedPayStatus = Pick; @@ -25,14 +25,28 @@ const BIGINT_STRING = /^(?:\d+|0x[0-9a-fA-F]+)$/; const HEX_DATA = /^0x[0-9a-fA-F]*$/; const EVM_ADDRESS = /^0x[0-9a-fA-F]{40}$/; +const squidrouterRouteEstimateSchema = z + .looseObject({ + aggregateSlippage: z.number().optional(), + toAmount: z.string().regex(RAW_UNITS), + toAmountMin: z.string().regex(RAW_UNITS), + toToken: z.looseObject({ decimals: z.number().int().positive() }) + }) + .superRefine((estimate, ctx) => { + if (!RAW_UNITS.test(estimate.toAmount) || !RAW_UNITS.test(estimate.toAmountMin)) return; + if (BigInt(estimate.toAmountMin) > BigInt(estimate.toAmount)) { + ctx.addIssue({ + code: "custom", + message: "toAmountMin must not exceed toAmount", + path: ["toAmountMin"] + }); + } + }); + /** The body of a POST /v2/route response (`data` after JSON parsing). */ export const squidrouterRouteResponseSchema = z.looseObject({ route: z.looseObject({ - estimate: z.looseObject({ - aggregateSlippage: z.number().optional(), - toAmount: z.string().regex(RAW_UNITS), - toToken: z.looseObject({ decimals: z.number().int().positive() }) - }), + estimate: squidrouterRouteEstimateSchema, quoteId: z.string().min(1), transactionRequest: z.looseObject({ data: z.string().regex(HEX_DATA), From a2a83ac076251efce54782cab5e518ee2ab11b4b Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 11 Aug 2026 20:11:11 +0200 Subject: [PATCH 2/9] fix(api): reconcile AlfredPay sell quote targets --- .../__tests__/alfredpay-offramp.flow.test.ts | 10 +- .../alfredpay-offramp.registration.test.ts | 239 ++++++++++++++- .../phases/blocks/core/squidrouter.ts | 13 + .../phases/blocks/flows/alfredpay-offramp.ts | 8 +- .../services/phases/blocks/flows/catalog.ts | 12 +- .../phases/alfredpay-offramp/lifecycle.ts | 6 +- .../phases/alfredpay-offramp/registration.ts | 87 ++++-- .../phases/alfredpay-offramp/simulation.ts | 286 ++++++++++++++---- .../phases/alfredpay-offramp/transactions.ts | 40 ++- .../src/test-utils/fake-world/fake-anchors.ts | 100 ++++-- .../test-utils/fake-world/fake-squidrouter.ts | 3 + .../contracts/alfredpay.contract.test.ts | 136 ++++++++- .../alfredpay-currencies.scenario.test.ts | 5 +- .../sdk-contract.alfredpay-offramp.test.ts | 5 +- 14 files changed, 802 insertions(+), 148 deletions(-) diff --git a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.flow.test.ts index 424065ace..13c627609 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.flow.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.flow.test.ts @@ -30,11 +30,11 @@ const CORE_PHASES: RampPhase[] = [ ]; describe("Alfredpay offramp flow", () => { - it("fails closed for persisted pre-v3 identities (drain-then-deploy contract)", () => { - expect(alfredpayOfframpFlow.identity.version).toBe(3); - expect(alfredpayOfframpFlow.identity.blockSchemaVersions.alfredpayOfframp).toBe(2); - expect(() => getBlockFlowByIdentity({ ...alfredpayOfframpFlow.identity, version: 2 })).toThrow( - /Unsupported persisted flow AlfredpayOfframp@2/ + it("uses v4/schema 3 and rejects pre-rollout flow versions", () => { + expect(alfredpayOfframpFlow.identity.version).toBe(4); + expect(alfredpayOfframpFlow.identity.blockSchemaVersions.alfredpayOfframp).toBe(3); + expect(() => getBlockFlowByIdentity({ ...alfredpayOfframpFlow.identity, version: 3 })).toThrow( + /Unsupported persisted flow AlfredpayOfframp@3/ ); }); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.registration.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.registration.test.ts index d38c0351a..1b88d444f 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.registration.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.registration.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, mock } from "bun:test"; import { AlfredpayFeeType, + AlfredpayChain, + AlfredpayOfframpStatus, AlfredpayOnChainCurrency, type EvmNetworks, EvmToken, @@ -10,6 +12,8 @@ import { import { registerAlfredpayOfframp } from "../phases/alfredpay-offramp/registration"; import type { AlfredpayOfframpMetadata } from "../phases/alfredpay-offramp/simulation"; +const safeExpiration = new Date(Date.now() + 10 * 60_000).toISOString(); + const metadata: AlfredpayOfframpMetadata = { adjustedDifference: "0", adjustedTargetDiscount: "0", @@ -17,6 +21,7 @@ const metadata: AlfredpayOfframpMetadata = { bridgeOutputAmountDecimal: "99", bridgeOutputAmountRaw: "99000000", currency: FiatToken.MXN, + executableBridgeOutputRaw: "99000000", expirationDate: new Date("2026-01-01T00:00:00Z"), fee: "1", fromNetwork: Networks.Base as EvmNetworks, @@ -69,14 +74,27 @@ describe("Alfredpay offramp registration", () => { it("refreshes exact quotes, creates the order, and updates only provider identity metadata", async () => { const service = { createOfframp: mock(async () => ({ + chain: AlfredpayChain.MATIC, + customerId: "customer-1", depositAddress: "0x5555555555555555555555555555555555555555", + expiration: safeExpiration, + fiatAccountId: "fiat-1", + fromAmount: "99", + fromCurrency: AlfredpayOnChainCurrency.USDT, + status: AlfredpayOfframpStatus.CREATED, + toAmount: "1980", + toCurrency: FiatToken.MXN, transactionId: "transaction-1" })), createOfframpQuote: mock(async () => ({ - expiration: "2026-01-01T00:01:00Z", + chain: AlfredpayChain.MATIC, + expiration: safeExpiration, fees: [{ amount: "1", currency: "MXN" }], + fromAmount: "99", + fromCurrency: AlfredpayOnChainCurrency.USDT, quoteId: "quote-new", - toAmount: "1980" + toAmount: "1980", + toCurrency: FiatToken.MXN })) } as never; const result = await registerAlfredpayOfframp(context(), { @@ -85,7 +103,7 @@ describe("Alfredpay offramp registration", () => { }); expect(result.metadata).toEqual({ ...metadata, - expirationDate: new Date("2026-01-01T00:01:00Z"), + expirationDate: new Date(safeExpiration), quoteId: "quote-new" }); expect(result.facts).toEqual({ @@ -97,15 +115,61 @@ describe("Alfredpay offramp registration", () => { }); }); - it("hard-fails on refreshed amount drift before creating an order", async () => { + it("hard-fails on refreshed output drift before creating an order", async () => { + const createOrder = mock(async () => ({})); + const service = { + createOfframp: createOrder, + createOfframpQuote: mock(async () => ({ + chain: AlfredpayChain.MATIC, + expiration: safeExpiration, + fees: [{ amount: "1", currency: "MXN" }], + fromAmount: "99", + fromCurrency: AlfredpayOnChainCurrency.USDT, + quoteId: "quote-new", + toAmount: "1979", + toCurrency: FiatToken.MXN + })) + } as never; + await expect( + registerAlfredpayOfframp(context(), { resolveCustomerId: async () => "customer-1", service }) + ).rejects.toThrow("drifted"); + expect(createOrder).not.toHaveBeenCalled(); + }); + + it("hard-fails on refreshed input drift before creating an order", async () => { + const createOrder = mock(async () => ({})); + const service = { + createOfframp: createOrder, + createOfframpQuote: mock(async () => ({ + chain: AlfredpayChain.MATIC, + expiration: safeExpiration, + fees: [{ amount: "1", currency: "MXN" }], + fromAmount: "98.999999", + fromCurrency: AlfredpayOnChainCurrency.USDT, + quoteId: "quote-new", + toAmount: "1980", + toCurrency: FiatToken.MXN + })) + } as never; + await expect( + registerAlfredpayOfframp(context(), { resolveCustomerId: async () => "customer-1", service }) + ).rejects.toThrow("drifted"); + expect(createOrder).not.toHaveBeenCalled(); + }); + + it("hard-fails on refreshed currency drift before creating an order", async () => { const createOrder = mock(async () => ({})); const service = { createOfframp: createOrder, createOfframpQuote: mock(async () => ({ - expiration: "2026-01-01T00:01:00Z", + chain: AlfredpayChain.MATIC, + expiration: safeExpiration, fees: [{ amount: "1", currency: "MXN" }], + fromAmount: "99", + fromCurrency: AlfredpayOnChainCurrency.USDT, quoteId: "quote-new", - toAmount: "1979" + toAmount: "1980", + toCurrency: FiatToken.COP })) } as never; await expect( @@ -113,4 +177,167 @@ describe("Alfredpay offramp registration", () => { ).rejects.toThrow("drifted"); expect(createOrder).not.toHaveBeenCalled(); }); + + it("rejects a refreshed quote that cannot safely survive registration and signing", async () => { + const createOrder = mock(async () => ({})); + const service = { + createOfframp: createOrder, + createOfframpQuote: mock(async () => ({ + chain: AlfredpayChain.MATIC, + expiration: new Date(Date.now() + 5_000).toISOString(), + fees: [{ amount: "1", currency: "MXN" }], + fromAmount: "99", + fromCurrency: AlfredpayOnChainCurrency.USDT, + quoteId: "quote-near-expiry", + toAmount: "1980", + toCurrency: FiatToken.MXN + })) + } as never; + + await expect( + registerAlfredpayOfframp(context(), { resolveCustomerId: async () => "customer-1", service }) + ).rejects.toThrow("drifted"); + expect(createOrder).not.toHaveBeenCalled(); + }); + + it("fails closed when the created order is not bound to the refreshed quote", async () => { + const createOrder = mock(async () => ({ + chain: AlfredpayChain.MATIC, + customerId: "customer-1", + depositAddress: "0x5555555555555555555555555555555555555555", + expiration: safeExpiration, + fiatAccountId: "fiat-1", + fromAmount: "99", + fromCurrency: AlfredpayOnChainCurrency.USDT, + status: AlfredpayOfframpStatus.CREATED, + toAmount: "1979", + toCurrency: FiatToken.MXN, + transactionId: "transaction-drifted" + })); + const service = { + createOfframp: createOrder, + createOfframpQuote: mock(async () => ({ + chain: AlfredpayChain.MATIC, + expiration: safeExpiration, + fees: [{ amount: "1", currency: "MXN" }], + fromAmount: "99", + fromCurrency: AlfredpayOnChainCurrency.USDT, + quoteId: "quote-new", + toAmount: "1980", + toCurrency: FiatToken.MXN + })) + } as never; + + await expect( + registerAlfredpayOfframp(context(), { resolveCustomerId: async () => "customer-1", service }) + ).rejects.toThrow("Created Alfredpay offramp order drifted"); + expect(createOrder).toHaveBeenCalledTimes(1); + }); + + it("fails closed when the created order cannot safely outlive broadcast and indexing", async () => { + const createOrder = mock(async () => ({ + chain: AlfredpayChain.MATIC, + customerId: "customer-1", + depositAddress: "0x5555555555555555555555555555555555555555", + expiration: new Date(Date.now() + 30_000).toISOString(), + fiatAccountId: "fiat-1", + fromAmount: "99", + fromCurrency: AlfredpayOnChainCurrency.USDT, + status: AlfredpayOfframpStatus.CREATED, + toAmount: "1980", + toCurrency: FiatToken.MXN, + transactionId: "transaction-near-expiry" + })); + const service = { + createOfframp: createOrder, + createOfframpQuote: mock(async () => ({ + chain: AlfredpayChain.MATIC, + expiration: safeExpiration, + fees: [{ amount: "1", currency: "MXN" }], + fromAmount: "99", + fromCurrency: AlfredpayOnChainCurrency.USDT, + quoteId: "quote-new", + toAmount: "1980", + toCurrency: FiatToken.MXN + })) + } as never; + + await expect( + registerAlfredpayOfframp(context(), { resolveCustomerId: async () => "customer-1", service }) + ).rejects.toThrow("Created Alfredpay offramp order drifted"); + expect(createOrder).toHaveBeenCalledTimes(1); + }); + + it("accepts a usable short-lived provider quote when the created order has safe execution lifetime", async () => { + const createOrder = mock(async () => ({ + chain: AlfredpayChain.MATIC, + customerId: "customer-1", + depositAddress: "0x5555555555555555555555555555555555555555", + expiration: safeExpiration, + fiatAccountId: "fiat-1", + fromAmount: "99", + fromCurrency: AlfredpayOnChainCurrency.USDT, + status: AlfredpayOfframpStatus.CREATED, + toAmount: "1980", + toCurrency: FiatToken.MXN, + transactionId: "transaction-short-quote" + })); + const service = { + createOfframp: createOrder, + createOfframpQuote: mock(async () => ({ + chain: AlfredpayChain.MATIC, + expiration: new Date(Date.now() + 30_000).toISOString(), + fees: [{ amount: "1", currency: "MXN" }], + fromAmount: "99", + fromCurrency: AlfredpayOnChainCurrency.USDT, + quoteId: "quote-short-lived", + toAmount: "1980", + toCurrency: FiatToken.MXN + })) + } as never; + + const result = await registerAlfredpayOfframp(context(), { + resolveCustomerId: async () => "customer-1", + service + }); + + expect(result.facts.alfredpayTransactionId).toBe("transaction-short-quote"); + expect(createOrder).toHaveBeenCalledTimes(1); + }); + + it("rejects created-order responses whose lifecycle is not CREATED", async () => { + for (const status of [AlfredpayOfframpStatus.FAILED, AlfredpayOfframpStatus.FIAT_TRANSFER_COMPLETED]) { + const createOrder = mock(async () => ({ + chain: AlfredpayChain.MATIC, + customerId: "customer-1", + depositAddress: "0x5555555555555555555555555555555555555555", + expiration: safeExpiration, + fiatAccountId: "fiat-1", + fromAmount: "99", + fromCurrency: AlfredpayOnChainCurrency.USDT, + status, + toAmount: "1980", + toCurrency: FiatToken.MXN, + transactionId: `transaction-${status}` + })); + const service = { + createOfframp: createOrder, + createOfframpQuote: mock(async () => ({ + chain: AlfredpayChain.MATIC, + expiration: safeExpiration, + fees: [{ amount: "1", currency: "MXN" }], + fromAmount: "99", + fromCurrency: AlfredpayOnChainCurrency.USDT, + quoteId: "quote-new", + toAmount: "1980", + toCurrency: FiatToken.MXN + })) + } as never; + + await expect( + registerAlfredpayOfframp(context(), { resolveCustomerId: async () => "customer-1", service }) + ).rejects.toThrow("Created Alfredpay offramp order drifted"); + expect(createOrder).toHaveBeenCalledTimes(1); + } + }); }); diff --git a/apps/api/src/api/services/phases/blocks/core/squidrouter.ts b/apps/api/src/api/services/phases/blocks/core/squidrouter.ts index 9a4468983..da98c9e24 100644 --- a/apps/api/src/api/services/phases/blocks/core/squidrouter.ts +++ b/apps/api/src/api/services/phases/blocks/core/squidrouter.ts @@ -188,12 +188,25 @@ async function getSquidrouterRouteData(routeParams: RouteParams, fromNetwork: Ne const routeData = routeResult.data; const outputTokenDecimals = routeData.route.estimate.toToken.decimals; const outputAmountRaw = routeData.route.estimate.toAmount; + const minimumOutputAmountRaw = routeData.route.estimate.toAmountMin; + if (BigInt(minimumOutputAmountRaw) > BigInt(outputAmountRaw)) { + throw new APIError({ + message: "Invalid Squidrouter response: minimum output exceeds estimated output", + status: httpStatus.SERVICE_UNAVAILABLE + }); + } const outputAmountDecimal = parseContractBalanceResponse(outputTokenDecimals, BigInt(outputAmountRaw)).preciseBigDecimal; + const minimumOutputAmountDecimal = parseContractBalanceResponse( + outputTokenDecimals, + BigInt(minimumOutputAmountRaw) + ).preciseBigDecimal; const networkFeeUSD = await calculateSquidrouterNetworkFee(routeData.route, fromNetwork); return { fromToken: routeParams.fromToken, inputAmountRaw: routeParams.fromAmount, + minimumOutputAmountDecimal, + minimumOutputAmountRaw, networkFeeUSD, outputAmountDecimal, outputAmountRaw, diff --git a/apps/api/src/api/services/phases/blocks/flows/alfredpay-offramp.ts b/apps/api/src/api/services/phases/blocks/flows/alfredpay-offramp.ts index 9324d6c12..fb9c7fde8 100644 --- a/apps/api/src/api/services/phases/blocks/flows/alfredpay-offramp.ts +++ b/apps/api/src/api/services/phases/blocks/flows/alfredpay-offramp.ts @@ -4,10 +4,10 @@ import { evmRequestIO } from "../core/io"; import { AlfredpayOfframp } from "../phases/alfredpay-offramp"; import { DistributeFees } from "../phases/distribute-fees"; -// Version 2 appends the Polygon fee-collection phase: the vortex/partner fee residual -// that AlfredpayOfframp's pricing reserves on the Polygon ephemeral is paid out after -// the Alfredpay deposit succeeded. Deploys are gated on draining v1 quotes/ramps. -export const ALFREDPAY_OFFRAMP_FLOW_VERSION = 3; +// Version 4 introduces context schema 3 and persists Squid's executable minimum for +// provider-aware target-discount/cap reconciliation. Deployments must be timed for +// a window with no pending AlfredPay quotes or ramps from flow v3. +export const ALFREDPAY_OFFRAMP_FLOW_VERSION = 4; export function makeAlfredpayOfframpFlow(fromToken: EvmToken, fromNetwork: EvmNetworks) { return FlowBuilder.start(evmRequestIO(fromToken, fromNetwork), AlfredpayOfframp(fromToken, fromNetwork)) diff --git a/apps/api/src/api/services/phases/blocks/flows/catalog.ts b/apps/api/src/api/services/phases/blocks/flows/catalog.ts index d01af94f2..a3e0ca5d3 100644 --- a/apps/api/src/api/services/phases/blocks/flows/catalog.ts +++ b/apps/api/src/api/services/phases/blocks/flows/catalog.ts @@ -339,18 +339,18 @@ export function resolveBlockFlow(request: FlowRequest): Flow { export function resolvePersistedBlockFlow(metadataValue: unknown): Flow { const metadata = getFlowMetadata(metadataValue); if (!metadata.flow) { - const legacyFlow = resolveBlockFlow(metadata.globals.request); - legacyFlow.assertMetadata(metadata, { allowLegacy: true }); - return legacyFlow; + const flow = resolveBlockFlow(metadata.globals.request); + flow.assertMetadata(metadata, { allowLegacy: true }); + return flow; } - const candidates = flowDefinitions.filter(definition => { - const identity = definition.executorFlow.identity; + const candidates = flowDefinitions.filter(candidate => { + const identity = candidate.executorFlow.identity; return ( identity.id === metadata.flow?.id && identity.version === metadata.flow.version && identity.catalogVersion === metadata.flow.catalogVersion && - definition.matches(metadata.globals.request) + candidate.matches(metadata.globals.request) ); }); if (candidates.length !== 1) { diff --git a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/lifecycle.ts b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/lifecycle.ts index 9d2e1aa16..9b5949008 100644 --- a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/lifecycle.ts +++ b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/lifecycle.ts @@ -3,9 +3,9 @@ import { APIError } from "../../../../../errors/api-error"; import type { StartCtx, StartResult } from "../../core/types"; import type { AlfredpayOfframpMetadata } from "./simulation"; -export async function startAlfredpayOfframp( - ctx: StartCtx -): Promise> { +export async function startAlfredpayOfframp( + ctx: StartCtx +): Promise> { if (ctx.state.alfredpayTransactionId) { return {}; } diff --git a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/registration.ts b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/registration.ts index 4a69d510a..dc5ae7d41 100644 --- a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/registration.ts +++ b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/registration.ts @@ -3,6 +3,7 @@ import { AlfredpayApiService, AlfredpayChain, type AlfredpayFiatCurrency, + AlfredpayOfframpStatus, AlfredpayPaymentMethodType, type CreateAlfredpayOfframpQuoteRequest, EphemeralAccountType @@ -12,8 +13,9 @@ import httpStatus from "http-status"; import { APIError } from "../../../../../errors/api-error"; import { resolveAlfredpayCustomerId } from "../../../../quote/alfredpay-customer"; import { requireAccount } from "../../core/accounts"; +import { FinancialOperationRejectedError } from "../../core/financial-operation"; import type { RegisterCtx, RegistrationResult } from "../../core/types"; -import type { AlfredpayOfframpMetadata } from "./simulation"; +import { type AlfredpayOfframpMetadata, hasSafeAlfredpayExecutionLifetime, hasSafeAlfredpayQuoteLifetime } from "./simulation"; export interface AlfredpayOfframpRegistrationInput extends Record { fiatAccountId?: string; @@ -28,14 +30,14 @@ export interface AlfredpayOfframpRegistrationFacts { walletAddress: string; } -export async function registerAlfredpayOfframp( - ctx: RegisterCtx, +export async function registerAlfredpayOfframp( + ctx: RegisterCtx, dependencies: { resolveCustomerId?: typeof resolveAlfredpayCustomerId; service?: Pick; sumFees?: typeof AlfredpayApiService.sumFeesByCurrency; } = {} -): Promise> { +): Promise> { if (!ctx.input.fiatAccountId) { throw new APIError({ message: "fiatAccountId is required for Alfredpay offramp", status: httpStatus.BAD_REQUEST }); } @@ -46,31 +48,49 @@ export async function registerAlfredpayOfframp( Object.fromEntries(ctx.signingAccounts.map(account => [account.type, account])), EphemeralAccountType.EVM ); - const customerId = await (dependencies.resolveCustomerId ?? resolveAlfredpayCustomerId)( - ctx.metadata.currency, - ctx.authenticatedUser.id - ); + let customerId: string; + let freshQuote: Awaited>; const service = dependencies.service ?? AlfredpayApiService.getInstance(); const toCurrency = ctx.metadata.currency as unknown as AlfredpayFiatCurrency; - const freshQuote = await service.createOfframpQuote({ - chain: AlfredpayChain.MATIC, - fromAmount: new Big(ctx.metadata.inputAmountDecimal as unknown as string).toString(), - fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, - metadata: { businessId: "vortex", customerId }, - paymentMethodType: AlfredpayPaymentMethodType.BANK, - toCurrency - } satisfies CreateAlfredpayOfframpQuoteRequest); + try { + customerId = await (dependencies.resolveCustomerId ?? resolveAlfredpayCustomerId)( + ctx.metadata.currency, + ctx.authenticatedUser.id + ); + freshQuote = await service.createOfframpQuote({ + chain: AlfredpayChain.MATIC, + fromAmount: new Big(ctx.metadata.inputAmountDecimal as unknown as string).toString(), + fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, + metadata: { businessId: "vortex", customerId }, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + toCurrency + } satisfies CreateAlfredpayOfframpQuoteRequest); + } catch (error) { + throw new FinancialOperationRejectedError( + `Alfredpay offramp registration preflight failed before order creation: ${error instanceof Error ? error.message : String(error)}` + ); + } + const originalInput = new Big(ctx.metadata.inputAmountDecimal as unknown as string); + const freshInput = new Big(freshQuote.fromAmount); const originalOutput = new Big(ctx.metadata.outputAmountDecimal as unknown as string); const freshOutput = new Big(freshQuote.toAmount); const originalFee = new Big(ctx.metadata.fee as unknown as string); const freshFee = (dependencies.sumFees ?? AlfredpayApiService.sumFeesByCurrency)(freshQuote.fees, toCurrency); - if (!freshOutput.eq(originalOutput) || !freshFee.eq(originalFee)) { - throw new APIError({ - message: - `Refreshed Alfredpay offramp quote drifted: toAmount original=${originalOutput.toString()} fresh=${freshOutput.toString()}, ` + - `fee original=${originalFee.toString()} fresh=${freshFee.toString()}. Cannot proceed with offramp order.`, - status: httpStatus.INTERNAL_SERVER_ERROR - }); + if ( + (freshQuote.chain !== undefined && freshQuote.chain !== AlfredpayChain.MATIC) || + freshQuote.fromCurrency !== ALFREDPAY_ONCHAIN_CURRENCY || + freshQuote.toCurrency !== toCurrency || + !freshInput.eq(originalInput) || + !freshOutput.eq(originalOutput) || + !freshFee.eq(originalFee) || + !hasSafeAlfredpayQuoteLifetime(freshQuote.expiration) + ) { + throw new FinancialOperationRejectedError( + `Refreshed Alfredpay offramp quote drifted: pair expected=${ALFREDPAY_ONCHAIN_CURRENCY}/${toCurrency} fresh=${freshQuote.fromCurrency}/${freshQuote.toCurrency}, ` + + `fromAmount original=${originalInput.toString()} fresh=${freshInput.toString()}, ` + + `toAmount original=${originalOutput.toString()} fresh=${freshOutput.toString()}, ` + + `fee original=${originalFee.toString()} fresh=${freshFee.toString()}. Cannot proceed with offramp order.` + ); } const order = await service.createOfframp({ amount: new Big(ctx.metadata.inputAmountDecimal as unknown as string).toString(), @@ -82,6 +102,27 @@ export async function registerAlfredpayOfframp( quoteId: freshQuote.quoteId, toCurrency }); + if ( + order.chain !== AlfredpayChain.MATIC || + order.status !== AlfredpayOfframpStatus.CREATED || + order.customerId !== customerId || + order.fiatAccountId !== ctx.input.fiatAccountId || + order.fromCurrency !== ALFREDPAY_ONCHAIN_CURRENCY || + order.toCurrency !== toCurrency || + !order.transactionId || + !new Big(order.fromAmount).eq(originalInput) || + !new Big(order.toAmount).eq(originalOutput) || + !hasSafeAlfredpayExecutionLifetime(order.expiration) + ) { + throw new APIError({ + message: + `Created Alfredpay offramp order drifted from the registered quote: chain expected=${AlfredpayChain.MATIC} order=${order.chain}, ` + + `pair expected=${ALFREDPAY_ONCHAIN_CURRENCY}/${toCurrency} order=${order.fromCurrency}/${order.toCurrency}, ` + + `fromAmount expected=${originalInput.toString()} order=${order.fromAmount}, ` + + `toAmount expected=${originalOutput.toString()} order=${order.toAmount}`, + status: httpStatus.SERVICE_UNAVAILABLE + }); + } return { facts: { alfredpayTransactionId: order.transactionId, diff --git a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/simulation.ts index 12c4d22e4..059151395 100644 --- a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/simulation.ts +++ b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/simulation.ts @@ -8,6 +8,7 @@ import { type AlfredpayFeeType, type AlfredpayFiatCurrency, AlfredpayPaymentMethodType, + AlfredpayTradeLimitError, type EvmNetworks, type EvmToken, type FiatToken, @@ -18,18 +19,15 @@ import { RampDirection } from "@vortexfi/shared"; import Big from "big.js"; +import logger from "../../../../../../config/logger"; +import { MAX_FINAL_SETTLEMENT_SUBSIDY_USD } from "../../../../../../constants/constants"; import { type FiatExchangeRateSource, priceFeedService } from "../../../../priceFeed.service"; import { resolveAlfredpayQuoteCustomerId } from "../../../../quote/alfredpay-customer"; -import { - calculateExpectedOutput, - calculateSubsidyAmount, - getUsdDenominatedInputAmount, - resolveDiscountPartner -} from "../../core/discount"; -import { calculateFees } from "../../core/fees"; +import { getAdjustedDifference, getUsdDenominatedInputAmount, resolveDiscountPartner } from "../../core/discount"; +import { getEvmFeeTotalRawFromUsd } from "../../core/fee-distribution"; +import { overrideFees } from "../../core/fees"; import { evmIO } from "../../core/io"; import { defineContext, type SerializableBig } from "../../core/metadata"; -import { calculatePreNablaDeductibleFees } from "../../core/quote-fees"; import { getEvmBridgeQuote } from "../../core/squidrouter"; import type { PhaseCtx, PhaseIO, PhaseResult } from "../../core/types"; @@ -41,6 +39,8 @@ export interface AlfredpayOfframpMetadata { bridgeOutputAmountRaw: string; currency: FiatToken; expirationDate: Date; + /** Guaranteed Polygon USDT amount. Introduced by context schema 3 / flow v4. */ + executableBridgeOutputRaw: string; fee: SerializableBig; fromNetwork: EvmNetworks; fromToken: `0x${string}`; @@ -82,7 +82,37 @@ export interface AlfredpayOfframpMetadata { toToken: `0x${string}`; } -export const AlfredpayOfframpContext = defineContext()("alfredpayOfframp", 2); +/** Executable Squid minimum and quote-bound settlement subsidy. */ +export const AlfredpayOfframpContext = defineContext()("alfredpayOfframp", 3); + +export const ALFREDPAY_MIN_EXECUTION_LIFETIME_MS = 2 * 60 * 1000; +export const ALFREDPAY_MIN_QUOTE_LIFETIME_MS = 10 * 1000; + +export function hasSafeAlfredpayQuoteLifetime(expiration: string): boolean { + const expiresAt = Date.parse(expiration); + return Number.isFinite(expiresAt) && expiresAt > Date.now() + ALFREDPAY_MIN_QUOTE_LIFETIME_MS; +} + +export function hasSafeAlfredpayExecutionLifetime(expiration: string, nowMs = Date.now()): boolean { + const expiresAt = Date.parse(expiration); + return Number.isFinite(expiresAt) && expiresAt > nowMs + ALFREDPAY_MIN_EXECUTION_LIFETIME_MS; +} + +export function getAlfredpayExecutableBridgeOutputRaw( + metadata: Pick, + feesUsd: Parameters[0] +): string { + const derivedMinimumRaw = new Big(metadata.inputAmountRaw) + .plus(getEvmFeeTotalRawFromUsd(feesUsd, ALFREDPAY_ERC20_DECIMALS)) + .minus(metadata.subsidyAmountRaw) + .toFixed(0); + if (!new Big(metadata.executableBridgeOutputRaw).eq(derivedMinimumRaw)) { + throw new Error( + `Alfredpay executable bridge minimum mismatch: persisted=${metadata.executableBridgeOutputRaw}, derived=${derivedMinimumRaw}` + ); + } + return metadata.executableBridgeOutputRaw; +} function directAlfredpaySettlementQuote(amountDecimal: string) { const outputAmountDecimal = new Big(amountDecimal); @@ -91,6 +121,8 @@ function directAlfredpaySettlementQuote(amountDecimal: string) { return { fromToken: ALFREDPAY_ERC20_TOKEN, inputAmountRaw: amountRaw, + minimumOutputAmountDecimal: outputAmountDecimal, + minimumOutputAmountRaw: amountRaw, outputAmountDecimal, outputAmountRaw: amountRaw, toToken: ALFREDPAY_ERC20_TOKEN @@ -115,72 +147,194 @@ export function simulateAlfredpayOfframp + alfredpay.createOfframpQuote({ + ...amount, + chain: AlfredpayChain.MATIC, + fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, + metadata: { businessId: "vortex", customerId }, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + toCurrency + }); + const providerInputRaw = (amount: string): Big => { + const raw = multiplyByPowerOfTen(amount, ALFREDPAY_ERC20_DECIMALS); + if (!raw.eq(raw.round(0, Big.roundDown))) { + throw new Error(`AlfredpayOfframp: Provider input ${amount} exceeds USDT precision`); + } + return raw; + }; + const providerInputDecimal = (raw: Big): string => raw.div(new Big(10).pow(ALFREDPAY_ERC20_DECIMALS)).toString(); + const matchesProviderPair = (quote: Awaited>): boolean => + (quote.chain === undefined || quote.chain === AlfredpayChain.MATIC) && + quote.fromCurrency === ALFREDPAY_ONCHAIN_CURRENCY && + quote.toCurrency === toCurrency; + const createFixedInputQuote = async (raw: Big) => { + const requestedFromAmount = providerInputDecimal(raw); + const quote = await createProviderQuote({ fromAmount: requestedFromAmount }); + if (!matchesProviderPair(quote) || !new Big(quote.fromAmount).eq(requestedFromAmount)) { + throw new Error( + `AlfredpayOfframp: Fixed-input quote drifted from ${requestedFromAmount} ${ALFREDPAY_ONCHAIN_CURRENCY} to ${quote.fromAmount} ${quote.fromCurrency}/${quote.toCurrency}` + ); + } + return quote; + }; + + const configuredPartnerCapUsd = expectedOutput.mul(maxSubsidy).div(referenceRate); + const partnerCapUsd = configuredPartnerCapUsd.gt(0) ? configuredPartnerCapUsd : new Big(0); + const partnerCapRaw = multiplyByPowerOfTen(partnerCapUsd, ALFREDPAY_ERC20_DECIMALS).round(0, Big.roundDown); + const runtimeCapRaw = multiplyByPowerOfTen(MAX_FINAL_SETTLEMENT_SUBSIDY_USD, ALFREDPAY_ERC20_DECIMALS); + const allowedSubsidyRaw = partnerCapRaw.lt(runtimeCapRaw) ? partnerCapRaw : runtimeCapRaw; + const requestedTargetOutput = expectedOutput.round(2, Big.roundUp); + + let providerQuote: Awaited> | undefined; + let targetRequiredSubsidyRaw = new Big(0); + let targetWasCapped = false; + let providerLimitBoundCap = false; + let providerMaximumInput: string | undefined; + let requiredSubsidyUsd: string | undefined; + if (subsidyEnabled) { + let targetQuote: Awaited> | undefined; + try { + targetQuote = await createProviderQuote({ toAmount: requestedTargetOutput.toFixed(2) }); + } catch (error) { + if ( + !(error instanceof AlfredpayTradeLimitError) || + error.kind !== "above" || + error.fromCurrency !== ALFREDPAY_ONCHAIN_CURRENCY + ) { + throw error; + } + const providerMaximumInputRaw = multiplyByPowerOfTen(error.quantity, ALFREDPAY_ERC20_DECIMALS).round(0, Big.roundDown); + if (providerMaximumInputRaw.lt(baselineProviderInputRaw)) { + throw error; + } + const vortexCappedInputRaw = baselineProviderInputRaw.plus(allowedSubsidyRaw); + const cappedInputRaw = providerMaximumInputRaw.lt(vortexCappedInputRaw) + ? providerMaximumInputRaw + : vortexCappedInputRaw; + providerLimitBoundCap = providerMaximumInputRaw.lt(vortexCappedInputRaw); + providerMaximumInput = providerInputDecimal(providerMaximumInputRaw); + const providerMaximumSubsidyRaw = providerMaximumInputRaw.plus(feeReserveRaw).minus(bridgeOutputRaw); + requiredSubsidyUsd = `>${(providerMaximumSubsidyRaw.gt(0) ? providerMaximumSubsidyRaw : new Big(0)) + .div(new Big(10).pow(ALFREDPAY_ERC20_DECIMALS)) + .toString()}`; + targetWasCapped = true; + providerQuote = await createFixedInputQuote(cappedInputRaw); + } + if (targetQuote) { + if (!matchesProviderPair(targetQuote) || new Big(targetQuote.toAmount).lt(requestedTargetOutput)) { + throw new Error( + `AlfredpayOfframp: Exact-output quote returned ${targetQuote.fromCurrency}/${targetQuote.toCurrency} ${targetQuote.toAmount}, below requested ${requestedTargetOutput.toFixed(2)} ${ctx.request.outputCurrency}` + ); + } + const targetProviderInputRaw = providerInputRaw(targetQuote.fromAmount); + targetRequiredSubsidyRaw = targetProviderInputRaw.plus(feeReserveRaw).minus(bridgeOutputRaw); + requiredSubsidyUsd = (targetRequiredSubsidyRaw.gt(0) ? targetRequiredSubsidyRaw : new Big(0)) + .div(new Big(10).pow(ALFREDPAY_ERC20_DECIMALS)) + .toString(); + + if (targetRequiredSubsidyRaw.lte(0)) { + providerQuote = await createFixedInputQuote(baselineProviderInputRaw); + } else if (targetRequiredSubsidyRaw.lte(allowedSubsidyRaw)) { + providerQuote = targetQuote; + } else { + targetWasCapped = true; + providerQuote = await createFixedInputQuote(baselineProviderInputRaw.plus(allowedSubsidyRaw)); + } + } + } else { + providerQuote = await createFixedInputQuote(baselineProviderInputRaw); + } + if (!providerQuote) { + throw new Error("AlfredpayOfframp: Provider quote selection did not produce a quote"); + } + const expirationDate = new Date(providerQuote.expiration); + if (!hasSafeAlfredpayQuoteLifetime(providerQuote.expiration)) { + throw new Error( + `AlfredpayOfframp: Provider quote lifetime is too short for safe registration (${providerQuote.expiration})` + ); + } + + const providerInputAmountRaw = providerInputRaw(providerQuote.fromAmount); + const providerInput = providerInputAmountRaw.div(new Big(10).pow(ALFREDPAY_ERC20_DECIMALS)); + const requiredSubsidyRaw = providerInputAmountRaw.plus(feeReserveRaw).minus(bridgeOutputRaw); + const subsidyAmountRaw = requiredSubsidyRaw.gt(0) ? requiredSubsidyRaw : new Big(0); + if (subsidyAmountRaw.gt(allowedSubsidyRaw) && subsidyEnabled) { + throw new Error( + `AlfredpayOfframp: Provider input requires ${subsidyAmountRaw.toString()} subsidy raw units, above the quoted cap ${allowedSubsidyRaw.toString()}` + ); + } const outputAmount = new Big(providerQuote.toAmount); + if (subsidyEnabled && !targetWasCapped && outputAmount.lt(requestedTargetOutput)) { + throw new Error( + `AlfredpayOfframp: Selected provider quote returned ${outputAmount.toString()}, below the uncapped target ${requestedTargetOutput.toFixed(2)}` + ); + } + if (targetWasCapped && outputAmount.lt(requestedTargetOutput)) { + const capReason = providerLimitBoundCap + ? "provider" + : partnerCapRaw.eq(runtimeCapRaw) + ? "partner-and-runtime" + : partnerCapRaw.lt(runtimeCapRaw) + ? "partner" + : "runtime"; + logger.warn("ALFREDPAY_OFFRAMP_TARGET_DISCOUNT_CAPPED", { + adjustedTargetDiscount: adjustedTargetDiscount.toString(), + allowedSubsidyUsd: allowedSubsidyRaw.div(new Big(10).pow(ALFREDPAY_ERC20_DECIMALS)).toString(), + appliedSubsidyUsd: subsidyAmountRaw.div(new Big(10).pow(ALFREDPAY_ERC20_DECIMALS)).toString(), + capReason, + deliveredOutput: outputAmount.toString(), + fiatCurrency: ctx.request.outputCurrency, + inputAmountUsd: inputAmountUsd.toString(), + partnerId: partner?.id, + providerMaximumInput, + requestedTargetOutput: requestedTargetOutput.toString(), + requiredSubsidyUsd + }); + ctx.addNote( + `AlfredpayOfframp: target output ${requestedTargetOutput.toString()} capped to ${outputAmount.toString()} ${ctx.request.outputCurrency}` + ); + } const providerGrossRate = new Big(providerQuote.rate); const providerNetRate = outputAmount.div(providerInput); const customerAllInRate = outputAmount.div(inputAmountUsd); @@ -188,12 +342,11 @@ export function simulateAlfredpayOfframp ${outputAmount.toString()} ${ctx.request.outputCurrency}` ); @@ -204,9 +357,14 @@ export function simulateAlfredpayOfframp +): void { + if (new Big(freshMinimumRaw).gt(freshEstimateRaw)) { + throw new Error( + `Alfredpay offramp route minimum exceeds its estimate: estimate=${freshEstimateRaw}, minimum=${freshMinimumRaw}` + ); + } + const quotedMinimumRaw = new Big(getAlfredpayExecutableBridgeOutputRaw(ctx.ownMetadata, ctx.globals.fees.usd)); + if (new Big(freshMinimumRaw).lt(quotedMinimumRaw)) { + throw new Error( + `Alfredpay offramp route minimum drifted below the quote: expected at least ${quotedMinimumRaw.toFixed(0)}, fresh=${freshMinimumRaw}` + ); + } +} + function permitTypedData( domain: Awaited>, owner: string, @@ -169,6 +185,7 @@ export async function prepareAlfredpayOfframpTxs( toNetwork: Networks.Polygon, toToken: ALFREDPAY_ERC20_TOKEN }); + assertExecutableBridgeMinimum(bridge.route.estimate.toAmount, bridge.route.estimate.toAmountMin, ctx); const relayer = ALFREDPAY_RELAYER_ADDRESSES[fromNetwork]; if (!relayer) throw new Error(`Alfredpay offramp permit flow is not supported on ${fromNetwork}`); const payloadNonce = BigInt(Math.floor(now / 1000)); @@ -238,6 +255,7 @@ export async function prepareAlfredpayOfframpTxs( toNetwork: Networks.Polygon, toToken: ALFREDPAY_ERC20_TOKEN }); + assertExecutableBridgeMinimum(bridge.route.estimate.toAmount, bridge.route.estimate.toAmountMin, ctx); squidRouterPermitExecutionValue = bridge.swapData.value; intents.push( { @@ -263,13 +281,10 @@ export async function prepareAlfredpayOfframpTxs( toAddress: facts.depositAddress as `0x${string}`, toToken: ALFREDPAY_ERC20_TOKEN }); - // The fallback refunds the user's full bridged value: deposit plus charged - // vortex/partner fees MINUS any platform subsidy. bridgeOutputAmountRaw is exactly - // that — for undiscounted quotes it equals deposit + fees, while the net-rate - // deposit of a discounted quote additionally contains the platform subsidy, which - // must never be paid out to the user on a failed ramp. + // Size the fallback from the guaranteed bridge minimum, excluding the + // platform-funded subsidy that is not part of the user's principal. const fallbackTransfer = await createDestinationTransferTransaction({ - amountRaw: ctx.ownMetadata.bridgeOutputAmountRaw, + amountRaw: getAlfredpayExecutableBridgeOutputRaw(ctx.ownMetadata, ctx.globals.fees.usd), destinationNetwork: Networks.Polygon, toAddress: facts.walletAddress, toToken: ALFREDPAY_ERC20_TOKEN @@ -291,10 +306,11 @@ export async function prepareAlfredpayOfframpTxs( txData: fallbackTransfer } ); - const axlUsdc = evmTokenConfig[Networks.Polygon][EvmToken.AXLUSDC]?.erc20AddressSourceChain; - if (!axlUsdc) throw new Error("Invalid Polygon AXLUSDC configuration"); + // Squid may deliver above its guaranteed minimum. The provider deposit and + // fee transfers consume only the guaranteed quote obligations, so authorize + // post-processing to return any residual USDT to the user's source wallet. const cleanup = await preparePolygonCleanupApproval( - axlUsdc as `0x${string}`, + ALFREDPAY_ERC20_TOKEN, getEvmFundingAccount(Networks.Polygon).address, Networks.Polygon ); diff --git a/apps/api/src/test-utils/fake-world/fake-anchors.ts b/apps/api/src/test-utils/fake-world/fake-anchors.ts index 2c1329418..865065c2a 100644 --- a/apps/api/src/test-utils/fake-world/fake-anchors.ts +++ b/apps/api/src/test-utils/fake-world/fake-anchors.ts @@ -1,5 +1,6 @@ import { AlfredpayApiService, + AlfredpayChain, type AlfredpayFee, type AlfredpayFiatAccount, type AlfredpayFiatPaymentInstructions, @@ -11,6 +12,7 @@ import { type AlfredpayOnrampStatusMetadata, type AlfredpayOnrampTransaction, AlfredpayPaymentMethodType, + AlfredpayTradeLimitError, AveniaTicketStatus, BrlaApiService, type CreateAlfredpayOfframpQuoteRequest, @@ -31,6 +33,7 @@ import { MykoboTransactionStatus, MykoboTransactionType } from "@vortexfi/shared"; +import Big from "big.js"; function unimplementedProxy(impl: object, label: string): T { return new Proxy(impl, { @@ -275,12 +278,33 @@ export class FakeAlfredpay { readonly transactions = new Map(); /** toAmount = fromAmount * offrampRate for offramp quotes. */ offrampRate = 1; + /** Optional provider-side maximum input used to exercise capped quote boundaries. */ + offrampMaxFromAmount: string | null = null; + /** Optional hook for deterministic provider-latency/clock tests. */ + onCreateOfframpQuote?: () => void; + /** One-shot quote output adjustment for registration drift/retry tests. */ + offrampQuoteToAmountAdjustmentOnce: string | null = null; + /** Optional one-shot quote expiration for quote-lifetime tests. */ + nextOfframpQuoteExpiration: string | null = null; + /** Optional one-shot quote chain for recovery-boundary tests. */ + nextOfframpQuoteChain: AlfredpayChain | null = null; + /** Optional one-shot transaction id for provider-length boundary tests. */ + nextOfframpTransactionId: string | null = null; + /** Optional one-shot order expiration for recovery-boundary tests. */ + nextOfframpExpiration: string | null = null; + /** Optional one-shot lifecycle status returned by createOfframp. */ + nextOfframpOrderStatus: AlfredpayOfframpStatus | null = null; + /** Optional one-shot lifecycle status returned when that new order is re-read. */ + nextOfframpRereadStatus: AlfredpayOfframpStatus | null = null; /** Status reported for every order by getOfframpTransaction. */ - offrampStatus: AlfredpayOfframpStatus = AlfredpayOfframpStatus.FIAT_TRANSFER_COMPLETED; + offrampStatus: AlfredpayOfframpStatus = AlfredpayOfframpStatus.CREATED; /** Deposit address handed out for every offramp order. */ offrampDepositAddress = "0x5afe00000000000000000000000000000000d0e5"; readonly offrampOrders: CreateAlfredpayOfframpRequest[] = []; + readonly issuedOfframpQuotes = new Map(); readonly offrampTransactions = new Map(); + /** Per-order lifecycle overrides; persistent to model monotonic provider state. */ + readonly offrampStatusOverrides = new Map(); /** Accounts served by listFiatAccounts, keyed by Alfredpay customer id. */ readonly fiatAccountsByCustomer = new Map(); private counter = 0; @@ -349,56 +373,90 @@ export class FakeAlfredpay { } private offrampQuote(request: CreateAlfredpayOfframpQuoteRequest): AlfredpayOfframpQuote { - const fromAmount = request.fromAmount ?? "0"; - return { - chain: request.chain, - expiration: new Date(Date.now() + 5 * 60_000).toISOString(), + const fee = AlfredpayApiService.sumFeesByCurrency(this.quoteFees, request.toCurrency); + const fromAmount = request.fromAmount + ? new Big(request.fromAmount) + : new Big(request.toAmount ?? "0").plus(fee).div(this.offrampRate).round(6, Big.roundUp); + if (this.offrampMaxFromAmount && fromAmount.gt(this.offrampMaxFromAmount)) { + throw AlfredpayTradeLimitError.above(this.offrampMaxFromAmount, request.fromCurrency); + } + let toAmount = fromAmount.mul(this.offrampRate).minus(fee); + if (this.offrampQuoteToAmountAdjustmentOnce !== null) { + toAmount = toAmount.plus(this.offrampQuoteToAmountAdjustmentOnce); + this.offrampQuoteToAmountAdjustmentOnce = null; + } + const expiration = this.nextOfframpQuoteExpiration ?? new Date(Date.now() + 5 * 60_000).toISOString(); + this.nextOfframpQuoteExpiration = null; + const quote = { + chain: this.nextOfframpQuoteChain ?? request.chain, + expiration, fees: [...this.quoteFees], - fromAmount, + fromAmount: fromAmount.toString(), fromCurrency: request.fromCurrency, metadata: {}, paymentMethodType: request.paymentMethodType, quoteId: `alfredpay-offramp-quote-${++this.counter}`, rate: this.offrampRate.toString(), - toAmount: (Number(fromAmount) * this.offrampRate).toString(), + toAmount: toAmount.toString(), toCurrency: request.toCurrency }; + this.nextOfframpQuoteChain = null; + this.issuedOfframpQuotes.set(quote.quoteId, quote); + return quote; } private readonly impl = { createOfframp: async (request: CreateAlfredpayOfframpRequest): Promise => { + const quote = this.issuedOfframpQuotes.get(request.quoteId); + if (!quote) { + throw new Error(`FakeAlfredpay: unknown offramp quote ${request.quoteId}`); + } + if ( + Date.parse(quote.expiration) <= Date.now() || + quote.chain !== request.chain || + quote.fromCurrency !== request.fromCurrency || + quote.toCurrency !== request.toCurrency || + !new Big(quote.fromAmount).eq(request.amount) + ) { + throw new Error(`FakeAlfredpay: offramp order request does not match quote ${request.quoteId}`); + } this.offrampOrders.push(request); - const transactionId = `alfredpay-offramp-${++this.counter}`; + const transactionId = this.nextOfframpTransactionId ?? `alfredpay-offramp-${++this.counter}`; + this.nextOfframpTransactionId = null; + const expiration = this.nextOfframpExpiration ?? new Date(Date.now() + 30 * 60_000).toISOString(); + this.nextOfframpExpiration = null; + const status = this.nextOfframpOrderStatus ?? AlfredpayOfframpStatus.CREATED; + this.nextOfframpOrderStatus = null; const now = new Date().toISOString(); const transaction: AlfredpayOfframpTransaction = { chain: request.chain, createdAt: now, customerId: request.customerId, depositAddress: this.offrampDepositAddress, - expiration: new Date(Date.now() + 30 * 60_000).toISOString(), + expiration, fiatAccountId: request.fiatAccountId, fromAmount: request.amount, fromCurrency: request.fromCurrency, memo: request.memo, - quote: this.offrampQuote({ - fromAmount: request.amount, - fromCurrency: request.fromCurrency, - metadata: { businessId: "vortex", customerId: request.customerId }, - paymentMethodType: AlfredpayPaymentMethodType.BANK, - toCurrency: request.toCurrency - }), + quote, quoteId: request.quoteId, - status: AlfredpayOfframpStatus.CREATED, - toAmount: (Number(request.amount) * this.offrampRate).toString(), + status, + toAmount: quote.toAmount, toCurrency: request.toCurrency, transactionId, updatedAt: now }; this.offrampTransactions.set(transactionId, transaction); + if (this.nextOfframpRereadStatus !== null) { + this.offrampStatusOverrides.set(transactionId, this.nextOfframpRereadStatus); + this.nextOfframpRereadStatus = null; + } return transaction; }, - createOfframpQuote: async (request: CreateAlfredpayOfframpQuoteRequest): Promise => - this.offrampQuote(request), + createOfframpQuote: async (request: CreateAlfredpayOfframpQuoteRequest): Promise => { + this.onCreateOfframpQuote?.(); + return this.offrampQuote(request); + }, createOnramp: async (request: CreateAlfredpayOnrampRequest): Promise => { this.onrampOrders.push(request); const transactionId = `alfredpay-onramp-${++this.counter}`; @@ -441,7 +499,7 @@ export class FakeAlfredpay { if (!transaction) { throw new Error(`FakeAlfredpay: unknown offramp transaction ${transactionId}`); } - return { ...transaction, status: this.offrampStatus }; + return { ...transaction, status: this.offrampStatusOverrides.get(transactionId) ?? this.offrampStatus }; }, getOnrampTransaction: async (transactionId: string): Promise => { const transaction = this.transactions.get(transactionId); diff --git a/apps/api/src/test-utils/fake-world/fake-squidrouter.ts b/apps/api/src/test-utils/fake-world/fake-squidrouter.ts index 06cb4acbc..cb2563b9d 100644 --- a/apps/api/src/test-utils/fake-world/fake-squidrouter.ts +++ b/apps/api/src/test-utils/fake-world/fake-squidrouter.ts @@ -21,6 +21,8 @@ export class FakeSquidRouter { transactionGasLimit = "500000"; /** Raw destination amount for a requested route. Default: 1:1 with the input. */ computeToAmount: (params: RouteParams) => string = params => params.fromAmount; + /** Guaranteed raw destination amount. Default: the estimated amount. */ + computeToAmountMin: (params: RouteParams) => string = params => this.computeToAmount(params); toTokenDecimals = 18; failNextRoute: Error | null = null; readonly requestedRoutes: RouteParams[] = []; @@ -39,6 +41,7 @@ export class FakeSquidRouter { route: { estimate: { toAmount: this.computeToAmount(params), + toAmountMin: this.computeToAmountMin(params), toToken: { decimals: this.toTokenDecimals } }, quoteId: "fake-squid-quote", diff --git a/apps/api/src/tests/contracts/alfredpay.contract.test.ts b/apps/api/src/tests/contracts/alfredpay.contract.test.ts index 766db0a30..dcbabeb6a 100644 --- a/apps/api/src/tests/contracts/alfredpay.contract.test.ts +++ b/apps/api/src/tests/contracts/alfredpay.contract.test.ts @@ -26,6 +26,7 @@ * RUN_LIVE_TESTS=1 ALFREDPAY_CONTRACT_RUN_KYB_FLOW=1 bun test alfredpay.contract */ import { describe, expect, test } from "bun:test"; +import Big from "big.js"; import { AlfredpayApiService, AlfredpayChain, @@ -198,6 +199,7 @@ function onrampQuoteRequest(fromAmount: string): CreateAlfredpayOnrampQuoteReque describe("Alfredpay external API contract — hermetic (fake)", () => { function seededFake() { const fake = new FakeAlfredpay(); + fake.offrampRate = 17; fake.quoteFees = [{ amount: "12.50", currency: "MXN", type: AlfredpayFeeType.PROCESSING_FEE }]; return fake; } @@ -216,6 +218,18 @@ describe("Alfredpay external API contract — hermetic (fake)", () => { toCurrency: AlfredpayFiatCurrency.MXN }); expect(() => alfredpayQuoteResponseSchema.parse(offrampQuote)).not.toThrow(); + expect(offrampQuote.toAmount).toBe("497.5"); + + const exactOutputQuote = await api.createOfframpQuote({ + chain: AlfredpayChain.MATIC, + fromCurrency: AlfredpayOnChainCurrency.USDC, + metadata: QUOTE_METADATA, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + toAmount: "497.5", + toCurrency: AlfredpayFiatCurrency.MXN + }); + expect(exactOutputQuote.fromAmount).toBe("30"); + expect(exactOutputQuote.toAmount).toBe("497.5"); }); test("fake onramp order and transaction polling satisfy their contracts", async () => { @@ -239,22 +253,62 @@ describe("Alfredpay external API contract — hermetic (fake)", () => { test("fake offramp order and transaction polling satisfy their contracts", async () => { const api = seededFake().asService(); + const quote = await api.createOfframpQuote({ + chain: AlfredpayChain.MATIC, + fromAmount: "30", + fromCurrency: AlfredpayOnChainCurrency.USDC, + metadata: QUOTE_METADATA, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + toCurrency: AlfredpayFiatCurrency.MXN + }); const order = await api.createOfframp({ - amount: "30", + amount: quote.fromAmount, chain: AlfredpayChain.MATIC, customerId: "cust-1", fiatAccountId: "fa-1", fromCurrency: AlfredpayOnChainCurrency.USDC, originAddress: TEST_ADDRESS, - quoteId: "quote-1", + quoteId: quote.quoteId, toCurrency: AlfredpayFiatCurrency.MXN }); expect(() => alfredpayOfframpTransactionSchema.parse(order)).not.toThrow(); + expect(order).toMatchObject({ + fromAmount: quote.fromAmount, + fromCurrency: quote.fromCurrency, + quoteId: quote.quoteId, + toAmount: quote.toAmount, + toCurrency: quote.toCurrency + }); const transaction = await api.getOfframpTransaction(order.transactionId); expect(() => alfredpayOfframpTransactionSchema.parse(transaction)).not.toThrow(); }); + test("fake offramp orders stay bound to an issued matching quote", async () => { + const api = seededFake().asService(); + const quote = await api.createOfframpQuote({ + chain: AlfredpayChain.MATIC, + fromAmount: "30", + fromCurrency: AlfredpayOnChainCurrency.USDC, + metadata: QUOTE_METADATA, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + toCurrency: AlfredpayFiatCurrency.MXN + }); + const request = { + amount: quote.fromAmount, + chain: AlfredpayChain.MATIC, + customerId: "cust-1", + fiatAccountId: "fa-1", + fromCurrency: AlfredpayOnChainCurrency.USDC, + originAddress: TEST_ADDRESS, + quoteId: quote.quoteId, + toCurrency: AlfredpayFiatCurrency.MXN + }; + + await expect(api.createOfframp({ ...request, quoteId: "unknown-quote" })).rejects.toThrow("unknown offramp quote"); + await expect(api.createOfframp({ ...request, amount: "31" })).rejects.toThrow("does not match quote"); + }); + test("fake fiat account listing satisfies the contract", async () => { const fake = seededFake(); fake.fiatAccountsByCustomer.set("cust-1", [ @@ -344,6 +398,43 @@ describe.skipIf(!RUN_LIVE || !HAS_CREDS)("Alfredpay external API contract — li }) ); if (offrampQuote) alfredpayQuoteResponseSchema.parse(offrampQuote); + + const exactOutputQuote = await runLive("alfredpay createOfframpQuote by output", () => + api().createOfframpQuote({ + chain: AlfredpayChain.MATIC, + fromCurrency: AlfredpayOnChainCurrency.USDT, + metadata: QUOTE_METADATA, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + toAmount: "500", + toCurrency: AlfredpayFiatCurrency.MXN + }) + ); + if (exactOutputQuote) { + alfredpayQuoteResponseSchema.parse(exactOutputQuote); + expect(Number(exactOutputQuote.fromAmount)).toBeGreaterThan(0); + expect(new Big(exactOutputQuote.toAmount).gte(500)).toBe(true); + const roundTripQuote = await runLive("alfredpay exact-output fixed-input round trip", () => + api().createOfframpQuote({ + chain: AlfredpayChain.MATIC, + fromAmount: exactOutputQuote.fromAmount, + fromCurrency: AlfredpayOnChainCurrency.USDT, + metadata: QUOTE_METADATA, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + toCurrency: AlfredpayFiatCurrency.MXN + }) + ); + if (roundTripQuote) { + expect(roundTripQuote.fromCurrency).toBe(exactOutputQuote.fromCurrency); + expect(roundTripQuote.toCurrency).toBe(exactOutputQuote.toCurrency); + expect(new Big(roundTripQuote.fromAmount).eq(exactOutputQuote.fromAmount)).toBe(true); + expect(new Big(roundTripQuote.toAmount).eq(exactOutputQuote.toAmount)).toBe(true); + expect( + AlfredpayApiService.sumFeesByCurrency(roundTripQuote.fees, AlfredpayFiatCurrency.MXN).eq( + AlfredpayApiService.sumFeesByCurrency(exactOutputQuote.fees, AlfredpayFiatCurrency.MXN) + ) + ).toBe(true); + } + } }, 60_000 ); @@ -371,6 +462,33 @@ describe.skipIf(!RUN_LIVE || !HAS_CREDS)("Alfredpay external API contract — li 60_000 ); + test( + "an absurd exact-output offramp pins the provider maximum-input error contract", + async () => { + const limitError = await runLive("alfredpay exact-output offramp limit breach", async () => { + try { + await api().createOfframpQuote({ + chain: AlfredpayChain.MATIC, + fromCurrency: AlfredpayOnChainCurrency.USDT, + metadata: QUOTE_METADATA, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + toAmount: "999999999999", + toCurrency: AlfredpayFiatCurrency.MXN + }); + return null; + } catch (error) { + if (error instanceof AlfredpayTradeLimitError) return error; + throw error; + } + }); + if (!limitError) return; + expect(limitError.kind).toBe("above"); + expect(limitError.fromCurrency).toBe(AlfredpayOnChainCurrency.USDT); + expect(limitError.quantity).toMatch(/^\d+(\.\d+)?$/); + }, + 60_000 + ); + test.skipIf(!CUSTOMER_ID)( "GET /fiatAccounts response satisfies the fiat accounts contract", async () => { @@ -465,10 +583,24 @@ describe.skipIf(!RUN_LIVE || !HAS_CREDS)("Alfredpay external API contract — li ); if (!order) return; alfredpayOfframpTransactionSchema.parse(order); + expect(order).toMatchObject({ + fromAmount: quote.fromAmount, + fromCurrency: quote.fromCurrency, + quoteId: quote.quoteId, + toAmount: quote.toAmount, + toCurrency: quote.toCurrency + }); const transaction = await runLive("alfredpay getOfframpTransaction", () => api().getOfframpTransaction(order.transactionId)); if (!transaction) return; alfredpayOfframpTransactionSchema.parse(transaction); + expect(transaction).toMatchObject({ + fromAmount: quote.fromAmount, + fromCurrency: quote.fromCurrency, + quoteId: quote.quoteId, + toAmount: quote.toAmount, + toCurrency: quote.toCurrency + }); }, 120_000 ); diff --git a/apps/api/src/tests/corridors/alfredpay-currencies.scenario.test.ts b/apps/api/src/tests/corridors/alfredpay-currencies.scenario.test.ts index cfaab204d..98a2e3ed3 100644 --- a/apps/api/src/tests/corridors/alfredpay-currencies.scenario.test.ts +++ b/apps/api/src/tests/corridors/alfredpay-currencies.scenario.test.ts @@ -186,7 +186,7 @@ describe("Alfredpay currency corridors (USD/COP/ARS, on- and offramp)", () => { world.alfredpay.onCreateOnramp = undefined; world.alfredpay.onrampStatus = AlfredpayOnrampStatus.TRADE_COMPLETED; world.alfredpay.onrampStatusMetadata = null; - world.alfredpay.offrampStatus = AlfredpayOfframpStatus.FIAT_TRANSFER_COMPLETED; + world.alfredpay.offrampStatus = AlfredpayOfframpStatus.CREATED; world.alfredpay.offrampDepositAddress = privateKeyToAccount(generatePrivateKey()).address.toLowerCase(); // Both direct and cross-chain Alfredpay SELL simulations price a // Squid-delivered Polygon USDT settlement leg. @@ -214,6 +214,9 @@ describe("Alfredpay currency corridors (USD/COP/ARS, on- and offramp)", () => { recipient, world.evm.erc20Balance(tx.network, parsed.to, recipient) + amount ); + if (recipient.toLowerCase() === world.alfredpay.offrampDepositAddress.toLowerCase()) { + world.alfredpay.offrampStatus = AlfredpayOfframpStatus.FIAT_TRANSFER_COMPLETED; + } }; } diff --git a/apps/api/src/tests/sdk-contract.alfredpay-offramp.test.ts b/apps/api/src/tests/sdk-contract.alfredpay-offramp.test.ts index 62f1ee2c5..b183efa0e 100644 --- a/apps/api/src/tests/sdk-contract.alfredpay-offramp.test.ts +++ b/apps/api/src/tests/sdk-contract.alfredpay-offramp.test.ts @@ -175,7 +175,7 @@ describe("SDK ↔ API contract (Alfredpay offramps, USDT on Polygon → bank pay await resetTestDatabase(); world.evm.failNextSends = 0; world.evm.onTransaction = undefined; - world.alfredpay.offrampStatus = AlfredpayOfframpStatus.FIAT_TRANSFER_COMPLETED; + world.alfredpay.offrampStatus = AlfredpayOfframpStatus.CREATED; // The quote simulator asks Squid for the USDT settlement leg even on the // direct Polygon corridor. Squid therefore reports 6-decimal output. world.squidRouter.toTokenDecimals = ALFREDPAY_ERC20_DECIMALS; @@ -275,6 +275,9 @@ describe("SDK ↔ API contract (Alfredpay offramps, USDT on Polygon → bank pay recipient, world.evm.erc20Balance(tx.network, parsed.to, recipient) + amount ); + if (recipient.toLowerCase() === world.alfredpay.offrampDepositAddress.toLowerCase()) { + world.alfredpay.offrampStatus = AlfredpayOfframpStatus.FIAT_TRANSFER_COMPLETED; + } }; return { inputAmountRaw }; } From ac6b24b37479b272caa5cfdbbda120ebf9d70614 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 11 Aug 2026 20:11:44 +0200 Subject: [PATCH 3/9] fix(api): harden AlfredPay sell settlement execution --- .../blocks/core/financial-operation.test.ts | 53 +++ .../phases/blocks/core/financial-operation.ts | 25 +- .../phases/alfredpay-offramp/execution.ts | 415 ++++++++++++++---- .../final-settlement-subsidy/execution.ts | 313 +++++++------ .../polygon-post-process-handler.ts | 24 +- 5 files changed, 617 insertions(+), 213 deletions(-) diff --git a/apps/api/src/api/services/phases/blocks/core/financial-operation.test.ts b/apps/api/src/api/services/phases/blocks/core/financial-operation.test.ts index 547b47e8b..0f4c11a25 100644 --- a/apps/api/src/api/services/phases/blocks/core/financial-operation.test.ts +++ b/apps/api/src/api/services/phases/blocks/core/financial-operation.test.ts @@ -109,6 +109,28 @@ describe("runFinancialOperation", () => { expect(perform).toHaveBeenCalledTimes(1); }); + it("allows a not-started operation to adopt a stable corrected request", async () => { + const beforePerform = mock(async () => { + throw new Error("preflight unavailable"); + }); + const perform = mock(async () => ({ id: "funding-1" })); + + await expect(runFinancialOperation({ ...baseOperation, beforePerform, perform })).rejects.toThrow( + "preflight unavailable" + ); + expect(await FinancialOperation.findOne()).toMatchObject({ status: "not_started" }); + + const result = await runFinancialOperation({ + ...baseOperation, + perform, + request: { acquisitionCapUsd: "11", destination: "recipient-1" } + }); + + expect(result).toEqual({ id: "funding-1" }); + expect(perform).toHaveBeenCalledTimes(1); + expect(await FinancialOperation.findOne()).toMatchObject({ status: "confirmed" }); + }); + it("halts retries after an ambiguous provider failure", async () => { const perform = mock(async () => { throw new Error("connection reset after submission"); @@ -138,6 +160,37 @@ describe("runFinancialOperation", () => { ).rejects.toThrow("different inputs"); }); + it("replays a confirmed legacy request shape when compatibility is explicit", async () => { + const perform = mock(async () => ({ id: "external-1" })); + const first = await runFinancialOperation({ ...baseOperation, perform }); + const replayed = await runFinancialOperation({ + ...baseOperation, + allowExistingRequestMismatch: true, + perform, + request: { acquisitionCapUsd: "11", destination: "recipient-1" } + }); + + expect(replayed).toEqual(first); + expect(perform).toHaveBeenCalledTimes(1); + }); + + it("surfaces reconciliation for an ambiguous legacy request shape", async () => { + const perform = mock(async () => { + throw new Error("receipt timeout"); + }); + await expect(runFinancialOperation({ ...baseOperation, perform })).rejects.toThrow("receipt timeout"); + + await expect( + runFinancialOperation({ + ...baseOperation, + allowExistingRequestMismatch: true, + perform, + request: { acquisitionCapUsd: "11", destination: "recipient-1" } + }) + ).rejects.toThrow("requires reconciliation"); + expect(perform).toHaveBeenCalledTimes(1); + }); + it("allows corrected input after a definitive rejection without a side effect", async () => { const rejected = new FinancialOperationRejectedError("invalid recipient"); await expect( diff --git a/apps/api/src/api/services/phases/blocks/core/financial-operation.ts b/apps/api/src/api/services/phases/blocks/core/financial-operation.ts index ef4f8647a..06827fe0a 100644 --- a/apps/api/src/api/services/phases/blocks/core/financial-operation.ts +++ b/apps/api/src/api/services/phases/blocks/core/financial-operation.ts @@ -14,6 +14,13 @@ export interface RunFinancialOperationArgs { attemptClass: string; provider: string; request: unknown; + /** + * Recovery-only compatibility for an existing durable operation whose prior + * request shape changed across a deployment. The original claim remains + * authoritative: confirmed replays and ambiguous outcomes are handled by + * status, while only not_started rows may adopt the new request hash. + */ + allowExistingRequestMismatch?: boolean; retryFailed?: boolean; signal?: AbortSignal; /** Runs only after replay/reconciliation is exhausted and immediately before claiming a new side effect. */ @@ -78,6 +85,7 @@ export async function runFinancialOperation({ attemptClass, provider, request, + allowExistingRequestMismatch = false, beforePerform, perform, reconcile, @@ -111,11 +119,18 @@ export async function runFinancialOperation({ where: { operationKey } }); - if (operation.requestHash !== requestHash && !(operation.status === "failed" && retryFailed)) { - throw new APIError({ - message: `Financial operation ${operation.id} was already claimed with different inputs`, - status: httpStatus.CONFLICT - }); + if (operation.requestHash !== requestHash) { + if (operation.status === "not_started") { + // No financial side effect has been claimed yet. Refreshing a preflight's + // request is safe and lets legacy/live observations converge on the stable + // authorization used by the current executor. + await operation.update({ requestHash }); + } else if (!(operation.status === "failed" && retryFailed) && !allowExistingRequestMismatch) { + throw new APIError({ + message: `Financial operation ${operation.id} was already claimed with different inputs`, + status: httpStatus.CONFLICT + }); + } } if (!created) { if (operation.status === "confirmed" && operation.response !== null) { diff --git a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/execution.ts b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/execution.ts index 30feecbbe..f0d8ef909 100644 --- a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/execution.ts +++ b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/execution.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { ALFREDPAY_ONCHAIN_CURRENCY, AlfredpayApiService, @@ -15,11 +16,14 @@ import { SignedTypedData, sleep } from "@vortexfi/shared"; +import Big from "big.js"; import { erc20Abi, keccak256 } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import logger from "../../../../../../config/logger"; import { config } from "../../../../../../config/vars"; import { tokenRelayerAbi } from "../../../../../../contracts/TokenRelayer"; +import FinancialOperation from "../../../../../../models/financialOperation.model"; +import QuoteTicket from "../../../../../../models/quoteTicket.model"; import RampState from "../../../../../../models/rampState.model"; import { PhaseError } from "../../../../../errors/phase-error"; import { BasePhaseHandler } from "../../../../phases/base-phase-handler"; @@ -28,10 +32,17 @@ import { StateMetadata } from "../../../../phases/meta-state-types"; import { abortableCall, throwIfAborted } from "../../core/cancellation"; import { ensurePresignedTransferFunded } from "../../core/destination-funding"; import { FinancialOperationRejectedError } from "../../core/financial-operation"; +import { getBlockMetadata } from "../../core/metadata"; import { getAnchorPayoutMaxRetries, isAnchorMockingEnabled } from "../anchor-test-mode"; import { FinalSettlementSubsidyExecutor } from "../final-settlement-subsidy/execution"; import { FundEphemeralExecutor } from "../fund-ephemeral/execution"; import { getAlfredpayRelayerAddress } from "./permit"; +import { + AlfredpayOfframpContext, + type AlfredpayOfframpMetadata, + hasSafeAlfredpayExecutionLifetime, + hasSafeAlfredpayQuoteLifetime +} from "./simulation"; type VrsSignature = { v: number; r: `0x${string}`; s: `0x${string}` }; @@ -378,6 +389,46 @@ function getErrorName(error: unknown): string | undefined { return error && typeof error === "object" && "name" in error ? String(error.name) : undefined; } +type AlfredpayOfframpTerms = Pick & { + chain: typeof AlfredpayChain.MATIC; + customerId: string; + depositAddress: string; + fiatAccountId: string; +}; + +function recoveryAttemptClass(transactionId: string): string { + const suffix = createHash("sha256").update(transactionId).digest("hex").slice(0, 32); + return `alfredpay-recovery:${suffix}`; +} + +function matchesImmutableOfframpIdentity( + transaction: Awaited>, + promised: AlfredpayOfframpTerms, + expectedTransactionId?: string +): boolean { + return ( + (expectedTransactionId === undefined || transaction.transactionId === expectedTransactionId) && + transaction.chain === promised.chain && + transaction.customerId === promised.customerId && + transaction.fiatAccountId === promised.fiatAccountId && + transaction.fromCurrency === ALFREDPAY_ONCHAIN_CURRENCY && + transaction.toCurrency === promised.currency && + transaction.depositAddress.toLowerCase() === promised.depositAddress.toLowerCase() + ); +} + +function matchesPromisedOfframpTerms( + transaction: Awaited>, + promised: AlfredpayOfframpTerms, + expectedTransactionId?: string +): boolean { + return ( + matchesImmutableOfframpIdentity(transaction, promised, expectedTransactionId) && + new Big(transaction.fromAmount).eq(promised.inputAmountDecimal as unknown as string) && + new Big(transaction.toAmount).gte(promised.outputAmountDecimal as unknown as string) + ); +} + export class AlfredpayOfframpTransferExecutor extends BasePhaseHandler { public getPhaseName(): RampPhase { return "alfredpayOfframpTransfer"; @@ -395,69 +446,161 @@ export class AlfredpayOfframpTransferExecutor extends BasePhaseHandler { const { alfredpayTransactionId, alfredpayOfframpTransferTxHash } = state.state as StateMetadata; if (!alfredpayTransactionId) throw new Error("AlfredpayOfframpTransferExecutor: Missing alfredpayTransactionId in state."); - const alfredpayApiService = AlfredpayApiService.getInstance(); - const evmClientManager = EvmClientManager.getInstance(); - let alfredpayTx = await abortableCall(signal, () => alfredpayApiService.getOfframpTransaction(alfredpayTransactionId)); - if (!alfredpayTx) { - throw new Error(`AlfredpayOfframpTransferExecutor: Transaction ${alfredpayTransactionId} not found in Alfredpay.`); + const quote = await QuoteTicket.findByPk(state.quoteId); + if (!quote) throw new Error("AlfredpayOfframpTransferExecutor: Quote not found"); + const promisedMetadata = getBlockMetadata(quote.metadata, AlfredpayOfframpContext); + const blockFacts = state.state.blockState?.[AlfredpayOfframpContext.key] as + | { alfredpayUserId?: string; depositAddress?: string; fiatAccountId?: string } + | undefined; + const immutableDepositAddress = + blockFacts?.depositAddress ?? (state.state as StateMetadata & { depositAddress?: string }).depositAddress; + if (!immutableDepositAddress) { + throw new Error("AlfredpayOfframpTransferExecutor: Missing immutable provider deposit address"); } - - if (!alfredpayOfframpTransferTxHash && new Date(alfredpayTx.expiration) < new Date()) { - const recovered = await this.recreateAlfredpayOfframp(state, alfredpayTx, signal); - if (!recovered) return this.transitionToNextPhase(state, "failed"); - alfredpayTx = recovered.alfredpayTx; - state = recovered.state; + const alfredpayUserId = blockFacts?.alfredpayUserId ?? (state.state as StateMetadata).alfredpayUserId; + const fiatAccountId = blockFacts?.fiatAccountId ?? (state.state as StateMetadata).fiatAccountId; + if (!alfredpayUserId || !fiatAccountId) { + throw new Error("AlfredpayOfframpTransferExecutor: Missing immutable provider customer/account identity"); } + const promised: AlfredpayOfframpTerms = { + ...promisedMetadata, + chain: AlfredpayChain.MATIC, + customerId: alfredpayUserId, + depositAddress: immutableDepositAddress, + fiatAccountId + }; + const alfredpayApiService = AlfredpayApiService.getInstance(); + const evmClientManager = EvmClientManager.getInstance(); if (!alfredpayOfframpTransferTxHash) { const { txData: offrampTransfer } = this.getPresignedTransaction(state, "alfredpayOfframpTransfer"); - try { - await ensurePresignedTransferFunded( - offrampTransfer as `0x${string}`, - Networks.Polygon as EvmNetworks, - this.getPhaseName(), - signal - ); - } catch (error) { - if (error instanceof PhaseError) throw error; - throw this.createRecoverableError( - `AlfredpayOfframpTransferExecutor: ephemeral balance does not cover the presigned final transfer: ${error instanceof Error ? error.message : String(error)}` - ); - } const network = Networks.Polygon as EvmNetworks; const signedTransaction = offrampTransfer as `0x${string}`; const deterministicHash = keccak256(signedTransaction); const networkClient = evmClientManager.getClient(network); - const { hash: txHash } = await this.runFinancialOperation(state, { - attemptClass: "alfredpay-final-transfer", - externalId: result => result.hash, - perform: async () => { - throwIfAborted(signal); - const hash = await abortableCall(signal, () => - evmClientManager.sendRawTransactionWithRetry(network, signedTransaction) - ); - return { hash }; - }, - provider: "polygon", - reconcile: async () => { - try { - const receipt = await abortableCall(signal, () => networkClient.getTransactionReceipt({ hash: deterministicHash })); - if (receipt.status !== "success") { - throw new FinancialOperationRejectedError(`Alfredpay final transfer ${deterministicHash} failed`); + try { + const { hash: txHash } = await this.runFinancialOperation(state, { + attemptClass: "alfredpay-final-transfer", + beforePerform: async () => { + const currentTransactionId = state.state.alfredpayTransactionId as string; + const recoveryOperation = await FinancialOperation.findOne({ + where: { + attemptClass: recoveryAttemptClass(currentTransactionId), + phase: this.getPhaseName(), + provider: "alfredpay", + scopeId: state.id, + scopeType: "ramp" + } + }); + let currentTx: Awaited>; + if (recoveryOperation && ["submitted", "unknown", "confirmed"].includes(recoveryOperation.status)) { + const recovered = await this.recreateAlfredpayOfframp(state, currentTransactionId, promised, signal); + if (!recovered) { + throw this.createRecoverableError( + "AlfredpayOfframpTransferExecutor: persisted replacement order could not be replayed; pausing before transfer" + ); + } + currentTx = recovered.alfredpayTx; + state = recovered.state; + } else { + currentTx = await abortableCall(signal, () => alfredpayApiService.getOfframpTransaction(currentTransactionId)); + if (!currentTx) { + throw this.createRecoverableError( + `AlfredpayOfframpTransferExecutor: Transaction ${currentTransactionId} not found in Alfredpay.` + ); + } + if (currentTx.transactionId !== currentTransactionId) { + throw this.createRecoverableError( + "AlfredpayOfframpTransferExecutor: provider returned a different transaction; pausing before transfer" + ); + } + if (currentTx.status === AlfredpayOfframpStatus.FAILED) { + throw { failureReason: "Alfredpay reported FAILED status before transfer", kind: "failed" as const }; + } + if (currentTx.status !== AlfredpayOfframpStatus.CREATED) { + throw this.createReconciliationRequiredError( + `AlfredpayOfframpTransferExecutor: provider order ${currentTransactionId} is already ${currentTx.status} without a confirmed local transfer` + ); + } + if (!hasSafeAlfredpayExecutionLifetime(currentTx.expiration)) { + const recovered = await this.recreateAlfredpayOfframp(state, currentTransactionId, promised, signal); + if (!recovered) { + throw this.createRecoverableError( + "AlfredpayOfframpTransferExecutor: no replacement order can preserve the promised payout; pausing before transfer" + ); + } + currentTx = recovered.alfredpayTx; + state = recovered.state; + } else if (!matchesImmutableOfframpIdentity(currentTx, promised, currentTransactionId)) { + throw this.createRecoverableError( + "AlfredpayOfframpTransferExecutor: provider order identity drifted; pausing before transfer" + ); + } else if (!matchesPromisedOfframpTerms(currentTx, promised, currentTransactionId)) { + logger.error("ALFREDPAY_OFFRAMP_ORDER_TERMS_REJECTED", { + promisedFromAmount: new Big(promised.inputAmountDecimal as unknown as string).toString(), + promisedToAmount: new Big(promised.outputAmountDecimal as unknown as string).toString(), + transactionFromAmount: currentTx.fromAmount, + transactionId: currentTx.transactionId, + transactionToAmount: currentTx.toAmount + }); + throw this.createRecoverableError( + "AlfredpayOfframpTransferExecutor: provider order no longer matches the promised payout; pausing before transfer" + ); + } + } + if (!hasSafeAlfredpayExecutionLifetime(currentTx.expiration)) { + throw this.createRecoverableError( + "AlfredpayOfframpTransferExecutor: replacement order has insufficient lifetime; pausing before transfer" + ); } - await abortableCall(signal, () => networkClient.getTransaction({ hash: deterministicHash })); - return { hash: deterministicHash }; - } catch (error) { + if (currentTx.status !== AlfredpayOfframpStatus.CREATED) { + throw this.createReconciliationRequiredError( + `AlfredpayOfframpTransferExecutor: replacement order ${currentTx.transactionId} is already ${currentTx.status}` + ); + } + try { + await ensurePresignedTransferFunded(signedTransaction, network, this.getPhaseName(), signal); + } catch (error) { + if (error instanceof PhaseError) throw error; + throw this.createRecoverableError( + `AlfredpayOfframpTransferExecutor: ephemeral balance does not cover the presigned final transfer: ${error instanceof Error ? error.message : String(error)}` + ); + } + }, + externalId: result => result.hash, + perform: async () => { throwIfAborted(signal); - if (error instanceof FinancialOperationRejectedError) throw error; - return null; - } - }, - request: { network, signedTransaction }, - signal - }); - await state.update({ state: { ...state.state, alfredpayOfframpTransferTxHash: txHash } }); - logger.info(`AlfredpayOfframpTransferExecutor: Final transfer sent. Hash: ${txHash}`); + const hash = await abortableCall(signal, () => + evmClientManager.sendRawTransactionWithRetry(network, signedTransaction) + ); + return { hash }; + }, + provider: "polygon", + reconcile: async () => { + try { + const receipt = await abortableCall(signal, () => + networkClient.getTransactionReceipt({ hash: deterministicHash }) + ); + if (receipt.status !== "success") { + throw new FinancialOperationRejectedError(`Alfredpay final transfer ${deterministicHash} failed`); + } + await abortableCall(signal, () => networkClient.getTransaction({ hash: deterministicHash })); + return { hash: deterministicHash }; + } catch (error) { + throwIfAborted(signal); + if (error instanceof FinancialOperationRejectedError) throw error; + return null; + } + }, + request: { network, signedTransaction }, + signal + }); + await state.update({ state: { ...state.state, alfredpayOfframpTransferTxHash: txHash } }); + logger.info(`AlfredpayOfframpTransferExecutor: Final transfer sent. Hash: ${txHash}`); + } catch (error) { + if (isAlfredpayFailedStatusError(error)) return this.transitionToNextPhase(state, "failed"); + throw error; + } } else { try { const client = evmClientManager.getClient(Networks.Polygon as EvmNetworks); @@ -474,8 +617,28 @@ export class AlfredpayOfframpTransferExecutor extends BasePhaseHandler { } } + const activeTransactionId = state.state.alfredpayTransactionId as string; + const alfredpayTx = await abortableCall(signal, () => alfredpayApiService.getOfframpTransaction(activeTransactionId)); + if (!alfredpayTx) { + throw this.createRecoverableError( + `AlfredpayOfframpTransferExecutor: Transaction ${activeTransactionId} not found after provider transfer.` + ); + } + if (!matchesPromisedOfframpTerms(alfredpayTx, promised, activeTransactionId)) { + logger.error("ALFREDPAY_OFFRAMP_POST_TRANSFER_TERMS_DRIFT", { + promisedFromAmount: new Big(promised.inputAmountDecimal as unknown as string).toString(), + promisedToAmount: new Big(promised.outputAmountDecimal as unknown as string).toString(), + transactionFromAmount: alfredpayTx.fromAmount, + transactionId: alfredpayTx.transactionId, + transactionToAmount: alfredpayTx.toAmount + }); + throw this.createRecoverableError( + "AlfredpayOfframpTransferExecutor: provider terms drifted after transfer; manual reconciliation required" + ); + } + try { - await this.pollAlfredpayOfframpStatus(alfredpayTx.transactionId, ALFREDPAY_POLL_INTERVAL_MS, signal); + await this.pollAlfredpayOfframpStatus(alfredpayTx.transactionId, promised, ALFREDPAY_POLL_INTERVAL_MS, signal); } catch (error) { if (isAlfredpayFailedStatusError(error)) return this.transitionToNextPhase(state, "failed"); throw this.createRecoverableError( @@ -487,64 +650,158 @@ export class AlfredpayOfframpTransferExecutor extends BasePhaseHandler { private async recreateAlfredpayOfframp( state: RampState, - expiredTx: Awaited>, + expiredTransactionId: string, + promised: AlfredpayOfframpTerms, signal?: AbortSignal ): Promise<{ state: RampState; alfredpayTx: Awaited> } | null> { - const { alfredpayUserId, fiatAccountId, walletAddress } = state.state as StateMetadata; - if (!alfredpayUserId || !fiatAccountId || !walletAddress) return null; + const { evmEphemeralAddress } = state.state as StateMetadata; + if (!evmEphemeralAddress) return null; + const alfredpayUserId = promised.customerId; + const fiatAccountId = promised.fiatAccountId; const alfredpayApiService = AlfredpayApiService.getInstance(); try { - const toCurrency = expiredTx.toCurrency as AlfredpayFiatCurrency; - const freshQuote = await abortableCall(signal, () => - alfredpayApiService.createOfframpQuote({ - chain: AlfredpayChain.MATIC, - fromAmount: expiredTx.fromAmount, - fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, - metadata: { businessId: "vortex", customerId: alfredpayUserId }, - paymentMethodType: AlfredpayPaymentMethodType.BANK, - toCurrency - }) - ); - throwIfAborted(signal); - const newOrder = await abortableCall(signal, () => - alfredpayApiService.createOfframp({ - amount: expiredTx.fromAmount, + const toCurrency = promised.currency as unknown as AlfredpayFiatCurrency; + let freshQuote: Awaited> | undefined; + const newOrder = await this.runFinancialOperation(state, { + attemptClass: recoveryAttemptClass(expiredTransactionId), + beforePerform: async () => { + freshQuote = await abortableCall(signal, () => + alfredpayApiService.createOfframpQuote({ + chain: AlfredpayChain.MATIC, + fromAmount: new Big(promised.inputAmountDecimal as unknown as string).toString(), + fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, + metadata: { businessId: "vortex", customerId: alfredpayUserId }, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + toCurrency + }) + ); + throwIfAborted(signal); + if ( + (freshQuote.chain !== undefined && freshQuote.chain !== AlfredpayChain.MATIC) || + freshQuote.fromCurrency !== ALFREDPAY_ONCHAIN_CURRENCY || + freshQuote.toCurrency !== toCurrency || + !new Big(freshQuote.fromAmount).eq(promised.inputAmountDecimal as unknown as string) || + new Big(freshQuote.toAmount).lt(promised.outputAmountDecimal as unknown as string) || + !hasSafeAlfredpayQuoteLifetime(freshQuote.expiration) + ) { + logger.warn("ALFREDPAY_OFFRAMP_RECOVERY_QUOTE_REJECTED", { + freshFromAmount: freshQuote.fromAmount, + freshToAmount: freshQuote.toAmount, + promisedFromAmount: new Big(promised.inputAmountDecimal as unknown as string).toString(), + promisedToAmount: new Big(promised.outputAmountDecimal as unknown as string).toString(), + transactionId: expiredTransactionId + }); + throw new FinancialOperationRejectedError("Fresh Alfredpay recovery quote degraded the promised payout"); + } + }, + externalId: order => order.transactionId, + perform: () => { + if (!freshQuote) throw new Error("Alfredpay recovery quote preflight did not produce a quote"); + return alfredpayApiService.createOfframp({ + amount: new Big(promised.inputAmountDecimal as unknown as string).toString(), + chain: AlfredpayChain.MATIC, + customerId: alfredpayUserId, + fiatAccountId, + fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, + originAddress: evmEphemeralAddress, + quoteId: freshQuote.quoteId, + toCurrency + }); + }, + provider: "alfredpay", + request: { + amount: new Big(promised.inputAmountDecimal as unknown as string).toString(), chain: AlfredpayChain.MATIC, customerId: alfredpayUserId, + expiredTransactionId, fiatAccountId, fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, - originAddress: walletAddress, - quoteId: freshQuote.quoteId, + originAddress: evmEphemeralAddress, toCurrency - }) - ); - if (newOrder.depositAddress.toLowerCase() !== expiredTx.depositAddress.toLowerCase()) return null; - await state.update({ state: { ...state.state, alfredpayTransactionId: newOrder.transactionId } }); + }, + retryFailed: true, + signal + }); + if (!matchesPromisedOfframpTerms(newOrder, promised) || !hasSafeAlfredpayExecutionLifetime(newOrder.expiration)) { + logger.error("ALFREDPAY_OFFRAMP_RECOVERY_ORDER_REJECTED", { transactionId: newOrder.transactionId }); + throw this.createReconciliationRequiredError( + `AlfredpayOfframpTransferExecutor: confirmed replacement order ${newOrder.transactionId} does not match immutable terms` + ); + } + if (newOrder.status === AlfredpayOfframpStatus.FAILED) { + throw { failureReason: "Alfredpay replacement order reported FAILED status", kind: "failed" as const }; + } + if (newOrder.status !== AlfredpayOfframpStatus.CREATED) { + throw this.createReconciliationRequiredError( + `AlfredpayOfframpTransferExecutor: confirmed replacement order ${newOrder.transactionId} is already ${newOrder.status}` + ); + } const refreshedTx = await abortableCall(signal, () => alfredpayApiService.getOfframpTransaction(newOrder.transactionId)); + if ( + !matchesPromisedOfframpTerms(refreshedTx, promised, newOrder.transactionId) || + !hasSafeAlfredpayExecutionLifetime(refreshedTx.expiration) + ) { + logger.error("ALFREDPAY_OFFRAMP_RECOVERY_TRANSACTION_REJECTED", { transactionId: newOrder.transactionId }); + throw this.createReconciliationRequiredError( + `AlfredpayOfframpTransferExecutor: confirmed replacement transaction ${newOrder.transactionId} drifted from immutable terms` + ); + } + if (refreshedTx.status === AlfredpayOfframpStatus.FAILED) { + throw { failureReason: "Alfredpay replacement transaction reported FAILED status", kind: "failed" as const }; + } + if (refreshedTx.status !== AlfredpayOfframpStatus.CREATED) { + throw this.createReconciliationRequiredError( + `AlfredpayOfframpTransferExecutor: confirmed replacement transaction ${newOrder.transactionId} is already ${refreshedTx.status}` + ); + } + await state.update({ state: { ...state.state, alfredpayTransactionId: newOrder.transactionId } }); return { alfredpayTx: refreshedTx, state }; } catch (error) { throwIfAborted(signal); + if (isAlfredpayFailedStatusError(error)) throw error; + if (error instanceof PhaseError) throw error; + if (error instanceof FinancialOperationRejectedError) return null; logger.error( `AlfredpayOfframpTransferExecutor: Error during recovery: ${error instanceof Error ? error.message : String(error)}` ); - return null; + throw this.createRecoverableError( + `AlfredpayOfframpTransferExecutor: Recovery outcome requires retry: ${error instanceof Error ? error.message : String(error)}` + ); } } - private async pollAlfredpayOfframpStatus(transactionId: string, intervalMs: number, signal?: AbortSignal): Promise { + private async pollAlfredpayOfframpStatus( + transactionId: string, + promised: AlfredpayOfframpTerms, + intervalMs: number, + signal?: AbortSignal + ): Promise { const alfredpayApiService = AlfredpayApiService.getInstance(); const startTime = Date.now(); while (Date.now() - startTime <= ALFREDPAY_OFFRAMP_TIMEOUT_MS) { throwIfAborted(signal); try { const response = await abortableCall(signal, () => alfredpayApiService.getOfframpTransaction(transactionId)); + if (!matchesPromisedOfframpTerms(response, promised, transactionId)) { + logger.error("ALFREDPAY_OFFRAMP_POLL_TERMS_DRIFT", { + promisedFromAmount: new Big(promised.inputAmountDecimal as unknown as string).toString(), + promisedToAmount: new Big(promised.outputAmountDecimal as unknown as string).toString(), + transactionFromAmount: response.fromAmount, + transactionId, + transactionToAmount: response.toAmount + }); + throw this.createRecoverableError( + "AlfredpayOfframpTransferExecutor: provider terms drifted while polling; manual reconciliation required" + ); + } if (response.status === AlfredpayOfframpStatus.FIAT_TRANSFER_COMPLETED) return; if (response.status === AlfredpayOfframpStatus.FAILED) { throw { failureReason: "Alfredpay reported FAILED status", kind: "failed" as const }; } } catch (error) { if (isAlfredpayFailedStatusError(error)) throw error; + if (error instanceof PhaseError) throw error; throwIfAborted(signal); logger.warn( `AlfredpayOfframpTransferExecutor: Error polling Alfredpay status for ${transactionId}: ${error instanceof Error ? error.message : String(error)}` diff --git a/apps/api/src/api/services/phases/blocks/phases/final-settlement-subsidy/execution.ts b/apps/api/src/api/services/phases/blocks/phases/final-settlement-subsidy/execution.ts index 4411148a0..edea306af 100644 --- a/apps/api/src/api/services/phases/blocks/phases/final-settlement-subsidy/execution.ts +++ b/apps/api/src/api/services/phases/blocks/phases/final-settlement-subsidy/execution.ts @@ -37,6 +37,10 @@ import { getEvmFundingAccount } from "../../core/evm-funding"; import { getEvmFeeTotalRawFromUsd } from "../../core/fee-distribution"; import { getFlowMetadata } from "../../core/metadata"; import { calculateSettlementSubsidyRaw, settlementBalanceKey } from "../../core/settlement"; +import { getAlfredpayExecutableBridgeOutputRaw } from "../alfredpay-offramp/simulation"; + +const FINAL_SETTLEMENT_ACQUISITION_BUFFER = new Big("1.1"); +const MAX_FINAL_SETTLEMENT_ACQUISITION_USD = new Big(MAX_FINAL_SETTLEMENT_SUBSIDY_USD).mul(FINAL_SETTLEMENT_ACQUISITION_BUFFER); const BALANCE_POLLING_TIME_MS = 5000; const EVM_BALANCE_CHECK_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes @@ -56,9 +60,9 @@ const NATIVE_TOKENS: Record = [Networks.BaseSepolia]: { decimals: 18, symbol: "ETH" } }; -// BUY slice of the production FinalSettlementSubsidyHandler: waits for the bridge to deliver on -// the destination chain, then tops the ephemeral up to exactly quote.outputAmount (swapping the -// funding account's native token to the output token via SquidRouter when needed). SELL is not ported. +// Waits for the destination bridge delivery, then tops the ephemeral up to the quoted settlement +// target. BUY may swap the funding account's native token through Squid; AlfredPay SELL tops up +// Polygon USDT while enforcing the quote-bound provider-input and subsidy ceilings. export class FinalSettlementSubsidyExecutor extends BasePhaseHandler { public getPhaseName(): RampPhase { return "finalSettlementSubsidy"; @@ -74,8 +78,18 @@ export class FinalSettlementSubsidyExecutor extends BasePhaseHandler { const evmClientManager = EvmClientManager.getInstance(); - const alfredpayMetadata = (quote.metadata as unknown as { blocks?: { alfredpayOfframp?: { inputAmountRaw: string } } }) - .blocks?.alfredpayOfframp; + const alfredpayMetadata = ( + quote.metadata as unknown as { + blocks?: { + alfredpayOfframp?: { + bridgeOutputAmountRaw: string; + executableBridgeOutputRaw?: string; + inputAmountRaw: string; + subsidyAmountRaw: string; + }; + }; + } + ).blocks?.alfredpayOfframp; const isAlfredpayOfframp = state.type === RampDirection.SELL && alfredpayMetadata !== undefined; const outputNetwork = isAlfredpayOfframp ? Networks.Polygon : quote.network; const outputCurrency = isAlfredpayOfframp ? ALFREDPAY_EVM_TOKEN : quote.outputCurrency; @@ -148,7 +162,12 @@ export class FinalSettlementSubsidyExecutor extends BasePhaseHandler { }; } ).blocks?.squidRouterSwap; - const bridgeExpectedAmountRaw = squidMetadata?.outputAmountRaw ?? expectedAmountRaw.toFixed(0); + const bridgeExpectedAmountRaw = isAlfredpayOfframp + ? getAlfredpayExecutableBridgeOutputRaw( + alfredpayMetadata as Parameters[0], + alfredpayFeesUsd + ) + : (squidMetadata?.outputAmountRaw ?? expectedAmountRaw.toFixed(0)); const existingEvidence = state.state.squidRouterDeliveryEvidence; if (existingEvidence) { this.assertMatchingDeliveryEvidence( @@ -269,6 +288,18 @@ export class FinalSettlementSubsidyExecutor extends BasePhaseHandler { return state; } + if (isAlfredpayOfframp && subsidyAmountRaw.gt(alfredpayMetadata.subsidyAmountRaw)) { + logger.warn("ALFREDPAY_OFFRAMP_SETTLEMENT_SUBSIDY_CAP_EXCEEDED", { + observedBalanceRaw: actualBalance.toFixed(0), + quotedSubsidyAmountRaw: alfredpayMetadata.subsidyAmountRaw, + rampId: state.id, + requiredSubsidyAmountRaw: subsidyAmountRaw.toFixed(0) + }); + throw this.createRecoverableError( + "FinalSettlementSubsidyExecutor: observed bridge delivery would exceed the AlfredPay quote's subsidy cap" + ); + } + const subsidyAmountDecimal = subsidyAmountRaw.div(new Big(10).pow(outTokenDetails.decimals)); const subsidyAmountUsd = await priceFeedService.convertCurrency( subsidyAmountDecimal.toFixed(), @@ -287,131 +318,161 @@ export class FinalSettlementSubsidyExecutor extends BasePhaseHandler { // 4. Top up funding account if insufficient balance (ERC-20 only; native tokens transfer directly) if (!isNative && actualBalanceFundingAccount.lt(subsidyAmountRaw)) { - logger.info( - `FinalSettlementSubsidyExecutor: Funding account has insufficient balance. Swapping native token to ${outTokenDetails.assetSymbol}` - ); - const nativeToken = NATIVE_TOKENS[destinationNetwork]; - const oneUsdInNative = await priceFeedService.convertCurrency( - "1", - "USD" as RampCurrency, - nativeToken.symbol as RampCurrency - ); - const oneUsdInNativeRaw = multiplyByPowerOfTen(oneUsdInNative, nativeToken.decimals).toFixed(0); - const chainId = getNetworkId(destinationNetwork).toString(); - - // Use a placeholder address for this query to prevent rate limiting issues - const placeholderAddress = privateKeyToAddress(generatePrivateKey()); - const testRouteResult = await getRoute( - { - bypassGuardrails: true, - enableExpress: true, - fromAddress: placeholderAddress, - fromAmount: oneUsdInNativeRaw, - fromChain: chainId, - fromToken: NATIVE_TOKEN_ADDRESS, - slippageConfig: { - autoMode: 1 + let preparedSwap: + | { + data: `0x${string}`; + gas: bigint; + maxFeePerGas: bigint; + maxPriorityFeePerGas: bigint; + nonce: number; + target: `0x${string}`; + value: bigint; + } + | undefined; + + try { + const { hash: txHashIdx } = await this.runFinancialOperation(state, { + allowExistingRequestMismatch: true, + attemptClass: "funding-swap", + beforePerform: async () => { + logger.info( + `FinalSettlementSubsidyExecutor: Funding account has insufficient balance. Swapping native token to ${outTokenDetails.assetSymbol}` + ); + const oneUsdInNative = await priceFeedService.convertCurrency( + "1", + "USD" as RampCurrency, + nativeToken.symbol as RampCurrency + ); + const oneUsdInNativeRaw = multiplyByPowerOfTen(oneUsdInNative, nativeToken.decimals).toFixed(0); + const placeholderAddress = privateKeyToAddress(generatePrivateKey()); + const testRouteResult = await getRoute( + { + bypassGuardrails: true, + enableExpress: true, + fromAddress: placeholderAddress, + fromAmount: oneUsdInNativeRaw, + fromChain: chainId, + fromToken: NATIVE_TOKEN_ADDRESS, + slippageConfig: { autoMode: 1 }, + toAddress: placeholderAddress, + toChain: chainId, + toToken: outTokenDetails.erc20AddressSourceChain + }, + { useCache: true } + ); + const rate = new Big(testRouteResult.data.route.estimate.toAmount).div(new Big(oneUsdInNativeRaw)); + const fundingShortfallRaw = subsidyAmountRaw.minus(actualBalanceFundingAccount); + const requiredNativeRaw = fundingShortfallRaw.div(rate).mul(FINAL_SETTLEMENT_ACQUISITION_BUFFER).toFixed(0); + const requiredNative = new Big(requiredNativeRaw).div(new Big(10).pow(nativeToken.decimals)); + const requiredNativeInUsd = await priceFeedService.convertCurrency( + requiredNative.toString(), + nativeToken.symbol as RampCurrency, + "USD" as RampCurrency + ); + if (new Big(requiredNativeInUsd).gt(MAX_FINAL_SETTLEMENT_ACQUISITION_USD)) { + throw this.createUnrecoverableError( + `FinalSettlementSubsidyExecutor: Required subsidy acquisition amount $${requiredNativeInUsd} exceeds maximum allowed $${MAX_FINAL_SETTLEMENT_ACQUISITION_USD.toString()}` + ); + } + + const swapRouteResult = await getRoute({ + bypassGuardrails: true, + enableExpress: true, + fromAddress: fundingAccount.address, + fromAmount: requiredNativeRaw, + fromChain: chainId, + fromToken: NATIVE_TOKEN_ADDRESS, + slippageConfig: { autoMode: 1 }, + toAddress: fundingAccount.address, + toChain: chainId, + toToken: outTokenDetails.erc20AddressSourceChain + }); + const { route: swapRoute } = swapRouteResult.data; + const estimatedOutput = new Big(swapRoute.estimate.toAmount); + const guaranteedOutput = new Big(swapRoute.estimate.toAmountMin); + if (guaranteedOutput.gt(estimatedOutput)) { + throw this.createRecoverableError( + `FinalSettlementSubsidyExecutor: SquidRouter guaranteed output ${guaranteedOutput.toString()} exceeds its estimate ${estimatedOutput.toString()}` + ); + } + if (guaranteedOutput.lt(fundingShortfallRaw)) { + throw this.createRecoverableError( + `FinalSettlementSubsidyExecutor: SquidRouter guaranteed output ${guaranteedOutput.toString()} is below funding shortfall ${fundingShortfallRaw.toString()}` + ); + } + const routeValue = BigInt(swapRoute.transactionRequest.value); + if (routeValue !== BigInt(requiredNativeRaw)) { + throw this.createRecoverableError( + `FinalSettlementSubsidyExecutor: SquidRouter executable value ${routeValue.toString()} does not match requested input ${requiredNativeRaw}` + ); + } + + const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); + const nonce = await publicClient.getTransactionCount({ + address: fundingAccount.address, + blockTag: "pending" + }); + preparedSwap = { + data: swapRoute.transactionRequest.data as `0x${string}`, + gas: BigInt(swapRoute.transactionRequest.gasLimit), + maxFeePerGas, + maxPriorityFeePerGas, + nonce, + target: swapRoute.transactionRequest.target as `0x${string}`, + value: routeValue + }; }, - toAddress: placeholderAddress, - toChain: chainId, - toToken: outTokenDetails.erc20AddressSourceChain - }, - { useCache: true } - ); - - const { route: testRoute } = testRouteResult.data; - const rate = new Big(testRoute.estimate.toAmount).div(new Big(oneUsdInNativeRaw)); - const requiredNativeRaw = subsidyAmountRaw.div(rate).mul(1.1).toFixed(0); - - logger.info( - `FinalSettlementSubsidyExecutor: Swapping ${requiredNativeRaw} native units (approx. rate ${rate}) to get required subsidy.` - ); - - // Check the amount of native is not higher than cap, cap specified in units of usd. - const requiredNative = new Big(requiredNativeRaw).div(new Big(10).pow(nativeToken.decimals)); - const requiredNativeInUsd = await priceFeedService.convertCurrency( - requiredNative.toString(), - nativeToken.symbol as RampCurrency, - "USD" as RampCurrency - ); - - if (new Big(requiredNativeInUsd).gt(MAX_FINAL_SETTLEMENT_SUBSIDY_USD)) { - throw this.createUnrecoverableError( - `FinalSettlementSubsidyExecutor: Required subsidy swap amount $${requiredNativeInUsd} exceeds maximum allowed $${MAX_FINAL_SETTLEMENT_SUBSIDY_USD}` - ); - } - - const swapRouteResult = await getRoute({ - bypassGuardrails: true, - enableExpress: true, - fromAddress: fundingAccount.address, - fromAmount: requiredNativeRaw, - fromChain: chainId, - fromToken: NATIVE_TOKEN_ADDRESS, - slippageConfig: { - autoMode: 1 - }, - toAddress: fundingAccount.address, - toChain: chainId, - toToken: outTokenDetails.erc20AddressSourceChain - }); - - const { route: swapRoute } = swapRouteResult.data; - - // Validate swap route output is within acceptable range (>=80% of required subsidy) - const estimatedOutput = new Big(swapRoute.estimate.toAmount); - const minimumAcceptableOutput = subsidyAmountRaw.mul(0.8); - if (estimatedOutput.lt(minimumAcceptableOutput)) { - throw this.createUnrecoverableError( - `FinalSettlementSubsidyExecutor: SquidRouter swap output ${estimatedOutput.toString()} is below 80% of required subsidy ${subsidyAmountRaw.toString()}` + externalId: operation => operation.hash, + perform: async () => { + throwIfAborted(signal); + const swap = preparedSwap; + if (!swap) throw new Error("FinalSettlementSubsidyExecutor: Funding swap preflight was not prepared"); + const hash = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { + data: swap.data, + gas: swap.gas, + maxFeePerGas: swap.maxFeePerGas, + maxPriorityFeePerGas: swap.maxPriorityFeePerGas, + nonce: swap.nonce, + to: swap.target, + value: swap.value + }); + const receipt = await abortableCall(signal, () => publicClient.waitForTransactionReceipt({ hash })); + if (receipt.status !== "success") throw new Error(`Swap transaction ${hash} failed`); + return { + hash, + nonce: swap.nonce, + routeTarget: swap.target, + valueRaw: swap.value.toString() + }; + }, + provider: destinationNetwork, + request: { + acquisitionCapUsd: MAX_FINAL_SETTLEMENT_ACQUISITION_USD.toString(), + destination: fundingAccount.address, + network: destinationNetwork, + token: outTokenDetails.erc20AddressSourceChain + }, + signal + }); + + logger.info(`FinalSettlementSubsidyExecutor: Swap transaction ${txHashIdx} confirmed. Waiting for balance update...`); + await checkEvmBalanceForToken({ + amountDesiredRaw: subsidyAmountRaw.toString(), + chain: destinationNetwork, + intervalMs: BALANCE_POLLING_TIME_MS, + ownerAddress: fundingAccount.address, + signal, + timeoutMs: EVM_BALANCE_CHECK_TIMEOUT_MS, + tokenDetails: outTokenDetails + }); + } catch (error) { + if (error instanceof PhaseError) throw error; + throw this.createRecoverableError( + `FinalSettlementSubsidyExecutor: funding acquisition failed: ${error instanceof Error ? error.message : String(error)}` ); } - - const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); - const nonce = await publicClient.getTransactionCount({ address: fundingAccount.address, blockTag: "pending" }); - const { hash: txHashIdx } = await this.runFinancialOperation(state, { - attemptClass: "funding-swap", - externalId: operation => operation.hash, - perform: async () => { - throwIfAborted(signal); - const hash = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { - data: swapRoute.transactionRequest.data as `0x${string}`, - gas: BigInt(swapRoute.transactionRequest.gasLimit), - maxFeePerGas, - maxPriorityFeePerGas, - nonce, - to: swapRoute.transactionRequest.target as `0x${string}`, - value: BigInt(swapRoute.transactionRequest.value) - }); - const receipt = await abortableCall(signal, () => publicClient.waitForTransactionReceipt({ hash })); - if (receipt.status !== "success") throw new Error(`Swap transaction ${hash} failed`); - return { hash }; - }, - provider: destinationNetwork, - request: { - amountRaw: requiredNativeRaw, - destination: fundingAccount.address, - network: destinationNetwork, - nonce, - routeTarget: swapRoute.transactionRequest.target, - token: outTokenDetails.erc20AddressSourceChain - }, - signal - }); - - logger.info(`FinalSettlementSubsidyExecutor: Swap transaction ${txHashIdx} confirmed. Waiting for balance update...`); - - await checkEvmBalanceForToken({ - amountDesiredRaw: subsidyAmountRaw.toString(), - chain: destinationNetwork, - intervalMs: BALANCE_POLLING_TIME_MS, - ownerAddress: fundingAccount.address, - signal, - timeoutMs: EVM_BALANCE_CHECK_TIMEOUT_MS, - tokenDetails: outTokenDetails - }); } // 5. Execute the subsidy transfer (native value transfer vs ERC-20 transfer) diff --git a/apps/api/src/api/services/phases/post-process/polygon-post-process-handler.ts b/apps/api/src/api/services/phases/post-process/polygon-post-process-handler.ts index 55b93f758..ead495dce 100644 --- a/apps/api/src/api/services/phases/post-process/polygon-post-process-handler.ts +++ b/apps/api/src/api/services/phases/post-process/polygon-post-process-handler.ts @@ -1,4 +1,12 @@ -import { CleanupPhase, EvmClientManager, EvmNetworks, Networks, PresignedTx, RampDirection } from "@vortexfi/shared"; +import { + ALFREDPAY_ERC20_TOKEN, + CleanupPhase, + EvmClientManager, + EvmNetworks, + Networks, + PresignedTx, + RampDirection +} from "@vortexfi/shared"; import { Transaction as EvmTransaction } from "ethers"; import { erc20Abi } from "viem"; import logger from "../../../../config/logger"; @@ -88,11 +96,21 @@ export class PolygonPostProcessHandler extends BasePostProcessHandler { const fundingAccount = getEvmFundingAccount(polygonNetwork); const walletClient = evmClientManager.getWalletClient(polygonNetwork, fundingAccount); + const isAlfredpayUsdtResidual = + state.type === RampDirection.SELL && tokenAddress.toLowerCase() === ALFREDPAY_ERC20_TOKEN.toLowerCase(); + const alfredpayWalletAddress = (state.state.blockState?.alfredpayOfframp as { walletAddress?: string } | undefined) + ?.walletAddress; + const recipient = ( + isAlfredpayUsdtResidual ? (alfredpayWalletAddress ?? state.state.walletAddress) : fundingAccount.address + ) as `0x${string}` | undefined; + if (!recipient) { + return [false, this.createErrorObject(`No wallet address found for AlfredPay USDT cleanup on ramp ${state.id}`)]; + } const transferFromHash = await walletClient.writeContract({ abi: erc20Abi, address: tokenAddress, - args: [ephemeralAddress, fundingAccount.address, balance], + args: [ephemeralAddress, recipient, balance], functionName: "transferFrom" }); @@ -101,7 +119,7 @@ export class PolygonPostProcessHandler extends BasePostProcessHandler { return [false, this.createErrorObject(`transferFrom tx ${transferFromHash} for ${phase} failed`)]; } - logger.info(`Successfully swept ${balance} tokens for Polygon cleanup ${phase} on ramp ${state.id}`); + logger.info(`Successfully swept ${balance} tokens to ${recipient} for Polygon cleanup ${phase} on ramp ${state.id}`); return [true, null]; } catch (e) { return [false, this.createErrorObject(`Error in Polygon cleanup ${phase}: ${e}`)]; From e2a125b78d688dbf1822b9e02ea056b041e51b21 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 11 Aug 2026 20:11:51 +0200 Subject: [PATCH 4/9] test(api): cover AlfredPay sell pricing and recovery --- .../evm-executor-regressions.test.ts | 460 +++++++- .../api/src/test-utils/fake-world/fake-evm.ts | 18 +- .../corridors/mxn-offramp.scenario.test.ts | 1022 ++++++++++++++++- 3 files changed, 1451 insertions(+), 49 deletions(-) diff --git a/apps/api/src/api/services/phases/blocks/__tests__/evm-executor-regressions.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/evm-executor-regressions.test.ts index d4af34b54..9ad1c3ced 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/evm-executor-regressions.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/evm-executor-regressions.test.ts @@ -13,9 +13,29 @@ const sharedReal = { ...sharedNamespace }; const quoteTicketReal = { ...quoteTicketNamespace }; const evmFundingReal = { ...evmFundingNamespace }; const financialOperationReal = { ...financialOperationNamespace }; +const financialOperationOutcomes = new Map< + string, + { response?: unknown; status: "confirmed" | "unknown" } +>(); const findQuote = mock(async () => undefined as unknown); const checkBalance = mock(async () => new Big(0)); const getFundingBalance = mock(async () => new Big("1000000000")); +const getRoute = mock(async (request: { fromAmount: string }) => ({ + data: { + route: { + estimate: { + toAmount: new Big(request.fromAmount).div("1000000000000").toFixed(0), + toAmountMin: new Big(request.fromAmount).div("1000000000000").toFixed(0) + }, + transactionRequest: { + data: "0x", + gasLimit: "100000", + target: "0x3333333333333333333333333333333333333333", + value: request.fromAmount + } + } + } +})); const getOnrampTransaction = mock( async (): Promise<{ metadata?: { txHash?: string }; status: AlfredpayOnrampStatus }> => ({ status: AlfredpayOnrampStatus.CREATED @@ -41,7 +61,8 @@ mock.module("@vortexfi/shared", () => ({ sendTransactionWithBlindRetry: sendTransaction }) }, - getEvmBalance: getFundingBalance + getEvmBalance: getFundingBalance, + getRoute })); mock.module("../../../../../models/quoteTicket.model", () => ({ ...quoteTicketReal, @@ -54,10 +75,36 @@ mock.module("../core/evm-funding", () => ({ mock.module("../core/financial-operation", () => ({ ...financialOperationReal, requireFinancialFlowIdentity: () => ({ id: "test-flow", version: 1 }), - runFinancialOperation: async ({ perform }: { perform(key: string): Promise }) => perform("test-operation") + runFinancialOperation: async ({ + attemptClass, + beforePerform, + perform + }: { + attemptClass: string; + beforePerform?(): Promise; + perform(key: string): Promise; + }) => { + const existing = financialOperationOutcomes.get(attemptClass); + if (existing?.status === "confirmed") return existing.response; + if (existing?.status === "unknown") { + throw Object.assign(new Error("financial operation requires reconciliation"), { + requiresManualReconciliation: true + }); + } + await beforePerform?.(); + try { + const response = await perform(`test-operation:${attemptClass}`); + financialOperationOutcomes.set(attemptClass, { response, status: "confirmed" }); + return response; + } catch (error) { + financialOperationOutcomes.set(attemptClass, { status: "unknown" }); + throw error; + } + } })); const { SubsidizePostSwapExecutor } = await import("../phases/subsidize-post/execution"); const { FinalSettlementSubsidyExecutor } = await import("../phases/final-settlement-subsidy/execution"); +const { getAlfredpayExecutableBridgeOutputRaw } = await import("../phases/alfredpay-offramp/simulation"); const { AlfredpayOnrampMintExecutor } = await import("../phases/alfredpay-mint/execution"); afterAll(() => { @@ -69,8 +116,11 @@ afterAll(() => { beforeEach(() => { findQuote.mockClear(); + financialOperationOutcomes.clear(); checkBalance.mockClear(); getFundingBalance.mockClear(); + getFundingBalance.mockResolvedValue(new Big("1000000000")); + getRoute.mockClear(); getOnrampTransaction.mockClear(); sendTransaction.mockClear(); waitForTransactionReceipt.mockClear(); @@ -199,10 +249,21 @@ describe("EVM block executor regressions", () => { expect(sendTransaction).not.toHaveBeenCalled(); }); - it("records AlfredPay SELL settlement subsidy as Polygon USDT", async () => { + it("uses AlfredPay SELL's guaranteed bridge minimum and records the subsidy as Polygon USDT", async () => { checkBalance.mockResolvedValue(new Big("900000")); findQuote.mockResolvedValue({ - metadata: { blocks: { alfredpayOfframp: { inputAmountRaw: "1000000" } } }, + metadata: { + blocks: { + alfredpayOfframp: { + bridgeOutputAmountRaw: "1000000", + executableBridgeOutputRaw: "800000", + inputAmountRaw: "1000000", + subsidyAmountRaw: "200000" + } + }, + flow: { id: "AlfredpayOfframp", version: 4 }, + globals: { fees: { usd: { anchor: "0", network: "0", partnerMarkup: "0", vortex: "0" } }, request: {} } + }, network: Networks.Polygon, outputAmount: "1", outputCurrency: FiatToken.MXN @@ -212,6 +273,16 @@ describe("EVM block executor regressions", () => { quoteId: "quote-1", state: { evmEphemeralAddress: "0x2222222222222222222222222222222222222222", + squidRouterDeliveryEvidence: { + baselineRaw: "0", + destinationNetwork: Networks.Polygon, + destinationToken: "0xc2132d05d31c914a87c6611c10748aeb04b58e8f", + expectedAmountRaw: "800000", + kind: "destination-balance", + minimumRatioBps: 9000, + observedAt: "2026-01-01T00:00:00.000Z", + sourceTransactionHash: "legacy-unavailable" + }, transactionPlan: { settlementBaselines: { "polygon:0x2222222222222222222222222222222222222222:0xc2132d05d31c914a87c6611c10748aeb04b58e8f": "0" @@ -232,9 +303,390 @@ describe("EVM block executor regressions", () => { priceFeedService.convertCurrency = originalConvertCurrency; } + expect(checkBalance).toHaveBeenCalledWith(expect.objectContaining({ amountDesiredRaw: "720000" })); expect(executor.createSubsidy).toHaveBeenCalledWith(state, 0.1, EvmToken.USDT, fundingAccount.address, expect.any(String)); }); + it.each([ + ["exact cap", "10000000", "0", "11000000000000000000"], + ["15 bps staging case", "9431953", "0", "10375148300000000000"], + ["partial inventory", "10000000", "4000000", "6600000000000000000"] + ])( + "acquires only the treasury shortfall and transfers the full subsidy (%s)", + async (_caseName, subsidyAmountRaw, fundingInventoryRaw, expectedNativeInputRaw) => { + const executableBridgeOutputRaw = "1000000000"; + const inputAmountRaw = new Big(executableBridgeOutputRaw).plus(subsidyAmountRaw).toFixed(0); + checkBalance.mockResolvedValue(new Big(executableBridgeOutputRaw)); + getFundingBalance.mockResolvedValue(new Big(fundingInventoryRaw)); + findQuote.mockResolvedValue({ + metadata: { + blocks: { + alfredpayOfframp: { + bridgeOutputAmountRaw: executableBridgeOutputRaw, + executableBridgeOutputRaw, + inputAmountRaw, + subsidyAmountRaw + } + }, + flow: { id: "AlfredpayOfframp", version: 4 }, + globals: { fees: { usd: { anchor: "0", network: "0", partnerMarkup: "0", vortex: "0" } }, request: {} } + }, + network: Networks.Polygon, + outputAmount: "1", + outputCurrency: FiatToken.MXN + }); + const state = { + id: `ramp-acquire-${subsidyAmountRaw}`, + quoteId: `quote-acquire-${subsidyAmountRaw}`, + state: { + evmEphemeralAddress: "0x2222222222222222222222222222222222222222", + transactionPlan: { + settlementBaselines: { + "polygon:0x2222222222222222222222222222222222222222:0xc2132d05d31c914a87c6611c10748aeb04b58e8f": + "0" + } + } + }, + type: RampDirection.SELL, + update: mock(async () => state) + } as unknown as RampState; + const executor = Object.create(FinalSettlementSubsidyExecutor.prototype) as any; + executor.createSubsidy = mock(async () => undefined); + const originalConvertCurrency = priceFeedService.convertCurrency; + priceFeedService.convertCurrency = mock(async amount => String(amount)) as typeof priceFeedService.convertCurrency; + + try { + await executor.executePhase(state); + } finally { + priceFeedService.convertCurrency = originalConvertCurrency; + } + + expect(getRoute).toHaveBeenCalledTimes(2); + expect(getRoute.mock.calls[1]?.[0]).toMatchObject({ fromAmount: expectedNativeInputRaw }); + expect(sendTransaction).toHaveBeenCalledTimes(2); + expect(executor.createSubsidy).toHaveBeenCalledWith( + state, + new Big(subsidyAmountRaw).div(1_000_000).toNumber(), + EvmToken.USDT, + fundingAccount.address, + expect.any(String) + ); + } + ); + + it("does not broadcast an acquisition whose guaranteed output leaves treasury inventory insolvent", async () => { + const executableBridgeOutputRaw = "1000000000"; + const subsidyAmountRaw = "10000000"; + checkBalance.mockResolvedValue(new Big(executableBridgeOutputRaw)); + getFundingBalance.mockResolvedValue(new Big(0)); + getRoute + .mockResolvedValueOnce({ + data: { + route: { + estimate: { toAmount: "1000000", toAmountMin: "1000000" }, + transactionRequest: { + data: "0x", + gasLimit: "100000", + target: "0x3333333333333333333333333333333333333333", + value: "1000000000000000000" + } + } + } + }) + .mockResolvedValueOnce({ + data: { + route: { + estimate: { toAmount: "11000000", toAmountMin: "7000000" }, + transactionRequest: { + data: "0x", + gasLimit: "100000", + target: "0x3333333333333333333333333333333333333333", + value: "11000000000000000000" + } + } + } + }); + findQuote.mockResolvedValue({ + metadata: { + blocks: { + alfredpayOfframp: { + bridgeOutputAmountRaw: executableBridgeOutputRaw, + executableBridgeOutputRaw, + inputAmountRaw: "1010000000", + subsidyAmountRaw + } + }, + flow: { id: "AlfredpayOfframp", version: 4 }, + globals: { fees: { usd: { anchor: "0", network: "0", partnerMarkup: "0", vortex: "0" } }, request: {} } + }, + network: Networks.Polygon, + outputAmount: "1", + outputCurrency: FiatToken.MXN + }); + const state = { + id: "ramp-insolvent-acquisition", + quoteId: "quote-insolvent-acquisition", + state: { + evmEphemeralAddress: "0x2222222222222222222222222222222222222222", + transactionPlan: { + settlementBaselines: { + "polygon:0x2222222222222222222222222222222222222222:0xc2132d05d31c914a87c6611c10748aeb04b58e8f": + "0" + } + } + }, + type: RampDirection.SELL, + update: mock(async () => state) + } as unknown as RampState; + const executor = Object.create(FinalSettlementSubsidyExecutor.prototype) as any; + const originalConvertCurrency = priceFeedService.convertCurrency; + priceFeedService.convertCurrency = mock(async amount => String(amount)) as typeof priceFeedService.convertCurrency; + + try { + await expect(executor.executePhase(state)).rejects.toMatchObject({ + isRecoverable: true, + message: expect.stringContaining("below funding shortfall") + }); + } finally { + priceFeedService.convertCurrency = originalConvertCurrency; + } + + expect(sendTransaction).not.toHaveBeenCalled(); + + getRoute + .mockResolvedValueOnce({ + data: { + route: { + estimate: { toAmount: "1000000", toAmountMin: "1000000" }, + transactionRequest: { + data: "0x", + gasLimit: "100000", + target: "0x3333333333333333333333333333333333333333", + value: "1000000000000000000" + } + } + } + }) + .mockResolvedValueOnce({ + data: { + route: { + estimate: { toAmount: "11000000", toAmountMin: "10000000" }, + transactionRequest: { + data: "0x", + gasLimit: "100000", + target: "0x3333333333333333333333333333333333333333", + value: "100000000000000000000" + } + } + } + }); + priceFeedService.convertCurrency = mock(async amount => String(amount)) as typeof priceFeedService.convertCurrency; + try { + await expect(executor.executePhase(state)).rejects.toMatchObject({ + isRecoverable: true, + message: expect.stringContaining("executable value") + }); + } finally { + priceFeedService.convertCurrency = originalConvertCurrency; + } + expect(sendTransaction).not.toHaveBeenCalled(); + + getRoute.mockResolvedValueOnce({ + data: { + route: { + estimate: { toAmount: "900000", toAmountMin: "900000" }, + transactionRequest: { + data: "0x", + gasLimit: "100000", + target: "0x3333333333333333333333333333333333333333", + value: "1000000000000000000" + } + } + } + }); + priceFeedService.convertCurrency = mock(async amount => String(amount)) as typeof priceFeedService.convertCurrency; + try { + await expect(executor.executePhase(state)).rejects.toThrow("exceeds maximum allowed $11"); + } finally { + priceFeedService.convertCurrency = originalConvertCurrency; + } + expect(sendTransaction).not.toHaveBeenCalled(); + }); + + it("replays a confirmed acquisition before fresh route data after a balance-wait failure", async () => { + const executableBridgeOutputRaw = "1000000000"; + checkBalance + .mockResolvedValueOnce(new Big(executableBridgeOutputRaw)) + .mockRejectedValueOnce(new Error("balance RPC timeout")) + .mockResolvedValue(new Big(executableBridgeOutputRaw)); + getFundingBalance.mockResolvedValue(new Big(0)); + findQuote.mockResolvedValue({ + metadata: { + blocks: { + alfredpayOfframp: { + bridgeOutputAmountRaw: executableBridgeOutputRaw, + executableBridgeOutputRaw, + inputAmountRaw: "1010000000", + subsidyAmountRaw: "10000000" + } + }, + flow: { id: "AlfredpayOfframp", version: 4 }, + globals: { fees: { usd: { anchor: "0", network: "0", partnerMarkup: "0", vortex: "0" } }, request: {} } + }, + network: Networks.Polygon, + outputAmount: "1", + outputCurrency: FiatToken.MXN + }); + const state = { + id: "ramp-acquisition-replay", + quoteId: "quote-acquisition-replay", + state: { + evmEphemeralAddress: "0x2222222222222222222222222222222222222222", + transactionPlan: { + settlementBaselines: { + "polygon:0x2222222222222222222222222222222222222222:0xc2132d05d31c914a87c6611c10748aeb04b58e8f": + "0" + } + } + }, + type: RampDirection.SELL, + update: mock(async () => state) + } as unknown as RampState; + const executor = Object.create(FinalSettlementSubsidyExecutor.prototype) as any; + executor.createSubsidy = mock(async () => undefined); + const originalConvertCurrency = priceFeedService.convertCurrency; + priceFeedService.convertCurrency = mock(async amount => String(amount)) as typeof priceFeedService.convertCurrency; + + try { + await expect(executor.executePhase(state)).rejects.toMatchObject({ isRecoverable: true }); + await expect(executor.executePhase(state)).resolves.toBe(state); + } finally { + priceFeedService.convertCurrency = originalConvertCurrency; + } + + expect(getRoute).toHaveBeenCalledTimes(2); + const financialCalls = sendTransaction.mock.calls as unknown as [unknown, unknown, { value: bigint }][]; + expect(financialCalls.filter(([, , transaction]) => transaction.value > 0n)).toHaveLength(1); + expect(sendTransaction).toHaveBeenCalledTimes(2); + }); + + it("classifies acquisition route and ambiguous receipt failures as recoverable without duplicate broadcast", async () => { + const executableBridgeOutputRaw = "1000000000"; + checkBalance.mockResolvedValue(new Big(executableBridgeOutputRaw)); + getFundingBalance.mockResolvedValue(new Big(0)); + findQuote.mockResolvedValue({ + metadata: { + blocks: { + alfredpayOfframp: { + bridgeOutputAmountRaw: executableBridgeOutputRaw, + executableBridgeOutputRaw, + inputAmountRaw: "1010000000", + subsidyAmountRaw: "10000000" + } + }, + flow: { id: "AlfredpayOfframp", version: 4 }, + globals: { fees: { usd: { anchor: "0", network: "0", partnerMarkup: "0", vortex: "0" } }, request: {} } + }, + network: Networks.Polygon, + outputAmount: "1", + outputCurrency: FiatToken.MXN + }); + const state = { + id: "ramp-acquisition-errors", + quoteId: "quote-acquisition-errors", + state: { + evmEphemeralAddress: "0x2222222222222222222222222222222222222222", + transactionPlan: { + settlementBaselines: { + "polygon:0x2222222222222222222222222222222222222222:0xc2132d05d31c914a87c6611c10748aeb04b58e8f": + "0" + } + } + }, + type: RampDirection.SELL, + update: mock(async () => state) + } as unknown as RampState; + const executor = Object.create(FinalSettlementSubsidyExecutor.prototype) as any; + const originalConvertCurrency = priceFeedService.convertCurrency; + priceFeedService.convertCurrency = mock(async amount => String(amount)) as typeof priceFeedService.convertCurrency; + + try { + getRoute.mockRejectedValueOnce(new Error("Squid temporarily unavailable")); + await expect(executor.executePhase(state)).rejects.toMatchObject({ + isRecoverable: true, + message: expect.stringContaining("Squid temporarily unavailable") + }); + expect(sendTransaction).not.toHaveBeenCalled(); + + waitForTransactionReceipt.mockRejectedValueOnce(new Error("receipt RPC timeout")); + await expect(executor.executePhase(state)).rejects.toMatchObject({ + isRecoverable: true, + message: expect.stringContaining("receipt RPC timeout") + }); + expect(sendTransaction).toHaveBeenCalledTimes(1); + + await expect(executor.executePhase(state)).rejects.toThrow("requires reconciliation"); + expect(sendTransaction).toHaveBeenCalledTimes(1); + } finally { + priceFeedService.convertCurrency = originalConvertCurrency; + } + }); + + it("fails closed when schema-3 executable minimum metadata disagrees with settlement arithmetic", () => { + expect(() => + getAlfredpayExecutableBridgeOutputRaw( + { executableBridgeOutputRaw: "800001", inputAmountRaw: "1000000", subsidyAmountRaw: "200000" }, + { network: "0", partnerMarkup: "0", vortex: "0" } + ) + ).toThrow("executable bridge minimum mismatch"); + }); + + it("does not fund AlfredPay bridge under-delivery beyond the quoted subsidy", async () => { + checkBalance.mockResolvedValue(new Big("900000")); + findQuote.mockResolvedValue({ + metadata: { + blocks: { + alfredpayOfframp: { + bridgeOutputAmountRaw: "1000000", + executableBridgeOutputRaw: "1000000", + inputAmountRaw: "1000000", + subsidyAmountRaw: "0" + } + }, + flow: { id: "AlfredpayOfframp", version: 4 }, + globals: { fees: { usd: { anchor: "0", network: "0", partnerMarkup: "0", vortex: "0" } }, request: {} } + }, + network: Networks.Polygon, + outputAmount: "1", + outputCurrency: FiatToken.MXN + }); + const state = { + id: "ramp-under-delivery", + quoteId: "quote-under-delivery", + state: { + evmEphemeralAddress: "0x2222222222222222222222222222222222222222", + transactionPlan: { + settlementBaselines: { + "polygon:0x2222222222222222222222222222222222222222:0xc2132d05d31c914a87c6611c10748aeb04b58e8f": "0" + } + } + }, + type: RampDirection.SELL, + update: mock(async () => state) + } as unknown as RampState; + const executor = Object.create(FinalSettlementSubsidyExecutor.prototype) as any; + executor.createSubsidy = mock(async () => undefined); + + await expect(executor.executePhase(state)).rejects.toMatchObject({ + isRecoverable: true, + message: expect.stringContaining("subsidy cap") + }); + + expect(checkBalance).toHaveBeenCalledWith(expect.objectContaining({ amountDesiredRaw: "900000" })); + expect(executor.createSubsidy).not.toHaveBeenCalled(); + expect(sendTransaction).not.toHaveBeenCalled(); + }); + it("propagates rejected AlfredPay statuses and records on-chain completion while balance confirmation continues", async () => { const executor = Object.create(AlfredpayOnrampMintExecutor.prototype) as any; const state = { state: {}, update: mock(async () => state) } as unknown as RampState; diff --git a/apps/api/src/test-utils/fake-world/fake-evm.ts b/apps/api/src/test-utils/fake-world/fake-evm.ts index 0a2e457e0..adc91d6dd 100644 --- a/apps/api/src/test-utils/fake-world/fake-evm.ts +++ b/apps/api/src/test-utils/fake-world/fake-evm.ts @@ -231,8 +231,22 @@ export class FakeEvm { to: params.to, value: params.value }), - writeContract: async (params: { address: string; functionName: string }) => - this.recordTransaction({ data: params.functionName, from: account.address, network, to: params.address }) + writeContract: async (params: { address: string; args?: readonly unknown[]; functionName: string }) => { + const hash = this.recordTransaction({ + data: params.functionName, + from: account.address, + network, + to: params.address + }); + if (params.functionName === "transferFrom") { + const [from, to, amount] = params.args as [string, string, bigint]; + const fromBalance = this.erc20Balance(network, params.address, from); + if (fromBalance < amount) throw new Error("FakeEvm: transferFrom balance is insufficient"); + this.setErc20Balance(network, params.address, from, fromBalance - amount); + this.setErc20Balance(network, params.address, to, this.erc20Balance(network, params.address, to) + amount); + } + return hash; + } }, `WalletClient(${network})` ); diff --git a/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts b/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts index fb1fb4bc7..e05fb9abd 100644 --- a/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts +++ b/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts @@ -1,10 +1,13 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, mock, setSystemTime, spyOn } from "bun:test"; import { ALFREDPAY_ERC20_DECIMALS, ALFREDPAY_ERC20_TOKEN, + AlfredpayChain, + AlfredpayFeeType, AlfredpayOfframpStatus, type EvmTransactionData, EvmToken, + evmTokenConfig, FiatToken, Networks, PRESIGNED_EVM_FEE_MULTIPLIER, @@ -12,11 +15,22 @@ import { type RampPhase, type UnsignedTx } from "@vortexfi/shared"; -import { BaseError, ContractFunctionExecutionError, decodeFunctionData, erc20Abi, parseTransaction } from "viem"; +import { + BaseError, + ContractFunctionExecutionError, + decodeFunctionData, + encodeFunctionData, + erc20Abi, + parseTransaction +} from "viem"; import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; import { parseUnits } from "viem/utils"; +import type { AlfredpayOfframpMetadata } from "../../api/services/phases/blocks/phases/alfredpay-offramp/simulation"; +import { AlfredpayOfframpTransferExecutor } from "../../api/services/phases/blocks/phases/alfredpay-offramp/execution"; import phaseProcessor from "../../api/services/phases/phase-processor"; +import { PolygonPostProcessHandler } from "../../api/services/phases/post-process/polygon-post-process-handler"; import { getEvmFundingAccount } from "../../api/services/phases/blocks/core/evm-funding"; +import logger from "../../config/logger"; import FinancialOperation from "../../models/financialOperation.model"; import Subsidy from "../../models/subsidy.model"; import QuoteTicket from "../../models/quoteTicket.model"; @@ -51,6 +65,7 @@ interface EvmTxBlueprint extends EvmTransactionData { interface CorridorSetup { rampId: string; quoteId: string; + quoteOutputAmount: string; /** Raw (6-decimal) USDT amount the offramp moves. */ inputAmountRaw: bigint; signedOfframpTransfer: `0x${string}`; @@ -94,9 +109,24 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () // tests that rely on the native-swap settlement path stay deterministic. world.evm.setErc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, getEvmFundingAccount(Networks.Polygon).address, 0n); world.squidRouter.computeToAmount = params => params.fromAmount; + world.squidRouter.computeToAmountMin = params => world.squidRouter.computeToAmount(params); world.squidRouter.toTokenDecimals = ALFREDPAY_ERC20_DECIMALS; world.alfredpay.offrampRate = ALFREDPAY_OFFRAMP_RATE; - world.alfredpay.offrampStatus = AlfredpayOfframpStatus.FIAT_TRANSFER_COMPLETED; + world.alfredpay.offrampMaxFromAmount = null; + world.alfredpay.onCreateOfframpQuote = undefined; + world.alfredpay.offrampQuoteToAmountAdjustmentOnce = null; + world.alfredpay.nextOfframpQuoteExpiration = null; + world.alfredpay.nextOfframpQuoteChain = null; + world.alfredpay.nextOfframpExpiration = null; + world.alfredpay.nextOfframpOrderStatus = null; + world.alfredpay.nextOfframpRereadStatus = null; + world.alfredpay.nextOfframpTransactionId = null; + world.alfredpay.offrampOrders.splice(0); + world.alfredpay.issuedOfframpQuotes.clear(); + world.alfredpay.offrampStatusOverrides.clear(); + world.alfredpay.offrampTransactions.clear(); + world.alfredpay.offrampStatus = AlfredpayOfframpStatus.CREATED; + world.alfredpay.quoteFees = []; // Fresh deposit address per test: the in-memory EVM ledger persists across // tests, so a shared address would accumulate balances between scenarios. world.alfredpay.offrampDepositAddress = privateKeyToAccount(generatePrivateKey()).address.toLowerCase(); @@ -115,12 +145,12 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () }; }); - async function createQuoteViaApi(): Promise<{ id: string; inputAmount: string; outputAmount: string }> { + async function createQuoteViaApi(inputAmount = "100"): Promise<{ id: string; inputAmount: string; outputAmount: string }> { const squidRouteCount = world.squidRouter.requestedRoutes.length; const response = await app.request("/v1/quotes", { body: JSON.stringify({ from: Networks.Polygon, - inputAmount: "100", + inputAmount, inputCurrency: EvmToken.USDT, network: Networks.Polygon, outputCurrency: FiatToken.MXN, @@ -163,13 +193,13 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () return blueprint?.txData as unknown as EvmTxBlueprint; } - async function setUpRegisteredRamp(): Promise { + async function setUpRegisteredRamp(inputAmount = "100"): Promise { const ephemeral = privateKeyToAccount(generatePrivateKey()); const userWallet = privateKeyToAccount(generatePrivateKey()); const user = await createTestUser(); await createTestAlfredpayCustomer(user.id); - const quote = await createQuoteViaApi(); + const quote = await createQuoteViaApi(inputAmount); const ramp = await registerViaApi(quote.id, user.id, ephemeral, userWallet); const persistedQuote = await QuoteTicket.findByPk(quote.id); @@ -250,6 +280,7 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () ephemeral, inputAmountRaw, quoteId: quote.id, + quoteOutputAmount: quote.outputAmount, rampId: ramp.id, signedOfframpTransfer, userTransferBlueprint, @@ -288,6 +319,9 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () recipient, world.evm.erc20Balance(tx.network, parsed.to, recipient) + amount ); + if (recipient.toLowerCase() === world.alfredpay.offrampDepositAddress.toLowerCase()) { + world.alfredpay.offrampStatus = AlfredpayOfframpStatus.FIAT_TRANSFER_COMPLETED; + } }; } @@ -295,6 +329,23 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () return world.evm.sentTransactions.filter(tx => tx.serialized === signedTransfer).length; } + async function processRampWithoutCompletionEmail(rampId: string): Promise { + // Completion-email enqueueing is deliberately detached from phase processing. + // This corridor suite resets the database between scenarios, so remove the + // unrelated recipient before processing to prevent that background query from + // racing the next test's TRUNCATE. + await RampState.update({ userId: null }, { where: { id: rampId } }); + await phaseProcessor.processRamp(rampId); + } + + async function getAlfredpayMetadata(quoteId: string): Promise { + const quote = await QuoteTicket.findByPk(quoteId); + const metadata = (quote?.metadata as unknown as { blocks?: { alfredpayOfframp?: AlfredpayOfframpMetadata } })?.blocks + ?.alfredpayOfframp; + expect(metadata).toBeDefined(); + return metadata as AlfredpayOfframpMetadata; + } + it("quotes direct Polygon USDT 1:1 without requesting a Squid route", async () => { const quote = await createQuoteViaApi(); const persistedQuote = await QuoteTicket.findByPk(quote.id); @@ -365,6 +416,255 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () ); }); + it("registration can retry safely after a pre-order provider quote drift", async () => { + const user = await createTestUser(); + await createTestAlfredpayCustomer(user.id); + const quote = await createQuoteViaApi(); + const ephemeral = privateKeyToAccount(generatePrivateKey()); + const userWallet = privateKeyToAccount(generatePrivateKey()); + const orderCount = world.alfredpay.offrampOrders.length; + world.alfredpay.offrampQuoteToAmountAdjustmentOnce = "-0.01"; + + const first = await app.request("/v1/ramp/register", { + body: JSON.stringify({ + additionalData: { fiatAccountId: FIAT_ACCOUNT_ID, walletAddress: userWallet.address }, + quoteId: quote.id, + signingAccounts: [{ address: ephemeral.address, type: "EVM" }] + }), + headers: { + Authorization: `Bearer ${testUserToken(user.id)}`, + "Content-Type": "application/json" + }, + method: "POST" + }); + expect(first.status).toBe(422); + expect(world.alfredpay.offrampOrders).toHaveLength(orderCount); + expect( + await FinancialOperation.findOne({ where: { attemptClass: "registration", scopeId: quote.id } }) + ).toMatchObject({ status: "failed" }); + + await registerViaApi(quote.id, user.id, ephemeral, userWallet); + expect(world.alfredpay.offrampOrders).toHaveLength(orderCount + 1); + expect( + await FinancialOperation.findOne({ where: { attemptClass: "registration", scopeId: quote.id } }) + ).toMatchObject({ status: "confirmed" }); + }); + + it("cross-chain quotes and prepared routes use Squid's guaranteed minimum", async () => { + world.squidRouter.computeToAmount = () => parseUnits("999", 6).toString(); + world.squidRouter.computeToAmountMin = () => parseUnits("989", 6).toString(); + world.squidRouter.toTokenDecimals = 6; + const quoteResponse = await app.request("/v1/quotes", { + body: JSON.stringify({ + from: Networks.Base, + inputAmount: "1000", + inputCurrency: EvmToken.USDT, + network: Networks.Polygon, + outputCurrency: FiatToken.MXN, + rampType: RampDirection.SELL, + to: "spei" + }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + expect(quoteResponse.status).toBe(201); + const quote = (await quoteResponse.json()) as { id: string; outputAmount: string }; + const metadata = await getAlfredpayMetadata(quote.id); + expect(metadata.bridgeOutputAmountRaw).toBe(parseUnits("999", 6).toString()); + expect(metadata.executableBridgeOutputRaw).toBe(parseUnits("989", 6).toString()); + expect(metadata.inputAmountRaw).toBe(parseUnits("989", 6).toString()); + expect(quote.outputAmount).toBe("19780.00"); + + const user = await createTestUser(); + await createTestAlfredpayCustomer(user.id); + const ephemeral = privateKeyToAccount(generatePrivateKey()); + const userWallet = privateKeyToAccount(generatePrivateKey()); + world.squidRouter.computeToAmount = () => parseUnits("988", 6).toString(); + world.squidRouter.computeToAmountMin = () => parseUnits("989", 6).toString(); + const registration = await app.request("/v1/ramp/register", { + body: JSON.stringify({ + additionalData: { fiatAccountId: FIAT_ACCOUNT_ID, walletAddress: userWallet.address }, + quoteId: quote.id, + signingAccounts: [{ address: ephemeral.address, type: "EVM" }] + }), + headers: { + Authorization: `Bearer ${testUserToken(user.id)}`, + "Content-Type": "application/json" + }, + method: "POST" + }); + expect(registration.status).not.toBe(201); + expect(await RampState.findOne({ where: { quoteId: quote.id } })).toBeNull(); + }); + + it("rejects a Squid route whose guaranteed minimum exceeds its estimate", async () => { + world.squidRouter.computeToAmount = () => parseUnits("989", 6).toString(); + world.squidRouter.computeToAmountMin = () => parseUnits("999", 6).toString(); + world.squidRouter.toTokenDecimals = 6; + const quoteCount = await QuoteTicket.count(); + + const response = await app.request("/v1/quotes", { + body: JSON.stringify({ + from: Networks.Base, + inputAmount: "1000", + inputCurrency: EvmToken.USDT, + network: Networks.Polygon, + outputCurrency: FiatToken.MXN, + rampType: RampDirection.SELL, + to: "spei" + }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + + expect(response.status).not.toBe(201); + expect(await QuoteTicket.count()).toBe(quoteCount); + }); + + it("does not persist an Alfredpay quote that expires before safe registration and signing", async () => { + world.alfredpay.nextOfframpQuoteExpiration = new Date(Date.now() + 5_000).toISOString(); + const quoteCount = await QuoteTicket.count(); + const response = await app.request("/v1/quotes", { + body: JSON.stringify({ + from: Networks.Polygon, + inputAmount: "1000", + inputCurrency: EvmToken.USDT, + network: Networks.Polygon, + outputCurrency: FiatToken.MXN, + rampType: RampDirection.SELL, + to: "spei" + }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + + expect(response.status).not.toBe(201); + expect(await QuoteTicket.count()).toBe(quoteCount); + }); + + it("measures provider quote lifetime after provider latency instead of from the pricing snapshot", async () => { + const startedAt = new Date("2026-08-11T12:00:00.000Z"); + setSystemTime(startedAt); + world.alfredpay.nextOfframpQuoteExpiration = new Date(startedAt.getTime() + 25_000).toISOString(); + world.alfredpay.onCreateOfframpQuote = () => setSystemTime(new Date(startedAt.getTime() + 20_000)); + const quoteCount = await QuoteTicket.count(); + try { + const response = await app.request("/v1/quotes", { + body: JSON.stringify({ + from: Networks.Polygon, + inputAmount: "1000", + inputCurrency: EvmToken.USDT, + network: Networks.Polygon, + outputCurrency: FiatToken.MXN, + rampType: RampDirection.SELL, + to: "spei" + }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + expect(response.status).not.toBe(201); + expect(await QuoteTicket.count()).toBe(quoteCount); + } finally { + world.alfredpay.onCreateOfframpQuote = undefined; + setSystemTime(); + } + }); + + it("returns positive Polygon USDT execution variance to the user's wallet", async () => { + const setup = await setUpRegisteredRamp(); + const registered = await RampState.findByPk(setup.rampId); + expect(registered).toBeDefined(); + if (!registered) throw new Error("missing registered AlfredPay cleanup state"); + const cleanupUnsigned = registered?.unsignedTxs.find(tx => tx.phase === "polygonCleanupAxlUsdc"); + expect(cleanupUnsigned).toBeDefined(); + if (!cleanupUnsigned) throw new Error("missing AlfredPay Polygon cleanup blueprint"); + const cleanupBlueprint = cleanupUnsigned.txData as unknown as EvmTxBlueprint; + const fundingAddress = getEvmFundingAccount(Networks.Polygon).address; + const residualRaw = parseUnits("10", ALFREDPAY_ERC20_DECIMALS); + expect(cleanupBlueprint.to.toLowerCase()).toBe(ALFREDPAY_ERC20_TOKEN.toLowerCase()); + expect(decodeFunctionData({ abi: erc20Abi, data: cleanupBlueprint.data })).toEqual({ + args: [fundingAddress, 2n ** 256n - 1n], + functionName: "approve" + }); + const signedApproval = await setup.ephemeral.signTransaction({ + chainId: 137, + data: cleanupBlueprint.data, + gas: BigInt(cleanupBlueprint.gas), + maxFeePerGas: BigInt(cleanupBlueprint.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: + BigInt(cleanupBlueprint.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + nonce: cleanupUnsigned.nonce, + to: cleanupBlueprint.to, + type: "eip1559" + }); + const driftedWallet = privateKeyToAccount(generatePrivateKey()).address; + world.evm.setErc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.ephemeral.address, residualRaw); + world.evm.setErc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.userWallet.address, 0n); + world.evm.setErc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, driftedWallet, 0n); + + const handler = new PolygonPostProcessHandler() as unknown as { + getPresignedTransaction: () => { txData: `0x${string}` }; + process(state: RampState): Promise<[boolean, Error | null]>; + }; + handler.getPresignedTransaction = () => ({ txData: signedApproval }); + registered.set({ + currentPhase: "complete", + state: { ...registered.state, walletAddress: driftedWallet } + }); + const [processed, error] = await handler.process(registered); + + expect(processed).toBe(true); + expect(error).toBeNull(); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.ephemeral.address)).toBe(0n); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.userWallet.address)).toBe(residualRaw); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, driftedWallet)).toBe(0n); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, fundingAddress)).toBe(0n); + }); + + it("keeps legacy Polygon AXLUSDC cleanup directed to treasury", async () => { + const ephemeral = privateKeyToAccount(generatePrivateKey()); + const userWallet = privateKeyToAccount(generatePrivateKey()); + const fundingAddress = getEvmFundingAccount(Networks.Polygon).address; + const axlUsdc = evmTokenConfig[Networks.Polygon][EvmToken.AXLUSDC]?.erc20AddressSourceChain; + expect(axlUsdc).toBeTruthy(); + if (!axlUsdc) throw new Error("missing Polygon AXLUSDC test configuration"); + const residualRaw = parseUnits("10", 6); + const signedApproval = await ephemeral.signTransaction({ + chainId: 137, + data: encodeFunctionData({ + abi: erc20Abi, + args: [fundingAddress, 2n ** 256n - 1n], + functionName: "approve" + }), + gas: 100_000n, + maxFeePerGas: 1_000_000_000n, + maxPriorityFeePerGas: 1_000_000_000n, + nonce: 1, + to: axlUsdc, + type: "eip1559" + }); + world.evm.setErc20Balance(Networks.Polygon, axlUsdc, ephemeral.address, residualRaw); + world.evm.setErc20Balance(Networks.Polygon, axlUsdc, fundingAddress, 0n); + + const handler = new PolygonPostProcessHandler() as unknown as { + getPresignedTransaction: () => { txData: `0x${string}` }; + process(state: RampState): Promise<[boolean, Error | null]>; + }; + handler.getPresignedTransaction = () => ({ txData: signedApproval }); + const [processed, error] = await handler.process({ + currentPhase: "complete", + id: "legacy-axlusdc-cleanup-test", + state: { evmEphemeralAddress: ephemeral.address, walletAddress: userWallet.address }, + type: RampDirection.SELL + } as RampState); + + expect(processed).toBe(true); + expect(error).toBeNull(); + expect(world.evm.erc20Balance(Networks.Polygon, axlUsdc, ephemeral.address)).toBe(0n); + expect(world.evm.erc20Balance(Networks.Polygon, axlUsdc, fundingAddress)).toBe(residualRaw); + expect(world.evm.erc20Balance(Networks.Polygon, axlUsdc, userWallet.address)).toBe(0n); + }); + it( "happy path: processes the full Alfredpay offramp phase sequence to complete", async () => { @@ -372,7 +672,7 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () scriptHappyWorld(setup); const depositAddress = world.alfredpay.offrampDepositAddress; - await phaseProcessor.processRamp(setup.rampId); + await processRampWithoutCompletionEmail(setup.rampId); const final = await RampState.findByPk(setup.rampId); expect(final?.currentPhase).toBe("complete"); @@ -474,7 +774,7 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () ); const depositAddress = world.alfredpay.offrampDepositAddress; - await phaseProcessor.processRamp(setup.rampId); + await processRampWithoutCompletionEmail(setup.rampId); const final = await RampState.findByPk(setup.rampId); expect(final?.currentPhase).toBe("complete"); @@ -491,34 +791,44 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () ); it( - "fee + target discount: the deposit reflects the promised net rate while the full fee is collected", + "15 bps target: reconciles Alfredpay spread and fees while funding the executable settlement", async () => { const vortexPayout = privateKeyToAccount(generatePrivateKey()).address as `0x${string}`; - // 17 MXN fee = 1 USD; a 1% target promises 1717 MXN gross. The subsidy is - // sized against the fee-net actual (1683 MXN), so the deposit becomes - // (1683 + 34) / 17 = 101 USDT while the 1 USDT fee is still collected. + world.alfredpay.offrampRate = 16.9; + world.alfredpay.quoteFees = [{ amount: "17", currency: "MXN", type: AlfredpayFeeType.PROCESSING_FEE }]; await updatePartnerPricing("vortex", RampDirection.SELL, { markupCurrency: FiatToken.MXN, markupType: "absolute", markupValue: 17, - maxSubsidy: 0.1, + maxSubsidy: 0.0095, payoutAddressEvm: vortexPayout, - targetDiscount: 0.01 + targetDiscount: 0.0015 }); - const setup = await setUpRegisteredRamp(); - expect(setup.inputAmountRaw).toBe(parseUnits("101", 6)); + const setup = await setUpRegisteredRamp("1000"); + const metadata = await getAlfredpayMetadata(setup.quoteId); + expect(setup.quoteOutputAmount).toBe("17025.50"); + expect(setup.inputAmountRaw).toBe(parseUnits("1008.431953", 6)); + expect(metadata.subsidyAmountRaw).toBe(parseUnits("9.431953", 6).toString()); + expect(Number(metadata.outputAmountDecimal)).toBeCloseTo(17025.5000057, 6); + expect(Number(metadata.pricing.customer.referenceDifferenceBps)).toBeCloseTo(15, 5); + expect(Number(metadata.pricing.reference.rate)).toBe(17); + expect(Number(metadata.pricing.provider.grossRate)).toBe(16.9); + expect(Number(metadata.pricing.provider.feeAmount)).toBe(17); + expect( + BigInt(metadata.inputAmountRaw) + parseUnits("1", 6) - BigInt(metadata.bridgeOutputAmountRaw) + ).toBe(BigInt(metadata.subsidyAmountRaw)); const rampState = await RampState.findByPk(setup.rampId); const allUnsignedTxs = rampState?.unsignedTxs ?? []; - // The refund fallback returns the user's bridged 100 USDT — NOT the subsidized - // deposit plus fees (102), which would hand the platform subsidy to the user + // The refund fallback returns the user's bridged 1000 USDT — not the subsidized + // deposit plus fees, which would hand the platform subsidy to the user // on a failed ramp. const fallbackBlueprint = allUnsignedTxs.find(tx => tx.phase === "alfredpayOfframpTransferFallback"); const fallbackData = (fallbackBlueprint?.txData as unknown as { data: `0x${string}` }).data; const fallbackArgs = decodeFunctionData({ abi: erc20Abi, data: fallbackData }).args as [string, bigint]; - expect(fallbackArgs[1]).toBe(parseUnits("100", 6)); + expect(fallbackArgs[1]).toBe(parseUnits("1000", 6)); const feeBlueprint = allUnsignedTxs.find(tx => tx.phase === "distributeFees"); expect(feeBlueprint).toBeDefined(); @@ -563,32 +873,232 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () expect(updateResponse.status).toBe(200); scriptHappyWorld(setup); - // The ephemeral starts with ONLY the user's bridged 100 USDT: final settlement - // must top it up to deposit + fee (102), i.e. a 2 USDT platform subsidy paid - // from the funding account's USDT balance. - world.evm.setErc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.ephemeral.address, parseUnits("100", 6)); + // The ephemeral starts with only the user's bridged 1000 USDT. Final settlement + // funds the exact provider input plus the 1 USDT fee reserve. + world.evm.setErc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.ephemeral.address, parseUnits("1000", 6)); const fundingAddress = getEvmFundingAccount(Networks.Polygon).address; world.evm.setErc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, fundingAddress, parseUnits("10", 6)); const depositAddress = world.alfredpay.offrampDepositAddress; - await phaseProcessor.processRamp(setup.rampId); + await processRampWithoutCompletionEmail(setup.rampId); const final = await RampState.findByPk(setup.rampId); expect(final?.currentPhase).toBe("complete"); - expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, depositAddress)).toBe(parseUnits("101", 6)); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, depositAddress)).toBe( + parseUnits("1008.431953", 6) + ); expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, vortexPayout)).toBe(parseUnits("1", 6)); expect(submissionsOf(signedFeeTransfer)).toBe(1); - // The settlement subsidy covered deposit + fee minus the user's 100 USDT: - // exactly 2 USDT. Sizing the target without the fee would record 1 instead - // and starve the fee transfer on a real chain. const settlementSubsidies = await Subsidy.findAll({ where: { phase: "finalSettlementSubsidy", rampId: setup.rampId } }); expect(settlementSubsidies).toHaveLength(1); - expect(Number(settlementSubsidies[0].amount)).toBe(2); + expect(Number(settlementSubsidies[0].amount)).toBeCloseTo(9.431953, 6); }, 30000 ); + it("partner subsidy cap: returns the best executable quote below the target", async () => { + world.alfredpay.offrampRate = 16.9; + world.alfredpay.quoteFees = [{ amount: "17", currency: "MXN", type: AlfredpayFeeType.PROCESSING_FEE }]; + await updatePartnerPricing("vortex", RampDirection.SELL, { + markupCurrency: FiatToken.MXN, + markupType: "absolute", + markupValue: 17, + maxSubsidy: 0.0093, + targetDiscount: 0.0015 + }); + + const warning = spyOn(logger, "warn"); + try { + const quote = await createQuoteViaApi("1000"); + const metadata = await getAlfredpayMetadata(quote.id); + expect(quote.outputAmount).toBe("17023.50"); + expect(metadata.inputAmountRaw).toBe(parseUnits("1008.31395", 6).toString()); + expect(metadata.subsidyAmountRaw).toBe(parseUnits("9.31395", 6).toString()); + expect(Number(metadata.outputAmountDecimal)).toBeCloseTo(17023.505755, 6); + expect(Number(metadata.pricing.customer.referenceDifferenceBps)).toBeCloseTo(13.8269147, 6); + expect(Number(metadata.pricing.customer.referenceDifferenceBps)).toBeLessThan(15); + expect(warning).toHaveBeenCalledWith( + "ALFREDPAY_OFFRAMP_TARGET_DISCOUNT_CAPPED", + expect.objectContaining({ + allowedSubsidyUsd: "9.31395", + appliedSubsidyUsd: "9.31395", + capReason: "partner", + deliveredOutput: "17023.505755", + requestedTargetOutput: "17025.5" + }) + ); + } finally { + warning.mockRestore(); + } + }); + + it("runtime subsidy cap: returns and settles the best quote at exactly ten USDT", async () => { + world.alfredpay.offrampRate = 16.9; + world.alfredpay.quoteFees = [{ amount: "17", currency: "MXN", type: AlfredpayFeeType.PROCESSING_FEE }]; + await updatePartnerPricing("vortex", RampDirection.SELL, { + maxSubsidy: 0.1, + targetDiscount: 0.02 + }); + + const warning = spyOn(logger, "warn"); + let setup!: CorridorSetup; + try { + setup = await setUpRegisteredRamp("1000"); + const metadata = await getAlfredpayMetadata(setup.quoteId); + expect(setup.quoteOutputAmount).toBe("17052.00"); + expect(metadata.inputAmountRaw).toBe(parseUnits("1010", 6).toString()); + expect(metadata.subsidyAmountRaw).toBe(parseUnits("10", 6).toString()); + expect(Number(metadata.pricing.customer.referenceDifferenceBps)).toBeLessThan(200); + expect(warning).toHaveBeenCalledWith( + "ALFREDPAY_OFFRAMP_TARGET_DISCOUNT_CAPPED", + expect.objectContaining({ + allowedSubsidyUsd: "10", + appliedSubsidyUsd: "10", + capReason: "runtime", + deliveredOutput: "17052", + requestedTargetOutput: "17340" + }) + ); + } finally { + warning.mockRestore(); + } + + scriptHappyWorld(setup); + world.evm.setErc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.ephemeral.address, parseUnits("1000", 6)); + const fundingAddress = getEvmFundingAccount(Networks.Polygon).address; + world.evm.setErc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, fundingAddress, parseUnits("10", 6)); + const depositAddress = world.alfredpay.offrampDepositAddress; + + await processRampWithoutCompletionEmail(setup.rampId); + + expect((await RampState.findByPk(setup.rampId))?.currentPhase).toBe("complete"); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, depositAddress)).toBe(parseUnits("1010", 6)); + const settlementSubsidies = await Subsidy.findAll({ where: { phase: "finalSettlementSubsidy", rampId: setup.rampId } }); + expect(settlementSubsidies).toHaveLength(1); + expect(Number(settlementSubsidies[0].amount)).toBe(10); + }); + + it("provider maximum: falls back to the best fixed-input quote instead of rejecting", async () => { + world.alfredpay.offrampRate = 16.9; + world.alfredpay.offrampMaxFromAmount = "1000"; + world.alfredpay.quoteFees = [{ amount: "17", currency: "MXN", type: AlfredpayFeeType.PROCESSING_FEE }]; + await updatePartnerPricing("vortex", RampDirection.SELL, { + markupCurrency: FiatToken.MXN, + markupType: "absolute", + markupValue: 17, + maxSubsidy: 0.1, + targetDiscount: 0.02 + }); + + const warning = spyOn(logger, "warn"); + try { + const quote = await createQuoteViaApi("1000"); + const metadata = await getAlfredpayMetadata(quote.id); + expect(quote.outputAmount).toBe("16883.00"); + expect(metadata.inputAmountRaw).toBe(parseUnits("1000", 6).toString()); + expect(metadata.subsidyAmountRaw).toBe(parseUnits("1", 6).toString()); + expect(warning).toHaveBeenCalledWith( + "ALFREDPAY_OFFRAMP_TARGET_DISCOUNT_CAPPED", + expect.objectContaining({ + appliedSubsidyUsd: "1", + capReason: "provider", + providerMaximumInput: "1000", + requiredSubsidyUsd: ">1" + }) + ); + } finally { + warning.mockRestore(); + } + }); + + it("rejects when the provider maximum cannot cover the fee-net baseline input", async () => { + world.alfredpay.offrampRate = 16.9; + world.alfredpay.offrampMaxFromAmount = "998"; + world.alfredpay.quoteFees = [{ amount: "17", currency: "MXN", type: AlfredpayFeeType.PROCESSING_FEE }]; + await updatePartnerPricing("vortex", RampDirection.SELL, { + markupCurrency: FiatToken.MXN, + markupType: "absolute", + markupValue: 17, + maxSubsidy: 0.1, + targetDiscount: 0.02 + }); + const quoteCount = await QuoteTicket.count(); + + const response = await app.request("/v1/quotes", { + body: JSON.stringify({ + from: Networks.Polygon, + inputAmount: "1000", + inputCurrency: EvmToken.USDT, + network: Networks.Polygon, + outputCurrency: FiatToken.MXN, + rampType: RampDirection.SELL, + to: "spei" + }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + + expect(response.status).not.toBe(201); + expect(await QuoteTicket.count()).toBe(quoteCount); + }); + + it("favorable provider pricing returns the natural upside without a subsidy", async () => { + world.alfredpay.offrampRate = 20; + world.alfredpay.quoteFees = [{ amount: "17", currency: "MXN", type: AlfredpayFeeType.PROCESSING_FEE }]; + await updatePartnerPricing("vortex", RampDirection.SELL, { + markupCurrency: FiatToken.MXN, + markupType: "absolute", + markupValue: 17, + maxSubsidy: 0.1, + targetDiscount: 0.0015 + }); + + const quote = await createQuoteViaApi("1000"); + const metadata = await getAlfredpayMetadata(quote.id); + expect(quote.outputAmount).toBe("19963.00"); + expect(metadata.inputAmountRaw).toBe(parseUnits("999", 6).toString()); + expect(metadata.subsidyAmountRaw).toBe("0"); + expect(Number(metadata.pricing.customer.referenceDifferenceBps)).toBeGreaterThan(15); + }); + + it("zero target discount leaves provider spread and fees unsubsidized", async () => { + world.alfredpay.offrampRate = 16.9; + world.alfredpay.quoteFees = [{ amount: "17", currency: "MXN", type: AlfredpayFeeType.PROCESSING_FEE }]; + await updatePartnerPricing("vortex", RampDirection.SELL, { + markupCurrency: FiatToken.MXN, + markupType: "absolute", + markupValue: 17, + maxSubsidy: 0.1, + targetDiscount: 0 + }); + + const quote = await createQuoteViaApi("1000"); + const metadata = await getAlfredpayMetadata(quote.id); + expect(quote.outputAmount).toBe("16866.10"); + expect(metadata.inputAmountRaw).toBe(parseUnits("999", 6).toString()); + expect(metadata.subsidyAmountRaw).toBe("0"); + }); + + it("negative target discount remains a rate floor without enabling subsidy", async () => { + world.alfredpay.offrampRate = 16.7; + world.alfredpay.quoteFees = [{ amount: "17", currency: "MXN", type: AlfredpayFeeType.PROCESSING_FEE }]; + await updatePartnerPricing("vortex", RampDirection.SELL, { + markupCurrency: FiatToken.MXN, + markupType: "absolute", + markupValue: 17, + maxSubsidy: 0.1, + targetDiscount: -0.01 + }); + + const quote = await createQuoteViaApi("1000"); + const metadata = await getAlfredpayMetadata(quote.id); + expect(quote.outputAmount).toBe("16666.30"); + expect(metadata.inputAmountRaw).toBe(parseUnits("999", 6).toString()); + expect(metadata.subsidyAmountRaw).toBe("0"); + expect(Number(metadata.adjustedTargetDiscount)).toBe(-0.01); + }); + it( "ambiguous funding failure: an RPC outage pauses the ramp for reconciliation", async () => { @@ -612,7 +1122,7 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () world.evm.failNextSends = 1; world.evm.sendFailureMessage = "FakeEvm: scripted RPC outage"; - await phaseProcessor.processRamp(setup.rampId); + await processRampWithoutCompletionEmail(setup.rampId); const final = await RampState.findByPk(setup.rampId); expect(final?.currentPhase).toBe("fundEphemeral"); @@ -654,7 +1164,7 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () state: { ...rampState.state, squidRouterNoPermitTransferHash: tamperedHash } }); - await phaseProcessor.processRamp(setup.rampId); + await processRampWithoutCompletionEmail(setup.rampId); const final = await RampState.findByPk(setup.rampId); expect(final?.currentPhase).toBe("failed"); @@ -668,31 +1178,28 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () ); it( - "subsidy cap (F-001): a settlement shortfall needing more than MAX_FINAL_SETTLEMENT_SUBSIDY_USD of native fails instead of paying", + "quoted subsidy cap: bridge under-delivery pauses without treasury funding", async () => { const setup = await setUpRegisteredRamp(); world.evm.setNativeBalance(Networks.Polygon, setup.ephemeral.address, parseUnits("2", 18)); // Only 90% of the expected USDT arrived (exactly the minimum bridge - // delivery ratio, so the balance poll passes): the 10 USDT shortfall - // must be subsidized. The funding account holds no USDT, so the handler - // prices a native→USDT swap; at 0.5 USD/MATIC the required ~22 MATIC - // (incl. the 10% buffer) is worth $11 — above the $10 F-001 cap. + // delivery ratio, so the balance poll passes). The quote authorized zero + // subsidy, so the observed bridge shortfall must not become treasury loss. world.evm.setErc20Balance( Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.ephemeral.address, (setup.inputAmountRaw * 9n) / 10n ); - world.squidRouter.computeToAmount = params => (BigInt(params.fromAmount) / 2n / 10n ** 12n).toString(); - await phaseProcessor.processRamp(setup.rampId); + await processRampWithoutCompletionEmail(setup.rampId); const final = await RampState.findByPk(setup.rampId); - expect(final?.currentPhase).toBe("failed"); + expect(final?.currentPhase).toBe("finalSettlementSubsidy"); expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); - expect(final?.errorLogs.some(log => log.error.includes("exceeds maximum allowed"))).toBe(true); + expect(final?.errorLogs.some(log => log.error.includes("quote's subsidy cap"))).toBe(true); - // The deposit transfer never reached the chain and nothing was subsidized. + // The deposit transfer never reached the chain and treasury sent nothing. expect(submissionsOf(setup.signedOfframpTransfer)).toBe(0); expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, world.alfredpay.offrampDepositAddress)).toBe(0n); }, @@ -706,12 +1213,441 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () scriptHappyWorld(setup); world.alfredpay.offrampStatus = AlfredpayOfframpStatus.FAILED; - await phaseProcessor.processRamp(setup.rampId); + await processRampWithoutCompletionEmail(setup.rampId); const final = await RampState.findByPk(setup.rampId); expect(final?.currentPhase).toBe("failed"); expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); + expect(submissionsOf(setup.signedOfframpTransfer)).toBe(0); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.ephemeral.address)).toBe( + setup.inputAmountRaw + ); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, world.alfredpay.offrampDepositAddress)).toBe(0n); + }, + 30000 + ); + + it("does not deposit into an order whose provider lifecycle is already advanced", async () => { + for (const status of [ + AlfredpayOfframpStatus.ON_CHAIN_DEPOSIT_RECEIVED, + AlfredpayOfframpStatus.TRADE_COMPLETED, + AlfredpayOfframpStatus.FIAT_TRANSFER_INITIATED, + AlfredpayOfframpStatus.FIAT_TRANSFER_COMPLETED + ]) { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + world.alfredpay.offrampStatus = status; + const registered = await RampState.findByPk(setup.rampId); + expect(registered).toBeDefined(); + const executor = new AlfredpayOfframpTransferExecutor() as unknown as { + executePhase(state: RampState): Promise; + }; + + await expect(executor.executePhase(registered as RampState)).rejects.toThrow( + `is already ${status} without a confirmed local transfer` + ); + expect(submissionsOf(setup.signedOfframpTransfer)).toBe(0); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.ephemeral.address)).toBe( + setup.inputAmountRaw + ); + } + }); + + it( + "expired-order recovery keeps the provider origin bound to the depositing ephemeral", + async () => { + world.alfredpay.nextOfframpTransactionId = "123e4567-e89b-12d3-a456-426614174000"; + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + const orderCount = world.alfredpay.offrampOrders.length; + const registered = await RampState.findByPk(setup.rampId); + const transactionId = registered?.state.alfredpayTransactionId; + expect(transactionId).toBeTruthy(); + const transaction = world.alfredpay.offrampTransactions.get(transactionId ?? ""); + expect(transaction).toBeDefined(); + if (transaction) transaction.expiration = new Date(0).toISOString(); + + await processRampWithoutCompletionEmail(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + expect(world.alfredpay.offrampOrders).toHaveLength(orderCount + 1); + expect(world.alfredpay.offrampOrders.at(-1)?.originAddress).toBe(setup.ephemeral.address); + expect(submissionsOf(setup.signedOfframpTransfer)).toBe(1); + const recoveryOperation = await FinancialOperation.findOne({ + where: { phase: "alfredpayOfframpTransfer", provider: "alfredpay", scopeId: setup.rampId } + }); + expect(recoveryOperation).toMatchObject({ externalId: expect.any(String), status: "confirmed" }); + expect(recoveryOperation?.attemptClass.startsWith("alfredpay-recovery:")).toBe(true); + expect(recoveryOperation?.attemptClass.length).toBeLessThanOrEqual(64); }, 30000 ); + + it("replays a confirmed replacement before inspecting the stale order after a persistence crash", async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + const registered = await RampState.findByPk(setup.rampId); + expect(registered).toBeDefined(); + const transactionId = registered?.state.alfredpayTransactionId; + const transaction = world.alfredpay.offrampTransactions.get(transactionId ?? ""); + expect(transaction).toBeDefined(); + if (!registered || !transaction) throw new Error("missing registered Alfredpay recovery fixture"); + transaction.expiration = new Date(0).toISOString(); + const executor = new AlfredpayOfframpTransferExecutor() as unknown as { + executePhase(state: RampState): Promise; + }; + const originalTransactionId = transaction.transactionId; + const originalUpdate = registered.update.bind(registered); + let crashed = false; + registered.update = mock(async values => { + const nextTransactionId = (values.state as typeof registered.state | undefined)?.alfredpayTransactionId; + if (!crashed && nextTransactionId && nextTransactionId !== originalTransactionId) { + crashed = true; + throw new Error("simulated crash before replacement id persistence"); + } + return originalUpdate(values); + }) as typeof registered.update; + const orderCount = world.alfredpay.offrampOrders.length; + try { + await expect(executor.executePhase(registered)).rejects.toMatchObject({ isRecoverable: true }); + } finally { + registered.update = originalUpdate as typeof registered.update; + } + expect(world.alfredpay.offrampOrders).toHaveLength(orderCount + 1); + const replacementTransactionId = world.alfredpay.offrampTransactions.keys().toArray().at(-1); + expect(replacementTransactionId).toBeTruthy(); + expect(registered.state.alfredpayTransactionId).toBe(originalTransactionId); + expect(submissionsOf(setup.signedOfframpTransfer)).toBe(0); + + // The stale order is now FAILED. The confirmed replacement journal must + // replay before this mutable response can incorrectly fail the ramp. + world.alfredpay.offrampStatusOverrides.set(originalTransactionId, AlfredpayOfframpStatus.FAILED); + await executor.executePhase(registered); + + expect(registered.state.alfredpayTransactionId).toBe(replacementTransactionId); + expect(world.alfredpay.offrampOrders).toHaveLength(orderCount + 1); + expect(submissionsOf(setup.signedOfframpTransfer)).toBe(1); + }); + + it("does not rebind or fund a confirmed replacement whose create status is already advanced", async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + const registered = await RampState.findByPk(setup.rampId); + expect(registered).toBeDefined(); + if (!registered) throw new Error("missing registered Alfredpay replacement-status fixture"); + const originalTransactionId = registered.state.alfredpayTransactionId as string; + const transaction = world.alfredpay.offrampTransactions.get(originalTransactionId); + expect(transaction).toBeDefined(); + if (!transaction) throw new Error("missing Alfredpay transaction fixture"); + transaction.expiration = new Date(0).toISOString(); + world.alfredpay.nextOfframpOrderStatus = AlfredpayOfframpStatus.FIAT_TRANSFER_INITIATED; + const orderCount = world.alfredpay.offrampOrders.length; + const executor = new AlfredpayOfframpTransferExecutor() as unknown as { + executePhase(state: RampState): Promise; + }; + + await expect(executor.executePhase(registered)).rejects.toThrow("is already FIAT_TRANSFER_INITIATED"); + await expect(executor.executePhase(registered)).rejects.toThrow("is already FIAT_TRANSFER_INITIATED"); + + expect(world.alfredpay.offrampOrders).toHaveLength(orderCount + 1); + expect(registered.state.alfredpayTransactionId).toBe(originalTransactionId); + expect(submissionsOf(setup.signedOfframpTransfer)).toBe(0); + }); + + it("does not rebind or fund a confirmed replacement whose reread status is already advanced", async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + const registered = await RampState.findByPk(setup.rampId); + expect(registered).toBeDefined(); + if (!registered) throw new Error("missing registered Alfredpay replacement-reread fixture"); + const originalTransactionId = registered.state.alfredpayTransactionId as string; + const transaction = world.alfredpay.offrampTransactions.get(originalTransactionId); + expect(transaction).toBeDefined(); + if (!transaction) throw new Error("missing Alfredpay transaction fixture"); + transaction.expiration = new Date(0).toISOString(); + world.alfredpay.nextOfframpRereadStatus = AlfredpayOfframpStatus.FIAT_TRANSFER_INITIATED; + const orderCount = world.alfredpay.offrampOrders.length; + const executor = new AlfredpayOfframpTransferExecutor() as unknown as { + executePhase(state: RampState): Promise; + }; + + await expect(executor.executePhase(registered)).rejects.toThrow("is already FIAT_TRANSFER_INITIATED"); + await expect(executor.executePhase(registered)).rejects.toThrow("is already FIAT_TRANSFER_INITIATED"); + + expect(world.alfredpay.offrampOrders).toHaveLength(orderCount + 1); + expect(registered.state.alfredpayTransactionId).toBe(originalTransactionId); + expect(submissionsOf(setup.signedOfframpTransfer)).toBe(0); + }); + + it("fails without funding when a confirmed replacement reread reports FAILED", async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + const registered = await RampState.findByPk(setup.rampId); + expect(registered).toBeDefined(); + if (!registered) throw new Error("missing registered Alfredpay failed-replacement fixture"); + const originalTransactionId = registered.state.alfredpayTransactionId as string; + const transaction = world.alfredpay.offrampTransactions.get(originalTransactionId); + expect(transaction).toBeDefined(); + if (!transaction) throw new Error("missing Alfredpay transaction fixture"); + transaction.expiration = new Date(0).toISOString(); + world.alfredpay.nextOfframpRereadStatus = AlfredpayOfframpStatus.FAILED; + const orderCount = world.alfredpay.offrampOrders.length; + const executor = new AlfredpayOfframpTransferExecutor() as unknown as { + executePhase(state: RampState): Promise; + }; + + const first = await executor.executePhase(registered); + const second = await executor.executePhase(registered); + + expect(first.currentPhase).toBe("failed"); + expect(second.currentPhase).toBe("failed"); + expect(world.alfredpay.offrampOrders).toHaveLength(orderCount + 1); + expect(registered.state.alfredpayTransactionId).toBe(originalTransactionId); + expect(submissionsOf(setup.signedOfframpTransfer)).toBe(0); + }); + + it( + "expired-order recovery retries a proven-safe rejection after provider terms improve", + async () => { + world.alfredpay.offrampRate = 16.9; + await updatePartnerPricing("vortex", RampDirection.SELL, { + maxSubsidy: 0.0095, + targetDiscount: 0.0015 + }); + const setup = await setUpRegisteredRamp("1000"); + const orderCount = world.alfredpay.offrampOrders.length; + const registered = await RampState.findByPk(setup.rampId); + const transactionId = registered?.state.alfredpayTransactionId; + expect(transactionId).toBeTruthy(); + const transaction = world.alfredpay.offrampTransactions.get(transactionId ?? ""); + expect(transaction).toBeDefined(); + if (transaction) transaction.expiration = new Date(0).toISOString(); + world.alfredpay.offrampRate = 16; + + const fundingAddress = getEvmFundingAccount(Networks.Polygon).address; + const principalRaw = parseUnits("1000", 6); + scriptHappyWorld(setup); + world.evm.setErc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.ephemeral.address, principalRaw); + world.evm.setErc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, fundingAddress, parseUnits("10", 6)); + world.evm.setErc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.userWallet.address, 0n); + + await processRampWithoutCompletionEmail(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("alfredpayOfframpTransfer"); + expect(world.alfredpay.offrampOrders).toHaveLength(orderCount); + expect(submissionsOf(setup.signedOfframpTransfer)).toBe(0); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, world.alfredpay.offrampDepositAddress)).toBe(0n); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.ephemeral.address)).toBe( + setup.inputAmountRaw + ); + expect(final?.errorLogs.some(log => log.error.includes("no replacement order can preserve"))).toBe(true); + + world.alfredpay.offrampRate = 16.9; + const executor = new AlfredpayOfframpTransferExecutor() as unknown as { + executePhase(state: RampState): Promise; + }; + await executor.executePhase(final as RampState); + expect(world.alfredpay.offrampOrders).toHaveLength(orderCount + 1); + expect(submissionsOf(setup.signedOfframpTransfer)).toBe(1); + }, + 30000 + ); + + it("recovers from immutable terms after a drifted live order expires", async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + const registered = await RampState.findByPk(setup.rampId); + expect(registered).toBeDefined(); + if (!registered) throw new Error("missing registered Alfredpay drift fixture"); + const transaction = world.alfredpay.offrampTransactions.get(registered.state.alfredpayTransactionId ?? ""); + expect(transaction).toBeDefined(); + if (!transaction) throw new Error("missing Alfredpay transaction fixture"); + const immutableDeposit = transaction.depositAddress; + transaction.depositAddress = privateKeyToAccount(generatePrivateKey()).address; + transaction.toAmount = "0"; + const executor = new AlfredpayOfframpTransferExecutor() as unknown as { + executePhase(state: RampState): Promise; + }; + + await expect(executor.executePhase(registered)).rejects.toMatchObject({ isRecoverable: true }); + expect(submissionsOf(setup.signedOfframpTransfer)).toBe(0); + + transaction.expiration = new Date(0).toISOString(); + const orderCount = world.alfredpay.offrampOrders.length; + await executor.executePhase(registered); + expect(world.alfredpay.offrampOrders).toHaveLength(orderCount + 1); + expect(world.alfredpay.offrampOrders.at(-1)?.customerId).toBe(registered.state.alfredpayUserId); + expect(world.alfredpay.offrampDepositAddress).toBe(immutableDeposit); + expect(submissionsOf(setup.signedOfframpTransfer)).toBe(1); + }); + + it("uses canonical block facts when compatibility identity projections drift", async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + const registered = await RampState.findByPk(setup.rampId); + expect(registered).toBeDefined(); + if (!registered) throw new Error("missing registered Alfredpay canonical-identity fixture"); + const transaction = world.alfredpay.offrampTransactions.get(registered.state.alfredpayTransactionId ?? ""); + expect(transaction).toBeDefined(); + if (!transaction) throw new Error("missing Alfredpay canonical-identity transaction fixture"); + transaction.expiration = new Date(0).toISOString(); + const canonicalFacts = registered.state.blockState?.alfredpayOfframp as + | { alfredpayUserId?: string; fiatAccountId?: string } + | undefined; + expect(canonicalFacts?.alfredpayUserId).toBeTruthy(); + expect(canonicalFacts?.fiatAccountId).toBeTruthy(); + await registered.update({ + state: { + ...registered.state, + alfredpayUserId: "drifted-compatibility-customer", + fiatAccountId: "drifted-compatibility-account" + } + }); + const executor = new AlfredpayOfframpTransferExecutor() as unknown as { + executePhase(state: RampState): Promise; + }; + + await executor.executePhase(registered); + + const replacement = world.alfredpay.offrampOrders.at(-1); + expect(replacement?.customerId).toBe(canonicalFacts?.alfredpayUserId); + expect(replacement?.fiatAccountId).toBe(canonicalFacts?.fiatAccountId); + expect(submissionsOf(setup.signedOfframpTransfer)).toBe(1); + }); + + it("rejects a wrong-chain recovery quote before creating a replacement order", async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + const registered = await RampState.findByPk(setup.rampId); + expect(registered).toBeDefined(); + if (!registered) throw new Error("missing registered Alfredpay recovery-chain fixture"); + const transaction = world.alfredpay.offrampTransactions.get(registered.state.alfredpayTransactionId ?? ""); + expect(transaction).toBeDefined(); + if (!transaction) throw new Error("missing Alfredpay recovery-chain transaction fixture"); + transaction.expiration = new Date(0).toISOString(); + world.alfredpay.nextOfframpQuoteChain = AlfredpayChain.ETH; + const orderCount = world.alfredpay.offrampOrders.length; + const executor = new AlfredpayOfframpTransferExecutor() as unknown as { + executePhase(state: RampState): Promise; + }; + + await expect(executor.executePhase(registered)).rejects.toMatchObject({ isRecoverable: true }); + expect(world.alfredpay.offrampOrders).toHaveLength(orderCount); + expect(submissionsOf(setup.signedOfframpTransfer)).toBe(0); + }); + + it("replays a confirmed provider transfer before inspecting mutable provider terms", async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + const registered = await RampState.findByPk(setup.rampId); + expect(registered).toBeDefined(); + if (!registered) throw new Error("missing registered Alfredpay transfer fixture"); + const transaction = world.alfredpay.offrampTransactions.get(registered.state.alfredpayTransactionId ?? ""); + expect(transaction).toBeDefined(); + if (!transaction) throw new Error("missing Alfredpay transaction fixture"); + + const executor = new AlfredpayOfframpTransferExecutor() as unknown as { + executePhase(state: RampState): Promise; + }; + const originalUpdate = registered.update.bind(registered); + registered.update = mock(async () => { + throw new Error("simulated crash before provider transfer hash persistence"); + }) as typeof registered.update; + try { + await expect(executor.executePhase(registered)).rejects.toThrow("simulated crash"); + } finally { + registered.update = originalUpdate as typeof registered.update; + } + expect(submissionsOf(setup.signedOfframpTransfer)).toBe(1); + + // The transfer has already consumed the ephemeral balance. Even if the + // provider's mutable response now drifts and expires, journal replay must + // persist the deterministic main-transfer hash before any new recovery. + world.evm.setErc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.ephemeral.address, 0n); + transaction.expiration = new Date(0).toISOString(); + transaction.toAmount = "0"; + const orderCount = world.alfredpay.offrampOrders.length; + await expect(executor.executePhase(registered)).rejects.toMatchObject({ isRecoverable: true }); + + const replayed = await RampState.findByPk(setup.rampId); + expect(replayed?.state.alfredpayOfframpTransferTxHash).toBeTruthy(); + expect(submissionsOf(setup.signedOfframpTransfer)).toBe(1); + expect(world.alfredpay.offrampOrders).toHaveLength(orderCount); + }); + + it("replaces a provider order that cannot safely outlive broadcast and indexing", async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + const registered = await RampState.findByPk(setup.rampId); + expect(registered).toBeDefined(); + if (!registered) throw new Error("missing registered Alfredpay lifetime fixture"); + const transaction = world.alfredpay.offrampTransactions.get(registered.state.alfredpayTransactionId ?? ""); + expect(transaction).toBeDefined(); + if (!transaction) throw new Error("missing Alfredpay transaction fixture"); + transaction.expiration = new Date(Date.now() + 30_000).toISOString(); + const orderCount = world.alfredpay.offrampOrders.length; + const executor = new AlfredpayOfframpTransferExecutor() as unknown as { + executePhase(state: RampState): Promise; + }; + + await executor.executePhase(registered); + + expect(world.alfredpay.offrampOrders).toHaveLength(orderCount + 1); + expect(submissionsOf(setup.signedOfframpTransfer)).toBe(1); + }); + + it("does not fund a replacement order whose remaining lifetime is unsafe", async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + const registered = await RampState.findByPk(setup.rampId); + expect(registered).toBeDefined(); + if (!registered) throw new Error("missing registered Alfredpay expiration fixture"); + const transaction = world.alfredpay.offrampTransactions.get(registered.state.alfredpayTransactionId ?? ""); + expect(transaction).toBeDefined(); + if (!transaction) throw new Error("missing Alfredpay transaction fixture"); + const originalTransactionId = transaction.transactionId; + transaction.expiration = new Date(0).toISOString(); + world.alfredpay.nextOfframpExpiration = "not-a-valid-date"; + const orderCount = world.alfredpay.offrampOrders.length; + const executor = new AlfredpayOfframpTransferExecutor() as unknown as { + executePhase(state: RampState): Promise; + }; + + await expect(executor.executePhase(registered)).rejects.toMatchObject({ isRecoverable: true }); + expect(world.alfredpay.offrampOrders).toHaveLength(orderCount + 1); + expect(registered.state.alfredpayTransactionId).toBe(originalTransactionId); + expect(submissionsOf(setup.signedOfframpTransfer)).toBe(0); + + await expect(executor.executePhase(registered)).rejects.toMatchObject({ isRecoverable: true }); + expect(world.alfredpay.offrampOrders).toHaveLength(orderCount + 1); + expect(registered.state.alfredpayTransactionId).toBe(originalTransactionId); + expect(submissionsOf(setup.signedOfframpTransfer)).toBe(0); + }); + + it("reconciles a confirmed replacement that changes the immutable deposit instead of creating duplicates", async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + const registered = await RampState.findByPk(setup.rampId); + expect(registered).toBeDefined(); + if (!registered) throw new Error("missing registered Alfredpay replacement fixture"); + const transaction = world.alfredpay.offrampTransactions.get(registered.state.alfredpayTransactionId ?? ""); + expect(transaction).toBeDefined(); + if (!transaction) throw new Error("missing Alfredpay transaction fixture"); + transaction.expiration = new Date(0).toISOString(); + world.alfredpay.offrampDepositAddress = privateKeyToAccount(generatePrivateKey()).address; + const orderCount = world.alfredpay.offrampOrders.length; + const executor = new AlfredpayOfframpTransferExecutor() as unknown as { + executePhase(state: RampState): Promise; + }; + + await expect(executor.executePhase(registered)).rejects.toThrow("does not match immutable terms"); + expect(world.alfredpay.offrampOrders).toHaveLength(orderCount + 1); + expect(submissionsOf(setup.signedOfframpTransfer)).toBe(0); + + await expect(executor.executePhase(registered)).rejects.toThrow("does not match immutable terms"); + expect(world.alfredpay.offrampOrders).toHaveLength(orderCount + 1); + expect(submissionsOf(setup.signedOfframpTransfer)).toBe(0); + }); }); From d5dcf5ff3039bced4755b37e426a3a6f516fa4d2 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 11 Aug 2026 20:11:58 +0200 Subject: [PATCH 5/9] docs(api): document AlfredPay sell quote guarantees --- .../src/api/services/phases/blocks/README.md | 14 ++++++++ .../03-ramp-engine/discount-mechanism.md | 11 ++++--- .../03-ramp-engine/ephemeral-accounts.md | 4 +-- .../03-ramp-engine/fee-integrity.md | 30 ++++++++++++----- .../03-ramp-engine/quote-lifecycle.md | 14 ++++---- .../03-ramp-engine/ramp-phase-flows.md | 5 +-- .../05-integrations/alfredpay.md | 33 +++++++++++-------- .../06-cross-chain/fund-routing.md | 14 ++++---- 8 files changed, 81 insertions(+), 44 deletions(-) diff --git a/apps/api/src/api/services/phases/blocks/README.md b/apps/api/src/api/services/phases/blocks/README.md index 60a9ef619..bd46034f4 100644 --- a/apps/api/src/api/services/phases/blocks/README.md +++ b/apps/api/src/api/services/phases/blocks/README.md @@ -592,6 +592,20 @@ Unmapped cases fail at quote resolution; there is no alternate engine: `EUR_ONRAMP_BASE_SAME_CHAIN_SWAP` with one Base-built same-chain Squid swap immediately before destination transfer. No same-chain variant includes Squid pay, backups, or final settlement. +9. **AlfredPay SELL reconciliation stays block-owned.** `AlfredpayOfframp` + derives the fiat target from the exact Vortex reference snapshot, requests + AlfredPay's executable exact-output terms, and caps the raw settlement top-up. + Flow v4/context schema 3 persists Squid's guaranteed `toAmountMin`, and + preparation rejects a fresh route whose minimum falls below that funding baseline. + Rollout requires no pending AlfredPay flow-v3 quotes or ramps. + It does not add a cross-reading subsidy block or make the provider rate a + global price source. `finalSettlementSubsidy` consumes the persisted provider + input and persisted executable bridge minimum without recalculating quote economics + or exceeding the persisted subsidy. Positive Squid execution variance is returned + to the user's wallet by Polygon cleanup. Provider orders + remain bound to those persisted terms; if an expired order cannot be replaced + without degrading them, execution pauses before the single-use provider transfer + and leaves the funds on the client-custodied Polygon ephemeral for reconciliation. ### Runtime ownership diff --git a/docs/security-spec/03-ramp-engine/discount-mechanism.md b/docs/security-spec/03-ramp-engine/discount-mechanism.md index 5965078b2..5ef56f8c1 100644 --- a/docs/security-spec/03-ramp-engine/discount-mechanism.md +++ b/docs/security-spec/03-ramp-engine/discount-mechanism.md @@ -8,7 +8,7 @@ For each quote, the block subsidy simulations use the shared math and state in ` 1. Resolve an `ActivePartner` row for pricing. The source can be an explicit partner-owned request, a validated public-key partner, a profile assignment's ramp-specific partner ID, or the system default `vortex`. Pricing configs are resolved per `(partner_id, ramp_type, fiat_currency)`: a config scoped to the corridor's fiat currency (the quote's fiat leg, via `getTargetFiatCurrency`) takes precedence over the partner's wildcard (`fiat_currency IS NULL`) config; a partner whose configs are all scoped to *other* corridors resolves to no config, and discount resolution falls back to `vortex` for that quote. 2. Reads two partner-scoped parameters: - - `targetDiscount` — the discount to advertise. A positive `targetDiscount` means the user receives **more** than the oracle implies (e.g. `targetDiscount=0.005` means the rate offered is 0.5% better than the oracle rate). + - `targetDiscount` — the discount target. A positive `targetDiscount` means the engine attempts to return **more** than the oracle implies (e.g. `targetDiscount=0.005` targets a rate 0.5% better than the oracle rate). AlfredPay SELL may return less when a documented subsidy cap binds; the returned executable quote, not the uncapped target, is advertised. - `maxSubsidy` — a fractional per-quote cap on the subsidy as a share of expected output. `0` disables subsidy; values in `(0, 1]` cap it to that fraction. 3. Reads dynamic state `partnerDiscountState[stateKey]`, keyed per `(partner id, ramp direction, fiat corridor)` — corridor-scoped configs accumulate dynamic difference independently of the same partner's wildcard config — which holds a `difference` value that drifts up while no quote is consumed and back down once a quote is consumed, bounded by `[minDynamicDifference, maxDynamicDifference]`. 4. Calculate `expectedOutput = inputAmount × oraclePrice × (1 + targetDiscount + adjustedDifference)`. For offramps the oracle price is inverted first (USD → fiat), so the input amount MUST be USD-denominated: `getUsdDenominatedInputAmount` first values the request input in USD — USD-like stables (USD, USDC, USDT, USDC.e, axlUSDC) pass through unchanged, fiat-pegged stables (BRLA → BRL, EURC → EUR) are valued at their peg's fresh FIAT-USD oracle rate, and any other input token may use an independently derived USDC-denominated route amount. If neither a valid rate nor such a route amount exists, quote creation fails. Raw input units MUST NOT be relabeled as USD. @@ -21,18 +21,18 @@ Discount behavior is wired explicitly by the phases composed in `phases/blocks/f The AlfredPay block flows compute subsidy in the AlfredPay-side currency: - **Onramp**: subsidy denominated in the AlfredPay on-chain currency (USDT on Polygon). In the cross-chain block flow, `AlfredpayMint` installs the provider-derived anchor fee, then `AlfredpaySubsidizePre` deducts the vortex/partner components from the mint, computes the bounded bridge target from that fee-net actual, and reserves the fee residual on top of the target (`feeReserveRaw`) for the later `distributeFees` collection. Squid quote and transaction preparation both consume that same target. The `alfredOnrampMintFallback` presigned contingency remains bounded to the provider mint amount. -- **Offramp**: subsidy denominated in the AlfredPay on-chain currency (USD on Polygon), computed by inverting the oracle's `outputCurrency -> ALFREDPAY_ONCHAIN_CURRENCY` rate. The vortex/partner components (`calculatePreNablaDeductibleFees`) are subtracted from the bridged USD value BEFORE the subsidy is sized (invariant 6), so the provider deposit reflects the promised net rate; the fee residual stays on the Polygon ephemeral, `finalSettlementSubsidy` targets deposit + fees, and `distributeFees` collects the residual after the deposit succeeds. +- **Offramp**: subsidy denominated in AlfredPay's Polygon USDT. `AlfredpayOfframp` derives the target fiat output directly from the same unrounded Vortex USD/fiat snapshot stored in pricing metadata, then asks AlfredPay for an exact-output quote. AlfredPay's returned `fromAmount` therefore incorporates provider spread and provider fees into the executable deposit. The actual settlement top-up is `providerInputRaw + feeReserveRaw - bridgeOutputRaw`; it is capped by both the partner allowance (`maxSubsidy × expectedOutput`, converted to USDT at the same Vortex reference) and `MAX_FINAL_SETTLEMENT_SUBSIDY_USD`. When either cap binds, the block requests a final executable quote using the maximum permitted provider input, returns that lower output, and emits `ALFREDPAY_OFFRAMP_TARGET_DISCOUNT_CAPPED` rather than rejecting quote creation. If the target probe itself exceeds AlfredPay's reported input maximum, that maximum is treated as a third bound only when it still covers the fee-net baseline provider input; a maximum below baseline means the requested source amount has no executable full-value provider quote and quote creation rejects. Runtime settlement cannot pay more than this persisted subsidy if Squid under-delivers. The fee residual stays on the Polygon ephemeral, `finalSettlementSubsidy` targets deposit + fees, and `distributeFees` collects it after the deposit succeeds. If AlfredPay naturally beats the target, the full fee-net input is quoted and the user keeps the upside with zero subsidy; positive Squid delivery variance returns to the user's wallet during cleanup. For onramps to non-trivial EVM destinations, `SubsidizePost` probes Squid Router (`getEvmBridgeQuote`) to convert the oracle-expected amount into the equivalent amount of the pre-bridge token so the subsidy is denominated in the token the ramp actually holds on the source chain. ## Security Invariants -1. **Subsidy amount MUST be bounded by `maxSubsidy × expectedOutput`** — `maxSubsidy = 0` disables subsidy; when `maxSubsidy` is in `(0, 1]`, `calculateSubsidyAmount` clamps the shortfall to `expectedOutput × maxSubsidy`. Values outside `[0, 1]` MUST be rejected at the administrative configuration boundary. The cap MUST always be enforced from the partner row, never from the request. +1. **Subsidy amount MUST be bounded by `maxSubsidy × expectedOutput`** — `maxSubsidy = 0` disables subsidy; when `maxSubsidy` is in `(0, 1]`, general discount blocks clamp the output shortfall to `expectedOutput × maxSubsidy`. AlfredPay SELL instead converts that same fiat-denominated allowance to USDT using the persisted Vortex reference and caps the actual settlement top-up, because provider spread and fees determine how much USDT must be deposited. Values outside `[0, 1]` MUST be rejected at the administrative configuration boundary. 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. Block discount resolution reads `targetDiscount`, `maxSubsidy`, `minDynamicDifference`, `maxDynamicDifference` from partner pricing. 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`.** 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.** The target discount promises the user's final, net-of-platform-fees rate: the subsidy shortfall is measured from the FEE-NET actual output (e.g. onramp `actualOutput = nablaOutput − (network + vortex + partnerMarkup)`; the AlfredPay offramp sizes against `actualFiat` minus the fee) against `expectedOutput`, so a subsidy may economically offset the charged fees — bounded by `maxSubsidy` — while the fee components themselves remain unchanged, separately reserved, and still flow to fee accounts. +6. **Subsidy MUST NOT bypass fee collection.** The target discount is applied to the user's final, net-of-platform-fees rate: general routes measure the shortfall from fee-net output, while AlfredPay SELL subtracts the canonical raw fee reserve from bridge proceeds before solving the executable provider deposit. A subsidy may economically offset the charged fees — bounded by `maxSubsidy` — while the fee components themselves remain unchanged, separately reserved, and still flow to fee accounts. 7. **For offramps, the anchor fee MUST be added back to `expectedOutput`** before computing the shortfall (`adjustedExpectedOutputDecimal = oracleExpected + anchorFeeInBrl`). Otherwise the user would receive `expectedOutput − anchorFee`, which is short of the advertised rate by the anchor's cut. 8. **Subsidy amounts written to `ctx.subsidy` MUST be deterministic for a given input.** With `targetDiscount=0` the actual subsidy is forced to zero (`actualSubsidyAmountDecimal = Big(0)`), even when `idealSubsidy > 0`. This is the contract the merge-subsidy and subsidy phase handlers rely on. 9. **Discount subsidy MUST remain distinct from runtime swap discrepancy subsidy.** `ctx.subsidy.subsidyAmountInOutputTokenRaw` is the quote-time discount component, bounded by partner `maxSubsidy`. On EVM `subsidizePostSwap`, any actual-vs-quoted swap-output discrepancy is calculated against the live post-swap balance and capped at the greater of $1.00 and env-configured `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` × quote output. Discount components below $1 bypass the separate runtime percentage safety cap; components of $1 or more are capped by env-configured `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION`. Both runtime fractions default to `0.05`. @@ -42,6 +42,7 @@ For onramps to non-trivial EVM destinations, `SubsidizePost` probes Squid Router 13. **Offramp `expectedOutput` MUST be computed from the USD value of the input, never the raw input amount.** The inverted oracle rate converts USD → fiat; feeding it a non-USD input amount misdenominates the target. Before this was enforced, a 1000 BRLA → PIX offramp was treated as 1000 USD, inflating `expectedOutput` (and the `maxSubsidy × expectedOutput` cap) by the BRL-USD rate (~5×) and over-paying the subsidy on every such quote; EURC → SEPA offramps were symmetrically under-subsidized. Enforced by `getUsdDenominatedInputAmount` in `phases/blocks/core/discount.ts` and the block offramp subsidy simulations. 14. **Public discount display MUST only expose applied quote-time discount subsidy.** `discountFiat` / `discountUsd` MUST be present only when `ctx.subsidy.applied` is true, the subsidy amount is positive, the subsidy currency can be inferred, and display conversion succeeds without fallback. Runtime swap-discrepancy top-ups MUST NOT be folded into this display field because they are execution-time protection, not a promotional rate improvement. 15. **Catalog BRL/EUR onramps MUST apply dynamic-discount math at the post-swap boundary.** The `SubsidizePost` block resolves pricing with `resolveDiscountPartner`, calls `calculateExpectedOutput` so `adjustedDifference` and `adjustedTargetDiscount` use the shared partner state, and treats its typed Base USDC input as `actualOutput`. Because `DistributeFees` precedes it, that input already has network, vortex, and partner-markup fees deducted. For non-trivial destinations it probes SquidRouter and divides the oracle target by the Base-USDC-to-destination conversion rate; probe failures retain the 1:1 fallback. This calculation MUST remain phase-hermetic and MUST NOT read Nabla, fee-distribution, or Squid block metadata. AlfredPay's specialized pre-bridge subsidy path is intentionally separate. +16. **AlfredPay SELL target reconciliation MUST remain executable and best-effort capped.** A positive target uses an exact-output provider quote against the unrounded persisted Vortex reference. Flow v4 cross-chain routes MUST size the settlement from Squid's guaranteed `toAmountMin`, not its optimistic estimate. Squid responses with `toAmountMin > toAmount` are invalid, and preparation MUST also reject a newly built route whose minimum is lower than quoted. The selected provider `fromAmount`, canonical fee reserve, and persisted subsidy MUST reconcile in raw units. If the target needs more than the partner, runtime, or reported provider allowance, the quote MUST use at most the allowed provider input, return the resulting lower fiat output, and log `ALFREDPAY_OFFRAMP_TARGET_DISCOUNT_CAPPED`; it MUST NOT advertise the uncapped target or reject solely because a cap binds while a valid fixed-input quote remains. A reported provider maximum below the fee-net baseline is not a cap-compatible quote: it cannot settle the user's full source value and MUST reject instead of silently creating a partial offramp. `targetDiscount = 0` MUST continue to quote the fee-net provider input without compensating provider spread or fees. Deployment of flow v4 requires a window with no pending AlfredPay flow-v3 quotes or ramps. ## Threat Vectors & Mitigations @@ -57,7 +58,7 @@ For onramps to non-trivial EVM destinations, `SubsidizePost` probes Squid Router | **Squid probe precision drift** | The Squid Router probe uses `oracleExpectedOutputDecimal` as the probe amount; the resulting `conversionRate` is then used to back-out the *true* expected pre-bridge amount. For large quotes, the probe amount and the final amount differ slightly, so the conversion rate is sampled at the wrong point on the Squid liquidity curve. | **OPEN (F-DISC-04).** Two-pass probe (probe at `oracleExpected`, recompute at `adjustedExpected`) would eliminate the drift. Impact is bounded by Squid's curvature over a small amount delta — typically sub-basis-point — so this is currently classified as a low-severity precision issue. | | **Decimal/raw drift in subsidy record** | `buildDiscountSubsidy` trims `subsidyAmountInOutputTokenDecimal` to 6 decimal places via `toFixed(6, 0)` (round-down), but `subsidyAmountInOutputTokenRaw` is computed earlier from the un-trimmed value. The two fields can disagree by up to one unit at the 6th decimal. Downstream consumers that mix decimal and raw representations (display vs on-chain transfer) see a tiny inconsistency. | **OPEN (F-DISC-05).** Either trim both fields from the same intermediate, or recompute `raw` from the trimmed `decimal` inside `buildDiscountSubsidy`. Sub-cent impact at typical token decimals; functional impact bounded but real. | | **AlfredPay onramp subsidy when provider quote degrades** | Between quote and start, the AlfredPay provider quote may return a worse `finalOutput`. The route emits an `alfredOnrampMintFallback` phase that uses the discount engine's `expectedOutput` instead. An attacker could attempt to game provider quote timing to maximise the fallback subsidy. | **Bounded.** The fallback is capped by `maxSubsidy × expectedOutput` and only triggers when `targetDiscount > 0` (otherwise `actualSubsidy = 0`). Provider quote TTL (30 seconds) limits the timing window; quote refresh at start (`refreshAlfredpayOnrampQuoteIfMatching`) only re-binds when the new provider quote is byte-identical on `toAmount` and `fee`. | -| **AlfredPay offramp inverted-rate misconfiguration** | If conversion returns a zero rate for `ALFREDPAY_ONCHAIN_CURRENCY -> outputCurrency`, dividing by it would corrupt downstream expected-output and subsidy math. | `AlfredpayOfframp.simulate` uses `Big.js` division, so a zero divisor throws and quote creation fails closed before a provider order can be created. | +| **AlfredPay offramp invalid reference rate** | A zero or invalid Vortex USD/fiat snapshot would corrupt the target and cap conversion. | `AlfredpayOfframp.simulate` performs the target and cap arithmetic with `Big.js`; invalid arithmetic fails quote creation before a provider order can be created. | ## Audit Checklist diff --git a/docs/security-spec/03-ramp-engine/ephemeral-accounts.md b/docs/security-spec/03-ramp-engine/ephemeral-accounts.md index a17963b14..ee5e00a9f 100644 --- a/docs/security-spec/03-ramp-engine/ephemeral-accounts.md +++ b/docs/security-spec/03-ramp-engine/ephemeral-accounts.md @@ -4,7 +4,7 @@ Every ramp operation creates temporary blockchain accounts (ephemeral accounts) on one or more chains. These accounts hold user funds in transit as tokens move between chains during the ramp. The lifecycle is: **create → fund → use during ramp phases → clean up residual tokens and reclaim balances**. If any step in this lifecycle fails or is incomplete, user or platform funds can become permanently stuck on an ephemeral account that nobody monitors. -The cleanup process runs as a background worker (`cleanup.worker.ts`) on a 5-minute cron. After a ramp completes, chain-specific post-process handlers sweep residual tokens and reclaim native balances from the ephemeral accounts back to the platform funding accounts. +The cleanup process runs as a background worker (`cleanup.worker.ts`) on a 5-minute cron. After a ramp completes, chain-specific post-process handlers sweep residual tokens. Most platform-owned dust returns to the platform funding accounts; AlfredPay SELL's positive Squid USDT execution variance returns to the user's registered source wallet. ### Chains Involved @@ -23,7 +23,7 @@ Post-process handlers registered in `apps/api/src/api/services/phases/post-proce - **PendulumPostProcessHandler** — Submits the `pendulumCleanup` extrinsic to sweep Pendulum ephemeral tokens. - **MoonbeamPostProcessHandler** — Waits 3 hours for SquidRouter refunds to land, then submits `moonbeamCleanup` to sweep Moonbeam ephemeral tokens. -- **PolygonPostProcessHandler** — On Polygon-routed ramps with a `polygonCleanup` presigned tx, broadcasts the user's pre-signed `approve` and then runs `transferFrom(ephemeral, fundingAccount, balance)` from the funding key to sweep residual ERC-20 tokens. Skipped when ephemeral balance is zero. This is active for Alfredpay corridors and also protects any still-in-flight legacy Polygon ramps. +- **PolygonPostProcessHandler** — On completed Polygon-routed ramps with a cleanup approval, broadcasts the user's pre-signed `approve` and runs `transferFrom` from the funding key. Platform-owned/legacy dust goes to the funding account. For AlfredPay SELL USDT, which can remain when Squid executes above its guaranteed minimum, the recipient is the user's registered wallet. Cleanup is skipped when the ephemeral balance is zero. If the provider reports failure after the main transfer, unused fee nonces precede the cleanup approval; automatic post-processing therefore remains disabled and residual tokens stay accessible through the client-custodied ephemeral key for manual reconciliation. This is active for Alfredpay corridors and also protects still-in-flight legacy Polygon ramps. - **HydrationPostProcessHandler** — On BUY ramps with a `hydrationCleanup` presigned extrinsic, submits the cleanup extrinsic. - **AssetHubPostProcessHandler** — Registered but inert. `shouldProcess` returns `false` unconditionally; `process` returns `[true, null]`. No on-chain action is performed. Effectively a placeholder for future AssetHub cleanup. diff --git a/docs/security-spec/03-ramp-engine/fee-integrity.md b/docs/security-spec/03-ramp-engine/fee-integrity.md index b642cddad..e1afd28ef 100644 --- a/docs/security-spec/03-ramp-engine/fee-integrity.md +++ b/docs/security-spec/03-ramp-engine/fee-integrity.md @@ -133,16 +133,28 @@ always occur only after all user-facing phases is incorrect. ### Alfredpay corridors: solvency and failure safety - **Charging** — the onramp deducts vortex/partner components from the provider mint - before sizing the bridge/transfer leg; the offramp deducts them from the bridged USD - leg before pricing the Alfredpay deposit. The residual stays reserved on the Polygon - ephemeral until `distributeFees`. + before sizing the bridge/transfer leg. The offramp converts the snapshotted + network/vortex/partner components to one canonical raw USDT reserve and subtracts + it from bridge proceeds before provider pricing. The residual stays reserved on the + Polygon ephemeral until `distributeFees`. - **Solvency** — the onramp pre-swap settlement reserves the swap target PLUS the fee residual (`SubsidizePreMetadata.feeReserveRaw`), and the offramp - `finalSettlementSubsidy` targets deposit + fees, so a short provider leg cannot - starve the fee transfers. + exact-output quote sizes an executable provider deposit after spread/provider fees. + `finalSettlementSubsidy` targets that deposit plus the same canonical fee reserve, + so a short provider leg cannot starve the fee transfers. Quote-time partner/runtime + caps apply to this full settlement top-up; a binding cap lowers the provider input + and payout rather than changing or dropping the fee transfers. Flow-v4/context-schema-3 + runtime settlement cannot pay above the persisted quoted subsidy if a bridge + under-delivers. Rollout requires no pending AlfredPay flow-v3 quotes or ramps. - **Failure safety** — fees are collected only after the user-facing leg succeeded. - The offramp refund fallback is sized deposit + charged fees so a failed ramp - returns the user's full value; the onramp mint fallback stays full-mint. + The v4 prepared offramp fallback is sized from the persisted guaranteed bridge minimum and + excludes the platform-funded settlement top-up, so it cannot leak subsidy to the + user. Automated expired-order recovery does not broadcast that same-nonce + contingency: when replacement cannot preserve the quote, execution pauses before + the provider transfer and leaves principal/top-up on the client-custodied Polygon + ephemeral for authorized reconciliation. Positive Squid USDT execution variance + after a successful offramp returns to the user's wallet during Polygon cleanup. + The onramp mint fallback stays full-mint. - **Rollout** — the fee phase shipped as flow version 2 of the three Alfredpay flows with a drain-then-deploy gate; persisted v1 identities fail closed at registration/dispatch and require manual recovery. @@ -156,7 +168,9 @@ Big.js modes are explicit where security-sensitive: | Quote component/display totals | half-up to the documented decimal precision | | Substrate distribution raw units | round down (`toFixed(0, 0)`) | | EVM distribution raw units | half-up (`toFixed(0)`) | -| Provider-side amounts that require truncation | round down | +| AlfredPay target fiat output | round up to provider fiat precision | +| AlfredPay USDT input / subsidy | exact 6-decimal raw-unit reconciliation; over-precision fails | +| Other provider-side amounts that require truncation | round down | The EVM/Substrate raw-unit difference is current behavior, not a universal invariant. Changing it requires explicit compatibility and accounting review because existing diff --git a/docs/security-spec/03-ramp-engine/quote-lifecycle.md b/docs/security-spec/03-ramp-engine/quote-lifecycle.md index 4dda7052a..2186dcf6d 100644 --- a/docs/security-spec/03-ramp-engine/quote-lifecycle.md +++ b/docs/security-spec/03-ramp-engine/quote-lifecycle.md @@ -41,20 +41,20 @@ The system maintains an **in-memory** `Map 0`. When applied, the quote snapshots `discountFiat` / `discountUsd` display amounts from the quote-time subsidy component so clients can show the user-facing discount separately from fees without recomputing it later. +**Subsidy calculation:** After computing the expected output (oracle-based) and actual output (DEX-based), the shortfall is the "ideal subsidy." This is capped by `partner.maxSubsidy` (as a fraction of expected output). The subsidy is only applied if `targetDiscount > 0`. AlfredPay SELL solves the provider input needed for the oracle target through an exact-output quote and caps the actual USDT settlement top-up by both the partner allowance and `MAX_FINAL_SETTLEMENT_SUBSIDY_USD`; a binding cap returns the lower executable quote with a structured warning. When applied, general quote flows snapshot `discountFiat` / `discountUsd` display amounts from the promotional component so clients can show it separately from fees. The full AlfredPay settlement top-up is not exposed as promotional discount because it also covers provider spread, provider fees, and the platform fee reserve. ### AlfredPay Provider Quote TTL AlfredPay's upstream provider quote is short-lived (~30 seconds). Block flows propagate that provider expiry so the Vortex quote cannot outlive it. Registration derives the provider customer through the owning block, while quote refresh timing remains corridor-specific: -1. **At quote time** (`AlfredpayMint` / `AlfredpayOfframp` blocks): the platform calls the AlfredPay provider. Onramps store provider facts under `metadata.blocks.alfredpayMint`; offramps store them under `metadata.blocks.alfredpayOfframp`. Both replace the preliminary generic fee snapshot with the provider-derived anchor fee, and both return the provider expiry as the Vortex quote TTL. +1. **At quote time** (`AlfredpayMint` / `AlfredpayOfframp` blocks): the platform calls the AlfredPay provider. Onramps store provider facts under `metadata.blocks.alfredpayMint`; offramps store them under `metadata.blocks.alfredpayOfframp`. A positive SELL target is requested by exact fiat `toAmount`; the returned USDT `fromAmount` incorporates provider spread and fees and becomes the executable deposit. A second `fromAmount` quote is made only when the natural fee-net provider input beats the target, a subsidy cap binds, or the target probe exceeds AlfredPay's reported maximum while a lower fixed-input quote is still valid. Both directions replace the preliminary generic fee snapshot with the provider-derived anchor fee, and both return the selected provider expiry as the Vortex quote TTL. 2. **At ramp registration / prep time:** `AlfredpayMint.register` resolves the authenticated user's KYC-approved customer and stores it as phase-owned facts; it does not create an order. `AlfredpayOfframp.register` re-fetches a provider quote and creates the off-ramp order. - - **Off-ramp:** the phase registration hook compares `toAmount` and `fee` exactly. If identical, only that phase's `quoteId` and `expirationDate` are replaced within the registration transaction before the order is created. Any drift throws `INTERNAL_SERVER_ERROR`; the off-ramp never proceeds with a stale or changed quote. + - **Off-ramp:** the phase registration hook compares the currency pair, `fromAmount`, `toAmount`, and fee exactly. If identical, only that phase's `quoteId` and `expirationDate` are replaced within the registration transaction before the order is created. Proven pre-order drift is recorded as a retryable rejected financial operation; provider-order creation alone is the ambiguous side effect. The created order is validated against the same persisted pair/input/output before registration can commit. 3. **At on-ramp start:** the resolved flow's optional `start` lifecycle invokes `AlfredpayMint.start`, which refreshes the provider quote immediately before order creation. If the new response is byte-identical on `toAmount` and `fee`, the platform transactionally substitutes the new provider `quoteId`; otherwise it retains the original ID and preserves the bounded fallback behavior (see `alfredOnrampMintFallback`). The same transaction persists the provider transaction ID and payment instructions. Update returns newly created payment instructions; repeated update/start calls are no-ops once the transaction ID exists. -4. **Offramp expired-quote recovery** (`phases/blocks/phases/alfredpay-offramp/execution.ts`): if the provider rejects the stored `quoteId` as expired at execute time (post-registration), the executor requests a fresh provider quote and reattempts. +4. **Offramp expired-quote recovery** (`phases/blocks/phases/alfredpay-offramp/execution.ts`): if the registered provider order expires before transfer, the executor requests a replacement quote for the immutable provider input/output stored on the Vortex quote; the mutable expired-order response and flattened compatibility identity projections cannot weaken the block-owned floor. It creates a replacement order only when transaction ID, MATIC chain, customer, fiat account, deposit address, currencies/input, and payout remain bound, both create and re-read responses remain `CREATED`, and both retain at least two minutes of safe lifetime. Replacement creation uses a bounded, transaction-ID-derived financial-operation key so confirmed responses replay before inspecting the stale predecessor after a local persistence failure, while unknown provider outcomes pause for reconciliation instead of creating duplicates; the active provider ID changes only after every validation succeeds. If no safe replacement exists, execution pauses before the single-use provider transfer and leaves funds on the client-custodied Polygon ephemeral. A confirmed main-transfer journal is replayed before any mutable provider-term or lifecycle inspection, so a crash before hash persistence cannot trigger a competing same-nonce contingency; without such a journal, only a `CREATED` order may receive the first transfer, and post-transfer polling continues to validate the same immutable identity and payout. 5. **Dashboard client refresh:** dashboard quote queries derive their refresh time from the response's `createdAt` and `expiresAt`, refreshing when 60% of validity remains and on window focus. The transfer machine independently repeats this freshness check before registration and registers with the replacement when one is required, preventing an expired quote from reaching registration after a background-tab delay or confirmation race. -The refresh policy is intentionally strict (byte-identical `toAmount` and `fee` only). Any drift in amounts forces the on-ramp into the fallback path (bounded by the quote block's expected output and the partner's `maxSubsidy`) or aborts off-ramp registration. +The refresh policy is intentionally strict. Onramps require byte-identical `toAmount` and fee. Offramp registration additionally requires byte-identical `fromAmount`; expired-order replacement may improve but never reduce the persisted fiat payout. Drift forces the on-ramp into the bounded fallback path, aborts off-ramp registration, or rejects degraded execution-time recovery. ## Security Invariants @@ -67,7 +67,7 @@ The refresh policy is intentionally strict (byte-identical `toAmount` and `fee` 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`. 8. **Dynamic pricing state MUST NOT be externally modifiable** — The `partnerDiscountState` Map is in-memory and module-private. No API endpoint should expose or allow modification of discount state. 9. **Exchange rates MUST be sourced from authoritative sources** — Swap rates must come from the actual DEX (Nabla) or routing protocol (Squid). Fiat forex rates are sourced from fastforex.io and, when CoinGecko is available, sanity-checked against CoinGecko's `usd-coin` fiat price. If fastforex is missing, unavailable, invalid, or outside the configured per-currency sanity band, CoinGecko is used as fallback. If fastforex returns a valid rate but CoinGecko is unavailable or invalid, the API logs the missing sanity check and accepts fastforex rather than making CoinGecko a hard dependency. Cached forex rates must stay within the configured short TTL. If no valid fiat rate provider remains, quote/conversion paths must fail closed rather than reusing the input amount or proceeding with an unverified rate. Operators must treat the CoinGecko fallback/reference as a USDC-as-USD proxy, not as pure fiat FX, during USDC depeg conditions. -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. +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. For AlfredPay SELL, a positive target is best effort within partner and runtime caps: a binding cap lowers the quote itself and emits a warning, while the final returned `quote.outputAmount` remains immutable and guaranteed by invariant 4. 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/ARS 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`. @@ -76,7 +76,7 @@ The refresh policy is intentionally strict (byte-identical `toAmount` and `fee` 16. **Provider-backed ramp registration MUST derive the sender's provider identity from the effective user, not from request body** — BRL/Avenia tax ID, Alfredpay `alfredPayId`, and the Mykobo (EUR) `email` are resolved server-side from the credential context's `profileId` (or the Supabase session): `profile_id -> customer_entities -> provider_customers (avenia)`, `profile_id -> customer_entities -> provider_customers (alfredpay)`, and `profile_id -> profiles.email` respectively. The corresponding client-supplied field (`additionalData.taxId` / `additionalData.email`) is accepted only for backward compatibility and MUST match the derived sender value or the request is rejected with `400`. Every resolver requires canonical `provider_customers.status = approved`; provider-native state remains separately available in `status_external`. The `receiverTaxId` (where it differs from the sender — e.g. third-party PIX recipient) is supplied by the client and is allowed to differ from the derived sender tax ID; the Avenia payout registration hook passes it to block-owned `validateAveniaOfframpRecipient`, which compares it with the provider's masked PIX-owner tax ID and derives the payout wallet from the trusted subaccount response. The `RampService.registerRamp` quote/user consistency check ensures the caller cannot register a provider-backed quote using a different user context. 17. **User-owned quotes MUST only be registered by their owner; anonymous quotes MAY be claimed** — `RampService.registerRamp` rejects with `403` when `quote.userId` is set and differs from the authenticated caller. A quote created with an API credential also stores `api_credential_id`; secret-key registration MUST resolve that same credential ID, so another credential for the same profile or partner cannot consume it. A Supabase session for the owning profile remains valid. An anonymous quote (`quote.userId = null`) carries no owner and MAY be claimed by any authenticated caller — this is the normal web-app funnel (quote before login, register after). Claiming grants no access to anyone else's resources because provider identity is always derived from the claimer's own KYC records (inv. 16), never from the quote or the request body. 18. **Quote and ramp preparation MUST resolve the same persisted flow** — Registration resolves the catalog flow from `quote.metadata.globals.request`, calls that flow's `register` and `prepareTxs`, and transactionally persists metadata refreshed by registration hooks. No route resolver or corridor transaction assembler remains; registration MUST NOT select a different corridor from mutable input. Phase registration facts and response artifacts are projected into the compatibility `StateMetadata` / API response shape only for active ramps; provider operations remain owned by the resolved flow. -19. **Presigned Squid input MUST equal the quoted block input** — Cross-chain AlfredPay source and destination fallback transaction construction MUST use `metadata.blocks.squidRouterSwap.inputAmountRaw`. It MUST NOT substitute the gross AlfredPay mint amount, because fees and subsidy can make those values differ. +19. **Presigned Squid input and guaranteed output MUST match the quoted block** — Cross-chain AlfredPay source and destination fallback transaction construction MUST use `metadata.blocks.squidRouterSwap.inputAmountRaw`. It MUST NOT substitute the gross AlfredPay mint amount, because fees and subsidy can make those values differ. New AlfredPay SELL flow-v4/context-schema-3 quote/cap math MUST persist Squid's `toAmountMin` as its Polygon funding baseline, validate it against settlement arithmetic, and reject a freshly prepared route with a lower minimum. Recovery-only v3/schema-2 ramps retain their legacy bridge-output semantics until drained. 20. **Dashboard BUY quote direction MUST match the selected fiat rail and EVM destination** — Dashboard onramp requests set `from`/`paymentMethod` from the approved fiat corridor, `to` and `network` to the selected ramp-enabled EVM network, `inputCurrency`/`inputAmount` to the fiat payment, `outputCurrency` to the selected dynamic-catalog token key, and `rampType = BUY`. The displayed receive amount and registration quote ID must come from that server response, not client-side rate math. 21. **Dashboard SELL registration MUST fail closed when the connected wallet cannot fund the refreshed quote** — Dashboard SELL quotes are input-driven from the selected executable EVM token, network, and decimal amount. The dashboard reads the selected network's Alchemy token portfolio and matches the exact configured contract address, normalizing Alchemy's null native-token address to the shared native sentinel; it MUST NOT use the wallet's currently selected chain or another same-symbol token as the balance source. The funding UI blocks on loading, lookup failure, or insufficient raw units using the selected token's configured decimals. After the transfer machine refreshes a near-expiry quote, it repeats that exact-token balance check against the replacement `inputAmount` before generating ephemeral keys or calling `/ramp/register`. 22. **Dashboard BUY options MUST include only executable destination assets** — Native POL is supported as a Polygon SELL input, but MUST NOT appear in BUY selectors until every onramp transaction path can construct a native destination transfer. Ramp history exposes each ramp's server-derived 15-minute start deadline so expired initial BUY ramps are displayed as cancelled rather than awaiting payment. 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 3ae28e65e..7e6de10c2 100644 --- a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md +++ b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md @@ -60,10 +60,11 @@ offramp block executors raise a recoverable, zero-retry pause at `brlaPayoutOnBa before reading partner state or broadcasting the anchor-bound transfer. The ramp remains in the payout phase and is not cleanup-eligible, leaving the client-custodied ephemeral key available for fund recovery. The switch is active only when `NODE_ENV=development`. -- **Catalog-backed Alfredpay offramp family:** USD/ACH, MXN/SPEI, COP/ACH, and ARS/CBU use `initial` → `squidRouterPermitExecute` → `fundEphemeral` → `finalSettlementSubsidy` → `alfredpayOfframpTransfer` → `distributeFees` → `complete` (flow version 3). The source preparer statically selects direct Polygon USDT, Polygon same-chain Squid, or cross-chain Squid. EIP-2612 sources emit permit/relayer typed data; unsupported tokens emit user-wallet transfer or approve/swap blueprints whose reported hashes are content-verified before funding. Final transfer and recovery fallback share Polygon nonce 0, fee-charging quotes place one `distributeFees` transfer per recipient at the following main-lane nonces, and `polygonCleanupAxlUsdc` comes last. Version 3 adds source-labelled reference, provider, and customer all-in pricing observations to the persisted block metadata without changing quote arithmetic or making the provider rate a global price source. +- **Catalog-backed Alfredpay offramp family:** USD/ACH, MXN/SPEI, COP/ACH, and ARS/CBU use `initial` → `squidRouterPermitExecute` → `fundEphemeral` → `finalSettlementSubsidy` → `alfredpayOfframpTransfer` → `distributeFees` → `complete` (flow v4/context schema 3; rollout requires no pending flow-v3 quotes or ramps). The source preparer statically selects direct Polygon USDT, Polygon same-chain Squid, or cross-chain Squid. EIP-2612 sources emit permit/relayer typed data; unsupported tokens emit user-wallet transfer or approve/swap blueprints whose reported hashes are content-verified before funding. Cross-chain simulation persists Squid's guaranteed `toAmountMin` as `executableBridgeOutputRaw`, validates it against provider input + fee reserve − subsidy, and rejects a freshly prepared route below it. Final transfer and contingency fallback share Polygon nonce 0, fee-charging quotes place one `distributeFees` transfer per recipient at the following main-lane nonces, and the legacy-named `polygonCleanupAxlUsdc` approves USDT so positive Squid execution variance returns to the user's wallet. Metadata keeps source-labelled Vortex reference, provider, and customer all-in observations. The Vortex reference remains the target source; the block uses AlfredPay's executable exact-output terms locally to solve the provider deposit, then best-effort caps the actual settlement top-up. If expired-order replacement cannot preserve the persisted promise, execution pauses before the provider transfer with funds on the client-custodied ephemeral. - **Degenerate Polygon same-token onramp case:** Alfredpay mints `ALFREDPAY_EVM_TOKEN` (USDT) on Polygon. `AlfredpayOnrampDirect` composes a Squid passthrough block when the requested output is that same token and a same-chain Squid block for every other Polygon output. Both continue through `finalSettlementSubsidy`, `destinationTransfer`, and `distributeFees` (flow version 2). See `05-integrations/alfredpay.md`. - **Amount precision on routed Alfredpay onramps:** when Alfredpay mints on Polygon and the user requests a different EVM output token, the routed Squid output is the final settlement amount. `evmToEvm.inputAmountRaw` remains the Polygon source-token raw amount, while `evmToEvm.outputAmountRaw` and `quote.outputAmount` MUST use the final destination token's raw/decimal precision. The direct Polygon same-token case remains at the minted token's precision. - **Alfredpay offramp always runs `finalSettlementSubsidy`:** `phases/blocks/phases/alfredpay-offramp/index.ts` declares `fundEphemeral` → `finalSettlementSubsidy` → `alfredpayOfframpTransfer` for every source variant. No executor short-circuits this sequence. +- **Alfredpay bridge arrival and settlement targets are distinct:** delivery evidence waits for the executable source-route minimum persisted as `executableBridgeOutputRaw` and reconciled to `provider input + canonical fee reserve − subsidy`; `alfredpayOfframp.bridgeOutputAmountRaw` remains the diagnostic Squid estimate. Only after delivery evidence exists does `finalSettlementSubsidy` top the Polygon ephemeral up to `provider input + canonical fee reserve`. Waiting for the post-subsidy target before paying the subsidy would create a circular dependency. **Cross-chain delivery (post-swap):** After the Nabla swap, tokens are routed to their final destination: - From Pendulum to Moonbeam: `pendulumToMoonbeamXcm` @@ -197,7 +198,7 @@ graph TD ## Security Invariants 1. **Phase ordering MUST match the expected corridor flow** — Each corridor has a fixed phase sequence. The phase processor MUST NOT allow out-of-order transitions. The phase handler's return value determines the next phase, and it MUST match the expected sequence for the ramp's corridor. -2. **Subsidy amounts MUST be bounded** — Every subsidization handler (`subsidizePreSwap`, `subsidizePostSwap`, `fundEphemeral`, `finalSettlementSubsidy`) must enforce a maximum USD-equivalent cap to prevent draining the funding account on a single ramp. EVM 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. +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. AlfredPay SELL additionally applies the partner and final-settlement caps during quote simulation, reducing the provider input/output when the target would exceed them; the runtime check remains defense in depth for bridge under-delivery. 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 advancement MUST use the strongest evidence available for that corridor** — Squid flows prefer terminal Squid/Axelar status and persist any route-scoped EVM balance fallback. Moonbeam→Pendulum waits for source finalization plus the planned destination amount. Quote-disabled BRL↔AssetHub recovery keeps narrowly documented XCM exceptions in `06-cross-chain/xcm-transfers.md` and `RISK-REGISTER.md`; those exceptions MUST NOT be generalized or used after the corridor is re-enabled. diff --git a/docs/security-spec/05-integrations/alfredpay.md b/docs/security-spec/05-integrations/alfredpay.md index 7b76113f2..c56d6963c 100644 --- a/docs/security-spec/05-integrations/alfredpay.md +++ b/docs/security-spec/05-integrations/alfredpay.md @@ -37,11 +37,11 @@ Alfredpay is a fiat payment provider supporting on-ramp and off-ramp operations For routed Alfredpay onramps (any non-passthrough output), the final quote output is the Squid destination-token amount. `quote.outputAmount` MUST be stored with the destination token's decimals, and `evmToEvm.outputAmountRaw` MUST preserve Squid's destination-token raw output. The Polygon-minted Alfredpay token remains the Squid source amount; the spec must not treat Polygon source-token decimals as final settlement precision. **Off-ramp flow:** -1. The catalog `AlfredpayOfframp` block stores provider quote facts under `metadata.blocks.alfredpayOfframp` and returns the provider expiration as the Vortex quote TTL. Its `pricing` metadata records three separate observations: the source-labelled Vortex USD/fiat reference, Alfredpay's gross rate and fee-adjusted net rate, and the final customer all-in rate after Vortex pricing. These values are diagnostic; Alfredpay's rate does not replace the general Vortex conversion source. Its registration hook validates `fiatAccountId` and wallet address, resolves the authenticated KYC-approved Alfredpay customer, refreshes the provider quote with exact `toAmount` and fee equality, updates only that block's `quoteId`/expiration, and creates the order transactionally. Drift hard-fails registration. +1. The catalog `AlfredpayOfframp` block stores provider quote facts under `metadata.blocks.alfredpayOfframp` and returns the selected provider expiration as the Vortex quote TTL. Quote selection and registration require more than ten seconds of remaining provider-quote lifetime; created and replacement payout orders require at least two minutes before funds may move. Its `pricing` metadata records three separate observations: the source-labelled Vortex USD/fiat reference, Alfredpay's gross rate and fee-adjusted net rate, and the final customer all-in rate after Vortex pricing. The Vortex snapshot remains the reference source. For a positive target the block asks Alfredpay for the exact fiat `toAmount`, uses the returned USDT `fromAmount` to incorporate provider spread/fees, and caps the actual raw settlement top-up by partner `maxSubsidy` and the $10 runtime limit. A binding cap returns a lower executable quote and logs `ALFREDPAY_OFFRAMP_TARGET_DISCOUNT_CAPPED`; naturally better provider pricing is returned with zero subsidy. Registration validates `fiatAccountId` and wallet address, resolves the authenticated KYC-approved customer, refreshes by the persisted provider input, compares `fromAmount`, `toAmount`, and fee exactly, updates only that block's `quoteId`/expiration, and creates the order transactionally. Drift hard-fails registration. 2. `squidRouterPermitExecute` or `squidRouterNoPermitTransfer/Approve/Swap` phase: executes the user-signed permit (or the no-permit equivalent) and lands the Alfredpay on-chain token on Polygon. -3. `finalSettlementSubsidy` phase: always runs for Alfredpay offramps because `AlfredpayOfframp` declares it between funding and provider transfer for every source variant; its target is the Alfredpay deposit PLUS the charged vortex/partner fees so the later fee transfers stay funded. -4. `alfredpayOfframpTransfer` phase: transfers the Alfredpay on-chain token to Alfredpay's settlement address for fiat payout. If Alfredpay rejects the stored `quoteId` as expired, the handler requests a fresh provider quote at execute time and re-attempts (`alfredpayOfframpTransferFallback` phase records the re-attempt). -5. `distributeFees` (pays the reserved vortex/partner residual on Polygon, see `03-ramp-engine/fee-integrity.md`) → `polygonCleanupAxlUsdc` → `complete`. +3. `finalSettlementSubsidy` phase: always runs for Alfredpay offramps because `AlfredpayOfframp` declares it between funding and provider transfer for every source variant. Delivery evidence waits for the source route's persisted executable minimum (validated against `provider input + fee reserve − subsidy`); the phase then tops up to Alfredpay deposit PLUS the charged vortex/partner/network fee reserve so the later fee transfers stay funded. +4. `alfredpayOfframpTransfer` phase: transfers the Alfredpay on-chain token to Alfredpay's settlement address for fiat payout. Registration and the first local broadcast both require the provider order to be `CREATED`; `FAILED` terminates without funding the order, while any already-advanced lifecycle without a confirmed local transfer requires reconciliation. If the registered order expired, the handler requests a fresh quote for the immutable provider input. It creates a replacement order only when chain, canonical customer/account identity, currencies/input, and payout remain bound and the payout is not lower than the original; degraded recovery is logged and fails before the presigned provider transfer is broadcast. +5. `distributeFees` (pays the reserved vortex/partner residual on Polygon, see `03-ramp-engine/fee-integrity.md`) → `polygonCleanupAxlUsdc` → `complete`. The legacy-named cleanup now approves Polygon USDT and returns any positive Squid execution variance above the guaranteed minimum to the user's wallet; it does not treat that user-funded variance as platform subsidy or treasury dust. **Request validation:** Alfredpay middleware (`alfredpay.middleware.ts`) validates the `country` parameter against the `AlfredPayCountry` enum for all Alfredpay-related requests. @@ -57,12 +57,12 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu 6. **Alfredpay API responses MUST be validated** — Status codes, transaction IDs, and amounts confirmed before phase advancement. 7. **Alfredpay interactions MUST be retryable** — Transient failures should use `RecoverablePhaseError`. 8. **Provider quote refresh MUST be strict** — At on-ramp start, immediately before order creation, `AlfredpayMint.start` re-binds the provider `quoteId` only when the new provider response is byte-identical on `toAmount` and `fee`. Any drift keeps the original quote ID and preserves the bounded fallback path. -9. **Off-ramp expired-quote recovery MUST re-create the AlfredPay order, not the Vortex quote** — `phases/blocks/phases/alfredpay-offramp/execution.ts` re-quotes against the provider and re-issues `createOfframp` against the same Vortex quote; it MUST NOT mutate the Vortex `QuoteTicket`. +9. **Off-ramp expired-quote recovery MUST preserve the persisted promised payout and re-create only the AlfredPay order** — `phases/blocks/phases/alfredpay-offramp/execution.ts` re-quotes the immutable provider input/output stored on the Vortex quote; neither the mutable expired transaction nor a replacement order may weaken that floor. It may re-issue `createOfframp` only when transaction ID, MATIC chain, canonical block-owned customer and fiat-account identity, deposit address, currencies/input, and `toAmount >= promised toAmount` remain bound, both the create response and re-read order remain `CREATED`, and both retain at least two minutes of safe lifetime, with the funding origin fixed to the EVM ephemeral. Replacement creation is journaled under a bounded hash-derived attempt key, and a confirmed replacement replays before mutable observations of its stale predecessor; the active transaction ID is updated only after every validation passes. If terms degrade, it MUST NOT broadcast either same-nonce transfer: the phase pauses with funds on the client-custodied ephemeral. Once the main transfer operation is submitted/confirmed, its deterministic journal replay takes priority over all later provider-term observations; every post-transfer poll still validates the immutable order identity and payout before completion. 10. **KYB and KYC status mapping MUST be branched by `AlfredpayCustomerType`** — Business customers use `mapKybStatus`; individuals use `mapKycStatus`. Treating one as the other would allow incomplete due-diligence states to pass as `Success`. 11. **Polygon passthrough MUST preserve amount integrity** — `AlfredpayOnrampDirect` selects `SquidRouterPassthrough` only for the same-chain same-token case. The passthrough MUST round down (`toFixed(0, 0)`) and use its phase-owned input amount as the source of truth. 12. **The Polygon passthrough MUST be gated on the output token, not on the destination network alone** — Alfredpay mints `ALFREDPAY_EVM_TOKEN` (USDT) directly on Polygon, so `flows/alfredpay-onramp-direct.ts` composes `SquidRouterPassthrough` only when the requested output token is `ALFREDPAY_EVM_TOKEN`. Any other Polygon output composes `SameChainSquidRouterSwap`; gating on network alone would mis-deliver USDT instead of the requested asset. -13. **Offramp quote refresh at prep time MUST be strict and transactional** — `AlfredpayOfframp.register` re-fetches a provider quote and compares `toAmount` and `fee`. Any drift throws `INTERNAL_SERVER_ERROR`, aborting registration. Only the phase metadata `quoteId` and `expirationDate` are updated in the registration transaction, so partial refresh/order state cannot persist. -14. **`finalSettlementSubsidy` MUST NOT be skipped for Alfredpay offramps** — `phases/blocks/phases/alfredpay-offramp/index.ts` declares `fundEphemeral` → `finalSettlementSubsidy` → `alfredpayOfframpTransfer` for every source variant. This ensures the ephemeral on Polygon is topped up before provider settlement. +13. **Offramp quote refresh at prep time MUST be strict and transactional** — `AlfredpayOfframp.register` re-fetches a provider quote and compares the pair, `fromAmount`, `toAmount`, and fee. Proven pre-order drift is journaled as rejected/retryable and aborts registration without poisoning later attempts. Only `createOfframp` is an ambiguous external side effect; its returned order must again match the persisted pair/input/output before the registration transaction can commit. +14. **`finalSettlementSubsidy` MUST NOT be skipped or used as its own delivery threshold for Alfredpay offramps** — `phases/blocks/phases/alfredpay-offramp/index.ts` declares `fundEphemeral` → `finalSettlementSubsidy` → `alfredpayOfframpTransfer` for every source variant. The arrival threshold is the persisted executable bridge minimum validated against `provider input + fee reserve − quoted subsidy`; the settlement target is provider input plus canonical fee reserve. This ensures the Polygon ephemeral is topped up before provider settlement without waiting for the top-up itself, and observed bridge under-delivery cannot increase treasury funding above the quote's persisted subsidy amount. 15. **Routed Alfredpay onramp quote output precision MUST match the destination token** — For Alfredpay USD/MXN/COP/ARS onramps that route through Squid, `quote.outputAmount` MUST preserve the final destination token's decimal precision, and `evmToEvm.outputAmountRaw` MUST represent the destination token's raw units. The Polygon-minted Alfredpay token is only the Squid source-side input. Direct Polygon same-token passthrough remains at the minted token's 6-decimal precision. 16. **Alfredpay ramp registration MUST bind to a completed KYC/KYB customer** — `AlfredpayMint.register` and `AlfredpayOfframp.register` MUST reject customer records whose Alfredpay status is not `Success`. On-ramp registration stores only the verified customer ID as phase-owned facts; quote refresh, order creation, and payment instructions remain at start time. SDK/server integrations authenticate with partner API keys (`pk_*`/`sk_*`); Supabase Bearer tokens are frontend/user-session auth. 17. **Alfredpay ramp registration MUST derive the customer id from the effective user; quotes carry only tracking metadata** — The on-ramp and off-ramp flow registration hooks, on-ramp start-time quote refresh, and off-ramp transfer recovery path all resolve `alfredPayId` via the strict, KYC-gated `resolveAlfredpayCustomerId(fiatCurrency, effectiveUserId)`. Quote creation is anonymous-eligible: the quote blocks use `resolveAlfredpayQuoteCustomerId`, which fills the *tracking-only* quote `metadata.customerId` with the caller's real customer id when a KYC-completed customer resolves, and the `"anonymous"` sentinel otherwise. Alfredpay validates the top-level `customerId` only on order creation, so no provider *order* ever carries a placeholder identity. Public keys and unlinked secret keys can quote but cannot register Alfredpay ramps. @@ -76,7 +76,10 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu 24. **Reported Alfredpay usage MUST be user-scoped and provider-leg denominated** — `POST /v1/limits` derives the effective user from authentication and counts only that user's ramps whose `complete` phase-history timestamp falls in the current UTC calendar month. Routed BUY usage is the Alfredpay fiat input; routed SELL usage is `metadata.blocks.alfredpayOfframp.inputAmountDecimal` in `ALFREDPAY_EVM_TOKEN`, not the public source-token amount. This informational aggregate is cached in memory for 60 seconds; quote-time limit enforcement never reads that cache. Alfredpay does not document whether its cumulative quota resets by calendar month or uses a rolling window, so the calendar-month period is an explicit Vortex assumption rather than provider-confirmed semantics. 25. **A terminal verification outcome MUST be queued for notification before it is persisted** — Alfredpay publishes no verification webhook, so every observer that can make the customer terminal — the dashboard's shared refresh, `AlfredpayStatusWorker`, `/alfredpayStatus`, and `/getKycStatus` — MUST enqueue before its status write. An account written terminal while its enqueue failed could be excluded from every subsequent poll and never notified. A failure must leave the account non-terminal so a later poll retries both. The notification key is `(alfredpay, verification_*, submissionId)`, which makes retries and racing observers idempotent. See `resend.md` invariant 13. 26. **The background verification sweep MUST be bounded, fair, and MUST NOT poll accounts it cannot notify** — `AlfredpayStatusWorker` costs two to three Alfredpay calls per account (submission-id resolution, then status). It MUST bound the sweep by account age (60 days on `provider_customers.updatedAt`, since an account abandoned mid-wizard never reaches a terminal status) and by batch size. A stable keyset cursor advances after every full page and wraps at the end; repeatedly selecting only the newest page would starve older eligible accounts. Entities with a null `profile_id` are partner-owned and have no profile to email; they MUST be excluded in the query so they never consume provider requests. Only the `mykobo` flow-variant backend owns the provider status workers, and each cron uses `waitForCompletion`, preventing duplicate cross-backend polls and overlapping same-process cycles. -27. **Alfredpay offramp pricing observations MUST remain source-labelled and descriptive** — The persisted block metadata records the actual Vortex reference-feed source and observation time, Alfredpay's returned gross rate and fee breakdown, the provider net rate derived from `toAmount ÷ fromAmount`, and the customer all-in rate derived from final fiat output divided by the USD-valued quote input. These observations MUST NOT replace the normal Vortex price-conversion source or alter quote amounts, discounts, fees, or subsidies. +27. **Alfredpay offramp pricing observations MUST remain source-labelled, while executable provider terms may reconcile the local SELL deposit** — The persisted block metadata records the exact Vortex reference-feed source and observation time, Alfredpay's returned gross rate and fee breakdown, the provider net rate derived from `toAmount ÷ fromAmount`, and the customer all-in rate derived from final fiat output divided by the USD-valued quote input. Alfredpay's rate MUST NOT become a general Vortex reference source. Its executable `fromAmount`/`toAmount` may be used only inside `AlfredpayOfframp` to solve or cap the provider deposit needed for the Vortex-derived customer target. +28. **Cross-chain Alfredpay SELL funding MUST use Squid's guaranteed minimum** — flow v4/context schema 3 persists required `executableBridgeOutputRaw` from `estimate.toAmountMin`; preparation, settlement evidence, and contingency sizing validate it against `inputAmountRaw + feeReserveRaw − subsidyAmountRaw` and reject a fresh route below it. Rollout requires no pending flow-v3 quotes or ramps. +29. **Positive Squid execution variance MUST return to the user after successful completion** — AlfredPay provider input and fee obligations are sized from the guaranteed bridge minimum. When Polygon receives more USDT than that minimum, the legacy-named `polygonCleanupAxlUsdc` approval targets USDT and `PolygonPostProcessHandler` transfers the residual from the ephemeral to the canonical block-owned wallet address (falling back to the legacy flattened projection only for older ramps). It MUST NOT send that user-funded variance to the Vortex funding account. Legacy AXLUSDC cleanup transactions retain their historical treasury-dust behavior. Automatic post-processing remains complete-only: if AlfredPay reports failure after the provider transfer, later fee nonces were not consumed and the cleanup approval cannot be broadcast safely; residual USDT remains on the client-custodied ephemeral for manual reconciliation. +30. **An Alfredpay SELL order MUST be `CREATED` before Vortex's first provider-bound transfer** — a pre-transfer `FAILED` response terminates the ramp without moving the user's USDT; `ON_CHAIN_DEPOSIT_RECEIVED`, `TRADE_COMPLETED`, or either fiat-transfer state without a confirmed/replayed local transfer indicates an unexplained external side effect and requires reconciliation. A confirmed local transfer journal is replayed before this mutable status check. ## Threat Vectors & Mitigations @@ -89,9 +92,10 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu | **Alfredpay API compromise** | Attacker manipulates Alfredpay API responses | Validate response amounts against quote; HTTPS enforcement; monitor for discrepancies | | **Multi-country regulatory complexity** | Different countries have different KYC/AML requirements | Country-specific validation at Alfredpay level; KYB vs KYC mapping branched by `AlfredpayCustomerType` | | **Provider quote-quote-fall fallback abuse** | Attacker times provider quote drift between Vortex quote and ramp start to maximise the discount-engine fallback subsidy | Provider quote TTL is ~30s; `refreshAlfredpayOnrampQuoteIfMatching` only re-binds on byte-identical `toAmount`/`fee`; otherwise the fallback path is bounded by `maxSubsidy × expectedOutput` and only fires when `targetDiscount > 0` | -| **Expired provider quote on offramp transfer** | Provider rejects the stored `quoteId` at transfer time, blocking settlement | `phases/blocks/phases/alfredpay-offramp/execution.ts` re-quotes at execute time and emits `alfredpayOfframpTransferFallback`; the Vortex `QuoteTicket` is untouched | -| **Offramp quote drift at prep time** | Market moves between quote creation and ramp registration; the refreshed Alfredpay offramp quote has different `toAmount`/`fee` | `refreshAlfredpayOfframpQuoteIfMatching` compares `toAmount` and `fee` exactly; any drift throws `INTERNAL_SERVER_ERROR`, aborting registration. The user must re-quote. | -| **Offramp pricing source confusion** | Diagnostics present Alfredpay's executable rate as the general market reference, obscuring whether a difference comes from the reference feed, provider fees, or Vortex pricing | Persist separate source-labelled reference, provider gross/net, and customer all-in observations. Quote arithmetic continues to use the configured Vortex price feed. | +| **Expired provider quote on offramp transfer** | A replacement quote degrades payout, changes the declared funding origin, or an ambiguous create retry duplicates provider orders | Re-quote the fixed provider input; require matching currencies/input and `fresh toAmount >= original toAmount`; keep `originAddress` bound to the EVM ephemeral; journal replacement creation and pause unknown outcomes. The Vortex `QuoteTicket` remains untouched. | +| **Offramp quote drift at prep time** | Market moves between quote creation and ramp registration; the refreshed Alfredpay offramp quote has different executable terms | Registration compares pair, chain when returned, `fromAmount`, `toAmount`, fee, and safe lifetime exactly; proven pre-order drift returns 422 `UNPROCESSABLE_ENTITY`, leaves the durable operation retryable, and requires a fresh provider quote. | +| **Offramp pricing source confusion** | Diagnostics present Alfredpay's executable rate as the general market reference, obscuring whether a difference comes from the reference feed, provider fees, or Vortex pricing | Persist separate source-labelled reference, provider gross/net, and customer all-in observations. The Vortex snapshot defines the target; Alfredpay terms only solve the local executable deposit. | +| **Unfundable AlfredPay SELL target** | Provider spread/fees make the configured target require more than partner/runtime subsidy limits or the provider's trade maximum | Cap the provider input to the lowest allowance that still covers the fee-net baseline, return the resulting executable payout, and emit `ALFREDPAY_OFFRAMP_TARGET_DISCOUNT_CAPPED` with target, delivered output, required/applied subsidy, and binding cap. A provider maximum below baseline rejects because no full-value fixed-input quote exists. Squid routes with `toAmountMin > toAmount` reject as malformed before the claimed minimum can affect subsidy sizing. | | **Alfredpay offramp skipping subsidy** | An Alfredpay offramp reaches provider transfer without `finalSettlementSubsidy`, under-funding the settlement | The `AlfredpayOfframp` block declares subsidy before transfer for every source variant; flow tests pin the sequence | | **Polygon passthrough rounding** | Same-chain same-token shortcut rounds the bridge output incorrectly, leaking dust or under-funding the destination | `toFixed(0, 0)` round-down in the squid-router finalize; downstream subsidy ensures the destination receives the quoted amount | | **Polygon wrong-token delivery** | A user on-ramps via Alfredpay and requests a non-USDT Polygon output (e.g. USDC); the flow skips the swap on destination-network alone and transfers the minted USDT | `AlfredpayOnrampDirect` selects passthrough only for `ALFREDPAY_EVM_TOKEN`; non-USDT Polygon outputs compose `SameChainSquidRouterSwap` | @@ -113,14 +117,15 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu - [ ] Timeout configured for Alfredpay API calls. **FAIL F-014** — no explicit HTTP client timeout configured; relies on default system timeouts. - [x] `subsidizePreSwap` runs before `squidRouterSwap` on the onramp flow, and `finalSettlementSubsidy` runs before `alfredpayOfframpTransfer` on the offramp flow. **PASS** — flow tests pin both sequences. - [x] Onramp fallback emits `alfredOnrampMintFallback` from the `AlfredpayMint` block using the phase-owned mint output. **PASS** — `phases/blocks/phases/alfredpay-mint/transactions.ts`. Phase is registered as an EVM phase in `transactions/validation.ts`. -- [x] Offramp fallback emits `alfredpayOfframpTransferFallback` for expired-quote recovery; phase is registered as an EVM phase in `transactions/validation.ts:250`. **PASS**. +- [x] Offramp expiry recovery creates a terms-preserving replacement order and reuses the main `alfredpayOfframpTransfer`; the same-nonce `alfredpayOfframpTransferFallback` remains a prepared contingency artifact and is not automatically broadcast by recovery. **PASS**. - [x] KYB vs KYC status mapping is branched by `AlfredpayCustomerType.BUSINESS` in `alfredpay.controller.ts`. **PASS** — `mapKybStatus` for business, `mapKycStatus` for individual. - [x] Polygon same-chain same-token passthrough rounds down (`toFixed(0, 0)`) and uses the phase-owned input amount. **PASS** — `phases/blocks/phases/squid-router-swap/` flow and transaction tests. - [x] Alfredpay Polygon onramp passthrough is gated on `outputCurrency === ALFREDPAY_EVM_TOKEN`, not on Polygon alone. **PASS** — `phases/blocks/flows/alfredpay-onramp-direct.ts`; other Polygon outputs compose `SameChainSquidRouterSwap`. - [x] `AlfredpayMint.start` only re-binds the provider `quoteId` when `toAmount` and `fee` match byte-identically, creates the order once, and returns/persists the provider payment instructions through the generic flow lifecycle. **PASS** — block lifecycle tests. -- [x] `AlfredpayOfframp.register` re-fetches a fresh provider quote, compares `toAmount` and `fee` exactly, updates only its own metadata identity/expiry, and throws on drift before order creation. **PASS** — block registration tests. +- [x] `AlfredpayOfframp.register` re-fetches a fresh provider quote, compares `fromAmount`, `toAmount`, and fee exactly, updates only its own metadata identity/expiry, and throws on drift before order creation. **PASS** — block registration tests. - [x] `AlfredpayOfframp` always includes `finalSettlementSubsidy` before provider transfer. **PASS** — explicit phase list and flow tests. -- [x] Alfredpay offramp metadata separates the source-labelled Vortex reference, provider gross/net, and customer all-in rates without changing quote arithmetic. **PASS** — flow version 3 block metadata and MXN corridor coverage. +- [x] Alfredpay SELL derives the target from the exact source-labelled Vortex snapshot, uses exact-output provider terms to solve the executable deposit, and best-effort caps the actual settlement top-up. **PASS** — flow v4/context schema 3 plus MXN target/partner-cap/runtime-cap corridor coverage. +- [x] Expired Alfredpay SELL order recovery rejects a degraded payout before creating a replacement order or broadcasting the presigned transfer. **PASS** — MXN corridor recovery regression. - [x] AlfredPay offramp order is created by the block phase registration hook; `AlfredpayOfframp.start` retains the defensive validation-only no-op and is idempotent after registration. **PASS** — block lifecycle tests. - [x] Routed Alfredpay onramp quote output precision follows destination token decimals; direct Polygon same-token passthrough remains at minted-token precision. **PASS** — Alfredpay flow and transaction tests. - [x] Alfredpay onramp registration rejects missing customer context before customer lookup and requires a `Success` Alfredpay customer status. **PASS** — `phases/blocks/phases/alfredpay-mint/registration.ts`. diff --git a/docs/security-spec/06-cross-chain/fund-routing.md b/docs/security-spec/06-cross-chain/fund-routing.md index 260ccba40..b08b31368 100644 --- a/docs/security-spec/06-cross-chain/fund-routing.md +++ b/docs/security-spec/06-cross-chain/fund-routing.md @@ -16,6 +16,8 @@ The block catalog owns the subsidization and settlement executors across Substra The pre/post executors dispatch by the block's chain context. The EVM pre-swap branch tops the ephemeral up before `nablaSwap` and enforces a quote-relative cap of the greater of $1.00 and `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` (default `0.05`) × quote output. The EVM post-swap branch splits the required top-up into a swap-discrepancy component and a discount component: the actual-vs-quoted swap-output discrepancy is capped at the greater of $1.00 and `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` × quote output. Discount-derived top-ups below $1 bypass the separate runtime percentage safety cap; top-ups of $1 or more are capped by `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION` (default `0.05`). Quote-time partner `maxSubsidy` remains enforced for every amount. +AlfredPay SELL pre-calculates its expected final-settlement top-up from the executable provider input plus the canonical fee reserve. Flow v4/context schema 3 persists Squid's guaranteed `toAmountMin` as `executableBridgeOutputRaw` and validates it against `provider input + fee reserve − subsidy` for preparation, delivery evidence, settlement, and contingency sizing. A fresh route below that minimum is rejected. Quote simulation limits the top-up to the lower of the partner allowance and `MAX_FINAL_SETTLEMENT_SUBSIDY_USD`; when the target needs more, it lowers the provider input/output and logs a capped-target warning. Runtime settlement also refuses to fund more than the quote's persisted subsidy, so unexpected bridge under-delivery cannot consume treasury beyond either quote-time cap. If Squid delivers above its minimum, completed-flow Polygon cleanup returns the residual USDT to the user's wallet rather than sweeping it to treasury. If provider terms can no longer preserve the promised payout, execution pauses before the provider transfer and leaves funds on the client-custodied Polygon ephemeral for reconciliation. Flow-v4 rollout requires no pending AlfredPay flow-v3 quotes or ramps. + **How subsidization works:** 1. Read the ephemeral account's current balance 2. Compare against the expected amount (from ramp state metadata, e.g. `quote.metadata.nablaSwapEvm.inputAmountForSwapRaw` for pre-swap on the EVM branch) @@ -38,13 +40,13 @@ The current code resolves this through `EVM_FUNDING_PRIVATE_KEY` and the `getEvm 1. **Subsidization MUST only top up to the expected amount, never more** — Both `subsidize-pre/execution.ts` and `subsidize-post/execution.ts` calculate `expectedAmount - currentBalance` and transfer exactly that difference. If the balance already meets or exceeds the expected amount, no transfer occurs. 2. **Expected amounts MUST come from ramp state set at creation time** — The expected input/output amounts are derived from the quote and stored in ramp state. Handlers read these values, not recalculate them. This prevents manipulation via price changes between quote and execution. 3. **Funding account private keys MUST only be used for subsidization transfers** — `getFundingAccount()` derives a keypair from `PENDULUM_FUNDING_SEED`. This keypair should only sign subsidization transfers, not arbitrary transactions. -4. **Every final settlement subsidy MUST enforce a USD cap before funds move** — The full observable shortfall is converted from the destination token into USD and compared with `MAX_FINAL_SETTLEMENT_SUBSIDY_USD`, regardless of whether the funding wallet already holds the output token or first needs a swap. The swap-input check remains a second bound on routes that acquire the token. +4. **Every final settlement subsidy MUST enforce a USD cap before funds move** — The full observable shortfall is converted from the destination token into USD and compared with `MAX_FINAL_SETTLEMENT_SUBSIDY_USD`, regardless of whether the funding wallet already holds the output token or first needs a swap. When inventory acquisition is required, it is sized only for the funding wallet's actual token shortfall, Squid's guaranteed `toAmountMin` MUST cover that shortfall, and the executable transaction value MUST equal the capped requested native input before broadcast. Its independent spend bound is `1.1 × MAX_FINAL_SETTLEMENT_SUBSIDY_USD`, matching the route's 10% input buffer; the transferred customer subsidy remains capped at the unbuffered limit and any over-acquired token remains treasury inventory. The durable acquisition operation uses a stable cap/destination/token authorization, runs mutable route/nonce checks only in `beforePerform`, and replays a confirmed result before new market observations. Its explicit request-shape compatibility flag is recovery-only: legacy confirmed rows replay, ambiguous rows still require reconciliation, and only provably `not_started` rows adopt the new hash. 5. **Destination transfer MUST use a presigned transaction** — `destination-transfer/execution.ts` submits the presigned transfer from state. The server cannot modify the recipient address or amount at execution time. 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 phase ordering MUST be deterministic** — the resolved block flow owns the phase sequence, and `subsidize-post/execution.ts` must not select a corridor-specific successor independently. 8. **No subsidization handler MUST proceed if the funding account has insufficient balance** — If the funding account cannot cover the subsidy, the handler should fail with a recoverable error, not silently skip the top-up. 9. **EVM subsidy caps MUST stop transfers without forcing manual phase repair** — If an EVM pre-swap subsidy exceeds 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 before submitting a single transfer. The discrepancy cap always applies. The discount runtime percentage cap applies only when that component is at least $1; smaller discount components remain bounded by the quote-time partner `maxSubsidy`. 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 distinguish authoritative completion from the EVM balance fallback** — Terminal Squid/Axelar evidence is preferred. Because provider status can fail to index a transfer that did arrive, Squid-routed EVM flows may fall back to `settlementBaseline + floor(expectedBridgeOutput × 9000 / 10000)`. That threshold is route-scoped and persisted as `kind = destination-balance`; it is a bounded settlement heuristic, not proof of bridge finality. The subsidy remains clamped to `expected settlement balance - observed balance`. The percentage MUST NOT be reused for XCM, provider minting, or another bridge without a separate risk decision. +10. **`finalSettlementSubsidy` MUST distinguish authoritative completion from the EVM balance fallback** — Terminal Squid/Axelar evidence is preferred. Because provider status can fail to index a transfer that did arrive, Squid-routed EVM flows may fall back to `settlementBaseline + floor(expectedBridgeOutput × 9000 / 10000)`. AlfredPay SELL uses the executable bridge minimum derived as `provider input + fee reserve − subsidy` for that threshold; it MUST NOT use the larger post-subsidy settlement target, because that balance is attainable only after the phase pays the subsidy. The threshold is route-scoped and persisted as `kind = destination-balance`; it is a bounded settlement heuristic, not proof of bridge finality. The subsidy remains clamped to `expected settlement balance - observed balance`. The percentage MUST NOT be reused for XCM, provider minting, or another bridge without a separate risk decision. 11. **Degenerate same-token routes MUST omit `finalSettlementSubsidy` from their cataloged flow** — Same-chain routes that need no bridge do not include the settlement phase. The phase itself must not infer corridor topology from legacy booleans. 12. **Subsidy cap currency conversions MUST fail closed** — Any USD-denominated subsidy cap check that depends on `PriceFeedService.convertCurrency()` must stop the phase if fiat/crypto price providers fail or return invalid rates. It must not continue with the original unconverted amount, because that can understate the USD value of a funding-account transfer. @@ -52,11 +54,11 @@ The current code resolves this through `EVM_FUNDING_PRIVATE_KEY` and the `getEvm | Threat | Mitigation | |---|---| -| **Final settlement subsidy cap bypass** — A direct token transfer could bypass a cap enforced only in the optional native-to-token acquisition branch. | **Mitigated.** `final-settlement-subsidy/execution.ts` values every positive shortfall in USD and enforces `MAX_FINAL_SETTLEMENT_SUBSIDY_USD` before either a direct transfer or funding swap. The route-spend check remains defense in depth. | +| **Final settlement subsidy cap bypass** — A direct token transfer could bypass a cap enforced only in the optional native-to-token acquisition branch, or a malformed route could request more native value than the locally capped input. | **Mitigated.** `final-settlement-subsidy/execution.ts` values every positive shortfall in USD and enforces `MAX_FINAL_SETTLEMENT_SUBSIDY_USD` before either a direct transfer or funding swap. The separate acquisition-spend check permits only the explicit 10% route buffer, requires the executable native value to equal that checked input, and never increases the customer transfer. | | **Funding account balance drain** — Repeated ramps with incorrect expected amounts could drain the funding account | Expected amounts are bound to the quote at creation time. An attacker cannot change them after the fact. However, a bug in quote calculation or a stale price could result in over-subsidization at scale. | | **Expected amount manipulation** — Attacker modifies ramp state to inflate expected amounts, causing the platform to over-subsidize | Ramp state expected amounts are set at creation and not modifiable via the API. An attacker would need database access. No DB-level constraint prevents modifying these values. | | **Funding key compromise** — Attacker obtains `PENDULUM_FUNDING_SEED` or `MOONBEAM_FUNDING_PRIVATE_KEY` | Full drain of the funding account. These keys should be rotated immediately on suspicion of compromise. There is no rate limiting on funding account transactions at the chain level. | -| **SquidRouter swap manipulation in final settlement** — The SquidRouter swap (native → ERC-20) uses an API-provided route. If the SquidRouter API returns a malicious route, funds could be lost. | The handler trusts the SquidRouter API response. There is no independent verification that the swap output matches expectations. The 5-attempt retry loop could amplify losses if the route is consistently malicious. | +| **SquidRouter swap manipulation in final settlement** — The SquidRouter swap (native → ERC-20) uses an API-provided route. If the SquidRouter API returns a malicious route, funds could be lost. | Before broadcast, the handler requires `toAmountMin <= toAmount`, requires `toAmountMin` to cover the funding wallet's exact inventory shortfall, and binds `transactionRequest.value` exactly to the capped requested input. The transaction target and calldata still come from SquidRouter, so provider compromise remains a trusted-integration risk. | | **Destination transfer replay** — The presigned EVM transaction is somehow submitted multiple times | EVM nonce prevents replay. Each transaction is valid for exactly one nonce value. | | **Balance check race condition in destination transfer** — Balance changes between the check and the transaction submission | Possible but unlikely for ephemeral accounts (no other senders). If balance drops between check and submission, the EVM transaction reverts (no fund loss, just a failed phase that retries). | | **Post-swap routing logic inconsistency** — A block flow orders a successor that does not match the ramp's intended flow | The resolved catalog flow owns a fixed executor sequence and the phase processor advances through that sequence. | @@ -65,7 +67,7 @@ The current code resolves this through `EVM_FUNDING_PRIVATE_KEY` and the `getEvm ## Audit Checklist -- [x] **F-001 fixed**: `final-settlement-subsidy/execution.ts` enforces `MAX_FINAL_SETTLEMENT_SUBSIDY_USD` on every positive destination-token shortfall before any transfer, with an additional route-spend bound when a funding swap is needed. +- [x] **F-001 fixed**: `final-settlement-subsidy/execution.ts` enforces `MAX_FINAL_SETTLEMENT_SUBSIDY_USD` on every positive destination-token shortfall before any transfer, with an independent `1.1 ×` route-spend bound when a funding swap is needed so the acquisition buffer cannot make an otherwise executable quote fail. - [x] Verify `phases/blocks/phases/subsidize-pre/execution.ts` calculates subsidy as `expectedAmount - currentBalance` and transfers exactly that amount. **PASS**. - [x] Verify `phases/blocks/phases/subsidize-post/execution.ts` calculates subsidy the same way — no off-by-one, no rounding errors. **PASS**. - [x] Verify both pre/post swap handlers skip subsidization when `currentBalance >= expectedAmount` (no negative transfers). **PASS** — skip condition verified in both handlers. @@ -73,7 +75,7 @@ The current code resolves this through `EVM_FUNDING_PRIVATE_KEY` and the `getEvm - [ ] Verify `MOONBEAM_FUNDING_PRIVATE_KEY` is used only for EVM subsidization, not other Moonbeam operations. **FAIL F-029** — `MOONBEAM_FUNDING_PRIVATE_KEY` equals `MOONBEAM_EXECUTOR_PRIVATE_KEY`; same key used for funding, executor, legacy Monerium signing, Mykobo-related Base operations, and SquidRouter operations. With the BRL-on-Base and EUR-on-Base (Mykobo) flows this key is now also used for ephemeral subsidization on Base, BRLA + Mykobo EURC payouts on Base, and EVM fee distribution on Base — a single private key compromise drains funds across Moonbeam, Base, Polygon, and any other EVM chain in scope, including the dedicated BRLA and Mykobo payout paths. - [x] Verify `phases/blocks/phases/destination-transfer/execution.ts` checks ephemeral balance before submitting the presigned transaction. **PASS**. - [x] Verify the presigned destination transfer is submitted as-is — no server-side modification of recipient or amount. **PASS** — presigned transaction submitted unmodified. -- [x] `phases/blocks/phases/final-settlement-subsidy/execution.ts` bounds the funding swap input and rejects a Squid route whose estimated output is below 80% of the required subsidy before broadcast. **PASS** +- [x] `phases/blocks/phases/final-settlement-subsidy/execution.ts` bounds the actual native funding-swap value, sizes it from the exact inventory shortfall, and requires Squid's guaranteed `toAmountMin` to cover that shortfall before broadcast. **PASS** - [x] Final-settlement funding uses one durable operation claim rather than the deleted five-attempt handler loop; an ambiguous broadcast is not automatically repeated. **PASS** - [x] Post-swap routing is explicit in catalog flow composition; the subsidy executor does not select the next phase. **PASS**. - [ ] Verify funding account balance is checked before subsidization — insufficient balance should fail the phase, not silently skip. **FAIL F-032** — no pre-check of funding account balance; insufficient balance causes transaction revert at chain level, not a graceful phase error. From 1ad2134831669c4eaf26584263514b8243be36fa Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 13 Aug 2026 11:55:46 +0200 Subject: [PATCH 6/9] fix(shared): validate provider responses at service boundaries --- .../test-utils/fake-world/fake-squidrouter.ts | 3 + .../alfredpay/alfredpayApiService.test.ts | 89 ++++++++++++++++++- .../services/alfredpay/alfredpayApiService.ts | 11 ++- .../src/services/squidrouter/route.test.ts | 46 ++++++++++ .../shared/src/services/squidrouter/route.ts | 15 ++-- .../src/services/squidrouter/schemas.ts | 7 +- 6 files changed, 153 insertions(+), 18 deletions(-) create mode 100644 packages/shared/src/services/squidrouter/route.test.ts diff --git a/apps/api/src/test-utils/fake-world/fake-squidrouter.ts b/apps/api/src/test-utils/fake-world/fake-squidrouter.ts index cb2563b9d..612d3ab75 100644 --- a/apps/api/src/test-utils/fake-world/fake-squidrouter.ts +++ b/apps/api/src/test-utils/fake-world/fake-squidrouter.ts @@ -23,6 +23,8 @@ export class FakeSquidRouter { computeToAmount: (params: RouteParams) => string = params => params.fromAmount; /** Guaranteed raw destination amount. Default: the estimated amount. */ computeToAmountMin: (params: RouteParams) => string = params => this.computeToAmount(params); + /** USD value returned with the route estimate. */ + toAmountUsd = "1"; toTokenDecimals = 18; failNextRoute: Error | null = null; readonly requestedRoutes: RouteParams[] = []; @@ -42,6 +44,7 @@ export class FakeSquidRouter { estimate: { toAmount: this.computeToAmount(params), toAmountMin: this.computeToAmountMin(params), + toAmountUSD: this.toAmountUsd, toToken: { decimals: this.toTokenDecimals } }, quoteId: "fake-squid-quote", diff --git a/packages/shared/src/services/alfredpay/alfredpayApiService.test.ts b/packages/shared/src/services/alfredpay/alfredpayApiService.test.ts index f7f6d0479..c4890ac59 100644 --- a/packages/shared/src/services/alfredpay/alfredpayApiService.test.ts +++ b/packages/shared/src/services/alfredpay/alfredpayApiService.test.ts @@ -4,7 +4,16 @@ process.env.ALFREDPAY_API_KEY ||= "test-key"; process.env.ALFREDPAY_API_SECRET ||= "test-secret"; const { AlfredpayApiService, toAsciiFileName } = await import("./alfredpayApiService"); -const { AlfredpayKybRelatedPersonFileType, AlfredpayKycFileType, AlfredpayKybFileType } = await import("./types"); +const { + AlfredpayChain, + AlfredpayFiatCurrency, + AlfredpayKybRelatedPersonFileType, + AlfredpayKycFileType, + AlfredpayKybFileType, + AlfredpayOfframpStatus, + AlfredpayOnChainCurrency, + AlfredpayPaymentMethodType +} = await import("./types"); describe("toAsciiFileName", () => { test("transliterates accents and keeps the extension", () => { @@ -88,3 +97,81 @@ describe("uploads send an ASCII multipart filename", () => { expect(sentFileName("fileBody")).toBe("Identificacion_oficial.png"); }); }); + +describe("offramp responses are validated at the service boundary", () => { + const realFetch = globalThis.fetch; + const service = AlfredpayApiService.getInstance(); + + afterEach(() => { + globalThis.fetch = realFetch; + }); + + function respondWith(body: unknown): void { + globalThis.fetch = (async () => Response.json(body)) as unknown as typeof fetch; + } + + function validTransaction() { + return { + chain: AlfredpayChain.MATIC, + customerId: "customer-1", + depositAddress: "0x5afe00000000000000000000000000000000d0e5", + expiration: "2026-08-13T12:00:00.000Z", + fiatAccountId: "fiat-account-1", + fromAmount: "1000", + fromCurrency: AlfredpayOnChainCurrency.USDT, + status: AlfredpayOfframpStatus.CREATED, + toAmount: "17000", + toCurrency: AlfredpayFiatCurrency.MXN, + transactionId: "transaction-1" + }; + } + + test("createOfframpQuote rejects malformed pricing terms", async () => { + respondWith({ + chain: AlfredpayChain.MATIC, + expiration: "2026-08-13T12:00:00.000Z", + fees: [], + fromAmount: "1000", + fromCurrency: AlfredpayOnChainCurrency.USDT, + quoteId: "quote-1", + toAmount: "17000", + toCurrency: AlfredpayFiatCurrency.MXN + }); + + await expect( + service.createOfframpQuote({ + chain: AlfredpayChain.MATIC, + fromAmount: "1000", + fromCurrency: AlfredpayOnChainCurrency.USDT, + metadata: { businessId: "business-1", customerId: "customer-1" }, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + toCurrency: AlfredpayFiatCurrency.MXN + }) + ).rejects.toThrow(); + }); + + test("createOfframp rejects a malformed order", async () => { + const body = validTransaction(); + body.depositAddress = "not-an-address"; + respondWith(body); + + await expect( + service.createOfframp({ + amount: "1000", + chain: AlfredpayChain.MATIC, + customerId: "customer-1", + fiatAccountId: "fiat-account-1", + fromCurrency: AlfredpayOnChainCurrency.USDT, + originAddress: "0x5afe00000000000000000000000000000000d0e5", + quoteId: "quote-1", + toCurrency: AlfredpayFiatCurrency.MXN + }) + ).rejects.toThrow(); + }); + + test("getOfframpTransaction rejects malformed recovery terms", async () => { + respondWith({ ...validTransaction(), status: "SETTLED" }); + + await expect(service.getOfframpTransaction("transaction-1")).rejects.toThrow(); + }); +}); diff --git a/packages/shared/src/services/alfredpay/alfredpayApiService.ts b/packages/shared/src/services/alfredpay/alfredpayApiService.ts index a88c87884..44989803b 100644 --- a/packages/shared/src/services/alfredpay/alfredpayApiService.ts +++ b/packages/shared/src/services/alfredpay/alfredpayApiService.ts @@ -2,6 +2,7 @@ import Big from "big.js"; import { ALFREDPAY_API_KEY, ALFREDPAY_API_SECRET, ALFREDPAY_BASE_URL } from "../.."; import logger from "../../logger"; import { ProviderHttpError } from "../providerHttpError"; +import { alfredpayOfframpTransactionSchema, alfredpayQuoteResponseSchema } from "./schemas"; import { AlfredpayCustomerType, AlfredpayFee, @@ -288,7 +289,9 @@ export class AlfredpayApiService { public async createOfframpQuote(request: CreateAlfredpayOfframpQuoteRequest): Promise { const path = "/api/v1/third-party-service/penny/quotes"; - return (await this.executeRequest(path, "POST", request)) as AlfredpayOfframpQuote; + const response = await this.executeRequest(path, "POST", request); + // The loose schema preserves provider fields outside Vortex's consumed subset. + return alfredpayQuoteResponseSchema.parse(response) as unknown as AlfredpayOfframpQuote; } public async getQuote(quoteId: string): Promise { @@ -308,12 +311,14 @@ export class AlfredpayApiService { public async createOfframp(request: CreateAlfredpayOfframpRequest): Promise { const path = "/api/v1/third-party-service/penny/offramp"; - return (await this.executeRequest(path, "POST", request)) as CreateAlfredpayOfframpResponse; + const response = await this.executeRequest(path, "POST", request); + return alfredpayOfframpTransactionSchema.parse(response) as unknown as CreateAlfredpayOfframpResponse; } public async getOfframpTransaction(transactionId: string): Promise { const path = `/api/v1/third-party-service/penny/offramp/${transactionId}`; - return (await this.executeRequest(path, "GET")) as CreateAlfredpayOfframpResponse; + const response = await this.executeRequest(path, "GET"); + return alfredpayOfframpTransactionSchema.parse(response) as unknown as CreateAlfredpayOfframpResponse; } public async createFiatAccount( diff --git a/packages/shared/src/services/squidrouter/route.test.ts b/packages/shared/src/services/squidrouter/route.test.ts new file mode 100644 index 000000000..d7fc1edaa --- /dev/null +++ b/packages/shared/src/services/squidrouter/route.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { getRoute, type RouteParams } from "./route"; + +const realFetch = globalThis.fetch; + +const params: RouteParams = { + bypassGuardrails: true, + enableExpress: true, + fromAddress: "0x1000000000000000000000000000000000000001", + fromAmount: "1000000", + fromChain: "137", + fromToken: "0x2000000000000000000000000000000000000002", + toAddress: "0x3000000000000000000000000000000000000003", + toChain: "137", + toToken: "0x4000000000000000000000000000000000000004" +}; + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +describe("getRoute response validation", () => { + test("rejects malformed executable route terms before returning them", async () => { + globalThis.fetch = (async () => + Response.json({ + route: { + estimate: { + aggregateSlippage: 1, + toAmount: "1000000", + toAmountMin: "not-raw-units", + toAmountUSD: "1", + toToken: { decimals: 6 } + }, + quoteId: "quote-1", + transactionRequest: { + data: "0x", + gasLimit: "350000", + target: "0x5000000000000000000000000000000000000005", + value: "1000000" + } + } + })) as unknown as typeof fetch; + + await expect(getRoute(params)).rejects.toThrow(); + }); +}); diff --git a/packages/shared/src/services/squidrouter/route.ts b/packages/shared/src/services/squidrouter/route.ts index 956185db9..b01878d26 100644 --- a/packages/shared/src/services/squidrouter/route.ts +++ b/packages/shared/src/services/squidrouter/route.ts @@ -2,6 +2,7 @@ import PQueue from "p-queue"; import logger from "../../logger"; import { squidRouterConfigBase } from "./config"; import { generateRouteCacheKey, getCachedRoute, setCachedRoute, stripRouteForCache } from "./route-cache"; +import { squidrouterRouteResponseSchema } from "./schemas"; const SQUIDROUTER_BASE_URL = "https://v2.api.squidrouter.com/v2"; @@ -45,7 +46,7 @@ export interface SquidRouterPayResponse { export interface SquidrouterRouteEstimate { toToken: { decimals: number }; - aggregateSlippage: number; + aggregateSlippage?: number; toAmount: string; toAmountMin: string; toAmountUSD: string; @@ -228,9 +229,9 @@ async function getRouteInternal(params: RouteParams): Promise>>; + let fetchResult: Awaited>>; try { - fetchResult = await squidFetch<{ route: SquidrouterRoute }>(url, { + fetchResult = await squidFetch(url, { body: JSON.stringify(params), headers: { "Content-Type": "application/json", @@ -252,13 +253,9 @@ async function getRouteInternal(params: RouteParams): Promise & - Partial>; -type ConsumedRoute = Pick & { estimate: ConsumedRouteEstimate }; type ConsumedPayStatus = Pick; const RAW_UNITS = /^\d+$/; @@ -30,6 +26,7 @@ const squidrouterRouteEstimateSchema = z aggregateSlippage: z.number().optional(), toAmount: z.string().regex(RAW_UNITS), toAmountMin: z.string().regex(RAW_UNITS), + toAmountUSD: z.string().min(1), toToken: z.looseObject({ decimals: z.number().int().positive() }) }) .superRefine((estimate, ctx) => { @@ -55,7 +52,7 @@ export const squidrouterRouteResponseSchema = z.looseObject({ value: z.string().regex(RAW_UNITS) }) }) -}) satisfies z.ZodType<{ route: ConsumedRoute }>; +}) satisfies z.ZodType<{ route: SquidrouterRoute }>; /** The body of a GET /v2/status response. */ export const squidrouterStatusResponseSchema = z.looseObject({ From 3a40205e7af8800e0555bb9dc6760c5ff0a173fe Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 13 Aug 2026 11:55:54 +0200 Subject: [PATCH 7/9] fix(api): keep financial operation inputs immutable --- .../blocks/core/financial-operation.test.ts | 81 ++++++++----------- .../phases/blocks/core/financial-operation.ts | 25 ++---- .../final-settlement-subsidy/execution.ts | 1 - .../06-cross-chain/fund-routing.md | 2 +- 4 files changed, 40 insertions(+), 69 deletions(-) diff --git a/apps/api/src/api/services/phases/blocks/core/financial-operation.test.ts b/apps/api/src/api/services/phases/blocks/core/financial-operation.test.ts index 0f4c11a25..642445806 100644 --- a/apps/api/src/api/services/phases/blocks/core/financial-operation.test.ts +++ b/apps/api/src/api/services/phases/blocks/core/financial-operation.test.ts @@ -109,26 +109,44 @@ describe("runFinancialOperation", () => { expect(perform).toHaveBeenCalledTimes(1); }); - it("allows a not-started operation to adopt a stable corrected request", async () => { - const beforePerform = mock(async () => { - throw new Error("preflight unavailable"); + it("keeps operation inputs immutable while another caller is in preflight", async () => { + let releasePreflight: () => void = () => undefined; + let signalPreflightStarted: () => void = () => undefined; + const preflightStarted = new Promise(resolve => { + signalPreflightStarted = resolve; }); - const perform = mock(async () => ({ id: "funding-1" })); - - await expect(runFinancialOperation({ ...baseOperation, beforePerform, perform })).rejects.toThrow( - "preflight unavailable" - ); - expect(await FinancialOperation.findOne()).toMatchObject({ status: "not_started" }); + const preflightReleased = new Promise(resolve => { + releasePreflight = resolve; + }); + const firstPerform = mock(async () => ({ id: "external-1" })); + const competingPerform = mock(async () => ({ id: "external-2" })); - const result = await runFinancialOperation({ + const first = runFinancialOperation({ ...baseOperation, - perform, - request: { acquisitionCapUsd: "11", destination: "recipient-1" } + beforePerform: async () => { + signalPreflightStarted(); + await preflightReleased; + }, + perform: firstPerform }); - - expect(result).toEqual({ id: "funding-1" }); - expect(perform).toHaveBeenCalledTimes(1); - expect(await FinancialOperation.findOne()).toMatchObject({ status: "confirmed" }); + await preflightStarted; + + try { + await expect( + runFinancialOperation({ + ...baseOperation, + perform: competingPerform, + request: { amount: "11", recipient: "recipient-1" } + }) + ).rejects.toThrow("different inputs"); + } finally { + releasePreflight(); + } + + await expect(first).resolves.toEqual({ id: "external-1" }); + expect(firstPerform).toHaveBeenCalledTimes(1); + expect(competingPerform).not.toHaveBeenCalled(); + await expect(runFinancialOperation({ ...baseOperation, perform: firstPerform })).resolves.toEqual({ id: "external-1" }); }); it("halts retries after an ambiguous provider failure", async () => { @@ -160,37 +178,6 @@ describe("runFinancialOperation", () => { ).rejects.toThrow("different inputs"); }); - it("replays a confirmed legacy request shape when compatibility is explicit", async () => { - const perform = mock(async () => ({ id: "external-1" })); - const first = await runFinancialOperation({ ...baseOperation, perform }); - const replayed = await runFinancialOperation({ - ...baseOperation, - allowExistingRequestMismatch: true, - perform, - request: { acquisitionCapUsd: "11", destination: "recipient-1" } - }); - - expect(replayed).toEqual(first); - expect(perform).toHaveBeenCalledTimes(1); - }); - - it("surfaces reconciliation for an ambiguous legacy request shape", async () => { - const perform = mock(async () => { - throw new Error("receipt timeout"); - }); - await expect(runFinancialOperation({ ...baseOperation, perform })).rejects.toThrow("receipt timeout"); - - await expect( - runFinancialOperation({ - ...baseOperation, - allowExistingRequestMismatch: true, - perform, - request: { acquisitionCapUsd: "11", destination: "recipient-1" } - }) - ).rejects.toThrow("requires reconciliation"); - expect(perform).toHaveBeenCalledTimes(1); - }); - it("allows corrected input after a definitive rejection without a side effect", async () => { const rejected = new FinancialOperationRejectedError("invalid recipient"); await expect( diff --git a/apps/api/src/api/services/phases/blocks/core/financial-operation.ts b/apps/api/src/api/services/phases/blocks/core/financial-operation.ts index 06827fe0a..ef4f8647a 100644 --- a/apps/api/src/api/services/phases/blocks/core/financial-operation.ts +++ b/apps/api/src/api/services/phases/blocks/core/financial-operation.ts @@ -14,13 +14,6 @@ export interface RunFinancialOperationArgs { attemptClass: string; provider: string; request: unknown; - /** - * Recovery-only compatibility for an existing durable operation whose prior - * request shape changed across a deployment. The original claim remains - * authoritative: confirmed replays and ambiguous outcomes are handled by - * status, while only not_started rows may adopt the new request hash. - */ - allowExistingRequestMismatch?: boolean; retryFailed?: boolean; signal?: AbortSignal; /** Runs only after replay/reconciliation is exhausted and immediately before claiming a new side effect. */ @@ -85,7 +78,6 @@ export async function runFinancialOperation({ attemptClass, provider, request, - allowExistingRequestMismatch = false, beforePerform, perform, reconcile, @@ -119,18 +111,11 @@ export async function runFinancialOperation({ where: { operationKey } }); - if (operation.requestHash !== requestHash) { - if (operation.status === "not_started") { - // No financial side effect has been claimed yet. Refreshing a preflight's - // request is safe and lets legacy/live observations converge on the stable - // authorization used by the current executor. - await operation.update({ requestHash }); - } else if (!(operation.status === "failed" && retryFailed) && !allowExistingRequestMismatch) { - throw new APIError({ - message: `Financial operation ${operation.id} was already claimed with different inputs`, - status: httpStatus.CONFLICT - }); - } + if (operation.requestHash !== requestHash && !(operation.status === "failed" && retryFailed)) { + throw new APIError({ + message: `Financial operation ${operation.id} was already claimed with different inputs`, + status: httpStatus.CONFLICT + }); } if (!created) { if (operation.status === "confirmed" && operation.response !== null) { diff --git a/apps/api/src/api/services/phases/blocks/phases/final-settlement-subsidy/execution.ts b/apps/api/src/api/services/phases/blocks/phases/final-settlement-subsidy/execution.ts index edea306af..c266e1d16 100644 --- a/apps/api/src/api/services/phases/blocks/phases/final-settlement-subsidy/execution.ts +++ b/apps/api/src/api/services/phases/blocks/phases/final-settlement-subsidy/execution.ts @@ -334,7 +334,6 @@ export class FinalSettlementSubsidyExecutor extends BasePhaseHandler { try { const { hash: txHashIdx } = await this.runFinancialOperation(state, { - allowExistingRequestMismatch: true, attemptClass: "funding-swap", beforePerform: async () => { logger.info( diff --git a/docs/security-spec/06-cross-chain/fund-routing.md b/docs/security-spec/06-cross-chain/fund-routing.md index b08b31368..db10d4275 100644 --- a/docs/security-spec/06-cross-chain/fund-routing.md +++ b/docs/security-spec/06-cross-chain/fund-routing.md @@ -40,7 +40,7 @@ The current code resolves this through `EVM_FUNDING_PRIVATE_KEY` and the `getEvm 1. **Subsidization MUST only top up to the expected amount, never more** — Both `subsidize-pre/execution.ts` and `subsidize-post/execution.ts` calculate `expectedAmount - currentBalance` and transfer exactly that difference. If the balance already meets or exceeds the expected amount, no transfer occurs. 2. **Expected amounts MUST come from ramp state set at creation time** — The expected input/output amounts are derived from the quote and stored in ramp state. Handlers read these values, not recalculate them. This prevents manipulation via price changes between quote and execution. 3. **Funding account private keys MUST only be used for subsidization transfers** — `getFundingAccount()` derives a keypair from `PENDULUM_FUNDING_SEED`. This keypair should only sign subsidization transfers, not arbitrary transactions. -4. **Every final settlement subsidy MUST enforce a USD cap before funds move** — The full observable shortfall is converted from the destination token into USD and compared with `MAX_FINAL_SETTLEMENT_SUBSIDY_USD`, regardless of whether the funding wallet already holds the output token or first needs a swap. When inventory acquisition is required, it is sized only for the funding wallet's actual token shortfall, Squid's guaranteed `toAmountMin` MUST cover that shortfall, and the executable transaction value MUST equal the capped requested native input before broadcast. Its independent spend bound is `1.1 × MAX_FINAL_SETTLEMENT_SUBSIDY_USD`, matching the route's 10% input buffer; the transferred customer subsidy remains capped at the unbuffered limit and any over-acquired token remains treasury inventory. The durable acquisition operation uses a stable cap/destination/token authorization, runs mutable route/nonce checks only in `beforePerform`, and replays a confirmed result before new market observations. Its explicit request-shape compatibility flag is recovery-only: legacy confirmed rows replay, ambiguous rows still require reconciliation, and only provably `not_started` rows adopt the new hash. +4. **Every final settlement subsidy MUST enforce a USD cap before funds move** — The full observable shortfall is converted from the destination token into USD and compared with `MAX_FINAL_SETTLEMENT_SUBSIDY_USD`, regardless of whether the funding wallet already holds the output token or first needs a swap. When inventory acquisition is required, it is sized only for the funding wallet's actual token shortfall, Squid's guaranteed `toAmountMin` MUST cover that shortfall, and the executable transaction value MUST equal the capped requested native input before broadcast. Its independent spend bound is `1.1 × MAX_FINAL_SETTLEMENT_SUBSIDY_USD`, matching the route's 10% input buffer; the transferred customer subsidy remains capped at the unbuffered limit and any over-acquired token remains treasury inventory. The durable acquisition operation uses an immutable cap/destination/token authorization, runs mutable route/nonce checks only in `beforePerform`, and replays a confirmed result before new market observations. 5. **Destination transfer MUST use a presigned transaction** — `destination-transfer/execution.ts` submits the presigned transfer from state. The server cannot modify the recipient address or amount at execution time. 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 phase ordering MUST be deterministic** — the resolved block flow owns the phase sequence, and `subsidize-post/execution.ts` must not select a corridor-specific successor independently. From 889042f1bfeb91d1785d9c7c37786282d3c371c4 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 13 Aug 2026 18:51:06 +0200 Subject: [PATCH 8/9] refactor(api): simplify AlfredPay bridge pricing --- .../__tests__/alfredpay-offramp.flow.test.ts | 10 +- .../alfredpay-offramp.registration.test.ts | 1 - .../evm-executor-regressions.test.ts | 62 +++----- .../phases/blocks/core/squidrouter.ts | 13 -- .../phases/blocks/flows/alfredpay-offramp.ts | 8 +- .../services/phases/blocks/flows/catalog.ts | 12 +- .../phases/alfredpay-offramp/simulation.ts | 34 +---- .../phases/alfredpay-offramp/transactions.ts | 35 ++--- .../final-settlement-subsidy/execution.ts | 9 +- .../polygon-post-process-handler.ts | 24 +--- .../corridors/mxn-offramp.scenario.test.ts | 136 +----------------- .../src/services/squidrouter/offramp.ts | 14 +- 12 files changed, 65 insertions(+), 293 deletions(-) diff --git a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.flow.test.ts index 13c627609..424065ace 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.flow.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.flow.test.ts @@ -30,11 +30,11 @@ const CORE_PHASES: RampPhase[] = [ ]; describe("Alfredpay offramp flow", () => { - it("uses v4/schema 3 and rejects pre-rollout flow versions", () => { - expect(alfredpayOfframpFlow.identity.version).toBe(4); - expect(alfredpayOfframpFlow.identity.blockSchemaVersions.alfredpayOfframp).toBe(3); - expect(() => getBlockFlowByIdentity({ ...alfredpayOfframpFlow.identity, version: 3 })).toThrow( - /Unsupported persisted flow AlfredpayOfframp@3/ + it("fails closed for persisted pre-v3 identities (drain-then-deploy contract)", () => { + expect(alfredpayOfframpFlow.identity.version).toBe(3); + expect(alfredpayOfframpFlow.identity.blockSchemaVersions.alfredpayOfframp).toBe(2); + expect(() => getBlockFlowByIdentity({ ...alfredpayOfframpFlow.identity, version: 2 })).toThrow( + /Unsupported persisted flow AlfredpayOfframp@2/ ); }); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.registration.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.registration.test.ts index 1b88d444f..d2766f780 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.registration.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.registration.test.ts @@ -21,7 +21,6 @@ const metadata: AlfredpayOfframpMetadata = { bridgeOutputAmountDecimal: "99", bridgeOutputAmountRaw: "99000000", currency: FiatToken.MXN, - executableBridgeOutputRaw: "99000000", expirationDate: new Date("2026-01-01T00:00:00Z"), fee: "1", fromNetwork: Networks.Base as EvmNetworks, diff --git a/apps/api/src/api/services/phases/blocks/__tests__/evm-executor-regressions.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/evm-executor-regressions.test.ts index 9ad1c3ced..f566cbe1a 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/evm-executor-regressions.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/evm-executor-regressions.test.ts @@ -104,7 +104,6 @@ mock.module("../core/financial-operation", () => ({ })); const { SubsidizePostSwapExecutor } = await import("../phases/subsidize-post/execution"); const { FinalSettlementSubsidyExecutor } = await import("../phases/final-settlement-subsidy/execution"); -const { getAlfredpayExecutableBridgeOutputRaw } = await import("../phases/alfredpay-offramp/simulation"); const { AlfredpayOnrampMintExecutor } = await import("../phases/alfredpay-mint/execution"); afterAll(() => { @@ -249,19 +248,18 @@ describe("EVM block executor regressions", () => { expect(sendTransaction).not.toHaveBeenCalled(); }); - it("uses AlfredPay SELL's guaranteed bridge minimum and records the subsidy as Polygon USDT", async () => { + it("uses AlfredPay SELL's quoted bridge output and records the subsidy as Polygon USDT", async () => { checkBalance.mockResolvedValue(new Big("900000")); findQuote.mockResolvedValue({ metadata: { blocks: { alfredpayOfframp: { bridgeOutputAmountRaw: "1000000", - executableBridgeOutputRaw: "800000", inputAmountRaw: "1000000", subsidyAmountRaw: "200000" } }, - flow: { id: "AlfredpayOfframp", version: 4 }, + flow: { id: "AlfredpayOfframp", version: 3 }, globals: { fees: { usd: { anchor: "0", network: "0", partnerMarkup: "0", vortex: "0" } }, request: {} } }, network: Networks.Polygon, @@ -277,7 +275,7 @@ describe("EVM block executor regressions", () => { baselineRaw: "0", destinationNetwork: Networks.Polygon, destinationToken: "0xc2132d05d31c914a87c6611c10748aeb04b58e8f", - expectedAmountRaw: "800000", + expectedAmountRaw: "1000000", kind: "destination-balance", minimumRatioBps: 9000, observedAt: "2026-01-01T00:00:00.000Z", @@ -303,7 +301,7 @@ describe("EVM block executor regressions", () => { priceFeedService.convertCurrency = originalConvertCurrency; } - expect(checkBalance).toHaveBeenCalledWith(expect.objectContaining({ amountDesiredRaw: "720000" })); + expect(checkBalance).toHaveBeenCalledWith(expect.objectContaining({ amountDesiredRaw: "900000" })); expect(executor.createSubsidy).toHaveBeenCalledWith(state, 0.1, EvmToken.USDT, fundingAccount.address, expect.any(String)); }); @@ -314,21 +312,20 @@ describe("EVM block executor regressions", () => { ])( "acquires only the treasury shortfall and transfers the full subsidy (%s)", async (_caseName, subsidyAmountRaw, fundingInventoryRaw, expectedNativeInputRaw) => { - const executableBridgeOutputRaw = "1000000000"; - const inputAmountRaw = new Big(executableBridgeOutputRaw).plus(subsidyAmountRaw).toFixed(0); - checkBalance.mockResolvedValue(new Big(executableBridgeOutputRaw)); + const bridgeOutputAmountRaw = "1000000000"; + const inputAmountRaw = new Big(bridgeOutputAmountRaw).plus(subsidyAmountRaw).toFixed(0); + checkBalance.mockResolvedValue(new Big(bridgeOutputAmountRaw)); getFundingBalance.mockResolvedValue(new Big(fundingInventoryRaw)); findQuote.mockResolvedValue({ metadata: { blocks: { alfredpayOfframp: { - bridgeOutputAmountRaw: executableBridgeOutputRaw, - executableBridgeOutputRaw, + bridgeOutputAmountRaw, inputAmountRaw, subsidyAmountRaw } }, - flow: { id: "AlfredpayOfframp", version: 4 }, + flow: { id: "AlfredpayOfframp", version: 3 }, globals: { fees: { usd: { anchor: "0", network: "0", partnerMarkup: "0", vortex: "0" } }, request: {} } }, network: Networks.Polygon, @@ -375,9 +372,9 @@ describe("EVM block executor regressions", () => { ); it("does not broadcast an acquisition whose guaranteed output leaves treasury inventory insolvent", async () => { - const executableBridgeOutputRaw = "1000000000"; + const bridgeOutputAmountRaw = "1000000000"; const subsidyAmountRaw = "10000000"; - checkBalance.mockResolvedValue(new Big(executableBridgeOutputRaw)); + checkBalance.mockResolvedValue(new Big(bridgeOutputAmountRaw)); getFundingBalance.mockResolvedValue(new Big(0)); getRoute .mockResolvedValueOnce({ @@ -410,13 +407,12 @@ describe("EVM block executor regressions", () => { metadata: { blocks: { alfredpayOfframp: { - bridgeOutputAmountRaw: executableBridgeOutputRaw, - executableBridgeOutputRaw, + bridgeOutputAmountRaw, inputAmountRaw: "1010000000", subsidyAmountRaw } }, - flow: { id: "AlfredpayOfframp", version: 4 }, + flow: { id: "AlfredpayOfframp", version: 3 }, globals: { fees: { usd: { anchor: "0", network: "0", partnerMarkup: "0", vortex: "0" } }, request: {} } }, network: Networks.Polygon, @@ -514,23 +510,22 @@ describe("EVM block executor regressions", () => { }); it("replays a confirmed acquisition before fresh route data after a balance-wait failure", async () => { - const executableBridgeOutputRaw = "1000000000"; + const bridgeOutputAmountRaw = "1000000000"; checkBalance - .mockResolvedValueOnce(new Big(executableBridgeOutputRaw)) + .mockResolvedValueOnce(new Big(bridgeOutputAmountRaw)) .mockRejectedValueOnce(new Error("balance RPC timeout")) - .mockResolvedValue(new Big(executableBridgeOutputRaw)); + .mockResolvedValue(new Big(bridgeOutputAmountRaw)); getFundingBalance.mockResolvedValue(new Big(0)); findQuote.mockResolvedValue({ metadata: { blocks: { alfredpayOfframp: { - bridgeOutputAmountRaw: executableBridgeOutputRaw, - executableBridgeOutputRaw, + bridgeOutputAmountRaw, inputAmountRaw: "1010000000", subsidyAmountRaw: "10000000" } }, - flow: { id: "AlfredpayOfframp", version: 4 }, + flow: { id: "AlfredpayOfframp", version: 3 }, globals: { fees: { usd: { anchor: "0", network: "0", partnerMarkup: "0", vortex: "0" } }, request: {} } }, network: Networks.Polygon, @@ -571,20 +566,19 @@ describe("EVM block executor regressions", () => { }); it("classifies acquisition route and ambiguous receipt failures as recoverable without duplicate broadcast", async () => { - const executableBridgeOutputRaw = "1000000000"; - checkBalance.mockResolvedValue(new Big(executableBridgeOutputRaw)); + const bridgeOutputAmountRaw = "1000000000"; + checkBalance.mockResolvedValue(new Big(bridgeOutputAmountRaw)); getFundingBalance.mockResolvedValue(new Big(0)); findQuote.mockResolvedValue({ metadata: { blocks: { alfredpayOfframp: { - bridgeOutputAmountRaw: executableBridgeOutputRaw, - executableBridgeOutputRaw, + bridgeOutputAmountRaw, inputAmountRaw: "1010000000", subsidyAmountRaw: "10000000" } }, - flow: { id: "AlfredpayOfframp", version: 4 }, + flow: { id: "AlfredpayOfframp", version: 3 }, globals: { fees: { usd: { anchor: "0", network: "0", partnerMarkup: "0", vortex: "0" } }, request: {} } }, network: Networks.Polygon, @@ -632,15 +626,6 @@ describe("EVM block executor regressions", () => { } }); - it("fails closed when schema-3 executable minimum metadata disagrees with settlement arithmetic", () => { - expect(() => - getAlfredpayExecutableBridgeOutputRaw( - { executableBridgeOutputRaw: "800001", inputAmountRaw: "1000000", subsidyAmountRaw: "200000" }, - { network: "0", partnerMarkup: "0", vortex: "0" } - ) - ).toThrow("executable bridge minimum mismatch"); - }); - it("does not fund AlfredPay bridge under-delivery beyond the quoted subsidy", async () => { checkBalance.mockResolvedValue(new Big("900000")); findQuote.mockResolvedValue({ @@ -648,12 +633,11 @@ describe("EVM block executor regressions", () => { blocks: { alfredpayOfframp: { bridgeOutputAmountRaw: "1000000", - executableBridgeOutputRaw: "1000000", inputAmountRaw: "1000000", subsidyAmountRaw: "0" } }, - flow: { id: "AlfredpayOfframp", version: 4 }, + flow: { id: "AlfredpayOfframp", version: 3 }, globals: { fees: { usd: { anchor: "0", network: "0", partnerMarkup: "0", vortex: "0" } }, request: {} } }, network: Networks.Polygon, diff --git a/apps/api/src/api/services/phases/blocks/core/squidrouter.ts b/apps/api/src/api/services/phases/blocks/core/squidrouter.ts index da98c9e24..9a4468983 100644 --- a/apps/api/src/api/services/phases/blocks/core/squidrouter.ts +++ b/apps/api/src/api/services/phases/blocks/core/squidrouter.ts @@ -188,25 +188,12 @@ async function getSquidrouterRouteData(routeParams: RouteParams, fromNetwork: Ne const routeData = routeResult.data; const outputTokenDecimals = routeData.route.estimate.toToken.decimals; const outputAmountRaw = routeData.route.estimate.toAmount; - const minimumOutputAmountRaw = routeData.route.estimate.toAmountMin; - if (BigInt(minimumOutputAmountRaw) > BigInt(outputAmountRaw)) { - throw new APIError({ - message: "Invalid Squidrouter response: minimum output exceeds estimated output", - status: httpStatus.SERVICE_UNAVAILABLE - }); - } const outputAmountDecimal = parseContractBalanceResponse(outputTokenDecimals, BigInt(outputAmountRaw)).preciseBigDecimal; - const minimumOutputAmountDecimal = parseContractBalanceResponse( - outputTokenDecimals, - BigInt(minimumOutputAmountRaw) - ).preciseBigDecimal; const networkFeeUSD = await calculateSquidrouterNetworkFee(routeData.route, fromNetwork); return { fromToken: routeParams.fromToken, inputAmountRaw: routeParams.fromAmount, - minimumOutputAmountDecimal, - minimumOutputAmountRaw, networkFeeUSD, outputAmountDecimal, outputAmountRaw, diff --git a/apps/api/src/api/services/phases/blocks/flows/alfredpay-offramp.ts b/apps/api/src/api/services/phases/blocks/flows/alfredpay-offramp.ts index fb9c7fde8..9324d6c12 100644 --- a/apps/api/src/api/services/phases/blocks/flows/alfredpay-offramp.ts +++ b/apps/api/src/api/services/phases/blocks/flows/alfredpay-offramp.ts @@ -4,10 +4,10 @@ import { evmRequestIO } from "../core/io"; import { AlfredpayOfframp } from "../phases/alfredpay-offramp"; import { DistributeFees } from "../phases/distribute-fees"; -// Version 4 introduces context schema 3 and persists Squid's executable minimum for -// provider-aware target-discount/cap reconciliation. Deployments must be timed for -// a window with no pending AlfredPay quotes or ramps from flow v3. -export const ALFREDPAY_OFFRAMP_FLOW_VERSION = 4; +// Version 2 appends the Polygon fee-collection phase: the vortex/partner fee residual +// that AlfredpayOfframp's pricing reserves on the Polygon ephemeral is paid out after +// the Alfredpay deposit succeeded. Deploys are gated on draining v1 quotes/ramps. +export const ALFREDPAY_OFFRAMP_FLOW_VERSION = 3; export function makeAlfredpayOfframpFlow(fromToken: EvmToken, fromNetwork: EvmNetworks) { return FlowBuilder.start(evmRequestIO(fromToken, fromNetwork), AlfredpayOfframp(fromToken, fromNetwork)) diff --git a/apps/api/src/api/services/phases/blocks/flows/catalog.ts b/apps/api/src/api/services/phases/blocks/flows/catalog.ts index a3e0ca5d3..d01af94f2 100644 --- a/apps/api/src/api/services/phases/blocks/flows/catalog.ts +++ b/apps/api/src/api/services/phases/blocks/flows/catalog.ts @@ -339,18 +339,18 @@ export function resolveBlockFlow(request: FlowRequest): Flow { export function resolvePersistedBlockFlow(metadataValue: unknown): Flow { const metadata = getFlowMetadata(metadataValue); if (!metadata.flow) { - const flow = resolveBlockFlow(metadata.globals.request); - flow.assertMetadata(metadata, { allowLegacy: true }); - return flow; + const legacyFlow = resolveBlockFlow(metadata.globals.request); + legacyFlow.assertMetadata(metadata, { allowLegacy: true }); + return legacyFlow; } - const candidates = flowDefinitions.filter(candidate => { - const identity = candidate.executorFlow.identity; + const candidates = flowDefinitions.filter(definition => { + const identity = definition.executorFlow.identity; return ( identity.id === metadata.flow?.id && identity.version === metadata.flow.version && identity.catalogVersion === metadata.flow.catalogVersion && - candidate.matches(metadata.globals.request) + definition.matches(metadata.globals.request) ); }); if (candidates.length !== 1) { diff --git a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/simulation.ts index 059151395..32af8c1c5 100644 --- a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/simulation.ts +++ b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/simulation.ts @@ -39,8 +39,6 @@ export interface AlfredpayOfframpMetadata { bridgeOutputAmountRaw: string; currency: FiatToken; expirationDate: Date; - /** Guaranteed Polygon USDT amount. Introduced by context schema 3 / flow v4. */ - executableBridgeOutputRaw: string; fee: SerializableBig; fromNetwork: EvmNetworks; fromToken: `0x${string}`; @@ -82,8 +80,7 @@ export interface AlfredpayOfframpMetadata { toToken: `0x${string}`; } -/** Executable Squid minimum and quote-bound settlement subsidy. */ -export const AlfredpayOfframpContext = defineContext()("alfredpayOfframp", 3); +export const AlfredpayOfframpContext = defineContext()("alfredpayOfframp", 2); export const ALFREDPAY_MIN_EXECUTION_LIFETIME_MS = 2 * 60 * 1000; export const ALFREDPAY_MIN_QUOTE_LIFETIME_MS = 10 * 1000; @@ -98,22 +95,6 @@ export function hasSafeAlfredpayExecutionLifetime(expiration: string, nowMs = Da return Number.isFinite(expiresAt) && expiresAt > nowMs + ALFREDPAY_MIN_EXECUTION_LIFETIME_MS; } -export function getAlfredpayExecutableBridgeOutputRaw( - metadata: Pick, - feesUsd: Parameters[0] -): string { - const derivedMinimumRaw = new Big(metadata.inputAmountRaw) - .plus(getEvmFeeTotalRawFromUsd(feesUsd, ALFREDPAY_ERC20_DECIMALS)) - .minus(metadata.subsidyAmountRaw) - .toFixed(0); - if (!new Big(metadata.executableBridgeOutputRaw).eq(derivedMinimumRaw)) { - throw new Error( - `Alfredpay executable bridge minimum mismatch: persisted=${metadata.executableBridgeOutputRaw}, derived=${derivedMinimumRaw}` - ); - } - return metadata.executableBridgeOutputRaw; -} - function directAlfredpaySettlementQuote(amountDecimal: string) { const outputAmountDecimal = new Big(amountDecimal); const amountRaw = multiplyByPowerOfTen(outputAmountDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, Big.roundDown); @@ -121,8 +102,6 @@ function directAlfredpaySettlementQuote(amountDecimal: string) { return { fromToken: ALFREDPAY_ERC20_TOKEN, inputAmountRaw: amountRaw, - minimumOutputAmountDecimal: outputAmountDecimal, - minimumOutputAmountRaw: amountRaw, outputAmountDecimal, outputAmountRaw: amountRaw, toToken: ALFREDPAY_ERC20_TOKEN @@ -147,8 +126,6 @@ export function simulateAlfredpayOfframp -): void { - if (new Big(freshMinimumRaw).gt(freshEstimateRaw)) { - throw new Error( - `Alfredpay offramp route minimum exceeds its estimate: estimate=${freshEstimateRaw}, minimum=${freshMinimumRaw}` - ); - } - const quotedMinimumRaw = new Big(getAlfredpayExecutableBridgeOutputRaw(ctx.ownMetadata, ctx.globals.fees.usd)); - if (new Big(freshMinimumRaw).lt(quotedMinimumRaw)) { - throw new Error( - `Alfredpay offramp route minimum drifted below the quote: expected at least ${quotedMinimumRaw.toFixed(0)}, fresh=${freshMinimumRaw}` - ); - } -} - function permitTypedData( domain: Awaited>, owner: string, @@ -185,7 +169,6 @@ export async function prepareAlfredpayOfframpTxs( toNetwork: Networks.Polygon, toToken: ALFREDPAY_ERC20_TOKEN }); - assertExecutableBridgeMinimum(bridge.route.estimate.toAmount, bridge.route.estimate.toAmountMin, ctx); const relayer = ALFREDPAY_RELAYER_ADDRESSES[fromNetwork]; if (!relayer) throw new Error(`Alfredpay offramp permit flow is not supported on ${fromNetwork}`); const payloadNonce = BigInt(Math.floor(now / 1000)); @@ -255,7 +238,6 @@ export async function prepareAlfredpayOfframpTxs( toNetwork: Networks.Polygon, toToken: ALFREDPAY_ERC20_TOKEN }); - assertExecutableBridgeMinimum(bridge.route.estimate.toAmount, bridge.route.estimate.toAmountMin, ctx); squidRouterPermitExecutionValue = bridge.swapData.value; intents.push( { @@ -281,10 +263,10 @@ export async function prepareAlfredpayOfframpTxs( toAddress: facts.depositAddress as `0x${string}`, toToken: ALFREDPAY_ERC20_TOKEN }); - // Size the fallback from the guaranteed bridge minimum, excluding the + // The fallback refunds the user's quoted bridged value and excludes the // platform-funded subsidy that is not part of the user's principal. const fallbackTransfer = await createDestinationTransferTransaction({ - amountRaw: getAlfredpayExecutableBridgeOutputRaw(ctx.ownMetadata, ctx.globals.fees.usd), + amountRaw: ctx.ownMetadata.bridgeOutputAmountRaw, destinationNetwork: Networks.Polygon, toAddress: facts.walletAddress, toToken: ALFREDPAY_ERC20_TOKEN @@ -306,11 +288,10 @@ export async function prepareAlfredpayOfframpTxs( txData: fallbackTransfer } ); - // Squid may deliver above its guaranteed minimum. The provider deposit and - // fee transfers consume only the guaranteed quote obligations, so authorize - // post-processing to return any residual USDT to the user's source wallet. + const axlUsdc = evmTokenConfig[Networks.Polygon][EvmToken.AXLUSDC]?.erc20AddressSourceChain; + if (!axlUsdc) throw new Error("Invalid Polygon AXLUSDC configuration"); const cleanup = await preparePolygonCleanupApproval( - ALFREDPAY_ERC20_TOKEN, + axlUsdc as `0x${string}`, getEvmFundingAccount(Networks.Polygon).address, Networks.Polygon ); diff --git a/apps/api/src/api/services/phases/blocks/phases/final-settlement-subsidy/execution.ts b/apps/api/src/api/services/phases/blocks/phases/final-settlement-subsidy/execution.ts index c266e1d16..73889a013 100644 --- a/apps/api/src/api/services/phases/blocks/phases/final-settlement-subsidy/execution.ts +++ b/apps/api/src/api/services/phases/blocks/phases/final-settlement-subsidy/execution.ts @@ -37,7 +37,6 @@ import { getEvmFundingAccount } from "../../core/evm-funding"; import { getEvmFeeTotalRawFromUsd } from "../../core/fee-distribution"; import { getFlowMetadata } from "../../core/metadata"; import { calculateSettlementSubsidyRaw, settlementBalanceKey } from "../../core/settlement"; -import { getAlfredpayExecutableBridgeOutputRaw } from "../alfredpay-offramp/simulation"; const FINAL_SETTLEMENT_ACQUISITION_BUFFER = new Big("1.1"); const MAX_FINAL_SETTLEMENT_ACQUISITION_USD = new Big(MAX_FINAL_SETTLEMENT_SUBSIDY_USD).mul(FINAL_SETTLEMENT_ACQUISITION_BUFFER); @@ -62,7 +61,7 @@ const NATIVE_TOKENS: Record = // Waits for the destination bridge delivery, then tops the ephemeral up to the quoted settlement // target. BUY may swap the funding account's native token through Squid; AlfredPay SELL tops up -// Polygon USDT while enforcing the quote-bound provider-input and subsidy ceilings. +// Polygon USDT while enforcing the quote-bound subsidy ceiling. export class FinalSettlementSubsidyExecutor extends BasePhaseHandler { public getPhaseName(): RampPhase { return "finalSettlementSubsidy"; @@ -83,7 +82,6 @@ export class FinalSettlementSubsidyExecutor extends BasePhaseHandler { blocks?: { alfredpayOfframp?: { bridgeOutputAmountRaw: string; - executableBridgeOutputRaw?: string; inputAmountRaw: string; subsidyAmountRaw: string; }; @@ -163,10 +161,7 @@ export class FinalSettlementSubsidyExecutor extends BasePhaseHandler { } ).blocks?.squidRouterSwap; const bridgeExpectedAmountRaw = isAlfredpayOfframp - ? getAlfredpayExecutableBridgeOutputRaw( - alfredpayMetadata as Parameters[0], - alfredpayFeesUsd - ) + ? alfredpayMetadata.bridgeOutputAmountRaw : (squidMetadata?.outputAmountRaw ?? expectedAmountRaw.toFixed(0)); const existingEvidence = state.state.squidRouterDeliveryEvidence; if (existingEvidence) { diff --git a/apps/api/src/api/services/phases/post-process/polygon-post-process-handler.ts b/apps/api/src/api/services/phases/post-process/polygon-post-process-handler.ts index ead495dce..55b93f758 100644 --- a/apps/api/src/api/services/phases/post-process/polygon-post-process-handler.ts +++ b/apps/api/src/api/services/phases/post-process/polygon-post-process-handler.ts @@ -1,12 +1,4 @@ -import { - ALFREDPAY_ERC20_TOKEN, - CleanupPhase, - EvmClientManager, - EvmNetworks, - Networks, - PresignedTx, - RampDirection -} from "@vortexfi/shared"; +import { CleanupPhase, EvmClientManager, EvmNetworks, Networks, PresignedTx, RampDirection } from "@vortexfi/shared"; import { Transaction as EvmTransaction } from "ethers"; import { erc20Abi } from "viem"; import logger from "../../../../config/logger"; @@ -96,21 +88,11 @@ export class PolygonPostProcessHandler extends BasePostProcessHandler { const fundingAccount = getEvmFundingAccount(polygonNetwork); const walletClient = evmClientManager.getWalletClient(polygonNetwork, fundingAccount); - const isAlfredpayUsdtResidual = - state.type === RampDirection.SELL && tokenAddress.toLowerCase() === ALFREDPAY_ERC20_TOKEN.toLowerCase(); - const alfredpayWalletAddress = (state.state.blockState?.alfredpayOfframp as { walletAddress?: string } | undefined) - ?.walletAddress; - const recipient = ( - isAlfredpayUsdtResidual ? (alfredpayWalletAddress ?? state.state.walletAddress) : fundingAccount.address - ) as `0x${string}` | undefined; - if (!recipient) { - return [false, this.createErrorObject(`No wallet address found for AlfredPay USDT cleanup on ramp ${state.id}`)]; - } const transferFromHash = await walletClient.writeContract({ abi: erc20Abi, address: tokenAddress, - args: [ephemeralAddress, recipient, balance], + args: [ephemeralAddress, fundingAccount.address, balance], functionName: "transferFrom" }); @@ -119,7 +101,7 @@ export class PolygonPostProcessHandler extends BasePostProcessHandler { return [false, this.createErrorObject(`transferFrom tx ${transferFromHash} for ${phase} failed`)]; } - logger.info(`Successfully swept ${balance} tokens to ${recipient} for Polygon cleanup ${phase} on ramp ${state.id}`); + logger.info(`Successfully swept ${balance} tokens for Polygon cleanup ${phase} on ramp ${state.id}`); return [true, null]; } catch (e) { return [false, this.createErrorObject(`Error in Polygon cleanup ${phase}: ${e}`)]; diff --git a/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts b/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts index e05fb9abd..4e6d5fe95 100644 --- a/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts +++ b/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts @@ -7,7 +7,6 @@ import { AlfredpayOfframpStatus, type EvmTransactionData, EvmToken, - evmTokenConfig, FiatToken, Networks, PRESIGNED_EVM_FEE_MULTIPLIER, @@ -19,7 +18,6 @@ import { BaseError, ContractFunctionExecutionError, decodeFunctionData, - encodeFunctionData, erc20Abi, parseTransaction } from "viem"; @@ -28,7 +26,6 @@ import { parseUnits } from "viem/utils"; import type { AlfredpayOfframpMetadata } from "../../api/services/phases/blocks/phases/alfredpay-offramp/simulation"; import { AlfredpayOfframpTransferExecutor } from "../../api/services/phases/blocks/phases/alfredpay-offramp/execution"; import phaseProcessor from "../../api/services/phases/phase-processor"; -import { PolygonPostProcessHandler } from "../../api/services/phases/post-process/polygon-post-process-handler"; import { getEvmFundingAccount } from "../../api/services/phases/blocks/core/evm-funding"; import logger from "../../config/logger"; import FinancialOperation from "../../models/financialOperation.model"; @@ -450,7 +447,7 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () ).toMatchObject({ status: "confirmed" }); }); - it("cross-chain quotes and prepared routes use Squid's guaranteed minimum", async () => { + it("cross-chain quotes keep using Squid's estimated output", async () => { world.squidRouter.computeToAmount = () => parseUnits("999", 6).toString(); world.squidRouter.computeToAmountMin = () => parseUnits("989", 6).toString(); world.squidRouter.toTokenDecimals = 6; @@ -471,16 +468,14 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () const quote = (await quoteResponse.json()) as { id: string; outputAmount: string }; const metadata = await getAlfredpayMetadata(quote.id); expect(metadata.bridgeOutputAmountRaw).toBe(parseUnits("999", 6).toString()); - expect(metadata.executableBridgeOutputRaw).toBe(parseUnits("989", 6).toString()); - expect(metadata.inputAmountRaw).toBe(parseUnits("989", 6).toString()); - expect(quote.outputAmount).toBe("19780.00"); + expect(metadata.inputAmountRaw).toBe(parseUnits("999", 6).toString()); + expect(quote.outputAmount).toBe("19980.00"); const user = await createTestUser(); await createTestAlfredpayCustomer(user.id); const ephemeral = privateKeyToAccount(generatePrivateKey()); const userWallet = privateKeyToAccount(generatePrivateKey()); - world.squidRouter.computeToAmount = () => parseUnits("988", 6).toString(); - world.squidRouter.computeToAmountMin = () => parseUnits("989", 6).toString(); + world.squidRouter.computeToAmountMin = () => parseUnits("900", 6).toString(); const registration = await app.request("/v1/ramp/register", { body: JSON.stringify({ additionalData: { fiatAccountId: FIAT_ACCOUNT_ID, walletAddress: userWallet.address }, @@ -493,32 +488,8 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () }, method: "POST" }); - expect(registration.status).not.toBe(201); - expect(await RampState.findOne({ where: { quoteId: quote.id } })).toBeNull(); - }); - - it("rejects a Squid route whose guaranteed minimum exceeds its estimate", async () => { - world.squidRouter.computeToAmount = () => parseUnits("989", 6).toString(); - world.squidRouter.computeToAmountMin = () => parseUnits("999", 6).toString(); - world.squidRouter.toTokenDecimals = 6; - const quoteCount = await QuoteTicket.count(); - - const response = await app.request("/v1/quotes", { - body: JSON.stringify({ - from: Networks.Base, - inputAmount: "1000", - inputCurrency: EvmToken.USDT, - network: Networks.Polygon, - outputCurrency: FiatToken.MXN, - rampType: RampDirection.SELL, - to: "spei" - }), - headers: { "Content-Type": "application/json" }, - method: "POST" - }); - - expect(response.status).not.toBe(201); - expect(await QuoteTicket.count()).toBe(quoteCount); + expect(registration.status).toBe(201); + expect(await RampState.findOne({ where: { quoteId: quote.id } })).toBeDefined(); }); it("does not persist an Alfredpay quote that expires before safe registration and signing", async () => { @@ -570,101 +541,6 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () } }); - it("returns positive Polygon USDT execution variance to the user's wallet", async () => { - const setup = await setUpRegisteredRamp(); - const registered = await RampState.findByPk(setup.rampId); - expect(registered).toBeDefined(); - if (!registered) throw new Error("missing registered AlfredPay cleanup state"); - const cleanupUnsigned = registered?.unsignedTxs.find(tx => tx.phase === "polygonCleanupAxlUsdc"); - expect(cleanupUnsigned).toBeDefined(); - if (!cleanupUnsigned) throw new Error("missing AlfredPay Polygon cleanup blueprint"); - const cleanupBlueprint = cleanupUnsigned.txData as unknown as EvmTxBlueprint; - const fundingAddress = getEvmFundingAccount(Networks.Polygon).address; - const residualRaw = parseUnits("10", ALFREDPAY_ERC20_DECIMALS); - expect(cleanupBlueprint.to.toLowerCase()).toBe(ALFREDPAY_ERC20_TOKEN.toLowerCase()); - expect(decodeFunctionData({ abi: erc20Abi, data: cleanupBlueprint.data })).toEqual({ - args: [fundingAddress, 2n ** 256n - 1n], - functionName: "approve" - }); - const signedApproval = await setup.ephemeral.signTransaction({ - chainId: 137, - data: cleanupBlueprint.data, - gas: BigInt(cleanupBlueprint.gas), - maxFeePerGas: BigInt(cleanupBlueprint.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, - maxPriorityFeePerGas: - BigInt(cleanupBlueprint.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, - nonce: cleanupUnsigned.nonce, - to: cleanupBlueprint.to, - type: "eip1559" - }); - const driftedWallet = privateKeyToAccount(generatePrivateKey()).address; - world.evm.setErc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.ephemeral.address, residualRaw); - world.evm.setErc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.userWallet.address, 0n); - world.evm.setErc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, driftedWallet, 0n); - - const handler = new PolygonPostProcessHandler() as unknown as { - getPresignedTransaction: () => { txData: `0x${string}` }; - process(state: RampState): Promise<[boolean, Error | null]>; - }; - handler.getPresignedTransaction = () => ({ txData: signedApproval }); - registered.set({ - currentPhase: "complete", - state: { ...registered.state, walletAddress: driftedWallet } - }); - const [processed, error] = await handler.process(registered); - - expect(processed).toBe(true); - expect(error).toBeNull(); - expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.ephemeral.address)).toBe(0n); - expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.userWallet.address)).toBe(residualRaw); - expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, driftedWallet)).toBe(0n); - expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, fundingAddress)).toBe(0n); - }); - - it("keeps legacy Polygon AXLUSDC cleanup directed to treasury", async () => { - const ephemeral = privateKeyToAccount(generatePrivateKey()); - const userWallet = privateKeyToAccount(generatePrivateKey()); - const fundingAddress = getEvmFundingAccount(Networks.Polygon).address; - const axlUsdc = evmTokenConfig[Networks.Polygon][EvmToken.AXLUSDC]?.erc20AddressSourceChain; - expect(axlUsdc).toBeTruthy(); - if (!axlUsdc) throw new Error("missing Polygon AXLUSDC test configuration"); - const residualRaw = parseUnits("10", 6); - const signedApproval = await ephemeral.signTransaction({ - chainId: 137, - data: encodeFunctionData({ - abi: erc20Abi, - args: [fundingAddress, 2n ** 256n - 1n], - functionName: "approve" - }), - gas: 100_000n, - maxFeePerGas: 1_000_000_000n, - maxPriorityFeePerGas: 1_000_000_000n, - nonce: 1, - to: axlUsdc, - type: "eip1559" - }); - world.evm.setErc20Balance(Networks.Polygon, axlUsdc, ephemeral.address, residualRaw); - world.evm.setErc20Balance(Networks.Polygon, axlUsdc, fundingAddress, 0n); - - const handler = new PolygonPostProcessHandler() as unknown as { - getPresignedTransaction: () => { txData: `0x${string}` }; - process(state: RampState): Promise<[boolean, Error | null]>; - }; - handler.getPresignedTransaction = () => ({ txData: signedApproval }); - const [processed, error] = await handler.process({ - currentPhase: "complete", - id: "legacy-axlusdc-cleanup-test", - state: { evmEphemeralAddress: ephemeral.address, walletAddress: userWallet.address }, - type: RampDirection.SELL - } as RampState); - - expect(processed).toBe(true); - expect(error).toBeNull(); - expect(world.evm.erc20Balance(Networks.Polygon, axlUsdc, ephemeral.address)).toBe(0n); - expect(world.evm.erc20Balance(Networks.Polygon, axlUsdc, fundingAddress)).toBe(residualRaw); - expect(world.evm.erc20Balance(Networks.Polygon, axlUsdc, userWallet.address)).toBe(0n); - }); - it( "happy path: processes the full Alfredpay offramp phase sequence to complete", async () => { diff --git a/packages/shared/src/services/squidrouter/offramp.ts b/packages/shared/src/services/squidrouter/offramp.ts index 87a418c8a..30e45719b 100644 --- a/packages/shared/src/services/squidrouter/offramp.ts +++ b/packages/shared/src/services/squidrouter/offramp.ts @@ -39,7 +39,6 @@ export interface OfframpTransactionData { export interface OfframpTransactionDataToEvm { approveData: EvmTransactionData; - route: SquidrouterRoute; swapData: EvmTransactionData; squidRouterQuoteId?: string; } @@ -105,13 +104,10 @@ export async function createOfframpSquidrouterTransactionsToEvm( const routeResult = await getRoute(routeParams); const { route } = routeResult.data; - return { - ...(await createTransactionDataFromRoute({ - inputTokenErc20Address: params.fromToken, - publicClient: fromNetworkClient, - rawAmount: params.rawAmount, - route - })), + return createTransactionDataFromRoute({ + inputTokenErc20Address: params.fromToken, + publicClient: fromNetworkClient, + rawAmount: params.rawAmount, route - }; + }); } From ff8b5157eeb69eb397837a4152dec5c800547909 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 13 Aug 2026 18:51:22 +0200 Subject: [PATCH 9/9] docs(api): simplify AlfredPay bridge pricing guarantees --- apps/api/src/api/services/phases/blocks/README.md | 10 +++------- .../03-ramp-engine/discount-mechanism.md | 4 ++-- .../03-ramp-engine/ephemeral-accounts.md | 4 ++-- docs/security-spec/03-ramp-engine/fee-integrity.md | 11 ++++------- .../03-ramp-engine/quote-lifecycle.md | 2 +- .../03-ramp-engine/ramp-phase-flows.md | 4 ++-- docs/security-spec/05-integrations/alfredpay.md | 14 ++++++-------- docs/security-spec/06-cross-chain/fund-routing.md | 2 +- 8 files changed, 21 insertions(+), 30 deletions(-) diff --git a/apps/api/src/api/services/phases/blocks/README.md b/apps/api/src/api/services/phases/blocks/README.md index bd46034f4..ea8107b58 100644 --- a/apps/api/src/api/services/phases/blocks/README.md +++ b/apps/api/src/api/services/phases/blocks/README.md @@ -595,15 +595,11 @@ Unmapped cases fail at quote resolution; there is no alternate engine: 9. **AlfredPay SELL reconciliation stays block-owned.** `AlfredpayOfframp` derives the fiat target from the exact Vortex reference snapshot, requests AlfredPay's executable exact-output terms, and caps the raw settlement top-up. - Flow v4/context schema 3 persists Squid's guaranteed `toAmountMin`, and - preparation rejects a fresh route whose minimum falls below that funding baseline. - Rollout requires no pending AlfredPay flow-v3 quotes or ramps. It does not add a cross-reading subsidy block or make the provider rate a global price source. `finalSettlementSubsidy` consumes the persisted provider - input and persisted executable bridge minimum without recalculating quote economics - or exceeding the persisted subsidy. Positive Squid execution variance is returned - to the user's wallet by Polygon cleanup. Provider orders - remain bound to those persisted terms; if an expired order cannot be replaced + input and quoted bridge output without recalculating quote economics or exceeding + the persisted subsidy. Provider orders remain bound to those persisted terms; if + an expired order cannot be replaced without degrading them, execution pauses before the single-use provider transfer and leaves the funds on the client-custodied Polygon ephemeral for reconciliation. diff --git a/docs/security-spec/03-ramp-engine/discount-mechanism.md b/docs/security-spec/03-ramp-engine/discount-mechanism.md index 5ef56f8c1..575381db1 100644 --- a/docs/security-spec/03-ramp-engine/discount-mechanism.md +++ b/docs/security-spec/03-ramp-engine/discount-mechanism.md @@ -21,7 +21,7 @@ Discount behavior is wired explicitly by the phases composed in `phases/blocks/f The AlfredPay block flows compute subsidy in the AlfredPay-side currency: - **Onramp**: subsidy denominated in the AlfredPay on-chain currency (USDT on Polygon). In the cross-chain block flow, `AlfredpayMint` installs the provider-derived anchor fee, then `AlfredpaySubsidizePre` deducts the vortex/partner components from the mint, computes the bounded bridge target from that fee-net actual, and reserves the fee residual on top of the target (`feeReserveRaw`) for the later `distributeFees` collection. Squid quote and transaction preparation both consume that same target. The `alfredOnrampMintFallback` presigned contingency remains bounded to the provider mint amount. -- **Offramp**: subsidy denominated in AlfredPay's Polygon USDT. `AlfredpayOfframp` derives the target fiat output directly from the same unrounded Vortex USD/fiat snapshot stored in pricing metadata, then asks AlfredPay for an exact-output quote. AlfredPay's returned `fromAmount` therefore incorporates provider spread and provider fees into the executable deposit. The actual settlement top-up is `providerInputRaw + feeReserveRaw - bridgeOutputRaw`; it is capped by both the partner allowance (`maxSubsidy × expectedOutput`, converted to USDT at the same Vortex reference) and `MAX_FINAL_SETTLEMENT_SUBSIDY_USD`. When either cap binds, the block requests a final executable quote using the maximum permitted provider input, returns that lower output, and emits `ALFREDPAY_OFFRAMP_TARGET_DISCOUNT_CAPPED` rather than rejecting quote creation. If the target probe itself exceeds AlfredPay's reported input maximum, that maximum is treated as a third bound only when it still covers the fee-net baseline provider input; a maximum below baseline means the requested source amount has no executable full-value provider quote and quote creation rejects. Runtime settlement cannot pay more than this persisted subsidy if Squid under-delivers. The fee residual stays on the Polygon ephemeral, `finalSettlementSubsidy` targets deposit + fees, and `distributeFees` collects it after the deposit succeeds. If AlfredPay naturally beats the target, the full fee-net input is quoted and the user keeps the upside with zero subsidy; positive Squid delivery variance returns to the user's wallet during cleanup. +- **Offramp**: subsidy denominated in AlfredPay's Polygon USDT. `AlfredpayOfframp` derives the target fiat output directly from the same unrounded Vortex USD/fiat snapshot stored in pricing metadata, then asks AlfredPay for an exact-output quote. AlfredPay's returned `fromAmount` therefore incorporates provider spread and provider fees into the executable deposit. The actual settlement top-up is `providerInputRaw + feeReserveRaw - bridgeOutputRaw`, where `bridgeOutputRaw` keeps the existing Squid quoted-output semantics. It is capped by both the partner allowance (`maxSubsidy × expectedOutput`, converted to USDT at the same Vortex reference) and `MAX_FINAL_SETTLEMENT_SUBSIDY_USD`. When either cap binds, the block requests a final executable quote using the maximum permitted provider input, returns that lower output, and emits `ALFREDPAY_OFFRAMP_TARGET_DISCOUNT_CAPPED` rather than rejecting quote creation. If the target probe itself exceeds AlfredPay's reported input maximum, that maximum is treated as a third bound only when it still covers the fee-net baseline provider input; a maximum below baseline means the requested source amount has no executable full-value provider quote and quote creation rejects. Runtime settlement cannot pay more than this persisted subsidy if Squid under-delivers. The fee residual stays on the Polygon ephemeral, `finalSettlementSubsidy` targets deposit + fees, and `distributeFees` collects it after the deposit succeeds. If AlfredPay naturally beats the target, the full fee-net input is quoted and the user keeps the upside with zero subsidy. For onramps to non-trivial EVM destinations, `SubsidizePost` probes Squid Router (`getEvmBridgeQuote`) to convert the oracle-expected amount into the equivalent amount of the pre-bridge token so the subsidy is denominated in the token the ramp actually holds on the source chain. @@ -42,7 +42,7 @@ For onramps to non-trivial EVM destinations, `SubsidizePost` probes Squid Router 13. **Offramp `expectedOutput` MUST be computed from the USD value of the input, never the raw input amount.** The inverted oracle rate converts USD → fiat; feeding it a non-USD input amount misdenominates the target. Before this was enforced, a 1000 BRLA → PIX offramp was treated as 1000 USD, inflating `expectedOutput` (and the `maxSubsidy × expectedOutput` cap) by the BRL-USD rate (~5×) and over-paying the subsidy on every such quote; EURC → SEPA offramps were symmetrically under-subsidized. Enforced by `getUsdDenominatedInputAmount` in `phases/blocks/core/discount.ts` and the block offramp subsidy simulations. 14. **Public discount display MUST only expose applied quote-time discount subsidy.** `discountFiat` / `discountUsd` MUST be present only when `ctx.subsidy.applied` is true, the subsidy amount is positive, the subsidy currency can be inferred, and display conversion succeeds without fallback. Runtime swap-discrepancy top-ups MUST NOT be folded into this display field because they are execution-time protection, not a promotional rate improvement. 15. **Catalog BRL/EUR onramps MUST apply dynamic-discount math at the post-swap boundary.** The `SubsidizePost` block resolves pricing with `resolveDiscountPartner`, calls `calculateExpectedOutput` so `adjustedDifference` and `adjustedTargetDiscount` use the shared partner state, and treats its typed Base USDC input as `actualOutput`. Because `DistributeFees` precedes it, that input already has network, vortex, and partner-markup fees deducted. For non-trivial destinations it probes SquidRouter and divides the oracle target by the Base-USDC-to-destination conversion rate; probe failures retain the 1:1 fallback. This calculation MUST remain phase-hermetic and MUST NOT read Nabla, fee-distribution, or Squid block metadata. AlfredPay's specialized pre-bridge subsidy path is intentionally separate. -16. **AlfredPay SELL target reconciliation MUST remain executable and best-effort capped.** A positive target uses an exact-output provider quote against the unrounded persisted Vortex reference. Flow v4 cross-chain routes MUST size the settlement from Squid's guaranteed `toAmountMin`, not its optimistic estimate. Squid responses with `toAmountMin > toAmount` are invalid, and preparation MUST also reject a newly built route whose minimum is lower than quoted. The selected provider `fromAmount`, canonical fee reserve, and persisted subsidy MUST reconcile in raw units. If the target needs more than the partner, runtime, or reported provider allowance, the quote MUST use at most the allowed provider input, return the resulting lower fiat output, and log `ALFREDPAY_OFFRAMP_TARGET_DISCOUNT_CAPPED`; it MUST NOT advertise the uncapped target or reject solely because a cap binds while a valid fixed-input quote remains. A reported provider maximum below the fee-net baseline is not a cap-compatible quote: it cannot settle the user's full source value and MUST reject instead of silently creating a partial offramp. `targetDiscount = 0` MUST continue to quote the fee-net provider input without compensating provider spread or fees. Deployment of flow v4 requires a window with no pending AlfredPay flow-v3 quotes or ramps. +16. **AlfredPay SELL target reconciliation MUST remain executable and best-effort capped.** A positive target uses an exact-output provider quote against the unrounded persisted Vortex reference. The selected provider `fromAmount`, canonical fee reserve, quoted bridge output, and persisted subsidy MUST reconcile in raw units. If the target needs more than the partner, runtime, or reported provider allowance, the quote MUST use at most the allowed provider input, return the resulting lower fiat output, and log `ALFREDPAY_OFFRAMP_TARGET_DISCOUNT_CAPPED`; it MUST NOT advertise the uncapped target or reject solely because a cap binds while a valid fixed-input quote remains. A reported provider maximum below the fee-net baseline is not a cap-compatible quote: it cannot settle the user's full source value and MUST reject instead of silently creating a partial offramp. `targetDiscount <= 0` MUST continue to quote the fee-net provider input without compensating provider spread or fees. ## Threat Vectors & Mitigations diff --git a/docs/security-spec/03-ramp-engine/ephemeral-accounts.md b/docs/security-spec/03-ramp-engine/ephemeral-accounts.md index ee5e00a9f..49196f060 100644 --- a/docs/security-spec/03-ramp-engine/ephemeral-accounts.md +++ b/docs/security-spec/03-ramp-engine/ephemeral-accounts.md @@ -4,7 +4,7 @@ Every ramp operation creates temporary blockchain accounts (ephemeral accounts) on one or more chains. These accounts hold user funds in transit as tokens move between chains during the ramp. The lifecycle is: **create → fund → use during ramp phases → clean up residual tokens and reclaim balances**. If any step in this lifecycle fails or is incomplete, user or platform funds can become permanently stuck on an ephemeral account that nobody monitors. -The cleanup process runs as a background worker (`cleanup.worker.ts`) on a 5-minute cron. After a ramp completes, chain-specific post-process handlers sweep residual tokens. Most platform-owned dust returns to the platform funding accounts; AlfredPay SELL's positive Squid USDT execution variance returns to the user's registered source wallet. +The cleanup process runs as a background worker (`cleanup.worker.ts`) on a 5-minute cron. After a ramp completes, chain-specific post-process handlers sweep residual tokens and reclaim native balances from the ephemeral accounts back to the platform funding accounts. ### Chains Involved @@ -23,7 +23,7 @@ Post-process handlers registered in `apps/api/src/api/services/phases/post-proce - **PendulumPostProcessHandler** — Submits the `pendulumCleanup` extrinsic to sweep Pendulum ephemeral tokens. - **MoonbeamPostProcessHandler** — Waits 3 hours for SquidRouter refunds to land, then submits `moonbeamCleanup` to sweep Moonbeam ephemeral tokens. -- **PolygonPostProcessHandler** — On completed Polygon-routed ramps with a cleanup approval, broadcasts the user's pre-signed `approve` and runs `transferFrom` from the funding key. Platform-owned/legacy dust goes to the funding account. For AlfredPay SELL USDT, which can remain when Squid executes above its guaranteed minimum, the recipient is the user's registered wallet. Cleanup is skipped when the ephemeral balance is zero. If the provider reports failure after the main transfer, unused fee nonces precede the cleanup approval; automatic post-processing therefore remains disabled and residual tokens stay accessible through the client-custodied ephemeral key for manual reconciliation. This is active for Alfredpay corridors and also protects still-in-flight legacy Polygon ramps. +- **PolygonPostProcessHandler** — On completed Polygon-routed ramps with a cleanup approval, broadcasts the user's pre-signed `approve` and runs `transferFrom` from the funding key to the platform funding account. Cleanup is skipped when the ephemeral balance is zero. This is active for Alfredpay corridors and also protects still-in-flight legacy Polygon ramps. - **HydrationPostProcessHandler** — On BUY ramps with a `hydrationCleanup` presigned extrinsic, submits the cleanup extrinsic. - **AssetHubPostProcessHandler** — Registered but inert. `shouldProcess` returns `false` unconditionally; `process` returns `[true, null]`. No on-chain action is performed. Effectively a placeholder for future AssetHub cleanup. diff --git a/docs/security-spec/03-ramp-engine/fee-integrity.md b/docs/security-spec/03-ramp-engine/fee-integrity.md index e1afd28ef..f7ce48a0d 100644 --- a/docs/security-spec/03-ramp-engine/fee-integrity.md +++ b/docs/security-spec/03-ramp-engine/fee-integrity.md @@ -143,18 +143,15 @@ always occur only after all user-facing phases is incorrect. `finalSettlementSubsidy` targets that deposit plus the same canonical fee reserve, so a short provider leg cannot starve the fee transfers. Quote-time partner/runtime caps apply to this full settlement top-up; a binding cap lowers the provider input - and payout rather than changing or dropping the fee transfers. Flow-v4/context-schema-3 - runtime settlement cannot pay above the persisted quoted subsidy if a bridge - under-delivers. Rollout requires no pending AlfredPay flow-v3 quotes or ramps. + and payout rather than changing or dropping the fee transfers. Runtime settlement + cannot pay above the persisted quoted subsidy if a bridge under-delivers. - **Failure safety** — fees are collected only after the user-facing leg succeeded. - The v4 prepared offramp fallback is sized from the persisted guaranteed bridge minimum and + The prepared offramp fallback is sized from the persisted quoted bridge output and excludes the platform-funded settlement top-up, so it cannot leak subsidy to the user. Automated expired-order recovery does not broadcast that same-nonce contingency: when replacement cannot preserve the quote, execution pauses before the provider transfer and leaves principal/top-up on the client-custodied Polygon - ephemeral for authorized reconciliation. Positive Squid USDT execution variance - after a successful offramp returns to the user's wallet during Polygon cleanup. - The onramp mint fallback stays full-mint. + ephemeral for authorized reconciliation. The onramp mint fallback stays full-mint. - **Rollout** — the fee phase shipped as flow version 2 of the three Alfredpay flows with a drain-then-deploy gate; persisted v1 identities fail closed at registration/dispatch and require manual recovery. diff --git a/docs/security-spec/03-ramp-engine/quote-lifecycle.md b/docs/security-spec/03-ramp-engine/quote-lifecycle.md index 2186dcf6d..ada632fd9 100644 --- a/docs/security-spec/03-ramp-engine/quote-lifecycle.md +++ b/docs/security-spec/03-ramp-engine/quote-lifecycle.md @@ -76,7 +76,7 @@ The refresh policy is intentionally strict. Onramps require byte-identical `toAm 16. **Provider-backed ramp registration MUST derive the sender's provider identity from the effective user, not from request body** — BRL/Avenia tax ID, Alfredpay `alfredPayId`, and the Mykobo (EUR) `email` are resolved server-side from the credential context's `profileId` (or the Supabase session): `profile_id -> customer_entities -> provider_customers (avenia)`, `profile_id -> customer_entities -> provider_customers (alfredpay)`, and `profile_id -> profiles.email` respectively. The corresponding client-supplied field (`additionalData.taxId` / `additionalData.email`) is accepted only for backward compatibility and MUST match the derived sender value or the request is rejected with `400`. Every resolver requires canonical `provider_customers.status = approved`; provider-native state remains separately available in `status_external`. The `receiverTaxId` (where it differs from the sender — e.g. third-party PIX recipient) is supplied by the client and is allowed to differ from the derived sender tax ID; the Avenia payout registration hook passes it to block-owned `validateAveniaOfframpRecipient`, which compares it with the provider's masked PIX-owner tax ID and derives the payout wallet from the trusted subaccount response. The `RampService.registerRamp` quote/user consistency check ensures the caller cannot register a provider-backed quote using a different user context. 17. **User-owned quotes MUST only be registered by their owner; anonymous quotes MAY be claimed** — `RampService.registerRamp` rejects with `403` when `quote.userId` is set and differs from the authenticated caller. A quote created with an API credential also stores `api_credential_id`; secret-key registration MUST resolve that same credential ID, so another credential for the same profile or partner cannot consume it. A Supabase session for the owning profile remains valid. An anonymous quote (`quote.userId = null`) carries no owner and MAY be claimed by any authenticated caller — this is the normal web-app funnel (quote before login, register after). Claiming grants no access to anyone else's resources because provider identity is always derived from the claimer's own KYC records (inv. 16), never from the quote or the request body. 18. **Quote and ramp preparation MUST resolve the same persisted flow** — Registration resolves the catalog flow from `quote.metadata.globals.request`, calls that flow's `register` and `prepareTxs`, and transactionally persists metadata refreshed by registration hooks. No route resolver or corridor transaction assembler remains; registration MUST NOT select a different corridor from mutable input. Phase registration facts and response artifacts are projected into the compatibility `StateMetadata` / API response shape only for active ramps; provider operations remain owned by the resolved flow. -19. **Presigned Squid input and guaranteed output MUST match the quoted block** — Cross-chain AlfredPay source and destination fallback transaction construction MUST use `metadata.blocks.squidRouterSwap.inputAmountRaw`. It MUST NOT substitute the gross AlfredPay mint amount, because fees and subsidy can make those values differ. New AlfredPay SELL flow-v4/context-schema-3 quote/cap math MUST persist Squid's `toAmountMin` as its Polygon funding baseline, validate it against settlement arithmetic, and reject a freshly prepared route with a lower minimum. Recovery-only v3/schema-2 ramps retain their legacy bridge-output semantics until drained. +19. **Presigned Squid input MUST match the quoted block** — Cross-chain AlfredPay source and destination fallback transaction construction MUST use `metadata.blocks.squidRouterSwap.inputAmountRaw`. It MUST NOT substitute the gross AlfredPay mint amount, because fees and subsidy can make those values differ. 20. **Dashboard BUY quote direction MUST match the selected fiat rail and EVM destination** — Dashboard onramp requests set `from`/`paymentMethod` from the approved fiat corridor, `to` and `network` to the selected ramp-enabled EVM network, `inputCurrency`/`inputAmount` to the fiat payment, `outputCurrency` to the selected dynamic-catalog token key, and `rampType = BUY`. The displayed receive amount and registration quote ID must come from that server response, not client-side rate math. 21. **Dashboard SELL registration MUST fail closed when the connected wallet cannot fund the refreshed quote** — Dashboard SELL quotes are input-driven from the selected executable EVM token, network, and decimal amount. The dashboard reads the selected network's Alchemy token portfolio and matches the exact configured contract address, normalizing Alchemy's null native-token address to the shared native sentinel; it MUST NOT use the wallet's currently selected chain or another same-symbol token as the balance source. The funding UI blocks on loading, lookup failure, or insufficient raw units using the selected token's configured decimals. After the transfer machine refreshes a near-expiry quote, it repeats that exact-token balance check against the replacement `inputAmount` before generating ephemeral keys or calling `/ramp/register`. 22. **Dashboard BUY options MUST include only executable destination assets** — Native POL is supported as a Polygon SELL input, but MUST NOT appear in BUY selectors until every onramp transaction path can construct a native destination transfer. Ramp history exposes each ramp's server-derived 15-minute start deadline so expired initial BUY ramps are displayed as cancelled rather than awaiting payment. 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 7e6de10c2..b8c713718 100644 --- a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md +++ b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md @@ -60,11 +60,11 @@ offramp block executors raise a recoverable, zero-retry pause at `brlaPayoutOnBa before reading partner state or broadcasting the anchor-bound transfer. The ramp remains in the payout phase and is not cleanup-eligible, leaving the client-custodied ephemeral key available for fund recovery. The switch is active only when `NODE_ENV=development`. -- **Catalog-backed Alfredpay offramp family:** USD/ACH, MXN/SPEI, COP/ACH, and ARS/CBU use `initial` → `squidRouterPermitExecute` → `fundEphemeral` → `finalSettlementSubsidy` → `alfredpayOfframpTransfer` → `distributeFees` → `complete` (flow v4/context schema 3; rollout requires no pending flow-v3 quotes or ramps). The source preparer statically selects direct Polygon USDT, Polygon same-chain Squid, or cross-chain Squid. EIP-2612 sources emit permit/relayer typed data; unsupported tokens emit user-wallet transfer or approve/swap blueprints whose reported hashes are content-verified before funding. Cross-chain simulation persists Squid's guaranteed `toAmountMin` as `executableBridgeOutputRaw`, validates it against provider input + fee reserve − subsidy, and rejects a freshly prepared route below it. Final transfer and contingency fallback share Polygon nonce 0, fee-charging quotes place one `distributeFees` transfer per recipient at the following main-lane nonces, and the legacy-named `polygonCleanupAxlUsdc` approves USDT so positive Squid execution variance returns to the user's wallet. Metadata keeps source-labelled Vortex reference, provider, and customer all-in observations. The Vortex reference remains the target source; the block uses AlfredPay's executable exact-output terms locally to solve the provider deposit, then best-effort caps the actual settlement top-up. If expired-order replacement cannot preserve the persisted promise, execution pauses before the provider transfer with funds on the client-custodied ephemeral. +- **Catalog-backed Alfredpay offramp family:** USD/ACH, MXN/SPEI, COP/ACH, and ARS/CBU use `initial` → `squidRouterPermitExecute` → `fundEphemeral` → `finalSettlementSubsidy` → `alfredpayOfframpTransfer` → `distributeFees` → `complete` (flow v3/context schema 2). The source preparer statically selects direct Polygon USDT, Polygon same-chain Squid, or cross-chain Squid. EIP-2612 sources emit permit/relayer typed data; unsupported tokens emit user-wallet transfer or approve/swap blueprints whose reported hashes are content-verified before funding. Final transfer and contingency fallback share Polygon nonce 0, with fee transfers following on the main nonce lane. Metadata keeps source-labelled Vortex reference, provider, and customer all-in observations. The Vortex reference remains the target source; the block uses AlfredPay's executable exact-output terms locally to solve the provider deposit, then best-effort caps the actual settlement top-up. If expired-order replacement cannot preserve the persisted promise, execution pauses before the provider transfer with funds on the client-custodied ephemeral. - **Degenerate Polygon same-token onramp case:** Alfredpay mints `ALFREDPAY_EVM_TOKEN` (USDT) on Polygon. `AlfredpayOnrampDirect` composes a Squid passthrough block when the requested output is that same token and a same-chain Squid block for every other Polygon output. Both continue through `finalSettlementSubsidy`, `destinationTransfer`, and `distributeFees` (flow version 2). See `05-integrations/alfredpay.md`. - **Amount precision on routed Alfredpay onramps:** when Alfredpay mints on Polygon and the user requests a different EVM output token, the routed Squid output is the final settlement amount. `evmToEvm.inputAmountRaw` remains the Polygon source-token raw amount, while `evmToEvm.outputAmountRaw` and `quote.outputAmount` MUST use the final destination token's raw/decimal precision. The direct Polygon same-token case remains at the minted token's precision. - **Alfredpay offramp always runs `finalSettlementSubsidy`:** `phases/blocks/phases/alfredpay-offramp/index.ts` declares `fundEphemeral` → `finalSettlementSubsidy` → `alfredpayOfframpTransfer` for every source variant. No executor short-circuits this sequence. -- **Alfredpay bridge arrival and settlement targets are distinct:** delivery evidence waits for the executable source-route minimum persisted as `executableBridgeOutputRaw` and reconciled to `provider input + canonical fee reserve − subsidy`; `alfredpayOfframp.bridgeOutputAmountRaw` remains the diagnostic Squid estimate. Only after delivery evidence exists does `finalSettlementSubsidy` top the Polygon ephemeral up to `provider input + canonical fee reserve`. Waiting for the post-subsidy target before paying the subsidy would create a circular dependency. +- **Alfredpay bridge arrival and settlement targets are distinct:** delivery evidence waits for the persisted quoted bridge output. Only after delivery evidence exists does `finalSettlementSubsidy` top the Polygon ephemeral up to `provider input + canonical fee reserve`. Waiting for the post-subsidy target before paying the subsidy would create a circular dependency. Runtime refuses to exceed the subsidy amount authorized by the quote. **Cross-chain delivery (post-swap):** After the Nabla swap, tokens are routed to their final destination: - From Pendulum to Moonbeam: `pendulumToMoonbeamXcm` diff --git a/docs/security-spec/05-integrations/alfredpay.md b/docs/security-spec/05-integrations/alfredpay.md index c56d6963c..2bcc21ba5 100644 --- a/docs/security-spec/05-integrations/alfredpay.md +++ b/docs/security-spec/05-integrations/alfredpay.md @@ -39,9 +39,9 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu **Off-ramp flow:** 1. The catalog `AlfredpayOfframp` block stores provider quote facts under `metadata.blocks.alfredpayOfframp` and returns the selected provider expiration as the Vortex quote TTL. Quote selection and registration require more than ten seconds of remaining provider-quote lifetime; created and replacement payout orders require at least two minutes before funds may move. Its `pricing` metadata records three separate observations: the source-labelled Vortex USD/fiat reference, Alfredpay's gross rate and fee-adjusted net rate, and the final customer all-in rate after Vortex pricing. The Vortex snapshot remains the reference source. For a positive target the block asks Alfredpay for the exact fiat `toAmount`, uses the returned USDT `fromAmount` to incorporate provider spread/fees, and caps the actual raw settlement top-up by partner `maxSubsidy` and the $10 runtime limit. A binding cap returns a lower executable quote and logs `ALFREDPAY_OFFRAMP_TARGET_DISCOUNT_CAPPED`; naturally better provider pricing is returned with zero subsidy. Registration validates `fiatAccountId` and wallet address, resolves the authenticated KYC-approved customer, refreshes by the persisted provider input, compares `fromAmount`, `toAmount`, and fee exactly, updates only that block's `quoteId`/expiration, and creates the order transactionally. Drift hard-fails registration. 2. `squidRouterPermitExecute` or `squidRouterNoPermitTransfer/Approve/Swap` phase: executes the user-signed permit (or the no-permit equivalent) and lands the Alfredpay on-chain token on Polygon. -3. `finalSettlementSubsidy` phase: always runs for Alfredpay offramps because `AlfredpayOfframp` declares it between funding and provider transfer for every source variant. Delivery evidence waits for the source route's persisted executable minimum (validated against `provider input + fee reserve − subsidy`); the phase then tops up to Alfredpay deposit PLUS the charged vortex/partner/network fee reserve so the later fee transfers stay funded. +3. `finalSettlementSubsidy` phase: always runs for Alfredpay offramps because `AlfredpayOfframp` declares it between funding and provider transfer for every source variant. Delivery evidence waits for the persisted quoted bridge output; the phase then tops up to Alfredpay deposit PLUS the charged vortex/partner/network fee reserve so the later fee transfers stay funded, without exceeding the subsidy authorized by the quote. 4. `alfredpayOfframpTransfer` phase: transfers the Alfredpay on-chain token to Alfredpay's settlement address for fiat payout. Registration and the first local broadcast both require the provider order to be `CREATED`; `FAILED` terminates without funding the order, while any already-advanced lifecycle without a confirmed local transfer requires reconciliation. If the registered order expired, the handler requests a fresh quote for the immutable provider input. It creates a replacement order only when chain, canonical customer/account identity, currencies/input, and payout remain bound and the payout is not lower than the original; degraded recovery is logged and fails before the presigned provider transfer is broadcast. -5. `distributeFees` (pays the reserved vortex/partner residual on Polygon, see `03-ramp-engine/fee-integrity.md`) → `polygonCleanupAxlUsdc` → `complete`. The legacy-named cleanup now approves Polygon USDT and returns any positive Squid execution variance above the guaranteed minimum to the user's wallet; it does not treat that user-funded variance as platform subsidy or treasury dust. +5. `distributeFees` (pays the reserved vortex/partner residual on Polygon, see `03-ramp-engine/fee-integrity.md`) → `polygonCleanupAxlUsdc` → `complete`. Cleanup retains its existing Polygon AXLUSDC-to-funding-account behavior; this pricing change does not introduce a new user-directed transaction. **Request validation:** Alfredpay middleware (`alfredpay.middleware.ts`) validates the `country` parameter against the `AlfredPayCountry` enum for all Alfredpay-related requests. @@ -62,7 +62,7 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu 11. **Polygon passthrough MUST preserve amount integrity** — `AlfredpayOnrampDirect` selects `SquidRouterPassthrough` only for the same-chain same-token case. The passthrough MUST round down (`toFixed(0, 0)`) and use its phase-owned input amount as the source of truth. 12. **The Polygon passthrough MUST be gated on the output token, not on the destination network alone** — Alfredpay mints `ALFREDPAY_EVM_TOKEN` (USDT) directly on Polygon, so `flows/alfredpay-onramp-direct.ts` composes `SquidRouterPassthrough` only when the requested output token is `ALFREDPAY_EVM_TOKEN`. Any other Polygon output composes `SameChainSquidRouterSwap`; gating on network alone would mis-deliver USDT instead of the requested asset. 13. **Offramp quote refresh at prep time MUST be strict and transactional** — `AlfredpayOfframp.register` re-fetches a provider quote and compares the pair, `fromAmount`, `toAmount`, and fee. Proven pre-order drift is journaled as rejected/retryable and aborts registration without poisoning later attempts. Only `createOfframp` is an ambiguous external side effect; its returned order must again match the persisted pair/input/output before the registration transaction can commit. -14. **`finalSettlementSubsidy` MUST NOT be skipped or used as its own delivery threshold for Alfredpay offramps** — `phases/blocks/phases/alfredpay-offramp/index.ts` declares `fundEphemeral` → `finalSettlementSubsidy` → `alfredpayOfframpTransfer` for every source variant. The arrival threshold is the persisted executable bridge minimum validated against `provider input + fee reserve − quoted subsidy`; the settlement target is provider input plus canonical fee reserve. This ensures the Polygon ephemeral is topped up before provider settlement without waiting for the top-up itself, and observed bridge under-delivery cannot increase treasury funding above the quote's persisted subsidy amount. +14. **`finalSettlementSubsidy` MUST NOT be skipped or used as its own delivery threshold for Alfredpay offramps** — `phases/blocks/phases/alfredpay-offramp/index.ts` declares `fundEphemeral` → `finalSettlementSubsidy` → `alfredpayOfframpTransfer` for every source variant. The arrival threshold is the persisted quoted bridge output; the settlement target is provider input plus canonical fee reserve. This ensures the Polygon ephemeral is topped up before provider settlement without waiting for the top-up itself, and observed bridge under-delivery cannot increase treasury funding above the quote's persisted subsidy amount. 15. **Routed Alfredpay onramp quote output precision MUST match the destination token** — For Alfredpay USD/MXN/COP/ARS onramps that route through Squid, `quote.outputAmount` MUST preserve the final destination token's decimal precision, and `evmToEvm.outputAmountRaw` MUST represent the destination token's raw units. The Polygon-minted Alfredpay token is only the Squid source-side input. Direct Polygon same-token passthrough remains at the minted token's 6-decimal precision. 16. **Alfredpay ramp registration MUST bind to a completed KYC/KYB customer** — `AlfredpayMint.register` and `AlfredpayOfframp.register` MUST reject customer records whose Alfredpay status is not `Success`. On-ramp registration stores only the verified customer ID as phase-owned facts; quote refresh, order creation, and payment instructions remain at start time. SDK/server integrations authenticate with partner API keys (`pk_*`/`sk_*`); Supabase Bearer tokens are frontend/user-session auth. 17. **Alfredpay ramp registration MUST derive the customer id from the effective user; quotes carry only tracking metadata** — The on-ramp and off-ramp flow registration hooks, on-ramp start-time quote refresh, and off-ramp transfer recovery path all resolve `alfredPayId` via the strict, KYC-gated `resolveAlfredpayCustomerId(fiatCurrency, effectiveUserId)`. Quote creation is anonymous-eligible: the quote blocks use `resolveAlfredpayQuoteCustomerId`, which fills the *tracking-only* quote `metadata.customerId` with the caller's real customer id when a KYC-completed customer resolves, and the `"anonymous"` sentinel otherwise. Alfredpay validates the top-level `customerId` only on order creation, so no provider *order* ever carries a placeholder identity. Public keys and unlinked secret keys can quote but cannot register Alfredpay ramps. @@ -77,9 +77,7 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu 25. **A terminal verification outcome MUST be queued for notification before it is persisted** — Alfredpay publishes no verification webhook, so every observer that can make the customer terminal — the dashboard's shared refresh, `AlfredpayStatusWorker`, `/alfredpayStatus`, and `/getKycStatus` — MUST enqueue before its status write. An account written terminal while its enqueue failed could be excluded from every subsequent poll and never notified. A failure must leave the account non-terminal so a later poll retries both. The notification key is `(alfredpay, verification_*, submissionId)`, which makes retries and racing observers idempotent. See `resend.md` invariant 13. 26. **The background verification sweep MUST be bounded, fair, and MUST NOT poll accounts it cannot notify** — `AlfredpayStatusWorker` costs two to three Alfredpay calls per account (submission-id resolution, then status). It MUST bound the sweep by account age (60 days on `provider_customers.updatedAt`, since an account abandoned mid-wizard never reaches a terminal status) and by batch size. A stable keyset cursor advances after every full page and wraps at the end; repeatedly selecting only the newest page would starve older eligible accounts. Entities with a null `profile_id` are partner-owned and have no profile to email; they MUST be excluded in the query so they never consume provider requests. Only the `mykobo` flow-variant backend owns the provider status workers, and each cron uses `waitForCompletion`, preventing duplicate cross-backend polls and overlapping same-process cycles. 27. **Alfredpay offramp pricing observations MUST remain source-labelled, while executable provider terms may reconcile the local SELL deposit** — The persisted block metadata records the exact Vortex reference-feed source and observation time, Alfredpay's returned gross rate and fee breakdown, the provider net rate derived from `toAmount ÷ fromAmount`, and the customer all-in rate derived from final fiat output divided by the USD-valued quote input. Alfredpay's rate MUST NOT become a general Vortex reference source. Its executable `fromAmount`/`toAmount` may be used only inside `AlfredpayOfframp` to solve or cap the provider deposit needed for the Vortex-derived customer target. -28. **Cross-chain Alfredpay SELL funding MUST use Squid's guaranteed minimum** — flow v4/context schema 3 persists required `executableBridgeOutputRaw` from `estimate.toAmountMin`; preparation, settlement evidence, and contingency sizing validate it against `inputAmountRaw + feeReserveRaw − subsidyAmountRaw` and reject a fresh route below it. Rollout requires no pending flow-v3 quotes or ramps. -29. **Positive Squid execution variance MUST return to the user after successful completion** — AlfredPay provider input and fee obligations are sized from the guaranteed bridge minimum. When Polygon receives more USDT than that minimum, the legacy-named `polygonCleanupAxlUsdc` approval targets USDT and `PolygonPostProcessHandler` transfers the residual from the ephemeral to the canonical block-owned wallet address (falling back to the legacy flattened projection only for older ramps). It MUST NOT send that user-funded variance to the Vortex funding account. Legacy AXLUSDC cleanup transactions retain their historical treasury-dust behavior. Automatic post-processing remains complete-only: if AlfredPay reports failure after the provider transfer, later fee nonces were not consumed and the cleanup approval cannot be broadcast safely; residual USDT remains on the client-custodied ephemeral for manual reconciliation. -30. **An Alfredpay SELL order MUST be `CREATED` before Vortex's first provider-bound transfer** — a pre-transfer `FAILED` response terminates the ramp without moving the user's USDT; `ON_CHAIN_DEPOSIT_RECEIVED`, `TRADE_COMPLETED`, or either fiat-transfer state without a confirmed/replayed local transfer indicates an unexplained external side effect and requires reconciliation. A confirmed local transfer journal is replayed before this mutable status check. +28. **An Alfredpay SELL order MUST be `CREATED` before Vortex's first provider-bound transfer** — a pre-transfer `FAILED` response terminates the ramp without moving the user's USDT; `ON_CHAIN_DEPOSIT_RECEIVED`, `TRADE_COMPLETED`, or either fiat-transfer state without a confirmed/replayed local transfer indicates an unexplained external side effect and requires reconciliation. A confirmed local transfer journal is replayed before this mutable status check. ## Threat Vectors & Mitigations @@ -95,7 +93,7 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu | **Expired provider quote on offramp transfer** | A replacement quote degrades payout, changes the declared funding origin, or an ambiguous create retry duplicates provider orders | Re-quote the fixed provider input; require matching currencies/input and `fresh toAmount >= original toAmount`; keep `originAddress` bound to the EVM ephemeral; journal replacement creation and pause unknown outcomes. The Vortex `QuoteTicket` remains untouched. | | **Offramp quote drift at prep time** | Market moves between quote creation and ramp registration; the refreshed Alfredpay offramp quote has different executable terms | Registration compares pair, chain when returned, `fromAmount`, `toAmount`, fee, and safe lifetime exactly; proven pre-order drift returns 422 `UNPROCESSABLE_ENTITY`, leaves the durable operation retryable, and requires a fresh provider quote. | | **Offramp pricing source confusion** | Diagnostics present Alfredpay's executable rate as the general market reference, obscuring whether a difference comes from the reference feed, provider fees, or Vortex pricing | Persist separate source-labelled reference, provider gross/net, and customer all-in observations. The Vortex snapshot defines the target; Alfredpay terms only solve the local executable deposit. | -| **Unfundable AlfredPay SELL target** | Provider spread/fees make the configured target require more than partner/runtime subsidy limits or the provider's trade maximum | Cap the provider input to the lowest allowance that still covers the fee-net baseline, return the resulting executable payout, and emit `ALFREDPAY_OFFRAMP_TARGET_DISCOUNT_CAPPED` with target, delivered output, required/applied subsidy, and binding cap. A provider maximum below baseline rejects because no full-value fixed-input quote exists. Squid routes with `toAmountMin > toAmount` reject as malformed before the claimed minimum can affect subsidy sizing. | +| **Unfundable AlfredPay SELL target** | Provider spread/fees make the configured target require more than partner/runtime subsidy limits or the provider's trade maximum | Cap the provider input to the lowest allowance that still covers the fee-net baseline, return the resulting executable payout, and emit `ALFREDPAY_OFFRAMP_TARGET_DISCOUNT_CAPPED` with target, delivered output, required/applied subsidy, and binding cap. A provider maximum below baseline rejects because no full-value fixed-input quote exists. | | **Alfredpay offramp skipping subsidy** | An Alfredpay offramp reaches provider transfer without `finalSettlementSubsidy`, under-funding the settlement | The `AlfredpayOfframp` block declares subsidy before transfer for every source variant; flow tests pin the sequence | | **Polygon passthrough rounding** | Same-chain same-token shortcut rounds the bridge output incorrectly, leaking dust or under-funding the destination | `toFixed(0, 0)` round-down in the squid-router finalize; downstream subsidy ensures the destination receives the quoted amount | | **Polygon wrong-token delivery** | A user on-ramps via Alfredpay and requests a non-USDT Polygon output (e.g. USDC); the flow skips the swap on destination-network alone and transfers the minted USDT | `AlfredpayOnrampDirect` selects passthrough only for `ALFREDPAY_EVM_TOKEN`; non-USDT Polygon outputs compose `SameChainSquidRouterSwap` | @@ -124,7 +122,7 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu - [x] `AlfredpayMint.start` only re-binds the provider `quoteId` when `toAmount` and `fee` match byte-identically, creates the order once, and returns/persists the provider payment instructions through the generic flow lifecycle. **PASS** — block lifecycle tests. - [x] `AlfredpayOfframp.register` re-fetches a fresh provider quote, compares `fromAmount`, `toAmount`, and fee exactly, updates only its own metadata identity/expiry, and throws on drift before order creation. **PASS** — block registration tests. - [x] `AlfredpayOfframp` always includes `finalSettlementSubsidy` before provider transfer. **PASS** — explicit phase list and flow tests. -- [x] Alfredpay SELL derives the target from the exact source-labelled Vortex snapshot, uses exact-output provider terms to solve the executable deposit, and best-effort caps the actual settlement top-up. **PASS** — flow v4/context schema 3 plus MXN target/partner-cap/runtime-cap corridor coverage. +- [x] Alfredpay SELL derives the target from the exact source-labelled Vortex snapshot, uses exact-output provider terms to solve the executable deposit, and best-effort caps the actual settlement top-up. **PASS** — flow v3/context schema 2 plus MXN target/partner-cap/runtime-cap corridor coverage. - [x] Expired Alfredpay SELL order recovery rejects a degraded payout before creating a replacement order or broadcasting the presigned transfer. **PASS** — MXN corridor recovery regression. - [x] AlfredPay offramp order is created by the block phase registration hook; `AlfredpayOfframp.start` retains the defensive validation-only no-op and is idempotent after registration. **PASS** — block lifecycle tests. - [x] Routed Alfredpay onramp quote output precision follows destination token decimals; direct Polygon same-token passthrough remains at minted-token precision. **PASS** — Alfredpay flow and transaction tests. diff --git a/docs/security-spec/06-cross-chain/fund-routing.md b/docs/security-spec/06-cross-chain/fund-routing.md index db10d4275..2fd861cf5 100644 --- a/docs/security-spec/06-cross-chain/fund-routing.md +++ b/docs/security-spec/06-cross-chain/fund-routing.md @@ -16,7 +16,7 @@ The block catalog owns the subsidization and settlement executors across Substra The pre/post executors dispatch by the block's chain context. The EVM pre-swap branch tops the ephemeral up before `nablaSwap` and enforces a quote-relative cap of the greater of $1.00 and `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` (default `0.05`) × quote output. The EVM post-swap branch splits the required top-up into a swap-discrepancy component and a discount component: the actual-vs-quoted swap-output discrepancy is capped at the greater of $1.00 and `MAX_EVM_SWAP_SUBSIDY_QUOTE_FRACTION` × quote output. Discount-derived top-ups below $1 bypass the separate runtime percentage safety cap; top-ups of $1 or more are capped by `MAX_EVM_POST_SWAP_DISCOUNT_SUBSIDY_QUOTE_FRACTION` (default `0.05`). Quote-time partner `maxSubsidy` remains enforced for every amount. -AlfredPay SELL pre-calculates its expected final-settlement top-up from the executable provider input plus the canonical fee reserve. Flow v4/context schema 3 persists Squid's guaranteed `toAmountMin` as `executableBridgeOutputRaw` and validates it against `provider input + fee reserve − subsidy` for preparation, delivery evidence, settlement, and contingency sizing. A fresh route below that minimum is rejected. Quote simulation limits the top-up to the lower of the partner allowance and `MAX_FINAL_SETTLEMENT_SUBSIDY_USD`; when the target needs more, it lowers the provider input/output and logs a capped-target warning. Runtime settlement also refuses to fund more than the quote's persisted subsidy, so unexpected bridge under-delivery cannot consume treasury beyond either quote-time cap. If Squid delivers above its minimum, completed-flow Polygon cleanup returns the residual USDT to the user's wallet rather than sweeping it to treasury. If provider terms can no longer preserve the promised payout, execution pauses before the provider transfer and leaves funds on the client-custodied Polygon ephemeral for reconciliation. Flow-v4 rollout requires no pending AlfredPay flow-v3 quotes or ramps. +AlfredPay SELL pre-calculates its expected final-settlement top-up from the executable provider input, canonical fee reserve, and the existing quoted Squid output. Quote simulation limits the top-up to the lower of the partner allowance and `MAX_FINAL_SETTLEMENT_SUBSIDY_USD`; when the target needs more, it lowers the provider input/output and logs a capped-target warning. Runtime settlement also refuses to fund more than the quote's persisted subsidy, so unexpected bridge under-delivery cannot consume treasury beyond either quote-time cap. If provider terms can no longer preserve the promised payout, execution pauses before the provider transfer and leaves funds on the client-custodied Polygon ephemeral for reconciliation. **How subsidization works:** 1. Read the ephemeral account's current balance