diff --git a/.agents/skills/vortex-integration/SKILL.md b/.agents/skills/vortex-integration/SKILL.md index e0de1e533..45c80acb8 100644 --- a/.agents/skills/vortex-integration/SKILL.md +++ b/.agents/skills/vortex-integration/SKILL.md @@ -504,7 +504,7 @@ const vortex = new VortexSdk({ }); ``` -For server processes that manage their own ephemeral key storage (e.g. HSM, encrypted DB), set `storeEphemeralKeys: false` and persist via your own mechanism. +For server processes that manage their own ephemeral key storage (e.g. HSM, encrypted DB), configure `storeEphemeralKeysCallback: async (keys, rampId) => { ... }`. The SDK calls it with the recovery material (`StoredEphemeralKey[]`: `{ address, rampId, secret, type }`) instead of writing the local file, and `storeEphemeralKeys` has no effect. A rejection aborts `registerRamp` before ephemeral-owned transactions are signed (same fail-closed contract as built-in storage). Setting only `storeEphemeralKeys: false` disables the recovery backup entirely — the secrets are not exposed anywhere else. For browser integrations, never configure `secretKey`. Resolve the current renewable Supabase token on every request: @@ -519,7 +519,7 @@ const vortex = new VortexSdk({ }); ``` -If both `secretKey` and `accessTokenProvider` are configured, the SDK uses the secret key and does not call the provider. Browser ephemeral recovery currently uses plain `localStorage`; this is intentionally prototype-grade. Set `storeEphemeralKeys: false` when the integrating application owns secure recovery storage. +If both `secretKey` and `accessTokenProvider` are configured, the SDK uses the secret key and does not call the provider. Browser ephemeral recovery currently uses plain `localStorage`; this is intentionally prototype-grade. Configure `storeEphemeralKeysCallback` when the integrating application owns secure recovery storage (or set `storeEphemeralKeys: false` to disable the backup entirely). ## REST fallback Use: diff --git a/apps/api/src/api/controllers/brla.controller.ts b/apps/api/src/api/controllers/brla.controller.ts index b65f8f641..7fa04159d 100644 --- a/apps/api/src/api/controllers/brla.controller.ts +++ b/apps/api/src/api/controllers/brla.controller.ts @@ -1336,7 +1336,7 @@ export const getKybAttemptStatus = async ( // Queue only after proving this is still the bound attempt. A queue failure rolls // back the terminal state so a later poll can retry the notification. - await enqueueVerificationNotification(attempt, effectiveUserId, "business"); + await enqueueVerificationNotification(attempt, effectiveUserId, "business", transaction); await lockedRecord.update( { lastFailureReasons: failureReason ? [failureReason] : [], diff --git a/apps/api/src/api/services/avenia/avenia-customer.service.test.ts b/apps/api/src/api/services/avenia/avenia-customer.service.test.ts index bcb1f81cc..3acfbfcbe 100644 --- a/apps/api/src/api/services/avenia/avenia-customer.service.test.ts +++ b/apps/api/src/api/services/avenia/avenia-customer.service.test.ts @@ -1,17 +1,26 @@ +import { KycAttemptResult, KycAttemptStatus } from "@vortexfi/shared"; import { afterEach, describe, expect, it, mock } from "bun:test"; import sequelize from "../../../config/database"; +import EmailNotification from "../../../models/emailNotification.model"; import KycCase from "../../../models/kycCase.model"; import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model"; +import { SupabaseAuthService } from "../auth"; import { updateAveniaKycOutcomeForCustomer, updateAveniaKycProgressForCustomer } from "./avenia-customer.service"; const originalTransaction = sequelize.transaction; const originalCustomerFindByPk = ProviderCustomer.findByPk; const originalCaseFindAll = KycCase.findAll; +const originalNotificationFindOne = EmailNotification.findOne; +const originalNotificationFindOrCreate = EmailNotification.findOrCreate; +const originalGetUserLocale = SupabaseAuthService.getUserLocale; afterEach(() => { sequelize.transaction = originalTransaction; ProviderCustomer.findByPk = originalCustomerFindByPk; KycCase.findAll = originalCaseFindAll; + EmailNotification.findOne = originalNotificationFindOne; + EmailNotification.findOrCreate = originalNotificationFindOrCreate; + SupabaseAuthService.getUserLocale = originalGetUserLocale; }); function setup( @@ -53,6 +62,7 @@ function setup( ProviderCustomer.findByPk = mock(async () => customer) as unknown as typeof ProviderCustomer.findByPk; KycCase.findAll = mock(async () => [kycCase]) as unknown as typeof KycCase.findAll; + const transaction = { LOCK: { UPDATE: "UPDATE" } } as never; sequelize.transaction = mock(async callback => { const customerSnapshot = { status: customer.status, statusExternal: customer.statusExternal }; const caseSnapshot = { @@ -62,7 +72,7 @@ function setup( statusExternal: kycCase.statusExternal }; try { - return await callback({ LOCK: { UPDATE: "UPDATE" } } as never); + return await callback(transaction); } catch (error) { Object.assign(customer, customerSnapshot); Object.assign(kycCase, caseSnapshot); @@ -70,7 +80,7 @@ function setup( } }) as unknown as typeof sequelize.transaction; - return { customer, kycCase }; + return { customer, kycCase, transaction }; } describe("updateAveniaKycOutcomeForCustomer", () => { @@ -107,6 +117,41 @@ describe("updateAveniaKycOutcomeForCustomer", () => { expect(kycCase.rejectedAt).toBeNull(); }); + it("queues the outcome notification in the status transaction", async () => { + const { customer, transaction } = setup(VerificationStatus.InReview, VerificationStatus.InReview); + let duplicateCheckTransaction: unknown; + let insertTransaction: unknown; + EmailNotification.findOne = mock(async options => { + duplicateCheckTransaction = options.transaction; + return null; + }) as unknown as typeof EmailNotification.findOne; + EmailNotification.findOrCreate = mock(async options => { + insertTransaction = options.transaction; + return [{} as EmailNotification, true]; + }) as unknown as typeof EmailNotification.findOrCreate; + SupabaseAuthService.getUserLocale = mock(async () => "en-US") as typeof SupabaseAuthService.getUserLocale; + + await updateAveniaKycOutcomeForCustomer( + customer, + VerificationStatus.Approved, + KycAttemptStatus.COMPLETED, + { id: "case-1", providerCaseId: "attempt-1" }, + { + attempt: { + id: "attempt-1", + result: KycAttemptResult.APPROVED, + status: KycAttemptStatus.COMPLETED, + updatedAt: "2026-08-25T12:00:00.000Z" + }, + profileId: "user-1", + subject: "individual" + } + ); + + expect(duplicateCheckTransaction).toBe(transaction); + expect(insertTransaction).toBe(transaction); + }); + it("does not downgrade either row for a stale rejection after approval", async () => { const { customer, kycCase } = setup(VerificationStatus.Approved, VerificationStatus.Approved, { customerStatusExternal: "COMPLETED", diff --git a/apps/api/src/api/services/avenia/avenia-customer.service.ts b/apps/api/src/api/services/avenia/avenia-customer.service.ts index ae10a04cc..b8cc33a1c 100644 --- a/apps/api/src/api/services/avenia/avenia-customer.service.ts +++ b/apps/api/src/api/services/avenia/avenia-customer.service.ts @@ -207,7 +207,7 @@ export async function updateAveniaKycOutcomeForCustomer( // Queue only after the case binding was proven above. Enqueuing is idempotent on the // attempt id, and a queue failure rolls back the terminal state so a later poll can // retry the notification (parity with GET /v1/brla/kyb/attempt-status). - await enqueueVerificationNotification(appliedNotify.attempt, appliedNotify.profileId, appliedNotify.subject); + await enqueueVerificationNotification(appliedNotify.attempt, appliedNotify.profileId, appliedNotify.subject, transaction); } return lockedRecord; }); diff --git a/apps/api/src/api/services/avenia/verification-notifications.ts b/apps/api/src/api/services/avenia/verification-notifications.ts index 09aba5715..11c967c3f 100644 --- a/apps/api/src/api/services/avenia/verification-notifications.ts +++ b/apps/api/src/api/services/avenia/verification-notifications.ts @@ -1,4 +1,5 @@ import { AveniaVerificationAttempt, KycAttemptResult, KycAttemptStatus, KycFailureReason } from "@vortexfi/shared"; +import type { Transaction } from "sequelize"; import { NotificationProvider, NotificationType } from "../../../models/emailNotification.model"; import { enqueueNotification } from "../email"; import { VerificationSubject } from "../email/types"; @@ -64,25 +65,29 @@ function terminalNotificationType(attempt: NotifiableAttempt): NotificationType export async function enqueueVerificationNotification( attempt: NotifiableAttempt, userId: string, - subject: VerificationSubject + subject: VerificationSubject, + transaction?: Transaction ): Promise { const type = terminalNotificationType(attempt); if (!type) { return false; } - await enqueueNotification({ - payload: { - reason: - type === NotificationType.VerificationRejected ? (attempt.resultMessage?.slice(0, MAX_REASON_LENGTH) ?? null) : null, - subject, - updatedAt: attempt.updatedAt + await enqueueNotification( + { + payload: { + reason: + type === NotificationType.VerificationRejected ? (attempt.resultMessage?.slice(0, MAX_REASON_LENGTH) ?? null) : null, + subject, + updatedAt: attempt.updatedAt + }, + provider: NotificationProvider.Avenia, + resourceId: attempt.id, + type, + userId }, - provider: NotificationProvider.Avenia, - resourceId: attempt.id, - type, - userId - }); + transaction + ); return true; } diff --git a/apps/api/src/api/services/email/notification.service.ts b/apps/api/src/api/services/email/notification.service.ts index 069dec178..914aff889 100644 --- a/apps/api/src/api/services/email/notification.service.ts +++ b/apps/api/src/api/services/email/notification.service.ts @@ -1,4 +1,4 @@ -import { literal, Op } from "sequelize"; +import { literal, Op, type Transaction } from "sequelize"; import sequelize from "../../../config/database"; import logger from "../../../config/logger"; import { config } from "../../../config/vars"; @@ -31,10 +31,13 @@ function describeKey({ provider, type, resourceId }: NotificationKey): string { * Records a notification to be emailed. Idempotent on the notification key: * enqueuing the same event twice is a no-op, so callers can fire without guarding. */ -export async function enqueueNotification({ userId, payload, ...key }: EnqueueParams): Promise { +export async function enqueueNotification( + { userId, payload, ...key }: EnqueueParams, + transaction?: Transaction +): Promise { // Duplicates are the common case (webhook replays, re-polled attempts), so check the // key before resolving the locale — that resolution is a Supabase admin API call. - if (await EmailNotification.findOne({ where: { ...key } })) { + if (await EmailNotification.findOne({ transaction, where: { ...key } })) { return; } @@ -42,6 +45,7 @@ export async function enqueueNotification({ userId, payload, ...key }: EnqueuePa const [, created] = await EmailNotification.findOrCreate({ defaults: { ...key, locale, payload, userId }, + transaction, where: { ...key } }); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.flow.test.ts index 650973ded..4e7cc81fc 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.flow.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.flow.test.ts @@ -1,11 +1,21 @@ -import { afterAll, describe, expect, it, mock } from "bun:test"; +import { afterAll, afterEach, describe, expect, it, mock } from "bun:test"; import Big from "big.js"; -import { BrlaApiService, EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection, RampPhase } from "@vortexfi/shared"; +import { + BrlaApiService, + EPaymentMethod, + EvmToken, + type EvmNetworks, + FiatToken, + Networks, + RampDirection, + RampPhase +} from "@vortexfi/shared"; import { config } from "../../../../../config/vars"; import * as partnerPricingNamespace from "../../../partners/partner-pricing.service"; const partnerPricingReal = { ...partnerPricingNamespace }; const brlaApiServiceGetInstanceReal = BrlaApiService.getInstance; +let activePricing: Awaited> = null; mock.module("../core/nabla", () => ({ calculateNablaSwapOutput: async () => { @@ -27,7 +37,8 @@ mock.module("../core/squidrouter", () => ({ }), getEvmBridgeQuote: async ({ amountDecimal }: { amountDecimal: string }) => ({ networkFeeUSD: "0.1", - outputAmountDecimal: new Big(amountDecimal) + outputAmountDecimal: new Big(amountDecimal), + outputAmountUsd: new Big(amountDecimal) }), getBridgeTargetTokenDetails: () => ({ erc20AddressSourceChain: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831" @@ -42,9 +53,13 @@ mock.module("../../../priceFeed.service", () => ({ })); mock.module("../../../partners/partner-pricing.service", () => ({ - findPartnerWithPricing: async () => null + findPartnerWithPricing: async () => activePricing })); +afterEach(() => { + activePricing = null; +}); + afterAll(() => { BrlaApiService.getInstance = brlaApiServiceGetInstanceReal; mock.module("../../../partners/partner-pricing.service", () => ({ ...partnerPricingReal })); @@ -167,7 +182,11 @@ describe("BRL cross-chain onramp flow compile-time adjacency", () => { }); }); -function buildCtx(includeDynamicFunding = true): PhaseCtx { +function buildCtx( + includeDynamicFunding = true, + to: EvmNetworks = Networks.Arbitrum, + outputCurrency: EvmToken = EvmToken.USDC +): PhaseCtx { const notes: string[] = []; return { addNote: (note: string) => { @@ -184,7 +203,7 @@ function buildCtx(includeDynamicFunding = true): PhaseCtx { fundingGasLimit: "21000", isNativeTransfer: false, maximumFeePerGas: "1", - network: Networks.Arbitrum, + network: to, programVersion: 2 as const, transferGasLimit: "100000" } @@ -198,9 +217,9 @@ function buildCtx(includeDynamicFunding = true): PhaseCtx { inputAmount: "100", inputCurrency: FiatToken.BRL, network: Networks.Base, - outputCurrency: EvmToken.USDC, + outputCurrency, rampType: RampDirection.BUY, - to: Networks.Arbitrum + to } }; } @@ -235,6 +254,47 @@ describe("BRL cross-chain onramp flow simulation", () => { config.evmDestinationGas.dynamicFundingEnabled = originalEnabled; } }); + + it("keeps non-stable destination subsidy bounded in source USDC", async () => { + activePricing = { + displayName: "Vortex", + fiatCurrency: FiatToken.BRL, + id: "vortex-pricing", + logoUrl: null, + markupCurrency: EvmToken.USDC, + markupType: "none", + markupValue: 0, + maxDynamicDifference: 0, + maxSubsidy: 0.003, + minDynamicDifference: 0, + name: "vortex", + payoutAddressEvm: null, + payoutAddressSubstrate: null, + rampType: RampDirection.BUY, + targetDiscount: -0.0017, + vortexFeeType: "none", + vortexFeeValue: 0 + }; + BrlaApiService.getInstance = mock(() => ({ + createPayInQuote: mock(async () => ({ + appliedFees: [{ amount: "0.2", type: "Gas Fee" }], + outputAmount: "99", + quoteToken: "mock-quote-token" + })) + })) as unknown as typeof BrlaApiService.getInstance; + + const flow = makeBrlOnrampBaseCrossChainFlow(Networks.Ethereum, EvmToken.ETH); + const { metadata } = await flow.simulate(buildCtx(true, Networks.Ethereum, EvmToken.ETH)); + const subsidizePost = getBlockMetadata(metadata, SubsidizePostContext); + const oracleExpectedUsd = new Big("100").times("0.18").times(new Big(1).minus("0.0017")); + + expect(subsidizePost.outputCurrency).toBe(EvmToken.USDC); + expect(Big(subsidizePost.expectedOutputAmountDecimal).lt(20)).toBe(true); + expect(Big(subsidizePost.subsidyAmountInOutputTokenDecimal).lte(oracleExpectedUsd.times("0.003"))).toBe(true); + const finalSettlement = getBlockMetadata(metadata, FinalSettlementSubsidyContext); + expect(finalSettlement.applied).toBe(false); + expect(Big(finalSettlement.expectedOutputAmountDecimal).eq(finalSettlement.actualOutputAmountDecimal)).toBe(true); + }); }); describe("BRL cross-chain onramp flow metadata ownership", () => { diff --git a/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-same-chain.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-same-chain.flow.test.ts index 8a8f3dea7..d46f1a8e3 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-same-chain.flow.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-same-chain.flow.test.ts @@ -40,7 +40,8 @@ mock.module("../core/squidrouter", () => ({ }, getEvmBridgeQuote: async ({ amountDecimal }: { amountDecimal: string }) => ({ networkFeeUSD: "0.1", - outputAmountDecimal: new Big(amountDecimal) + outputAmountDecimal: new Big(amountDecimal), + outputAmountUsd: new Big(amountDecimal) }), getBridgeTargetTokenDetails: (token: EvmToken) => evmTokenConfig[Networks.Base][token] })); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-cross-chain.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-cross-chain.flow.test.ts index b3d327feb..16e4dc3f0 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-cross-chain.flow.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-cross-chain.flow.test.ts @@ -52,6 +52,11 @@ mock.module("../core/squidrouter", () => ({ networkFeeUSD: "0.1", outputTokenDecimals: 6 }), + getEvmBridgeQuote: async ({ amountDecimal }: { amountDecimal: string }) => ({ + networkFeeUSD: "0.1", + outputAmountDecimal: new Big(amountDecimal), + outputAmountUsd: new Big(amountDecimal) + }), getBridgeTargetTokenDetails: () => ({ erc20AddressSourceChain: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831" }) diff --git a/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-same-chain.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-same-chain.flow.test.ts index d7f2807fa..c6f02bd8b 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-same-chain.flow.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/eur-onramp-base-same-chain.flow.test.ts @@ -49,6 +49,11 @@ mock.module("../core/squidrouter", () => ({ outputTokenDecimals: token?.decimals ?? 6 }; }, + getEvmBridgeQuote: async ({ amountDecimal }: { amountDecimal: string }) => ({ + networkFeeUSD: "0.1", + outputAmountDecimal: new Big(amountDecimal), + outputAmountUsd: new Big(amountDecimal) + }), getBridgeTargetTokenDetails: (token: EvmToken) => evmTokenConfig[Networks.Base][token] })); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/onramp-discount.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/onramp-discount.test.ts index bf66cba2a..d228a6e3d 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/onramp-discount.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/onramp-discount.test.ts @@ -1,6 +1,7 @@ -import { afterAll, afterEach, describe, expect, it, mock, setSystemTime } from "bun:test"; +import { afterAll, afterEach, describe, expect, it, mock, setSystemTime, spyOn } from "bun:test"; import { EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; import Big from "big.js"; +import logger from "../../../../../config/logger"; import { config } from "../../../../../config/vars"; import * as partnerPricingNamespace from "../../../partners/partner-pricing.service"; import * as priceFeedNamespace from "../../../priceFeed.service"; @@ -11,7 +12,7 @@ const partnerPricingReal = { ...partnerPricingNamespace }; const priceFeedReal = { ...priceFeedNamespace }; const squidrouterReal = { ...squidrouterNamespace }; -const pricingById = new Map(); +const pricingById = new Map(); const bridgeQuoteRequests: Array<{ amountDecimal: string; fromNetwork: Networks; @@ -19,6 +20,13 @@ const bridgeQuoteRequests: Array<{ outputCurrency: EvmToken; toNetwork: Networks; }> = []; +let bridgeQuoteFactory: (request: (typeof bridgeQuoteRequests)[number]) => { + outputAmountDecimal: Big; + outputAmountUsd: Big | null; +} = request => ({ + outputAmountDecimal: new Big(request.amountDecimal).times("0.9"), + outputAmountUsd: new Big(request.amountDecimal).times("0.9") +}); mock.module("../../../partners/partner-pricing.service", () => ({ findPartnerWithPricing: async ({ id }: { id?: string }, _rampType: RampDirection, fiatCurrency: FiatToken) => { @@ -33,7 +41,7 @@ mock.module("../../../partners/partner-pricing.service", () => ({ markupType: "none", markupValue: 0, maxDynamicDifference: 0.01, - maxSubsidy: 0.5, + maxSubsidy: pricing.maxSubsidy ?? 0.5, minDynamicDifference: -0.01, name: id, payoutAddressEvm: null, @@ -55,13 +63,17 @@ mock.module("../../../priceFeed.service", () => ({ mock.module("../core/squidrouter", () => ({ getEvmBridgeQuote: async (request: (typeof bridgeQuoteRequests)[number]) => { bridgeQuoteRequests.push(request); - return { outputAmountDecimal: new Big(request.amountDecimal).times("0.9") }; + return bridgeQuoteFactory(request); } })); afterEach(() => { pricingById.clear(); bridgeQuoteRequests.length = 0; + bridgeQuoteFactory = request => ({ + outputAmountDecimal: new Big(request.amountDecimal).times("0.9"), + outputAmountUsd: new Big(request.amountDecimal).times("0.9") + }); setSystemTime(); }); @@ -152,6 +164,134 @@ describe("onramp discount semantics", () => { ]); }); + it("keeps routed-onramp subsidy independent of destination-token quantity", async () => { + const partnerId = "token-unit-invariance-partner"; + pricingById.set(partnerId, { fiatCurrency: FiatToken.BRL, maxSubsidy: 0.003, targetDiscount: 0.02 }); + const oracleExpectedUsd = new Big("102"); + const expectedSourceUsdc = oracleExpectedUsd.div("0.9"); + const expectedSubsidy = oracleExpectedUsd.times("0.003"); + const cases = [ + { outputAmountDecimal: "91.8", outputCurrency: EvmToken.USDC, to: Networks.Arbitrum }, + { outputAmountDecimal: "0.0204", outputCurrency: "PAXG" as EvmToken, to: Networks.Ethereum }, + { outputAmountDecimal: "0.02295", outputCurrency: EvmToken.ETH, to: Networks.Ethereum }, + { outputAmountDecimal: "183.6", outputCurrency: EvmToken.POL, to: Networks.Polygon } + ]; + + for (const testCase of cases) { + bridgeQuoteFactory = () => ({ + outputAmountDecimal: new Big(testCase.outputAmountDecimal), + outputAmountUsd: oracleExpectedUsd.times("0.9") + }); + const ctx = buildCtx(FiatToken.BRL, partnerId, testCase.to, testCase.outputCurrency); + const result = await simulateSubsidizePost( + { amount: new Big("90"), amountRaw: "90000000", chain: Networks.Base, token: EvmToken.USDC }, + ctx + ); + + expect(Big(result.metadata.expectedOutputAmountDecimal).toFixed(6)).toBe(expectedSourceUsdc.toFixed(6)); + expect(Big(result.metadata.subsidyAmountInOutputTokenDecimal).toFixed(6)).toBe(expectedSubsidy.toFixed(6)); + expect(Big(result.metadata.subsidyAmountInOutputTokenDecimal).lte(expectedSubsidy)).toBe(true); + expect(result.metadata.outputCurrency).toBe(EvmToken.USDC); + } + }); + + it("shrinks the routed target when Squid reports better-than-oracle value retention", async () => { + const partnerId = "favorable-route-partner"; + pricingById.set(partnerId, { fiatCurrency: FiatToken.BRL, maxSubsidy: 0.5, targetDiscount: 0.02 }); + bridgeQuoteFactory = request => ({ + outputAmountDecimal: new Big(request.amountDecimal), + outputAmountUsd: new Big(request.amountDecimal).times("1.02") + }); + + const result = await simulateSubsidizePost( + { amount: new Big("95"), amountRaw: "95000000", chain: Networks.Base, token: EvmToken.USDC }, + buildCtx(FiatToken.BRL, partnerId, Networks.Arbitrum, EvmToken.USDT) + ); + + // The oracle target is 102 USD; retention 1.02 shrinks the source-USDC target to 100, + // so the subsidy tops up to the cheaper route target, not the raw oracle value. + expect(Big(result.metadata.expectedOutputAmountDecimal).toFixed(6)).toBe("100.000000"); + expect(Big(result.metadata.subsidyAmountInOutputTokenDecimal).toFixed(6)).toBe("5.000000"); + expect(result.metadata.applied).toBe(true); + }); + + it("falls back to the oracle target when Squid returns a non-positive USD value", async () => { + const partnerId = "invalid-route-value-partner"; + pricingById.set(partnerId, { fiatCurrency: FiatToken.BRL, maxSubsidy: 0.5, targetDiscount: 0.02 }); + bridgeQuoteFactory = request => ({ + outputAmountDecimal: new Big(request.amountDecimal).times("1000000"), + outputAmountUsd: new Big(0) + }); + + const warning = spyOn(logger, "warn"); + try { + const result = await simulateSubsidizePost( + { amount: new Big("97.5"), amountRaw: "97500000", chain: Networks.Base, token: EvmToken.USDC }, + buildCtx(FiatToken.BRL, partnerId, Networks.Ethereum, "PAXG" as EvmToken) + ); + + expect(Big(result.metadata.expectedOutputAmountDecimal).toFixed()).toBe("102"); + expect(Big(result.metadata.subsidyAmountInOutputTokenDecimal).toFixed()).toBe("4.5"); + expect(warning).toHaveBeenCalledWith( + "SUBSIDIZE_POST_ROUTE_VALUE_FALLBACK", + expect.objectContaining({ outputCurrency: "PAXG", toNetwork: Networks.Ethereum }) + ); + } finally { + warning.mockRestore(); + } + }); + + it("falls back to the oracle target when Squid's USD value cannot be parsed", async () => { + const partnerId = "invalid-route-number-partner"; + pricingById.set(partnerId, { fiatCurrency: FiatToken.BRL, maxSubsidy: 0.5, targetDiscount: 0.02 }); + bridgeQuoteFactory = () => { + throw new Error("Invalid Squid output USD value"); + }; + + const warning = spyOn(logger, "warn"); + try { + const result = await simulateSubsidizePost( + { amount: new Big("97.5"), amountRaw: "97500000", chain: Networks.Base, token: EvmToken.USDC }, + buildCtx(FiatToken.BRL, partnerId, Networks.Ethereum, "PAXG" as EvmToken) + ); + + expect(Big(result.metadata.expectedOutputAmountDecimal).toFixed()).toBe("102"); + expect(Big(result.metadata.subsidyAmountInOutputTokenDecimal).toFixed()).toBe("4.5"); + expect(warning).toHaveBeenCalledWith( + "SUBSIDIZE_POST_ROUTE_VALUE_FALLBACK", + expect.objectContaining({ error: "Invalid Squid output USD value" }) + ); + } finally { + warning.mockRestore(); + } + }); + + it("falls back to the oracle target when Squid's USD value is unparsable at the route layer", async () => { + const partnerId = "unparsable-route-value-partner"; + pricingById.set(partnerId, { fiatCurrency: FiatToken.BRL, maxSubsidy: 0.5, targetDiscount: 0.02 }); + bridgeQuoteFactory = request => ({ + outputAmountDecimal: new Big(request.amountDecimal), + outputAmountUsd: null + }); + + const warning = spyOn(logger, "warn"); + try { + const result = await simulateSubsidizePost( + { amount: new Big("97.5"), amountRaw: "97500000", chain: Networks.Base, token: EvmToken.USDC }, + buildCtx(FiatToken.BRL, partnerId, Networks.Ethereum, "PAXG" as EvmToken) + ); + + expect(Big(result.metadata.expectedOutputAmountDecimal).toFixed()).toBe("102"); + expect(Big(result.metadata.subsidyAmountInOutputTokenDecimal).toFixed()).toBe("4.5"); + expect(warning).toHaveBeenCalledWith( + "SUBSIDIZE_POST_ROUTE_VALUE_FALLBACK", + expect.objectContaining({ error: "Squid returned unusable output USD value: unparsable" }) + ); + } finally { + warning.mockRestore(); + } + }); + it("applies the resolved EUR partner discount on the Base USDC 1:1 route", async () => { const partnerId = "eur-discount-partner"; pricingById.set(partnerId, { fiatCurrency: FiatToken.EURC, targetDiscount: 0.01 }); @@ -173,6 +313,27 @@ describe("onramp discount semantics", () => { expect(bridgeQuoteRequests).toEqual([]); }); + it("anchors a routed EUR subsidy cap to oracle USD instead of destination-token units", async () => { + const partnerId = "eur-routed-partner"; + pricingById.set(partnerId, { fiatCurrency: FiatToken.EURC, maxSubsidy: 0.003, targetDiscount: -0.0008 }); + bridgeQuoteFactory = request => ({ + outputAmountDecimal: new Big("0.024"), + outputAmountUsd: new Big(request.amountDecimal).times("0.98") + }); + const ctx = buildCtx(FiatToken.EURC, partnerId, Networks.Ethereum, EvmToken.ETH); + const oracleExpectedUsd = new Big("100").times("1.08").times(new Big(1).minus("0.0008")); + const result = await simulateSubsidizePost( + { amount: new Big("104.5"), amountRaw: "104500000", chain: Networks.Base, token: EvmToken.USDC }, + ctx + ); + + expect(Big(result.metadata.expectedOutputAmountDecimal).toFixed(6)).toBe(oracleExpectedUsd.div("0.98").toFixed(6)); + expect(Big(result.metadata.subsidyAmountInOutputTokenDecimal).toFixed(6)).toBe( + oracleExpectedUsd.times("0.003").round(6, Big.roundDown).toFixed(6) + ); + expect(result.metadata.outputCurrency).toBe(EvmToken.USDC); + }); + it("regression: a negative target discount still subsidizes up to its worse-than-reference rate floor", async () => { const partnerId = "eur-negative-discount-partner"; pricingById.set(partnerId, { fiatCurrency: FiatToken.EURC, targetDiscount: -0.01 }); diff --git a/apps/api/src/api/services/phases/blocks/core/discount.test.ts b/apps/api/src/api/services/phases/blocks/core/discount.test.ts index adb3df2a4..207b2e53a 100644 --- a/apps/api/src/api/services/phases/blocks/core/discount.test.ts +++ b/apps/api/src/api/services/phases/blocks/core/discount.test.ts @@ -41,6 +41,11 @@ describe("calculateSubsidyAmount", () => { expect(result.toString()).toBe("5"); }); + it("can anchor the cap to an independently denominated value basis", () => { + const result = calculateSubsidyAmount(new Big(1000), new Big(80), 0.05, new Big(100)); + // The route-adjusted target is 1000, but the pricing allowance remains 5% of $100. + expect(result.toString()).toBe("5"); + }); }); // The negative-discount / rate-floor logic lives in calculateExpectedOutput, not diff --git a/apps/api/src/api/services/phases/blocks/core/discount.ts b/apps/api/src/api/services/phases/blocks/core/discount.ts index 01f2ff639..b1c3b65f3 100644 --- a/apps/api/src/api/services/phases/blocks/core/discount.ts +++ b/apps/api/src/api/services/phases/blocks/core/discount.ts @@ -240,7 +240,12 @@ export function handleQuoteConsumptionForDiscountState(partner?: ActivePartner): } } -export function calculateSubsidyAmount(expectedOutput: Big, actualOutput: Big, maxSubsidy: number): Big { +export function calculateSubsidyAmount( + expectedOutput: Big, + actualOutput: Big, + maxSubsidy: number, + capBasis: Big = expectedOutput +): Big { // If actual output is already >= expected, no subsidy needed if (actualOutput.gte(expectedOutput)) { return new Big(0); @@ -251,6 +256,6 @@ export function calculateSubsidyAmount(expectedOutput: Big, actualOutput: Big, m } const shortfall = expectedOutput.minus(actualOutput); - const maxAllowedSubsidy = expectedOutput.mul(maxSubsidy); + const maxAllowedSubsidy = capBasis.mul(maxSubsidy); return shortfall.gt(maxAllowedSubsidy) ? maxAllowedSubsidy : shortfall; } diff --git a/apps/api/src/api/services/phases/blocks/core/squidrouter.ts b/apps/api/src/api/services/phases/blocks/core/squidrouter.ts index 9a4468983..4c8fdd731 100644 --- a/apps/api/src/api/services/phases/blocks/core/squidrouter.ts +++ b/apps/api/src/api/services/phases/blocks/core/squidrouter.ts @@ -189,6 +189,15 @@ async function getSquidrouterRouteData(routeParams: RouteParams, fromNetwork: Ne const outputTokenDecimals = routeData.route.estimate.toToken.decimals; const outputAmountRaw = routeData.route.estimate.toAmount; const outputAmountDecimal = parseContractBalanceResponse(outputTokenDecimals, BigInt(outputAmountRaw)).preciseBigDecimal; + // Tolerant on purpose: only the SubsidizePost probe consumes this numerically (and + // degrades to its 1:1 fallback), so an unparsable value must not fail the mint fee + // probes or the swap leg that share this helper. + let outputAmountUsd: Big | null; + try { + outputAmountUsd = new Big(routeData.route.estimate.toAmountUSD); + } catch { + outputAmountUsd = null; + } const networkFeeUSD = await calculateSquidrouterNetworkFee(routeData.route, fromNetwork); return { @@ -197,6 +206,7 @@ async function getSquidrouterRouteData(routeParams: RouteParams, fromNetwork: Ne networkFeeUSD, outputAmountDecimal, outputAmountRaw, + outputAmountUsd, outputTokenDecimals, routeData, toToken: routeParams.toToken diff --git a/apps/api/src/api/services/phases/blocks/phases/final-settlement-subsidy/simulation.test.ts b/apps/api/src/api/services/phases/blocks/phases/final-settlement-subsidy/simulation.test.ts new file mode 100644 index 000000000..cff26a511 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/phases/final-settlement-subsidy/simulation.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "bun:test"; +import { EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; +import Big from "big.js"; +import type { PhaseCtx } from "../../core/types"; +import { simulateFinalSettlementSubsidy } from "./simulation"; + +function buildCtx(): PhaseCtx { + return { + addNote() {}, + notes: [], + now: new Date("2026-01-01T00:00:00.000Z"), + partner: { + id: "partner-id", + maxSubsidy: 0.5, + targetDiscount: 0.1 + }, + request: { + from: EPaymentMethod.PIX, + inputAmount: "500", + inputCurrency: FiatToken.BRL, + network: Networks.Ethereum, + outputCurrency: EvmToken.ETH, + rampType: RampDirection.BUY, + to: Networks.Ethereum + } + } as PhaseCtx; +} + +describe("simulateFinalSettlementSubsidy", () => { + it("records an arbitrary destination-token target without advertising a cross-denominated subsidy", async () => { + const result = await simulateFinalSettlementSubsidy( + { + amount: new Big("0.025"), + amountRaw: "25000000000000000", + chain: Networks.Ethereum, + token: EvmToken.ETH + }, + buildCtx() + ); + + expect(result.metadata.amountRaw).toBe("25000000000000000"); + expect(result.metadata.network).toBe(Networks.Ethereum); + expect(result.metadata.token).toBe(EvmToken.ETH); + expect(result.metadata.applied).toBe(false); + expect(Big(result.metadata.actualOutputAmountDecimal).toFixed()).toBe("0.025"); + expect(Big(result.metadata.expectedOutputAmountDecimal).toFixed()).toBe("0.025"); + expect(Big(result.metadata.subsidyAmountInOutputTokenDecimal).toFixed()).toBe("0"); + expect(result.metadata.targetOutputAmountRaw).toBe("25000000000000000"); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/phases/final-settlement-subsidy/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/final-settlement-subsidy/simulation.ts index a4662a0f8..aa3bfbe49 100644 --- a/apps/api/src/api/services/phases/blocks/phases/final-settlement-subsidy/simulation.ts +++ b/apps/api/src/api/services/phases/blocks/phases/final-settlement-subsidy/simulation.ts @@ -1,8 +1,7 @@ import { getOnChainTokenDetails, Networks, OnChainToken } from "@vortexfi/shared"; -import Big from "big.js"; import { defineContext } from "../../core/metadata"; import type { ChainBrand, PhaseCtx, PhaseIO, PhaseResult, TokenBrand } from "../../core/types"; -import { buildFullSubsidy, computeExpectedOutput, type SubsidyMetadata } from "../subsidize-pre/simulation"; +import { buildFullSubsidy, type SubsidyMetadata } from "../subsidize-pre/simulation"; export interface FinalSettlementSubsidyMetadata extends SubsidyMetadata { amountRaw: string; @@ -20,9 +19,11 @@ export async function simulateFinalSettlementSubsidy 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"; + /** USD value returned with the route estimate. Default: values the raw input as + * 6-decimal Base USDC at $1 (full value retention), so subsidy valuation sees a + * sane route; override for non-USDC inputs or lossy routes. */ + computeToAmountUsd: (params: RouteParams) => string = params => new Big(params.fromAmount).div(1_000_000).toFixed(); toTokenDecimals = 18; failNextRoute: Error | null = null; readonly requestedRoutes: RouteParams[] = []; @@ -44,7 +47,7 @@ export class FakeSquidRouter { estimate: { toAmount: this.computeToAmount(params), toAmountMin: this.computeToAmountMin(params), - toAmountUSD: this.toAmountUsd, + toAmountUSD: this.computeToAmountUsd(params), toToken: { decimals: this.toTokenDecimals } }, quoteId: "fake-squid-quote", diff --git a/apps/api/src/tests/corridors/brl-onramp-crosschain.scenario.test.ts b/apps/api/src/tests/corridors/brl-onramp-crosschain.scenario.test.ts index ecf87ec94..bb91a68e4 100644 --- a/apps/api/src/tests/corridors/brl-onramp-crosschain.scenario.test.ts +++ b/apps/api/src/tests/corridors/brl-onramp-crosschain.scenario.test.ts @@ -17,8 +17,10 @@ import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from import phaseProcessor from "../../api/services/phases/phase-processor"; import { config } from "../../config/vars"; import { getBlockMetadata, getFlowMetadata } from "../../api/services/phases/blocks/core/metadata"; +import { FinalSettlementSubsidyContext } from "../../api/services/phases/blocks/phases/final-settlement-subsidy/simulation"; import { NablaSwapContext } from "../../api/services/phases/blocks/phases/nabla-swap/simulation"; import { SquidRouterSwapContext } from "../../api/services/phases/blocks/phases/squid-router-swap/simulation"; +import { SubsidizePostContext } from "../../api/services/phases/blocks/phases/subsidize-post/simulation"; import QuoteTicket from "../../models/quoteTicket.model"; import RampState from "../../models/rampState.model"; import { resetTestDatabase, setupTestDatabase } from "../../test-utils/db"; @@ -158,6 +160,9 @@ describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Ar world.squidRouter.bridgeStatus = "success"; // The bridge leg swaps 6-decimal Base USDC into 6-decimal Arbitrum USDC; // the fake route must report matching decimals. + world.squidRouter.computeToAmount = params => params.fromAmount; + world.squidRouter.computeToAmountMin = params => world.squidRouter.computeToAmount(params); + world.squidRouter.computeToAmountUsd = params => new Big(params.fromAmount).div(1_000_000).toFixed(); world.squidRouter.toTokenDecimals = 6; // Deterministic Nabla quoter for BRLA (18 decimals) → USDC (6 decimals) at // a flat 5 BRLA per USDC, matching the FakePrices 5 BRL/USD feed. @@ -171,7 +176,8 @@ describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Ar }); async function createQuoteViaApi( - destinationNetwork: EvmNetworks = Networks.Arbitrum + destinationNetwork: EvmNetworks = Networks.Arbitrum, + outputCurrency: EvmToken = EvmToken.USDC ): Promise<{ id: string; networkFeeUsd: string; outputAmount: string }> { const response = await app.request("/v1/quotes", { body: JSON.stringify({ @@ -179,7 +185,7 @@ describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Ar inputAmount: "500", inputCurrency: FiatToken.BRL, network: destinationNetwork, - outputCurrency: EvmToken.USDC, + outputCurrency, rampType: RampDirection.BUY, to: destinationNetwork }), @@ -433,6 +439,50 @@ describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Ar } }); + it("keeps a non-stable destination quote subsidy bounded by oracle USD", async () => { + await updatePartnerPricing("vortex", RampDirection.BUY, { maxSubsidy: 0.003, targetDiscount: -0.0017 }); + world.squidRouter.toTokenDecimals = 18; + world.squidRouter.computeToAmount = params => (BigInt(params.fromAmount) * 250_000_000n).toString(); + // 500 BRL at the fake 0.2 BRL/USD oracle and -0.17% target is $99.83. + // Report 99% value retention independently of the tiny ETH token quantity. + world.squidRouter.computeToAmountUsd = () => "98.8317"; + + const quote = await createQuoteViaApi(Networks.Ethereum, EvmToken.ETH); + const persistedQuote = await QuoteTicket.findByPk(quote.id); + if (!persistedQuote) throw new Error("Non-stable destination quote was not persisted"); + const subsidy = getBlockMetadata(persistedQuote.metadata, SubsidizePostContext); + const oracleExpectedUsd = new Big("99.83"); + const configuredCap = oracleExpectedUsd.times("0.003"); + + expect(new Big(quote.outputAmount).lt(1)).toBe(true); + expect(subsidy.outputCurrency).toBe(EvmToken.USDC); + expect(Big(subsidy.expectedOutputAmountDecimal).toFixed(6)).toBe(oracleExpectedUsd.div("0.99").toFixed(6)); + expect(Big(subsidy.subsidyAmountInOutputTokenDecimal).lte(configuredCap)).toBe(true); + expect(Big(subsidy.subsidyAmountInOutputTokenDecimal).lt(1)).toBe(true); + const finalSettlement = getBlockMetadata(persistedQuote.metadata, FinalSettlementSubsidyContext); + expect(finalSettlement.applied).toBe(false); + expect(Big(finalSettlement.expectedOutputAmountDecimal).eq(finalSettlement.actualOutputAmountDecimal)).toBe(true); + }); + + it("keeps quoting when Squid reports a malformed USD value", async () => { + await updatePartnerPricing("vortex", RampDirection.BUY, { maxSubsidy: 0.003, targetDiscount: -0.0017 }); + world.squidRouter.toTokenDecimals = 18; + world.squidRouter.computeToAmount = params => (BigInt(params.fromAmount) * 250_000_000n).toString(); + // A malformed USD value must not abort quote creation: AveniaMint's fee probe and + // the Squid swap leg share the route helper but never consume this field, and the + // subsidy probe falls back to the oracle target instead of a route-adjusted one. + world.squidRouter.computeToAmountUsd = () => "N/A"; + + const quote = await createQuoteViaApi(Networks.Ethereum, EvmToken.ETH); + const persistedQuote = await QuoteTicket.findByPk(quote.id); + if (!persistedQuote) throw new Error("Malformed-USD quote was not persisted"); + const subsidy = getBlockMetadata(persistedQuote.metadata, SubsidizePostContext); + const oracleExpectedUsd = new Big("99.83"); + + expect(Big(subsidy.expectedOutputAmountDecimal).toFixed(2)).toBe(oracleExpectedUsd.toFixed(2)); + expect(Big(subsidy.subsidyAmountInOutputTokenDecimal).lte(oracleExpectedUsd.times("0.003"))).toBe(true); + }); + it("returns the typed 503 for normal and all-high best-quote requests", async () => { const originalCeiling = config.evmDestinationGas.maxExecutionFeeUsd; config.evmDestinationGas.maxExecutionFeeUsd = "0.000001"; diff --git a/apps/api/src/tests/notifications-onboarding.integration.test.ts b/apps/api/src/tests/notifications-onboarding.integration.test.ts index 3fb7096cd..f1c2bff1b 100644 --- a/apps/api/src/tests/notifications-onboarding.integration.test.ts +++ b/apps/api/src/tests/notifications-onboarding.integration.test.ts @@ -1143,6 +1143,60 @@ describe("GET /v1/onboarding/status", () => { expect(rows[0].userId).toBe(user.id); }); + it("rolls back the outcome email when the authenticated KYB status transaction fails", async () => { + const { user, token } = await createAuthedUser("avenia-kyb-rollback@example.com"); + const business = await createTestTaxId(user.id, { + customerType: "business", + subAccountId: "rollback-subaccount", + taxId: "66777888000186" + }); + await business.update({ status: VerificationStatus.InReview, statusExternal: KycAttemptStatus.PROCESSING }); + const kycCase = await KycCase.create({ + customerEntityId: business.customerEntityId, + level: "level_1", + provider: "avenia", + providerCaseId: "rollback-attempt", + providerCustomerId: business.id, + status: VerificationStatus.InReview, + statusExternal: KycAttemptStatus.PROCESSING, + type: "kyb" + }); + const getInstance = BrlaApiService.getInstance; + BrlaApiService.getInstance = mock( + () => + ({ + getKybAttemptStatus: mock(async () => ({ + attempt: { + id: "rollback-attempt", + result: KycAttemptResult.APPROVED, + status: KycAttemptStatus.COMPLETED, + updatedAt: "2026-08-25T12:00:00.000Z" + } + })) + }) as unknown as BrlaApiService + ); + const update = ProviderCustomer.prototype.update; + ProviderCustomer.prototype.update = mock(async () => { + throw new Error("forced provider-customer update failure"); + }) as unknown as typeof ProviderCustomer.prototype.update; + + try { + const response = await api.request("/v1/brla/kyb/attempt-status?attemptId=rollback-attempt", { + headers: authHeaders(token) + }); + expect(response.status).toBe(500); + } finally { + ProviderCustomer.prototype.update = update; + BrlaApiService.getInstance = getInstance; + } + + await business.reload(); + await kycCase.reload(); + expect(business.status).toBe(VerificationStatus.InReview); + expect(kycCase.status).toBe(VerificationStatus.InReview); + expect(await EmailNotification.count({ where: { resourceId: "rollback-attempt" } })).toBe(0); + }); + it("does not double-send when the webhook, worker, or authenticated route replays a dashboard-settled outcome", async () => { const { user, token } = await createAuthedUser("avenia-kyb-settled-race@example.com"); const business = await createTestTaxId(user.id, { diff --git a/apps/dashboard/e2e/funding-gate.spec.ts b/apps/dashboard/e2e/funding-gate.spec.ts index 847916181..e4c1e92d5 100644 --- a/apps/dashboard/e2e/funding-gate.spec.ts +++ b/apps/dashboard/e2e/funding-gate.spec.ts @@ -7,13 +7,23 @@ const POLYGON_USDC = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359"; const POLYGON_USDT = "0xc2132d05d31c914a87c6611c10748aeb04b58e8f"; const NATIVE_TOKEN = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; +test("transfer defaults to Base", async ({ page }) => { + const backend = await mockBackend(page); + await seedSession(page); + await page.goto("/transfer"); + + await expect(page.getByRole("combobox").filter({ hasText: "Base" })).toBeVisible(); + await page.locator("#token-amount").fill("54.054054"); + await expect.poll(() => backend.quoteRequests.at(-1)?.network).toBe("base"); +}); + // Self-custodial crypto deposits are not supported, so the connected wallet is the only funding path. test("funding panel offers connected-wallet submission only", async ({ page }) => { const backend = await mockBackend(page); await injectMockWallet(page, { chainIdHex: "0x89" }); await seedSession(page); - await page.goto("/transfer"); + await page.goto("/transfer?network=polygon"); const amountInput = page.locator("#token-amount"); await expect(amountInput).toBeVisible({ timeout: 20_000 }); @@ -36,7 +46,7 @@ test("insufficient selected-network USDC balance blocks an offramp", async ({ pa const backend = await mockBackend(page, { tokenBalances: { [POLYGON_USDC]: 50_000_000n } }); await injectMockWallet(page, { chainIdHex: "0x89" }); await seedSession(page); - await page.goto("/transfer"); + await page.goto("/transfer?network=polygon"); await page.locator("#token-amount").fill("54.054054"); @@ -55,7 +65,7 @@ test("balance check follows the selected payin network, not the wallet chain", a }); await injectMockWallet(page, { chainIdHex: "0x89" }); await seedSession(page); - await page.goto("/transfer"); + await page.goto("/transfer?network=polygon"); await page.locator("#token-amount").fill("54.054054"); const sendButton = page.getByRole("button", { name: /^Send/ }); @@ -77,7 +87,7 @@ test("registration rechecks balance after quote refresh", async ({ page }) => { }); await injectMockWallet(page, { chainIdHex: "0x89" }); await seedSession(page); - await page.goto("/transfer"); + await page.goto("/transfer?network=polygon"); await page.locator("#token-amount").fill("54.054054"); const sendButton = page.getByRole("button", { name: /^Send/ }); @@ -94,7 +104,7 @@ test("balance lookup failure blocks an offramp", async ({ page }) => { const backend = await mockBackend(page, { tokenBalances: null }); await injectMockWallet(page, { chainIdHex: "0x89" }); await seedSession(page); - await page.goto("/transfer"); + await page.goto("/transfer?network=polygon"); await page.locator("#token-amount").fill("54.054054"); @@ -112,7 +122,7 @@ test("balance gate checks the selected ERC-20 rather than another held token", a }); await injectMockWallet(page, { chainIdHex: "0x89" }); await seedSession(page); - await page.goto("/transfer"); + await page.goto("/transfer?network=polygon"); await page.getByRole("combobox").filter({ hasText: "USDC" }).click(); await page.getByRole("option", { exact: true, name: "USDT" }).click(); @@ -128,7 +138,7 @@ test("native POL uses the portfolio native balance", async ({ page }) => { await mockBackend(page, { tokenBalances: { [NATIVE_TOKEN]: 2n * 10n ** 18n } }); await injectMockWallet(page, { chainIdHex: "0x89" }); await seedSession(page); - await page.goto("/transfer"); + await page.goto("/transfer?network=polygon"); await page.getByRole("combobox").filter({ hasText: "USDC" }).click(); await page.getByRole("option", { exact: true, name: "POL" }).click(); diff --git a/apps/dashboard/e2e/transfer-mxn-journey.spec.ts b/apps/dashboard/e2e/transfer-mxn-journey.spec.ts index 2a256d5b7..b49721f9f 100644 --- a/apps/dashboard/e2e/transfer-mxn-journey.spec.ts +++ b/apps/dashboard/e2e/transfer-mxn-journey.spec.ts @@ -25,7 +25,7 @@ test("SELL preserves full token precision in prefilled and entered amounts", asy await injectMockWallet(page, { chainIdHex: "0x89" }); await seedSession(page); - await page.goto(`/transfer?amount=${EXPECTED_PAYIN_USDC}`); + await page.goto(`/transfer?amount=${EXPECTED_PAYIN_USDC}&network=polygon`); const amountInput = page.locator("#token-amount"); await expect(amountInput).toHaveValue(EXPECTED_PAYIN_USDC, { timeout: 20_000 }); @@ -44,7 +44,7 @@ test("SELL MXN transfer: quote, register, ephemeral presigning, wallet broadcast await injectMockWallet(page, { chainIdHex: "0x89" }); await seedSession(page); - await page.goto("/transfer"); + await page.goto("/transfer?network=polygon"); // Stage 1: the only approved corridor is MX, and its single saved payout account becomes an // approved self-recipient that the form auto-selects — so the amount field is already live. @@ -140,7 +140,7 @@ test("SELL refreshes a near-expiry quote before registration", async ({ page }) }); await injectMockWallet(page, { chainIdHex: "0x89" }); await seedSession(page); - await page.goto("/transfer"); + await page.goto("/transfer?network=polygon"); const amountInput = page.locator("#token-amount"); await expect(amountInput).toBeVisible({ timeout: 20_000 }); @@ -168,7 +168,7 @@ test("SELL MXN transfer: choosing a different payout account registers against t await injectMockWallet(page, { chainIdHex: "0x89" }); await seedSession(page); - await page.goto("/transfer"); + await page.goto("/transfer?network=polygon"); // The first account is selected on load; the payin-network select is the other combobox. const recipientSelect = page.getByRole("combobox").filter({ hasText: "Vortex E2E CLABE" }); diff --git a/bun.lock b/bun.lock index efff76cb1..c5a4b32fd 100644 --- a/bun.lock +++ b/bun.lock @@ -358,9 +358,9 @@ }, "packages/sdk": { "name": "@vortexfi/sdk", - "version": "0.9.0-rc.3", + "version": "0.9.0", "dependencies": { - "@vortexfi/shared": "=0.3.0", + "@vortexfi/shared": "=0.4.0", }, "devDependencies": { "@types/bun": "^1.3.1", @@ -376,7 +376,7 @@ }, "packages/shared": { "name": "@vortexfi/shared", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { "@paraspell/sdk-pjs": "^11.8.5", "@pendulum-chain/api-solang": "catalog:", diff --git a/docs/api/pages/02-quick-start-with-the-sdk.md b/docs/api/pages/02-quick-start-with-the-sdk.md index fad8dc318..59c76a430 100644 --- a/docs/api/pages/02-quick-start-with-the-sdk.md +++ b/docs/api/pages/02-quick-start-with-the-sdk.md @@ -230,7 +230,21 @@ Most updates happen inside the SDK. For BRL buys, `registerRamp` already submits The SDK creates fresh ephemeral accounts per ramp, signs the transactions Vortex returns, submits ramp updates, and can persist a local backup of ephemeral secrets. This removes the most error-prone parts of a custom integration. -The default backup is **unencrypted**: Node.js writes `ephemerals_{rampId}.json` in the current working directory, while browsers write that key to same-origin localStorage. Treat either as sensitive key material. Browser storage is prototype-grade and readable by every script on the origin. Setting `storeEphemeralKeys: false` disables the SDK backup entirely. See [Ephemeral Key Custody](https://api-docs.vortexfinance.co/ephemeral-key-custody). +The default backup is **unencrypted**: Node.js writes `ephemerals_{rampId}.json` in the current working directory, while browsers write that key to same-origin localStorage. Treat either as sensitive key material. Browser storage is prototype-grade and readable by every script on the origin. + +Production integrations can provide `storeEphemeralKeysCallback` to use encrypted or vault-backed storage instead: + +```js +const sdk = new VortexSdk({ + apiBaseUrl: "https://api.vortexfinance.co", + secretKey: process.env.VORTEX_SECRET_KEY, + storeEphemeralKeysCallback: async (keys, rampId) => { + await encryptedVault.store(rampId, keys); + } +}); +``` + +The callback receives an array of `{ address, rampId, secret, type }` entries and the ramp ID. It replaces the built-in backup, so `storeEphemeralKeys` has no effect when the callback is configured. The SDK awaits it during `registerRamp()` and stops before signing ephemeral-owned transactions if it rejects. Setting `storeEphemeralKeys: false` without a callback disables backup entirely and does not expose the keys elsewhere. See [Ephemeral Key Custody](https://api-docs.vortexfinance.co/ephemeral-key-custody). For quote request races, browser token refresh, wallet-network checks, resumable payment screens, and safe polling, see [Custom UI Integration](https://api-docs.vortexfinance.co/custom-ui-integration). diff --git a/docs/api/pages/05-ephemeral-key-custody.md b/docs/api/pages/05-ephemeral-key-custody.md index 186c84ed2..7901389a5 100644 --- a/docs/api/pages/05-ephemeral-key-custody.md +++ b/docs/api/pages/05-ephemeral-key-custody.md @@ -11,11 +11,25 @@ This is a critical integration responsibility: - Secrets must never be sent to Vortex endpoints, support channels, logs, or analytics. In a browser SDK integration they necessarily exist in browser-visible memory and, by default, same-origin localStorage. - If ephemeral secrets are lost, the partner may be unable to complete recovery for that ramp. Vortex has chain-specific cleanup mechanisms that can recover funds in some cases, but partners should not rely on this for normal operation. -The SDK can store local backups using `storeEphemeralKeys`, which defaults to `true`. In Node.js environments, it writes `ephemerals_{rampId}.json` to the process's current working directory. In browsers, it writes the same plaintext JSON under that key in same-origin localStorage. Neither form is encrypted at rest, and the storage location is not configurable in the current release. +The SDK's built-in backup is controlled by `storeEphemeralKeys`, which defaults to `true`. In Node.js environments, it writes `ephemerals_{rampId}.json` to the process's current working directory. In browsers, it writes the same plaintext JSON under that key in same-origin localStorage. Neither form is encrypted at rest. -When this backup is enabled, persistence is fail-closed. The SDK waits for the backup write after the API creates the ramp but before it signs ephemeral-owned transactions or submits the ramp update. If the write fails, `registerRamp()` rejects and does not continue to the update or start steps. The backend registration may remain incomplete until it expires, but the SDK does not report a usable ramp while its recovery keys are unprotected. Storage errors are deliberately propagated rather than logged and ignored. +For encrypted, vault-backed, or otherwise application-managed persistence, configure `storeEphemeralKeysCallback`: -Treat those backups as sensitive key material. Restrict Node filesystem permissions, exclude files from source control, and define a retention policy that matches operational recovery needs. Browser localStorage is prototype-grade: every same-origin script can read it, and the SDK does not prune terminal entries automatically. Setting `storeEphemeralKeys: false` disables the SDK backup; the current SDK does not expose a replacement storage adapter. +```js +const sdk = new VortexSdk({ + apiBaseUrl: "https://api.vortexfinance.co", + secretKey: process.env.VORTEX_SECRET_KEY, + storeEphemeralKeysCallback: async (keys, rampId) => { + await encryptedVault.store(rampId, keys); + } +}); +``` + +The callback receives an array of `StoredEphemeralKey` objects (`{ address, rampId, secret, type }`) and the ramp ID. When configured, it replaces the built-in file or localStorage backup, and `storeEphemeralKeys` has no effect. The callback owns the storage destination, encryption, access controls, and retention policy; the SDK still does not send the secrets to Vortex. + +Persistence is fail-closed for both the built-in backup and the custom callback. The SDK waits for storage after the API creates the ramp but before it signs ephemeral-owned transactions or submits the ramp update. If the write or callback fails, `registerRamp()` rejects and does not continue to the update or start steps. The backend registration may remain incomplete until it expires, but the SDK does not report a usable ramp while its recovery keys are unprotected. Storage errors are deliberately propagated rather than logged and ignored. + +Treat all backups as sensitive key material. For built-in Node.js storage, restrict filesystem permissions and exclude files from source control. Browser localStorage is prototype-grade: every same-origin script can read it, and the SDK does not prune terminal entries automatically. For custom storage, keep the callback available and deterministic throughout registration, and retain the keys until the operational recovery window has passed. Setting `storeEphemeralKeys: false` without a callback disables backup entirely; it does not expose the secrets through another SDK mechanism. Direct API integrations must implement equivalent custody behavior. At minimum, they should create fresh ephemerals per ramp, store encrypted backups, associate backups with the ramp ID, and verify that recovery material exists before allowing the user to continue. diff --git a/docs/api/pages/11-production-checklist.md b/docs/api/pages/11-production-checklist.md index 96740114c..e8f113e1f 100644 --- a/docs/api/pages/11-production-checklist.md +++ b/docs/api/pages/11-production-checklist.md @@ -6,7 +6,8 @@ Before going live, verify the following: - Store secret API keys only in trusted server-side environments. - Never expose `sk_live_*` or `sk_test_*` keys in browser or mobile code. - Store ephemeral account secrets securely until ramps complete and recovery is no longer needed. -- If using the SDK's default `storeEphemeralKeys: true`, run the SDK from a directory with restricted filesystem permissions, encrypt the backup file yourself, or set `storeEphemeralKeys: false` and implement secure storage. +- For application-managed custody, configure `storeEphemeralKeysCallback` to persist the supplied ephemeral keys in encrypted storage. It replaces the built-in backup, and `registerRamp()` waits for it to succeed before signing ephemeral-owned transactions. +- If using the SDK's default `storeEphemeralKeys: true`, run the SDK from a directory with restricted filesystem permissions and protect the plaintext backup. Set `storeEphemeralKeys: false` only when intentionally disabling backup; without a callback, it does not expose the keys through another storage path. - Persist `quoteId`, `rampId`, user/session ID, partner order ID, and webhook IDs. - Handle quote expiry by creating fresh quotes. - Use webhooks for transaction lifecycle events and verify every webhook signature against `GET /v1/public-key` using RSA-PSS with SHA-256. diff --git a/docs/api/wire-contract.snapshot.md b/docs/api/wire-contract.snapshot.md index b6baed725..670662d6d 100644 --- a/docs/api/wire-contract.snapshot.md +++ b/docs/api/wire-contract.snapshot.md @@ -4788,6 +4788,20 @@ StartRampError: class StartRampError { readonly status: number; } +StoreEphemeralKeysCallback: (keys: Array<{ + address: string; + rampId: string; + secret: string; + type: enum EphemeralAccountType { EVM = "EVM", Substrate = "Substrate" }; +}>, rampId: string) => Promise + +StoredEphemeralKey: { + address: string; + rampId: string; + secret: string; + type: enum EphemeralAccountType { EVM = "EVM", Substrate = "Substrate" }; +} + SubaccountNotFoundError: class SubaccountNotFoundError { constructor(); readonly code?: string; @@ -5510,6 +5524,12 @@ VortexSdk: class VortexSdk { publicKey?: string; secretKey?: string; storeEphemeralKeys?: boolean; + storeEphemeralKeysCallback?: (keys: Array<{ + address: string; + rampId: string; + secret: string; + type: enum EphemeralAccountType { EVM = "EVM", Substrate = "Substrate" }; + }>, rampId: string) => Promise; }); createQuote: , rampId: string) => Promise; } VortexSdkContext: { diff --git a/docs/security-spec/02-signing-keys/ephemeral-accounts.md b/docs/security-spec/02-signing-keys/ephemeral-accounts.md index c9b3555d0..988373aca 100644 --- a/docs/security-spec/02-signing-keys/ephemeral-accounts.md +++ b/docs/security-spec/02-signing-keys/ephemeral-accounts.md @@ -9,7 +9,7 @@ Ephemeral accounts are temporary blockchain accounts created per ramp operation. **Critical security property:** Ephemeral keys are generated client-side (in the SDK or frontend). The server never sees the private keys. Only the public addresses are sent to the API during ramp registration. -The SDK optionally stores ephemeral keys under `ephemerals_{rampId}.json` via the `storeEphemeralKeys` config option (defaults to `true`). Node.js writes a local JSON file; browser builds write the same plaintext JSON to same-origin localStorage. +The SDK optionally stores ephemeral keys under `ephemerals_{rampId}.json` via the `storeEphemeralKeys` config option (defaults to `true`). Node.js writes a local JSON file; browser builds write the same plaintext JSON to same-origin localStorage. Alternatively, the integrator can configure `storeEphemeralKeysCallback`; the SDK then passes the same recovery material (`{ address, rampId, secret, type }` items) to that callback instead of the built-in storage, and `storeEphemeralKeys` has no effect. This lets integrations encrypt the keys or persist them in their own vault. The frontend and dashboard store a backup of all ephemeral keypairs in separate same-origin localStorage maps, keyed by ramp ID (`rampEphemerals` for the widget and `vortex_dashboard_rampEphemerals` for the dashboard). The widget persists this through `PersistenceEffect` in `apps/frontend/src/contexts/rampState.tsx`. The dashboard writes a pending quote-keyed entry before registration, then rebinds it to the returned ramp ID. This archive is **not** cleared when ramp context, authentication, or other UI state resets. The purpose is a user-side failsafe: if a ramp fails mid-flow and the main state is wiped, the ephemeral secret keys remain recoverable from this separate localStorage entry. Once a client observes a terminal state it records `terminalObservedAt`; storage maintenance removes that entry on the first access at least 90 days later. Entries without a terminal observation are retained indefinitely (accepted as [RISK-006](../RISK-REGISTER.md)). The dashboard separately persists its serializable transfer-machine snapshot under `vortex-dashboard-transfer-state` so an onramp's server-issued payment instructions survive reload; reset/logout clears this snapshot but not the independent ephemeral archive. @@ -20,7 +20,7 @@ Frontend and SDK Substrate RPC clients are initialized lazily. Creating/importin 1. **Ephemeral private keys MUST be generated client-side** — The API MUST never generate, receive, store, or have access to ephemeral private keys. Only addresses (`accountMetas`) are sent to the API. 2. **Ephemeral accounts MUST be used for a single ramp only** — Each ramp gets fresh accounts. Reusing ephemerals across ramps creates cross-contamination risk. 3. **The API MUST validate that submitted addresses are well-formed** — Before using an ephemeral address in transactions, the API must validate the address format for the respective chain (Substrate SS58, EVM hex). -4. **Ephemeral key storage (SDK) MUST be local-only and fail closed when enabled** — The `storeEphemeralKeys` function writes to the local filesystem in Node.js or same-origin localStorage in browsers. Keys MUST NOT be transmitted to the API, logged, or stored in any remote database. After the API creates a ramp, the SDK MUST await successful persistence before signing ephemeral-owned transactions or submitting the ramp update. A storage failure MUST reject `registerRamp()` and MUST NOT be swallowed; leaving the backend registration incomplete is safer than advancing without recoverable key material. +4. **Ephemeral key storage (SDK) MUST fail closed, and built-in storage MUST be local-only** — The built-in `storeEphemeralKeys` function writes to the local filesystem in Node.js or same-origin localStorage in browsers; it MUST NOT transmit keys to the API, log them, or store them in any remote database. When the integrator configures `storeEphemeralKeysCallback`, the SDK hands the recovery material to that callback instead and custody of the destination shifts to the integrator; the SDK itself still MUST NOT transmit or log the keys. After the API creates a ramp, the SDK MUST await successful persistence (built-in or callback) before signing ephemeral-owned transactions or submitting the ramp update. A storage failure or callback rejection MUST reject `registerRamp()` and MUST NOT be swallowed; leaving the backend registration incomplete is safer than advancing without recoverable key material. 5. **The API MUST NOT assume the ephemeral address belongs to an honest user** — An attacker could register a ramp with an address they don't control or an address that's a contract (on EVM). Phase handlers must account for this. 6. **Pre-signed transactions MUST be bound to the specific ephemeral address** — Transactions generated by the API for client signing must include the ephemeral address as the source/signer, not a wildcard. 7. **Ephemeral addresses MUST be proven fresh on every chain the ramp will sign on, at ramp registration time** — Before building any transactions, the API MUST verify on-chain that each submitted ephemeral address is fresh on every chain the ramp's route actually signs on. Freshness is chain-appropriate but MUST cover both nonce and balance: Substrate requires `nonce === 0 && free === 0`; EVM requires `nonce === 0 && native balance === 0` (a nonce-0 EVM account can still hold a funded native balance, so a nonce-only check is insufficient). The chain set MUST be derived from the quote (`quoteToSigningNetworks`), not the full supported list: validating chains the route never touches makes an unrelated RPC outage able to block every registration (an availability-hostility the earlier all-chains rule created). The route-to-chains mapping MUST be kept in sync with the route builders — under-listing a chain the ephemeral signs on silently reopens the freshness gap — and is pinned by `ephemeral-freshness.test.ts`. Freshness checks MUST fail closed: any RPC error rejects the registration with `503`. Reused ephemerals cause mid-ramp halt because the server assumes a clean nonce. **Known limitation:** only the native balance is checked on EVM; a nonce-0 account pre-loaded with ERC-20 tokens is not detected (enumerating tokens per chain is out of scope). @@ -49,8 +49,8 @@ Frontend and SDK Substrate RPC clients are initialized lazily. Creating/importin - [x] `createPendulumEphemeral()` and `createMoonbeamEphemeral()` are only called in the SDK/frontend, never in `apps/api` — ✅ PASS - [x] The API's ramp registration endpoint only accepts addresses (public keys), never private keys or seed phrases — ✅ PASS -- [x] `storeEphemeralKeys` writes only to a local file in Node.js or same-origin localStorage in browsers; neither path makes network calls — ✅ PASS -- [x] With SDK storage enabled, registration awaits the backup before signing and update submission; storage failures propagate and stop the client flow — ✅ PASS +- [x] Built-in `storeEphemeralKeys` writes only to a local file in Node.js or same-origin localStorage in browsers; neither path makes network calls. A configured `storeEphemeralKeysCallback` replaces both paths and its destination is integrator-owned; the SDK makes no network calls of its own with the key material — ✅ PASS +- [x] With SDK storage enabled or a custom callback configured, registration awaits persistence before signing and update submission; storage failures and callback rejections propagate and stop the client flow — ✅ PASS - [ ] Ephemeral addresses are validated for format before use in transaction construction — ❌ FAIL (F-021) - [x] No code path in the API logs or persists ephemeral private keys — ✅ PASS - [x] Each call to `generateEphemerals()` produces fresh, unique keypairs — no memoization or caching — ✅ PASS diff --git a/docs/security-spec/03-ramp-engine/discount-mechanism.md b/docs/security-spec/03-ramp-engine/discount-mechanism.md index 797afc3fe..6d48584ec 100644 --- a/docs/security-spec/03-ramp-engine/discount-mechanism.md +++ b/docs/security-spec/03-ramp-engine/discount-mechanism.md @@ -9,11 +9,11 @@ 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 the parameters from the selected active pricing config: - `targetDiscount` — the base 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). A negative `targetDiscount` is valid and starts below the reference rate, but the bounded dynamic `difference` is added to it and can move the adjusted target to or above reference. Only `targetDiscount = 0` disables subsidy; the shared gate is `hasConfiguredTargetDiscount` in `phases/blocks/core/discount.ts`. 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. + - `maxSubsidy` — a fractional per-quote cap on the subsidy as a share of the oracle-expected value. `0` disables subsidy; values in `(0, 1]` cap it to that fraction. On routed BRL/EUR BUY quotes, route loss may increase the source-USDC target, but MUST NOT increase this oracle-value cap basis. 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]`. The bounds are re-applied against the partner's current config on every read and consumption, so an admin change to the range takes effect on the next quote: a raised minimum immediately lifts the difference to it, and a lowered maximum immediately caps it. Fresh state starts clamped as well (at `minDynamicDifference` when that is positive) rather than at `0`. 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. 5. Calculates `actualOutput` as what the user would receive without subsidy (Nabla output minus post-swap fees on onramp, anchor fee added back on offramp). -6. Calculates `idealSubsidy = max(0, expectedOutput − actualOutput)` and `actualSubsidy = min(idealSubsidy, maxSubsidy × expectedOutput)` (only when `targetDiscount ≠ 0`). +6. Calculates `idealSubsidy = max(0, expectedOutput − actualOutput)` and `actualSubsidy = min(idealSubsidy, maxSubsidy × oracleExpectedValue)` (only when `targetDiscount ≠ 0`). For same-denomination routes, `oracleExpectedValue = expectedOutput`. For routed BRL/EUR onramps, `expectedOutput` is the route-adjusted source-USDC target while `oracleExpectedValue` remains the pre-route USD target. 7. Write subsidy metadata under the owning block, consumed by transaction preparation and subsidy executors. On EVM post-swap routes this record represents the discount-derived subsidy component only; the runtime handler may additionally cover actual-vs-quoted swap-output discrepancy, which is capped separately. `phases/blocks/core/quote.ts` snapshots the quote-time discount component into public display fields (`discountFiat`, `discountUsd`, `discountCurrency`) when the subsidy is applied, allowing the UI to show the user-facing discount separately from fees. Discount behavior is wired explicitly by the phases composed in `phases/blocks/flows/`; there is no strategy/orchestrator fallback. @@ -23,11 +23,11 @@ The AlfredPay block flows compute subsidy in the AlfredPay-side currency: - **Onramp**: subsidy denominated in the AlfredPay on-chain currency (USDT on Polygon). In the cross-chain block flow, `AlfredpayMint` installs the provider-derived anchor fee, then `AlfredpaySubsidizePre` deducts the vortex/partner components from the mint, computes the bounded bridge target from that fee-net actual, and reserves the fee residual on top of the target (`feeReserveRaw`) for the later `distributeFees` collection. Squid quote and transaction preparation both consume that same target. The `alfredOnrampMintFallback` presigned contingency remains bounded to the provider mint amount. - **Offramp**: subsidy denominated in AlfredPay's Polygon USDT. `AlfredpayOfframp` derives the target fiat output directly from the same unrounded Vortex USD/fiat snapshot stored in pricing metadata. When the effective pricing-config/runtime subsidy allowance is positive, it asks AlfredPay for an exact-output quote; the returned `fromAmount` incorporates provider spread and provider fees into the executable deposit. When the allowance is zero, it keeps the valid fee-net fixed-input quote and does not probe the target. The actual settlement top-up is `providerInputRaw + feeReserveRaw - bridgeOutputRaw`, where `bridgeOutputRaw` keeps the existing Squid quoted-output semantics. It is capped by both the pricing-config allowance (`maxSubsidy × expectedOutput`, converted to USDT at the same Vortex reference) and `MAX_FINAL_SETTLEMENT_SUBSIDY_USD`. When a positive 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. If Squid under-delivers, runtime settlement funds this persisted subsidy plus the shortfall the delivery gate already tolerates, bounded absolutely by `MAX_FINAL_SETTLEMENT_SUBSIDY_USD`; a larger shortfall pauses the phase instead of drawing on treasury. 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. +For onramps to non-trivial EVM destinations, `SubsidizePost` probes Squid Router (`getEvmBridgeQuote`) and reads `estimate.toAmountUSD`. It derives a dimensionless route-value retention ratio (`toAmountUSD / oracleExpectedUsd`) and divides the oracle target by that ratio to obtain the equivalent pre-bridge USDC target. The raw destination-token amount is never used as a USD or USDC conversion rate; its price and decimals therefore cannot enlarge the subsidy. ## Security Invariants -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 selected active pricing-config row, never from the request. +1. **Subsidy amount MUST be bounded by `maxSubsidy × oracleExpectedValue`** — `maxSubsidy = 0` disables subsidy; when `maxSubsidy` is in `(0, 1]`, general discount blocks clamp the output shortfall to the oracle-denominated expectation times `maxSubsidy`. A routed onramp may gross up its source-USDC target for Squid value loss, but that grossed-up target MUST NOT become the cap basis. 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 selected active pricing-config 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` and `handleQuoteConsumptionForDiscountState` both clamp into `[minDynamicDifference, maxDynamicDifference]` against the partner's current config on every call (`clampToDynamicRange`), so admin range changes apply on the next quote: a raised minimum lifts the difference immediately, a lowered maximum caps it immediately, and fresh state starts at the clamped value of `0` (i.e. at a positive minimum). When a misconfigured row has `min > max`, the max cap wins, keeping the cost ceiling authoritative. A partner with no caps configured behaves as if both caps were `0` (no dynamic adjustment). 4. **The default partner (`name = "vortex"`) and an applicable pricing config MUST exist and MUST be active.** Discount resolution falls back to that partner's corridor-specific or wildcard config when no non-default pricing config applies. Without both an active default partner and a matching active config for the ramp direction/corridor, discount computation produces a `null` partner and `targetDiscount=0`, silently disabling subsidy for that quote. @@ -38,10 +38,10 @@ For onramps to non-trivial EVM destinations, `SubsidizePost` probes Squid Router 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`. 10. **EVM off-ramp Nabla minimums MUST use AMM-only output, not subsidy-merged output.** EVM Nabla quote producers write the AMM-only result to `nablaSwapEvm.ammOutputAmount*`. On Base EVM offramps, `MergeSubsidy` may then merge the quote-time discount subsidy into `nablaSwapEvm.outputAmount*` so downstream payout/finalization targets reflect the subsidized amount. The on-chain Nabla swap minimums MUST be derived from the preserved AMM-only amount (`ammOutputAmountRaw`, falling back to `outputAmountRaw` only for legacy quotes without the snapshot), otherwise the minimum can exceed what the AMM can deliver and cause deterministic swap reverts. 11. **The dynamic difference MUST NOT be incremented within `discountStateTimeoutMinutes` of the last quote** — `getAdjustedDifference` only adds `deltaD` when `isWithinStateTimeout` is **false**. Otherwise repeated quotes from the same partner would inflate the difference faster than intended. -12. **Squid Router probe failures MUST fall back to a 1:1 assumption, never block the quote.** `SubsidizePost` catches probe failures and retains the oracle expected output. A network failure on the probe MUST NOT cause the entire quote flow to throw. +12. **Squid Router probe failures MUST fall back to a 1:1 assumption, never block the quote.** `SubsidizePost` catches probe failures and retains the oracle expected output. Missing, non-numeric, zero, or negative `toAmountUSD` values are probe failures for this purpose. A network or validation failure on the probe MUST NOT cause the entire quote flow to throw. 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. +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, derives a dimensionless USD-value-retention ratio from `toAmountUSD`, and adjusts the source-USDC target by that ratio; destination-token quantity MUST NOT participate in this valuation. The subsidy ceiling remains `maxSubsidy × oracleExpectedUsd`, and 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 non-zero target (positive or negative) uses an exact-output provider quote against the unrounded persisted Vortex reference only when the effective partner/runtime subsidy allowance is positive. When that allowance is zero, the block MUST keep the valid fee-net fixed-input quote and MUST NOT make the fallible target probe. The selected provider `fromAmount`, canonical fee reserve, quoted bridge output, and persisted subsidy MUST reconcile in raw units. If a positive allowance is smaller than the target needs, 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, and the provider-input clamp's fee-net baseline floor guarantees a negative target can never push the user's output below the unsubsidized quote. ## Threat Vectors & Mitigations @@ -55,7 +55,8 @@ For onramps to non-trivial EVM destinations, `SubsidizePost` probes Squid Router | **Multi-replica state divergence** | Running the API behind multiple replicas with no sticky routing causes each replica to maintain its own `partnerDiscountState`. The total subsidy paid can exceed the intended cap because each replica enforces its own ceiling independently. | **OPEN (F-DISC-01).** The current deployment topology MUST run a single replica, or the discount state MUST be persisted/centralised (e.g. Redis or a `partner_discount_state` table with row-level locking) before horizontal scaling. | | **Side-effect on read (cache-poisoning analogue)** | `getAdjustedDifference` mutates `partnerDiscountState` whenever it's called (`partnerDiscountState.set` on lines 106, 111, 120). If a quote pipeline retries the discount stage, the dynamic difference is incremented twice for one logical quote, charging the platform more than intended. | **OPEN (F-DISC-02).** `getAdjustedDifference` MUST be split into a pure reader and an explicit `recordQuoteIssued()` mutator, invoked once per quote at a well-defined point. As long as the discount engine is called exactly once per quote (the current stage pipeline guarantees this), the practical impact is bounded. | | **Misleading `[CAPPED]` log on zero-discount partners** | `formatPartnerNote` appends `[CAPPED]` whenever `actualSubsidy < idealSubsidy`. When `targetDiscount=0`, line 79 of `offramp.ts` (and 211 of `onramp.ts`) force `actualSubsidy=0`, but `idealSubsidy` can still be positive whenever Nabla undershoots the oracle. Operators reading logs may interpret a flood of `[CAPPED]` notes as `maxSubsidy` exhaustion when the real reason is `targetDiscount=0`. | **OPEN (F-DISC-03).** `formatPartnerNote` SHOULD distinguish "no discount configured" (`targetDiscount=0`) from "discount configured but cap hit" (`targetDiscount>0 && actual { + await myVault.put(`ephemerals_${rampId}`, encrypt(JSON.stringify(keys))); + } +}); +``` + ## API Reference ### VortexSdk @@ -253,6 +265,7 @@ interface VortexSdkConfig { autoReconnect?: boolean; alchemyApiKey?: string; storeEphemeralKeys?: boolean; + storeEphemeralKeysCallback?: (keys: StoredEphemeralKey[], rampId: string) => Promise; offrampFundingMode?: "prefunded" | "deferred"; } ``` diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 22f32c367..c953ec8e7 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "dependencies": { - "@vortexfi/shared": "=0.3.0" + "@vortexfi/shared": "=0.4.0" }, "devDependencies": { "@types/bun": "^1.3.1", @@ -60,5 +60,5 @@ }, "type": "module", "types": "./dist/index.d.ts", - "version": "0.9.0-rc.3" + "version": "0.9.0" } diff --git a/packages/sdk/src/VortexSdk.ts b/packages/sdk/src/VortexSdk.ts index 8bff7e7a4..b77254c45 100644 --- a/packages/sdk/src/VortexSdk.ts +++ b/packages/sdk/src/VortexSdk.ts @@ -44,6 +44,7 @@ import type { EurOnrampAdditionalData, ExtendedQuoteResponse, RegisterRampAdditionalData, + StoredEphemeralKey, SubmitUserTransactionsHandlers, UpdateRampAdditionalData, VortexSdkConfig @@ -59,6 +60,7 @@ export class VortexSdk { private domesticHandler: DomesticHandler; private mykoboHandler: MykoboHandler; private storeEphemeralKeys: boolean; + private storeEphemeralKeysCallback: VortexSdkConfig["storeEphemeralKeysCallback"]; private offrampFundingMode: NonNullable; constructor(config: VortexSdkConfig) { @@ -69,6 +71,7 @@ export class VortexSdk { this.apiService = new ApiService(config.apiBaseUrl, config.publicKey, config.secretKey, config.accessTokenProvider); this.networkManager = new NetworkManager(config); this.storeEphemeralKeys = config.storeEphemeralKeys ?? true; + this.storeEphemeralKeysCallback = config.storeEphemeralKeysCallback; this.offrampFundingMode = config.offrampFundingMode ?? "prefunded"; this.publicKey = config.publicKey; this.secretKey = config.secretKey; @@ -334,11 +337,11 @@ export class VortexSdk { ephemerals: { [key in EphemeralAccountType]?: EphemeralAccount }, rampId: string ): Promise { - if (!this.storeEphemeralKeys) { + if (!this.storeEphemeralKeysCallback && !this.storeEphemeralKeys) { return; } - const ephemeralItems = []; + const ephemeralItems: StoredEphemeralKey[] = []; for (const type of Object.keys(ephemerals) as EphemeralAccountType[]) { const ephemeral = ephemerals[type]; if (ephemeral) { @@ -347,6 +350,11 @@ export class VortexSdk { } } + if (this.storeEphemeralKeysCallback) { + await this.storeEphemeralKeysCallback(ephemeralItems, rampId); + return; + } + const fileName = `ephemerals_${rampId}.json`; await storeEphemeralKeys(fileName, ephemeralItems); } diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index f54adc52d..418c9d488 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -257,6 +257,20 @@ export interface NetworkConfig { export type OfframpFundingMode = "prefunded" | "deferred"; +/** + * One ephemeral secret persisted for recovery. The built-in storage writes an + * array of these; a configured `storeEphemeralKeysCallback` receives the same + * array. + */ +export interface StoredEphemeralKey { + address: string; + rampId: string; + secret: string; + type: EphemeralAccountType; +} + +export type StoreEphemeralKeysCallback = (keys: StoredEphemeralKey[], rampId: string) => Promise; + export type AccessTokenProvider = () => Promise; export interface VortexSdkConfig { @@ -288,6 +302,14 @@ export interface VortexSdkConfig { autoReconnect?: boolean; alchemyApiKey?: string; storeEphemeralKeys?: boolean; + /** + * Custom persistence for ephemeral recovery keys. When set, the SDK calls it + * instead of the built-in storage (JSON file in Node.js, `localStorage` in + * browsers) and `storeEphemeralKeys` has no effect. `registerRamp` awaits the + * callback and fails closed: a rejection aborts registration before + * ephemeral-owned transactions are signed. + */ + storeEphemeralKeysCallback?: StoreEphemeralKeysCallback; /** * Controls whether `registerRamp` checks that the source wallet holds the * quoted offramp amount. Deferred integrations must fund the wallet before diff --git a/packages/sdk/test/vortexSdk.lazyNetworks.test.ts b/packages/sdk/test/vortexSdk.lazyNetworks.test.ts index 0f29cb094..a0f6bd3c1 100644 --- a/packages/sdk/test/vortexSdk.lazyNetworks.test.ts +++ b/packages/sdk/test/vortexSdk.lazyNetworks.test.ts @@ -262,6 +262,51 @@ describe("lazy chain WebSocket initialization", () => { expect(calls).toContain("POST /v1/ramp/update"); }); + test("registration awaits custom ephemeral storage and fails before signing or update", async () => { + const calls = mockBackend(Networks.Pendulum); + let rejectStorage!: (reason?: unknown) => void; + let storageStarted!: () => void; + const storageStart = new Promise(resolve => { + storageStarted = resolve; + }); + const storageResult = new Promise((_, reject) => { + rejectStorage = reject; + }); + const sdk = new VortexSdk({ + apiBaseUrl: "https://backend.test", + networkInitializationTimeoutMs: 40, + pendulumWsUrl: DEAD_WEBSOCKET_URL, + secretKey: "sk_test_user", + storeEphemeralKeysCallback: async () => { + storageStarted(); + await storageResult; + }, + }); + + const registration = sdk.registerRamp(quote, { destinationAddress: "0xuser" }); + let registrationSettled = false; + void registration.then( + () => { + registrationSettled = true; + }, + () => { + registrationSettled = true; + } + ); + + await withDeadline(storageStart); + await new Promise(resolve => setTimeout(resolve, 80)); + + expect(registrationSettled).toBe(false); + expect(calls).toContain("POST /v1/ramp/register"); + expect(calls).not.toContain("POST /v1/ramp/update"); + + rejectStorage(new Error("vault unavailable")); + + await expect(withDeadline(registration)).rejects.toThrow("vault unavailable"); + expect(calls).not.toContain("POST /v1/ramp/update"); + }); + test("BRL offramp registration also bypasses unavailable chain WebSockets", async () => { const calls = mockBackend(undefined, offrampQuote); const sdk = createSdk(); diff --git a/packages/sdk/test/vortexSdk.storeEphemerals.test.ts b/packages/sdk/test/vortexSdk.storeEphemerals.test.ts new file mode 100644 index 000000000..5183059ea --- /dev/null +++ b/packages/sdk/test/vortexSdk.storeEphemerals.test.ts @@ -0,0 +1,86 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "crypto"; +import { existsSync, rmSync } from "fs"; +import { EphemeralAccountType } from "@vortexfi/shared"; +import type { StoredEphemeralKey, VortexSdkConfig } from "../src/types"; +import { VortexSdk } from "../src/VortexSdk"; + +const RAMP_ID = `ramp_store_test_${randomUUID()}`; +const BUILT_IN_FILE = `ephemerals_${RAMP_ID}.json`; + +const ephemerals = { + [EphemeralAccountType.Substrate]: { address: "substrate-address", secret: "substrate-secret" }, + [EphemeralAccountType.EVM]: { address: "evm-address", secret: "evm-secret" }, +}; + +function makeSdk(config: Partial = {}): VortexSdk { + return new VortexSdk({ apiBaseUrl: "http://127.0.0.1:1", ...config }); +} + +afterAll(() => { + rmSync(BUILT_IN_FILE, { force: true }); +}); + +describe("VortexSdk.storeEphemerals", () => { + test("passes structured items to the callback instead of the built-in storage", async () => { + const calls: Array<{ keys: StoredEphemeralKey[]; rampId: string }> = []; + const sdk = makeSdk({ + storeEphemeralKeysCallback: async (keys, rampId) => { + calls.push({ keys, rampId }); + }, + }); + + await sdk.storeEphemerals(ephemerals, RAMP_ID); + + expect(calls).toHaveLength(1); + expect(calls[0].rampId).toBe(RAMP_ID); + expect(calls[0].keys).toEqual([ + { + address: "substrate-address", + rampId: RAMP_ID, + secret: "substrate-secret", + type: EphemeralAccountType.Substrate, + }, + { + address: "evm-address", + rampId: RAMP_ID, + secret: "evm-secret", + type: EphemeralAccountType.EVM, + }, + ]); + expect(existsSync(BUILT_IN_FILE)).toBe(false); + }); + + test("invokes the callback even when storeEphemeralKeys is false", async () => { + const calls: StoredEphemeralKey[][] = []; + const sdk = makeSdk({ + storeEphemeralKeys: false, + storeEphemeralKeysCallback: async keys => { + calls.push(keys); + }, + }); + + await sdk.storeEphemerals(ephemerals, RAMP_ID); + + expect(calls).toHaveLength(1); + expect(calls[0]).toHaveLength(2); + }); + + test("propagates a callback rejection", async () => { + const sdk = makeSdk({ + storeEphemeralKeysCallback: async () => { + throw new Error("vault unavailable"); + }, + }); + + await expect(sdk.storeEphemerals(ephemerals, RAMP_ID)).rejects.toThrow("vault unavailable"); + }); + + test("stores nothing when storage is disabled and no callback is configured", async () => { + const sdk = makeSdk({ storeEphemeralKeys: false }); + + await sdk.storeEphemerals(ephemerals, RAMP_ID); + + expect(existsSync(BUILT_IN_FILE)).toBe(false); + }); +}); diff --git a/packages/shared/package.json b/packages/shared/package.json index 7b8dcfdb0..6072bb66a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -65,5 +65,5 @@ "typecheck": "tsc --noEmit" }, "types": "./dist/index.d.ts", - "version": "0.3.0" + "version": "0.4.0" } diff --git a/packages/shared/src/services/squidrouter/schemas.test.ts b/packages/shared/src/services/squidrouter/schemas.test.ts index b8b61f8a1..75a215e86 100644 --- a/packages/shared/src/services/squidrouter/schemas.test.ts +++ b/packages/shared/src/services/squidrouter/schemas.test.ts @@ -64,6 +64,22 @@ describe("squidrouterRouteResponseSchema", () => { expect(() => squidrouterRouteResponseSchema.parse(body)).toThrow(); }); + test("tolerates unparsable, empty, and missing toAmountUSD so non-consuming route callers never fail on it", () => { + // Unusable values are tolerated at the wire boundary on purpose: the API's + // route helper Big-parses this field tolerantly and only the SubsidizePost + // probe consumes it, falling back to its oracle target on an unusable value. + for (const tolerated of ["9.95", "N/A", "+1", ""]) { + const body = validRouteBody(); + body.route.estimate.toAmountUSD = tolerated; + expect(() => squidrouterRouteResponseSchema.parse(body)).not.toThrow(); + } + + const missing = validRouteBody(); + delete (missing.route.estimate as Record).toAmountUSD; + const parsed = squidrouterRouteResponseSchema.parse(missing); + expect(parsed.route.estimate.toAmountUSD).toBe(""); + }); + test("accepts a hex gasLimit but rejects a non-integer one (BigInt-parsed downstream)", () => { const hex = validRouteBody(); hex.route.transactionRequest.gasLimit = "0x8e6a0"; diff --git a/packages/shared/src/services/squidrouter/schemas.ts b/packages/shared/src/services/squidrouter/schemas.ts index 4194cfe46..6c9be4f9b 100644 --- a/packages/shared/src/services/squidrouter/schemas.ts +++ b/packages/shared/src/services/squidrouter/schemas.ts @@ -26,7 +26,12 @@ const squidrouterRouteEstimateSchema = z aggregateSlippage: z.number().optional(), toAmount: z.string().regex(RAW_UNITS), toAmountMin: z.string().regex(RAW_UNITS), - toAmountUSD: z.string().min(1), + // Deliberately tolerant: quote creation for routes that never consume this field + // (mint fee probes, the swap leg) must not fail on it, even when Squid omits it. + // Absence normalizes to "" so the shared type stays a plain string; the API's + // getSquidrouterRouteData Big-parses it tolerantly, and the only numeric consumer + // (the SubsidizePost probe) degrades to its 1:1 fallback on an unusable value. + toAmountUSD: z.string().default(""), toToken: z.looseObject({ decimals: z.number().int().positive() }) }) .superRefine((estimate, ctx) => {