diff --git a/apps/api/src/api/services/phases/blocks/README.md b/apps/api/src/api/services/phases/blocks/README.md index 60a9ef619..ea8107b58 100644 --- a/apps/api/src/api/services/phases/blocks/README.md +++ b/apps/api/src/api/services/phases/blocks/README.md @@ -592,6 +592,16 @@ 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. + 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 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. ### Runtime ownership 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..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 @@ -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", @@ -69,14 +73,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 +102,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 +114,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 +176,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/__tests__/evm-executor-regressions.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/evm-executor-regressions.test.ts index d4af34b54..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 @@ -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,7 +75,32 @@ 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"); @@ -69,8 +115,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 +248,20 @@ describe("EVM block executor regressions", () => { expect(sendTransaction).not.toHaveBeenCalled(); }); - it("records AlfredPay SELL settlement 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: { inputAmountRaw: "1000000" } } }, + metadata: { + blocks: { + alfredpayOfframp: { + bridgeOutputAmountRaw: "1000000", + inputAmountRaw: "1000000", + subsidyAmountRaw: "200000" + } + }, + flow: { id: "AlfredpayOfframp", version: 3 }, + globals: { fees: { usd: { anchor: "0", network: "0", partnerMarkup: "0", vortex: "0" } }, request: {} } + }, network: Networks.Polygon, outputAmount: "1", outputCurrency: FiatToken.MXN @@ -212,6 +271,16 @@ describe("EVM block executor regressions", () => { quoteId: "quote-1", state: { evmEphemeralAddress: "0x2222222222222222222222222222222222222222", + squidRouterDeliveryEvidence: { + baselineRaw: "0", + destinationNetwork: Networks.Polygon, + destinationToken: "0xc2132d05d31c914a87c6611c10748aeb04b58e8f", + expectedAmountRaw: "1000000", + kind: "destination-balance", + minimumRatioBps: 9000, + observedAt: "2026-01-01T00:00:00.000Z", + sourceTransactionHash: "legacy-unavailable" + }, transactionPlan: { settlementBaselines: { "polygon:0x2222222222222222222222222222222222222222:0xc2132d05d31c914a87c6611c10748aeb04b58e8f": "0" @@ -232,9 +301,376 @@ describe("EVM block executor regressions", () => { priceFeedService.convertCurrency = originalConvertCurrency; } + expect(checkBalance).toHaveBeenCalledWith(expect.objectContaining({ amountDesiredRaw: "900000" })); 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 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, + inputAmountRaw, + subsidyAmountRaw + } + }, + flow: { id: "AlfredpayOfframp", version: 3 }, + 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 bridgeOutputAmountRaw = "1000000000"; + const subsidyAmountRaw = "10000000"; + checkBalance.mockResolvedValue(new Big(bridgeOutputAmountRaw)); + 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, + inputAmountRaw: "1010000000", + subsidyAmountRaw + } + }, + flow: { id: "AlfredpayOfframp", version: 3 }, + 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 bridgeOutputAmountRaw = "1000000000"; + checkBalance + .mockResolvedValueOnce(new Big(bridgeOutputAmountRaw)) + .mockRejectedValueOnce(new Error("balance RPC timeout")) + .mockResolvedValue(new Big(bridgeOutputAmountRaw)); + getFundingBalance.mockResolvedValue(new Big(0)); + findQuote.mockResolvedValue({ + metadata: { + blocks: { + alfredpayOfframp: { + bridgeOutputAmountRaw, + inputAmountRaw: "1010000000", + subsidyAmountRaw: "10000000" + } + }, + flow: { id: "AlfredpayOfframp", version: 3 }, + 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 bridgeOutputAmountRaw = "1000000000"; + checkBalance.mockResolvedValue(new Big(bridgeOutputAmountRaw)); + getFundingBalance.mockResolvedValue(new Big(0)); + findQuote.mockResolvedValue({ + metadata: { + blocks: { + alfredpayOfframp: { + bridgeOutputAmountRaw, + inputAmountRaw: "1010000000", + subsidyAmountRaw: "10000000" + } + }, + flow: { id: "AlfredpayOfframp", version: 3 }, + 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("does not fund AlfredPay bridge under-delivery beyond the quoted subsidy", async () => { + checkBalance.mockResolvedValue(new Big("900000")); + findQuote.mockResolvedValue({ + metadata: { + blocks: { + alfredpayOfframp: { + bridgeOutputAmountRaw: "1000000", + inputAmountRaw: "1000000", + subsidyAmountRaw: "0" + } + }, + flow: { id: "AlfredpayOfframp", version: 3 }, + 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/api/services/phases/blocks/core/financial-operation.test.ts b/apps/api/src/api/services/phases/blocks/core/financial-operation.test.ts index 547b47e8b..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,6 +109,46 @@ describe("runFinancialOperation", () => { expect(perform).toHaveBeenCalledTimes(1); }); + 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 preflightReleased = new Promise(resolve => { + releasePreflight = resolve; + }); + const firstPerform = mock(async () => ({ id: "external-1" })); + const competingPerform = mock(async () => ({ id: "external-2" })); + + const first = runFinancialOperation({ + ...baseOperation, + beforePerform: async () => { + signalPreflightStarted(); + await preflightReleased; + }, + perform: firstPerform + }); + 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 () => { const perform = mock(async () => { throw new Error("connection reset after submission"); 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/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..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 @@ -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"; @@ -84,6 +82,19 @@ export interface AlfredpayOfframpMetadata { 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; + +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; +} + function directAlfredpaySettlementQuote(amountDecimal: string) { const outputAmountDecimal = new Big(amountDecimal); const amountRaw = multiplyByPowerOfTen(outputAmountDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, Big.roundDown); @@ -115,38 +126,19 @@ 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 +319,11 @@ export function simulateAlfredpayOfframp ${outputAmount.toString()} ${ctx.request.outputCurrency}` ); @@ -243,8 +373,8 @@ export function simulateAlfredpayOfframp = [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 subsidy ceiling. export class FinalSettlementSubsidyExecutor extends BasePhaseHandler { public getPhaseName(): RampPhase { return "finalSettlementSubsidy"; @@ -74,8 +77,17 @@ 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; + 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 +160,9 @@ export class FinalSettlementSubsidyExecutor extends BasePhaseHandler { }; } ).blocks?.squidRouterSwap; - const bridgeExpectedAmountRaw = squidMetadata?.outputAmountRaw ?? expectedAmountRaw.toFixed(0); + const bridgeExpectedAmountRaw = isAlfredpayOfframp + ? alfredpayMetadata.bridgeOutputAmountRaw + : (squidMetadata?.outputAmountRaw ?? expectedAmountRaw.toFixed(0)); const existingEvidence = state.state.squidRouterDeliveryEvidence; if (existingEvidence) { this.assertMatchingDeliveryEvidence( @@ -269,6 +283,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 +313,160 @@ 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, { + 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/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-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/test-utils/fake-world/fake-squidrouter.ts b/apps/api/src/test-utils/fake-world/fake-squidrouter.ts index 06cb4acbc..612d3ab75 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,10 @@ 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); + /** USD value returned with the route estimate. */ + toAmountUsd = "1"; toTokenDecimals = 18; failNextRoute: Error | null = null; readonly requestedRoutes: RouteParams[] = []; @@ -39,6 +43,8 @@ export class FakeSquidRouter { route: { estimate: { toAmount: this.computeToAmount(params), + toAmountMin: this.computeToAmountMin(params), + toAmountUSD: this.toAmountUsd, 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 a8322d3b3..a9ddf09ae 100644 --- a/apps/api/src/tests/corridors/alfredpay-currencies.scenario.test.ts +++ b/apps/api/src/tests/corridors/alfredpay-currencies.scenario.test.ts @@ -227,7 +227,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. @@ -256,6 +256,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/corridors/mxn-offramp.scenario.test.ts b/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts index fb1fb4bc7..4e6d5fe95 100644 --- a/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts +++ b/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts @@ -1,7 +1,9 @@ -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, @@ -12,11 +14,20 @@ import { type RampPhase, type UnsignedTx } from "@vortexfi/shared"; -import { BaseError, ContractFunctionExecutionError, decodeFunctionData, erc20Abi, parseTransaction } from "viem"; +import { + BaseError, + ContractFunctionExecutionError, + decodeFunctionData, + 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 { 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 +62,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 +106,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 +142,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 +190,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 +277,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 +316,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 +326,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 +413,134 @@ 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 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; + 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.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.computeToAmountMin = () => parseUnits("900", 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).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 () => { + 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( "happy path: processes the full Alfredpay offramp phase sequence to complete", async () => { @@ -372,7 +548,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 +650,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 +667,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 +749,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 +998,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 +1040,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 +1054,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 +1089,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); + }); }); 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 }; } diff --git a/docs/security-spec/03-ramp-engine/discount-mechanism.md b/docs/security-spec/03-ramp-engine/discount-mechanism.md index 5965078b2..575381db1 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`, 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. ## 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. 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 @@ -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..49196f060 100644 --- a/docs/security-spec/03-ramp-engine/ephemeral-accounts.md +++ b/docs/security-spec/03-ramp-engine/ephemeral-accounts.md @@ -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 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 b642cddad..f7ce48a0d 100644 --- a/docs/security-spec/03-ramp-engine/fee-integrity.md +++ b/docs/security-spec/03-ramp-engine/fee-integrity.md @@ -133,16 +133,25 @@ 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. 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 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 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. 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 +165,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 ef65d4419..f255264c8 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`. 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..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,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 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 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` @@ -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 f8388106f..601b01443 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 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`. 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. @@ -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 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,8 +77,9 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu 25. **Managed Alfredpay operations MUST remain child-, type-, and corridor-scoped** — Customer creation, KYC/KYB, and fiat-account routes may use a manager-selected child or direct child credential as the effective profile. Before provider mutations, authorization requires the country corridor, the child's immutable entity type to match any route-specific KYC/KYB type, the canonical corridor capability matrix to support that type, and any current manager `allowedCustomerTypes` narrowing to include it; null policy adds no narrowing. Status, business-detail, and fiat-account reads do not require current mutation policy. Multipart routes authenticate the relationship and route-specific entity type before buffering, but enforce the country policy after `multer` parses the body. Individual/business customer creation MUST use the normalized immutable `managed_profiles.contact_email`, never the manager's login email. `(manager_profile_id, contact_email)` MUST remain unique, including deleted relationships, so one manager cannot provision multiple children against the same Alfredpay email identity. Conflict recovery MUST reject a found provider customer whose country or type differs from the request, and MUST reject one already claimed by a different profile — a manager chooses its child's contact email and Alfredpay identifies customers by that email, so adopting on email alone would transfer another profile's verification state. The authorized country corridor MUST be resolved from a source the handler itself reads: a request carrying a different `country` in its query string and body is rejected before authorization instead of being authorized on one value and executed on the other. 26. **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. 27. **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. -28. **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. +28. **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. 29. **Cross-manager email identity adoption is an accepted risk** — Contact-email uniqueness is scoped to one manager, but Alfredpay identifies customers by email. Two managers may therefore submit the same normalized email, and conflict recovery adopts Alfredpay's existing customer when country and type match without independently proving that the second manager controls that provider identity. This is explicitly accepted as [RISK-019](../RISK-REGISTER.md). Manager isolation, immutable manager-scoped email uniqueness, country/type matching, and local provider-ID uniqueness limit accidental attachment; global/provider-scoped ownership or provider claim proof is required before overlapping manager email namespaces are supported. +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 @@ -91,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. | | **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` | @@ -116,14 +118,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 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. - [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..2fd861cf5 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, 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 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 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. 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. 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/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/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 { 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..4194cfe46 100644 --- a/packages/shared/src/services/squidrouter/schemas.ts +++ b/packages/shared/src/services/squidrouter/schemas.ts @@ -12,10 +12,6 @@ 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 & - Partial>; -type ConsumedRoute = Pick & { estimate: ConsumedRouteEstimate }; type ConsumedPayStatus = Pick; const RAW_UNITS = /^\d+$/; @@ -25,14 +21,29 @@ 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), + toAmountUSD: z.string().min(1), + 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), @@ -41,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({