diff --git a/.changeset/swift-foxes-track.md b/.changeset/swift-foxes-track.md new file mode 100644 index 000000000..1ea950f7a --- /dev/null +++ b/.changeset/swift-foxes-track.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ add bridge fee window tracking diff --git a/server/api/ramp.ts b/server/api/ramp.ts index 13eec902f..dfba6aed8 100644 --- a/server/api/ramp.ts +++ b/server/api/ramp.ts @@ -5,6 +5,7 @@ import { Hono } from "hono"; import { array, literal, + number, object, optional, parse, @@ -238,6 +239,12 @@ async function getOrCreateInquiry(credentialId: string, template: string) { type QuoteResponse = undefined | { buyRate: string; sellRate: string }; +const SponsoredFees = object({ + count: object({ available: string(), threshold: string() }), + volume: object({ available: string(), threshold: string(), symbol: string() }), + window: number(), +}); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const DepositDetails = variant("network", [ object({ @@ -265,6 +272,7 @@ const DepositDetails = variant("network", [ displayName: literal("PIX BR"), beneficiaryName: string(), fee: string(), + sponsoredFees: optional(SponsoredFees), estimatedProcessingTime: string(), }), object({ @@ -277,6 +285,7 @@ const DepositDetails = variant("network", [ bankAddress: string(), beneficiaryAddress: string(), fee: string(), + sponsoredFees: optional(SponsoredFees), estimatedProcessingTime: string(), }), object({ @@ -289,6 +298,7 @@ const DepositDetails = variant("network", [ bankName: string(), beneficiaryAddress: string(), fee: string(), + sponsoredFees: optional(SponsoredFees), estimatedProcessingTime: string(), }), object({ @@ -297,6 +307,7 @@ const DepositDetails = variant("network", [ beneficiaryName: string(), iban: string(), // cspell:ignore iban fee: string(), + sponsoredFees: optional(SponsoredFees), estimatedProcessingTime: string(), }), object({ @@ -305,6 +316,7 @@ const DepositDetails = variant("network", [ beneficiaryName: string(), clabe: string(), // cspell:ignore clabe fee: string(), + sponsoredFees: optional(SponsoredFees), estimatedProcessingTime: string(), }), object({ @@ -312,6 +324,7 @@ const DepositDetails = variant("network", [ displayName: literal("TRON"), address: string(), fee: string(), + sponsoredFees: optional(SponsoredFees), estimatedProcessingTime: string(), }), object({ @@ -319,6 +332,7 @@ const DepositDetails = variant("network", [ displayName: literal("SOLANA"), address: string(), fee: string(), + sponsoredFees: optional(SponsoredFees), estimatedProcessingTime: string(), }), object({ @@ -326,6 +340,7 @@ const DepositDetails = variant("network", [ displayName: literal("STELLAR"), address: string(), fee: string(), + sponsoredFees: optional(SponsoredFees), estimatedProcessingTime: string(), }), object({ @@ -337,6 +352,7 @@ const DepositDetails = variant("network", [ bankName: string(), bankAddress: string(), fee: string(), + sponsoredFees: optional(SponsoredFees), estimatedProcessingTime: string(), }), ]); diff --git a/server/hooks/bridge.ts b/server/hooks/bridge.ts index e7c34f69d..7b5113a05 100644 --- a/server/hooks/bridge.ts +++ b/server/hooks/bridge.ts @@ -5,14 +5,14 @@ import { and, DrizzleQueryError, eq, isNull } from "drizzle-orm"; import { Hono } from "hono"; import { validator } from "hono/validator"; import { createHash, createVerify } from "node:crypto"; -import { literal, object, parse, picklist, string, unknown, variant } from "valibot"; +import { check, literal, object, parse, picklist, pipe, string, unknown, variant } from "valibot"; import { Address } from "@exactly/common/validation"; import database, { credentials } from "../database"; import { sendPushNotification } from "../utils/onesignal"; import { searchAccounts } from "../utils/persona"; -import { BridgeCurrency, getCustomer, publicKey } from "../utils/ramps/bridge"; +import { BridgeCurrency, feeRule, getCustomer, publicKey } from "../utils/ramps/bridge"; import { track } from "../utils/segment"; import validatorHook from "../utils/validatorHook"; @@ -52,6 +52,10 @@ export default new Hono().post( object({ event_type: literal("liquidation_address.drain.updated.status_transitioned"), event_object: object({ + created_at: pipe( + string(), + check((v) => !Number.isNaN(new Date(v).getTime()), "invalid date"), + ), currency: picklist(BridgeCurrency), customer_id: string(), id: string(), @@ -70,6 +74,10 @@ export default new Hono().post( object({ type: literal("in_review"), id: string(), customer_id: string() }), object({ type: literal("microdeposit"), id: string(), customer_id: string() }), // cspell:ignore microdeposit object({ + created_at: pipe( + string(), + check((v) => !Number.isNaN(new Date(v).getTime()), "invalid created at date"), + ), customer_id: string(), currency: picklist(BridgeCurrency), id: string(), @@ -190,6 +198,18 @@ export default new Hono().post( }).catch((error: unknown) => captureException(error, { level: "error" })); } if (payload.event_object.type === "payment_processed") { + const usdcAmount = Number(payload.event_object.receipt.final_amount); + if (Number.isNaN(usdcAmount)) { + captureException(new Error("invalid amount"), { + level: "error", + contexts: { details: { usdcAmount: payload.event_object.receipt.final_amount } }, + }); + return c.json({ code: "invalid amount" }, 200); + } + await feeRule.report( + { bridgeId, eventId: payload.event_object.id, amount: Math.round(usdcAmount * 100) }, + new Date(payload.event_object.created_at), + ); track({ userId: account, event: "Onramp", @@ -198,13 +218,25 @@ export default new Hono().post( amount: Number(payload.event_object.receipt.initial_amount), provider: "bridge", source: credential.source, - usdcAmount: Number(payload.event_object.receipt.final_amount), + usdcAmount, }, }); } return c.json({ code: "ok" }, 200); - case "liquidation_address.drain.updated.status_transitioned": + case "liquidation_address.drain.updated.status_transitioned": { if (payload.event_object.state !== "payment_submitted") return c.json({ code: "ok" }, 200); + const usdcAmount = Number(payload.event_object.receipt.outgoing_amount); + if (Number.isNaN(usdcAmount)) { + captureException(new Error("invalid amount"), { + level: "error", + contexts: { details: { usdcAmount: payload.event_object.receipt.outgoing_amount } }, + }); + return c.json({ code: "invalid amount" }, 200); + } + await feeRule.report( + { bridgeId, eventId: payload.event_object.id, amount: Math.round(usdcAmount * 100) }, + new Date(payload.event_object.created_at), + ); sendPushNotification({ userId: account, headings: { en: "Deposited funds" }, @@ -220,10 +252,11 @@ export default new Hono().post( amount: Number(payload.event_object.receipt.initial_amount), provider: "bridge", source: credential.source, - usdcAmount: Number(payload.event_object.receipt.outgoing_amount), + usdcAmount, }, }); return c.json({ code: "ok" }, 200); + } } }, ); diff --git a/server/index.ts b/server/index.ts index 667fbae63..3f1ec3b43 100644 --- a/server/index.ts +++ b/server/index.ts @@ -20,6 +20,7 @@ import panda from "./hooks/panda"; import persona from "./hooks/persona"; import androidFingerprints from "./utils/android/fingerprints"; import appOrigin from "./utils/appOrigin"; +import { feeRule } from "./utils/ramps/bridge"; import { close as closeRedis } from "./utils/redis"; import { closeAndFlush as closeSegment } from "./utils/segment"; @@ -322,8 +323,9 @@ const server = serve(app); export async function close() { return new Promise((resolve, reject) => { server.close((error) => { - Promise.allSettled([closeSentry(), closeRedis(), closeSegment(), database.$client.end()]) - .then((results) => { + Promise.allSettled([feeRule.stop(), closeSentry(), closeSegment(), database.$client.end()]) + .then(async (results) => { + await closeRedis(); if (error) reject(error); else if (results.some((result) => result.status === "rejected")) reject(new Error("closing services failed")); else resolve(null); diff --git a/server/test/api/ramp.test.ts b/server/test/api/ramp.test.ts index 55cfbe8af..f6dd90830 100644 --- a/server/test/api/ramp.test.ts +++ b/server/test/api/ramp.test.ts @@ -7,7 +7,7 @@ import { HTTPException } from "hono/http-exception"; import { testClient } from "hono/testing"; import { hexToBytes, padHex, zeroHash } from "viem"; import { privateKeyToAddress } from "viem/accounts"; -import { afterEach, beforeAll, describe, expect, inject, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, inject, it, vi } from "vitest"; import deriveAddress from "@exactly/common/deriveAddress"; @@ -16,6 +16,7 @@ import database, { credentials } from "../../database"; import * as persona from "../../utils/persona"; import * as bridge from "../../utils/ramps/bridge"; import * as manteca from "../../utils/ramps/manteca"; +import { close as closeRedis } from "../../utils/redis"; const appClient = testClient(app); @@ -38,6 +39,11 @@ describe("ramp api", () => { ]); }); + afterAll(async () => { + await bridge.feeRule.stop(); + await closeRedis(); + }); + afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); @@ -265,7 +271,7 @@ describe("ramp api", () => { bankAddress: "123 Test St", beneficiaryAddress: "456 Beneficiary Ave", bankName: "Test Bank", - fee: "0.0", + ...defaultSponsoredFees(), estimatedProcessingTime: "1 - 3 business days", }, { @@ -277,7 +283,7 @@ describe("ramp api", () => { bankAddress: "123 Test St", beneficiaryAddress: "456 Beneficiary Ave", bankName: "Test Bank", - fee: "0.0", + ...defaultSponsoredFees(), estimatedProcessingTime: "300", }, ]); @@ -301,7 +307,7 @@ describe("ramp api", () => { bankAddress: "123 Test St", beneficiaryAddress: "456 Beneficiary Ave", bankName: "Test Bank", - fee: "0.0", + ...defaultSponsoredFees(), estimatedProcessingTime: "1 - 3 business days", }, { @@ -313,7 +319,7 @@ describe("ramp api", () => { bankAddress: "123 Test St", beneficiaryAddress: "456 Beneficiary Ave", bankName: "Test Bank", - fee: "0.0", + ...defaultSponsoredFees(), estimatedProcessingTime: "300", }, ], @@ -328,7 +334,7 @@ describe("ramp api", () => { displayName: "SEPA" as const, beneficiaryName: "Test User", iban: "DE89370400440532013000", // cspell:ignore iban - fee: "0.0", + ...defaultSponsoredFees(), estimatedProcessingTime: "300", }, ]); @@ -348,7 +354,7 @@ describe("ramp api", () => { displayName: "SEPA", beneficiaryName: "Test User", iban: "DE89370400440532013000", - fee: "0.0", + ...defaultSponsoredFees(), estimatedProcessingTime: "300", }, ], @@ -363,7 +369,7 @@ describe("ramp api", () => { displayName: "SPEI" as const, beneficiaryName: "Test User", clabe: "032180000118359719", // cspell:ignore clabe - fee: "0.0", + ...defaultSponsoredFees(), estimatedProcessingTime: "300", }, ]); @@ -383,7 +389,7 @@ describe("ramp api", () => { displayName: "SPEI", beneficiaryName: "Test User", clabe: "032180000118359719", - fee: "0.0", + ...defaultSponsoredFees(), estimatedProcessingTime: "300", }, ], @@ -398,7 +404,7 @@ describe("ramp api", () => { displayName: "PIX BR" as const, beneficiaryName: "Test User", brCode: "00020126360014BR.GOV.BCB.PIX", // cspell:ignore brCode - fee: "0.0", + ...defaultSponsoredFees(), estimatedProcessingTime: "300", }, ]); @@ -418,7 +424,7 @@ describe("ramp api", () => { displayName: "PIX BR", beneficiaryName: "Test User", brCode: "00020126360014BR.GOV.BCB.PIX", - fee: "0.0", + ...defaultSponsoredFees(), estimatedProcessingTime: "300", }, ], @@ -436,7 +442,7 @@ describe("ramp api", () => { accountHolderName: "Test User", bankName: "Test Bank", bankAddress: "London, UK", - fee: "0.0", + ...defaultSponsoredFees(), estimatedProcessingTime: "300", }, ]); @@ -459,7 +465,7 @@ describe("ramp api", () => { accountHolderName: "Test User", bankName: "Test Bank", bankAddress: "London, UK", - fee: "0.0", + ...defaultSponsoredFees(), estimatedProcessingTime: "300", }, ], @@ -473,7 +479,7 @@ describe("ramp api", () => { network: "TRON" as const, displayName: "TRON" as const, address: "TXyz123456789", - fee: "0.0", + ...defaultSponsoredFees(), estimatedProcessingTime: "300", }, ]); @@ -490,7 +496,7 @@ describe("ramp api", () => { network: "TRON", displayName: "TRON", address: "TXyz123456789", - fee: "0.0", + ...defaultSponsoredFees(), estimatedProcessingTime: "300", }, ], @@ -504,7 +510,7 @@ describe("ramp api", () => { network: "SOLANA" as const, displayName: "SOLANA" as const, address: "So1anaAddress123", - fee: "0.0", + ...defaultSponsoredFees(), estimatedProcessingTime: "300", }, ]); @@ -521,7 +527,7 @@ describe("ramp api", () => { network: "SOLANA", displayName: "SOLANA", address: "So1anaAddress123", - fee: "0.0", + ...defaultSponsoredFees(), estimatedProcessingTime: "300", }, ], @@ -535,7 +541,7 @@ describe("ramp api", () => { network: "STELLAR" as const, displayName: "STELLAR" as const, address: "STELLAR123456", - fee: "0.0", + ...defaultSponsoredFees(), estimatedProcessingTime: "300", }, ]); @@ -552,6 +558,135 @@ describe("ramp api", () => { network: "STELLAR", displayName: "STELLAR", address: "STELLAR123456", + ...defaultSponsoredFees(), + estimatedProcessingTime: "300", + }, + ], + }); + }); + + it("returns deposit info with partially consumed sponsored fees", async () => { + vi.spyOn(bridge, "getCustomer").mockResolvedValue(bridgeCustomer); + vi.spyOn(bridge, "getDepositDetails").mockResolvedValue([ + { + network: "ACH" as const, + displayName: "ACH" as const, + beneficiaryName: "Test User", + routingNumber: "987654321", + accountNumber: "123456789", + bankAddress: "123 Test St", + beneficiaryAddress: "456 Beneficiary Ave", + bankName: "Test Bank", + fee: "0.0", + sponsoredFees: { + window: 2_592_000_000, + volume: { available: "1500", threshold: "3000", symbol: "USD" }, + count: { available: "50", threshold: "60" }, + }, + estimatedProcessingTime: "1 - 3 business days", + }, + ]); + vi.spyOn(bridge, "getQuote").mockResolvedValue({ buyRate: "1.00", sellRate: "1.00" }); + + const response = await appClient.quote.$get( + { query: { provider: "bridge", currency: "USD" } }, + { headers: { "test-credential-id": "ramp-bridge" } }, + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ + quote: { buyRate: "1.00", sellRate: "1.00" }, + depositInfo: [ + { + network: "ACH", + displayName: "ACH", + beneficiaryName: "Test User", + routingNumber: "987654321", + accountNumber: "123456789", + bankAddress: "123 Test St", + beneficiaryAddress: "456 Beneficiary Ave", + bankName: "Test Bank", + fee: "0.0", + sponsoredFees: { + window: 2_592_000_000, + volume: { available: "1500", threshold: "3000", symbol: "USD" }, + count: { available: "50", threshold: "60" }, + }, + estimatedProcessingTime: "1 - 3 business days", + }, + ], + }); + }); + + it("returns deposit info with undefined sponsored fees when fee window fails", async () => { + vi.spyOn(bridge, "getCustomer").mockResolvedValue(bridgeCustomer); + vi.spyOn(bridge, "getDepositDetails").mockResolvedValue([ + { + network: "ACH" as const, + displayName: "ACH" as const, + beneficiaryName: "Test User", + routingNumber: "987654321", + accountNumber: "123456789", + bankAddress: "123 Test St", + beneficiaryAddress: "456 Beneficiary Ave", + bankName: "Test Bank", + fee: "0.0", + sponsoredFees: undefined, + estimatedProcessingTime: "1 - 3 business days", + }, + ]); + vi.spyOn(bridge, "getQuote").mockResolvedValue({ buyRate: "1.00", sellRate: "1.00" }); + + const response = await appClient.quote.$get( + { query: { provider: "bridge", currency: "USD" } }, + { headers: { "test-credential-id": "ramp-bridge" } }, + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ + quote: { buyRate: "1.00", sellRate: "1.00" }, + depositInfo: [ + { + network: "ACH", + displayName: "ACH", + beneficiaryName: "Test User", + routingNumber: "987654321", + accountNumber: "123456789", + bankAddress: "123 Test St", + beneficiaryAddress: "456 Beneficiary Ave", + bankName: "Test Bank", + fee: "0.0", + estimatedProcessingTime: "1 - 3 business days", + }, + ], + }); + }); + + it("returns crypto deposit info with undefined sponsored fees when fee window fails", async () => { + vi.spyOn(bridge, "getCustomer").mockResolvedValue(bridgeCustomer); + vi.spyOn(bridge, "getCryptoDepositDetails").mockResolvedValue([ + { + network: "TRON" as const, + displayName: "TRON" as const, + address: "TXyz123456789", + fee: "0.0", + sponsoredFees: undefined, + estimatedProcessingTime: "300", + }, + ]); + + const response = await appClient.quote.$get( + { query: { provider: "bridge", currency: "USDT", network: "TRON" } }, + { headers: { "test-credential-id": "ramp-bridge" } }, + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ + depositInfo: [ + { + network: "TRON", + displayName: "TRON", + address: "TXyz123456789", fee: "0.0", estimatedProcessingTime: "300", }, @@ -895,6 +1030,17 @@ describe("ramp api", () => { }); }); +function defaultSponsoredFees() { + return { + fee: "0.0", + sponsoredFees: { + window: 2_592_000_000, + volume: { available: "3000", threshold: "3000", symbol: "USD" }, + count: { available: "60", threshold: "60" }, + }, + }; +} + const mantecaUser = { id: "user123", numberId: "456", diff --git a/server/test/hooks/bridge.test.ts b/server/test/hooks/bridge.test.ts index 9141defb6..f13776239 100644 --- a/server/test/hooks/bridge.test.ts +++ b/server/test/hooks/bridge.test.ts @@ -7,7 +7,7 @@ import { testClient } from "hono/testing"; import { createHash, createPrivateKey, createSign, generateKeyPairSync } from "node:crypto"; import { hexToBytes, padHex, zeroHash } from "viem"; import { privateKeyToAddress } from "viem/accounts"; -import { afterEach, beforeAll, describe, expect, inject, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, inject, it, vi } from "vitest"; import deriveAddress from "@exactly/common/deriveAddress"; @@ -16,6 +16,8 @@ import app from "../../hooks/bridge"; import * as onesignal from "../../utils/onesignal"; import * as persona from "../../utils/persona"; import * as bridge from "../../utils/ramps/bridge"; +import { feeRule } from "../../utils/ramps/bridge"; +import redis, { close as closeRedis } from "../../utils/redis"; import * as segment from "../../utils/segment"; const appClient = testClient(app); @@ -54,11 +56,21 @@ describe("bridge hook", () => { ]); }); + beforeEach(async () => { + const keys = await redis.keys("wr:bridge-fees:*"); + if (keys.length > 0) await redis.del(...keys); + }); + afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); }); + afterAll(async () => { + await feeRule.stop(); + await closeRedis(); + }); + it("returns 200 with valid signature and payload", async () => { vi.spyOn(segment, "track").mockReturnValue(); const response = await appClient.index.$post({ @@ -138,6 +150,39 @@ describe("bridge hook", () => { await expect(response.json()).resolves.toMatchObject({ code: "bad bridge" }); }); + it("rejects payment_processed with invalid created_at", async () => { + const payload = { + ...paymentProcessed, + event_object: { ...paymentProcessed.event_object, created_at: "not-a-date" }, + }; + const response = await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(payload) }, + json: payload as never, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ + code: "bad bridge", + legacy: "bad bridge", + message: expect.arrayContaining([expect.stringContaining("invalid created at date")]), // eslint-disable-line @typescript-eslint/no-unsafe-assignment + }); + }); + + it("rejects drain with invalid created_at", async () => { + const payload = { ...drain, event_object: { ...drain.event_object, created_at: "invalid" } }; + const response = await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(payload) }, + json: payload as never, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ + code: "bad bridge", + legacy: "bad bridge", + message: expect.arrayContaining([expect.stringContaining("invalid date")]), // eslint-disable-line @typescript-eslint/no-unsafe-assignment + }); + }); + it("returns 200 without side effects for non-payment virtual account types", async () => { vi.spyOn(segment, "track").mockReturnValue(); const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); @@ -153,8 +198,39 @@ describe("bridge hook", () => { expect(captureException).not.toHaveBeenCalled(); }); + it("returns 500 when feeRule.report fails on payment_processed", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + vi.spyOn(feeRule, "report").mockRejectedValue(new Error("redis down")); + const response = await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(paymentProcessed) }, + json: paymentProcessed as never, + }); + + expect(response.status).toBe(500); + expect(sendPushNotification).not.toHaveBeenCalled(); + expect(segment.track).not.toHaveBeenCalled(); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("returns 500 when feeRule.report fails on drain", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + vi.spyOn(feeRule, "report").mockRejectedValue(new Error("redis down")); + const response = await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(drain) }, + json: drain as never, + }); + + expect(response.status).toBe(500); + expect(sendPushNotification).not.toHaveBeenCalled(); + expect(segment.track).not.toHaveBeenCalled(); + expect(captureException).not.toHaveBeenCalled(); + }); + it("does not track onramp for payment_submitted virtual account", async () => { vi.spyOn(segment, "track").mockReturnValue(); + const reportSpy = vi.spyOn(feeRule, "report"); const response = await appClient.index.$post({ header: { "x-webhook-signature": createSignature(paymentSubmitted) }, json: paymentSubmitted as never, @@ -163,6 +239,7 @@ describe("bridge hook", () => { expect(response.status).toBe(200); await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); expect(segment.track).not.toHaveBeenCalled(); + expect(reportSpy).not.toHaveBeenCalled(); }); it("sends push notification on payment_submitted virtual account", async () => { @@ -185,6 +262,7 @@ describe("bridge hook", () => { it("tracks onramp for payment_processed virtual account", async () => { vi.spyOn(segment, "track").mockReturnValue(); const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + const reportSpy = vi.spyOn(feeRule, "report"); const response = await appClient.index.$post({ header: { "x-webhook-signature": createSignature(paymentProcessed) }, json: paymentProcessed as never, @@ -198,9 +276,40 @@ describe("bridge hook", () => { properties: { currency: "usd", amount: 1000, provider: "bridge", source: null, usdcAmount: 995 }, }); expect(sendPushNotification).not.toHaveBeenCalled(); + expect(reportSpy).toHaveBeenCalledExactlyOnceWith( + { amount: 99_500, bridgeId: "bridgeCustomerId", eventId: "evt_123" }, + new Date("2026-03-01T00:00:00.000Z"), + ); expect(captureException).not.toHaveBeenCalled(); }); + it("returns 200 with invalid amount when payment_processed final_amount is NaN", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const reportSpy = vi.spyOn(feeRule, "report"); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + const payload = { + ...paymentProcessed, + event_object: { + ...paymentProcessed.event_object, + receipt: { initial_amount: "1000", final_amount: "not-a-number" }, + }, + }; + const response = await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(payload) }, + json: payload as never, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "invalid amount" }); + expect(captureException).toHaveBeenCalledExactlyOnceWith(new Error("invalid amount"), { + level: "error", + contexts: { details: { usdcAmount: "not-a-number" } }, + }); + expect(reportSpy).not.toHaveBeenCalled(); + expect(segment.track).not.toHaveBeenCalled(); + expect(sendPushNotification).not.toHaveBeenCalled(); + }); + it("returns 200 with credential not found when bridgeId does not match", async () => { vi.spyOn(segment, "track").mockReturnValue(); const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); @@ -600,6 +709,105 @@ describe("bridge hook", () => { expect(sendPushNotification).not.toHaveBeenCalled(); expect(captureException).not.toHaveBeenCalled(); }); + + it("returns 200 with invalid amount when drain outgoing_amount is NaN", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const reportSpy = vi.spyOn(feeRule, "report"); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + const payload = { + ...drain, + event_object: { + ...drain.event_object, + receipt: { initial_amount: "500", outgoing_amount: "invalid" }, + }, + }; + const response = await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(payload) }, + json: payload as never, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "invalid amount" }); + expect(captureException).toHaveBeenCalledExactlyOnceWith(new Error("invalid amount"), { + level: "error", + contexts: { details: { usdcAmount: "invalid" } }, + }); + expect(reportSpy).not.toHaveBeenCalled(); + expect(segment.track).not.toHaveBeenCalled(); + expect(sendPushNotification).not.toHaveBeenCalled(); + }); + + it("tracks ramp amount via feeRule.report on payment_processed", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const reportSpy = vi.spyOn(feeRule, "report"); + await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(paymentProcessed) }, + json: paymentProcessed as never, + }); + + expect(reportSpy).toHaveBeenCalledExactlyOnceWith( + { amount: 99_500, bridgeId: "bridgeCustomerId", eventId: "evt_123" }, + new Date("2026-03-01T00:00:00.000Z"), + ); + }); + + it("tracks ramp amount via feeRule.report on drain payment_submitted", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const reportSpy = vi.spyOn(feeRule, "report"); + await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(drain) }, + json: drain as never, + }); + + expect(reportSpy).toHaveBeenCalledExactlyOnceWith( + { amount: 50_000, bridgeId: "bridgeCustomerId", eventId: "drain_123" }, + new Date("2026-03-01T00:00:00.000Z"), + ); + }); + + it("passes fractional amount to feeRule.report on payment_processed", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const reportSpy = vi.spyOn(feeRule, "report"); + const payload = { + ...paymentProcessed, + event_object: { + ...paymentProcessed.event_object, + id: "evt_frac_1", + receipt: { initial_amount: "100", final_amount: "99.999" }, + }, + }; + await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(payload) }, + json: payload as never, + }); + + expect(reportSpy).toHaveBeenCalledExactlyOnceWith( + { amount: 10_000, bridgeId: "bridgeCustomerId", eventId: "evt_frac_1" }, + expect.any(Date), + ); + }); + + it("passes fractional amount to feeRule.report on drain", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const reportSpy = vi.spyOn(feeRule, "report"); + const payload = { + ...drain, + event_object: { + ...drain.event_object, + id: "drain_frac_1", + receipt: { initial_amount: "49.991", outgoing_amount: "49.991" }, + }, + }; + await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(payload) }, + json: payload as never, + }); + + expect(reportSpy).toHaveBeenCalledExactlyOnceWith( + { amount: 4999, bridgeId: "bridgeCustomerId", eventId: "drain_frac_1" }, + expect.any(Date), + ); + }); }); const testSigningKey = createPrivateKey(`-----BEGIN PRIVATE KEY----- @@ -662,6 +870,7 @@ const paymentSubmitted = { const paymentProcessed = { event_type: "virtual_account.activity.created", event_object: { + created_at: "2026-03-01T00:00:00.000Z", id: "evt_123", type: "payment_processed", currency: "usd", @@ -678,6 +887,7 @@ const statusTransitioned = { const drain = { event_type: "liquidation_address.drain.updated.status_transitioned", event_object: { + created_at: "2026-03-01T00:00:00.000Z", id: "drain_123", state: "payment_submitted", currency: "usdc", @@ -687,3 +897,8 @@ const drain = { }; vi.mock("@sentry/core", { spy: true }); +vi.mock("../../utils/ramps/bridge", async (importOriginal) => ({ + ...(await importOriginal()), + enableFees: vi.fn(), + disableFees: vi.fn(), +})); diff --git a/server/test/utils/bridge.test.ts b/server/test/utils/bridge.test.ts index c9d30cbad..986085f9e 100644 --- a/server/test/utils/bridge.test.ts +++ b/server/test/utils/bridge.test.ts @@ -7,7 +7,7 @@ import { parse } from "valibot"; import { hexToBytes, padHex, zeroHash } from "viem"; import { privateKeyToAddress } from "viem/accounts"; import { optimism, optimismSepolia } from "viem/chains"; -import { afterEach, beforeAll, beforeEach, describe, expect, inject, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, inject, it, vi } from "vitest"; import deriveAddress from "@exactly/common/deriveAddress"; import { Address } from "@exactly/common/validation"; @@ -15,6 +15,7 @@ import { Address } from "@exactly/common/validation"; import database, { credentials } from "../../database"; import * as persona from "../../utils/persona"; import * as bridge from "../../utils/ramps/bridge"; +import redis, { close as closeRedis } from "../../utils/redis"; const chainMock = vi.hoisted(() => ({ id: 10 })); @@ -47,7 +48,14 @@ describe("bridge utils", () => { ]); }); - beforeEach(() => { + afterAll(async () => { + await bridge.feeRule.stop(); + await closeRedis(); + }); + + beforeEach(async () => { + const keys = await redis.keys("wr:bridge-fees:*"); + if (keys.length > 0) await redis.del(...keys); chainMock.id = optimism.id; vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, @@ -250,14 +258,18 @@ describe("bridge utils", () => { const result = await bridge.getProvider({ credentialId: "cred-1", customerId: "cust-1" }); - expect(result.status).toBe("ACTIVE"); - expect(result.onramp.currencies).toStrictEqual([ - { currency: "USDC", network: "SOLANA" }, - { currency: "USDC", network: "STELLAR" }, - { currency: "USDT", network: "TRON" }, - "USD", - "GBP", - ]); + expect(result).toStrictEqual({ + status: "ACTIVE", + onramp: { + currencies: [ + { currency: "USDC", network: "SOLANA" }, + { currency: "USDC", network: "STELLAR" }, + { currency: "USDT", network: "TRON" }, + "USD", + "GBP", + ], + }, + }); }); it("returns ACTIVE with currencies from approved endorsements", async () => { @@ -921,7 +933,7 @@ describe("bridge utils", () => { bankAddress: "123 Bank St", beneficiaryAddress: "456 Beneficiary Ave", bankName: "Test Bank", - fee: "0.0", + ...defaultSponsoredFees(), estimatedProcessingTime: "1 - 3 business days", }); expect(result[1]).toStrictEqual({ @@ -933,7 +945,7 @@ describe("bridge utils", () => { bankAddress: "123 Bank St", beneficiaryAddress: "456 Beneficiary Ave", bankName: "Test Bank", - fee: "0.0", + ...defaultSponsoredFees(), estimatedProcessingTime: "300", }); }); @@ -961,7 +973,7 @@ describe("bridge utils", () => { displayName: "SEPA", beneficiaryName: "Test Holder", iban: "DE89370400440532013000", - fee: "0.0", + ...defaultSponsoredFees(), estimatedProcessingTime: "300", }); }); @@ -984,7 +996,7 @@ describe("bridge utils", () => { displayName: "SPEI", beneficiaryName: "Test Holder MX", clabe: "646180171800000178", // cspell:ignore clabe - fee: "0.0", + ...defaultSponsoredFees(), estimatedProcessingTime: "300", }); }); @@ -1007,7 +1019,7 @@ describe("bridge utils", () => { displayName: "PIX BR", beneficiaryName: "Test Holder BR", brCode: "00020126580014br.gov.bcb.pix", // cspell:ignore bcb - fee: "0.0", + ...defaultSponsoredFees(), estimatedProcessingTime: "300", }); }); @@ -1046,11 +1058,68 @@ describe("bridge utils", () => { accountHolderName: "Test Holder GB", bankName: "UK Bank", bankAddress: "10 Downing St", - fee: "0.0", + ...defaultSponsoredFees(), estimatedProcessingTime: "300", }); }); + it("returns deposit details with partially consumed sponsored fees", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + fetchResponse({ count: 1, data: [usdVirtualAccount(account)] }), + ); + vi.spyOn(bridge.feeRule, "read").mockResolvedValueOnce({ + result: { trigger: false, volume: 150_000, count: 10 }, + triggered: false, + }); + const sponsoredFees = { + window: 2_592_000_000, + volume: { available: "1500", threshold: "3000", symbol: "USD" }, + count: { available: "50", threshold: "60" }, + }; + + await expect(bridge.getDepositDetails("USD", account, activeCustomerWithBaseEndorsement)).resolves.toStrictEqual([ + expect.objectContaining({ sponsoredFees }), + expect.objectContaining({ sponsoredFees }), + ]); + }); + + it("clamps sponsored fees to zero when thresholds are exceeded", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + fetchResponse({ count: 1, data: [usdVirtualAccount(account)] }), + ); + vi.spyOn(bridge.feeRule, "read").mockResolvedValueOnce({ + result: { trigger: true, volume: 500_000, count: 100 }, + triggered: true, + }); + const sponsoredFees = { + window: 2_592_000_000, + volume: { available: "0", threshold: "3000", symbol: "USD" }, + count: { available: "0", threshold: "60" }, + }; + + await expect(bridge.getDepositDetails("USD", account, activeCustomerWithBaseEndorsement)).resolves.toStrictEqual([ + expect.objectContaining({ sponsoredFees }), + expect.objectContaining({ sponsoredFees }), + ]); + }); + + it("returns undefined sponsored fees when fee window read fails", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + fetchResponse({ count: 1, data: [usdVirtualAccount(account)] }), + ); + const error = new Error("redis connection failed"); + vi.spyOn(bridge.feeRule, "read").mockRejectedValueOnce(error); + + await expect(bridge.getDepositDetails("USD", account, activeCustomerWithBaseEndorsement)).resolves.toStrictEqual([ + expect.objectContaining({ sponsoredFees: undefined }), + expect.objectContaining({ sponsoredFees: undefined }), + ]); + expect(captureException).toHaveBeenCalledWith(error, { + level: "error", + contexts: { bridge: { customerId: activeCustomerWithBaseEndorsement.id } }, + }); + }); + it("throws INVALID_ACCOUNT when virtual account destination does not match", async () => { const wrongAccount = parse(Address, padHex("0x999", { size: 20 })); vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( @@ -1141,6 +1210,77 @@ describe("bridge utils", () => { }); }); + describe("enableFees", () => { + it("updates all virtual accounts and liquidation addresses with developer fee", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse({ count: 2, data: [usdVirtualAccount("0x1"), eurVirtualAccount("0x1")] })) + .mockResolvedValueOnce( + fetchResponse({ + count: 1, + data: [{ id: "la-1", currency: "usdt", chain: "tron", address: "TAddr1", destination_address: "0x1" }], + }), + ) + .mockResolvedValueOnce(fetchResponse(usdVirtualAccount("0x1"))) + .mockResolvedValueOnce(fetchResponse(eurVirtualAccount("0x1"))) + .mockResolvedValueOnce( + fetchResponse({ id: "la-1", currency: "usdt", chain: "tron", address: "TAddr1", destination_address: "0x1" }), + ); + + await bridge.enableFees("cust-1"); + + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining("/virtual_accounts/va-usd"), + expect.objectContaining({ method: "PUT", body: JSON.stringify({ developer_fee_percent: "0.4" }) }), + ); + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining("/virtual_accounts/va-eur"), + expect.objectContaining({ method: "PUT", body: JSON.stringify({ developer_fee_percent: "0.7" }) }), + ); + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining("/liquidation_addresses/la-1"), + expect.objectContaining({ method: "PUT", body: JSON.stringify({ custom_developer_fee_percent: "0.3" }) }), + ); + }); + + it("succeeds with no virtual accounts or liquidation addresses", async () => { + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse({ count: 0, data: [] })) + .mockResolvedValueOnce(fetchResponse({ count: 0, data: [] })); + + await expect(bridge.enableFees("cust-empty")).resolves.toBeDefined(); + }); + }); + + describe("disableFees", () => { + it("updates all virtual accounts and liquidation addresses with zero fee", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse({ count: 1, data: [usdVirtualAccount("0x1")] })) + .mockResolvedValueOnce( + fetchResponse({ + count: 1, + data: [{ id: "la-1", currency: "usdt", chain: "tron", address: "TAddr1", destination_address: "0x1" }], + }), + ) + .mockResolvedValueOnce(fetchResponse(usdVirtualAccount("0x1"))) + .mockResolvedValueOnce( + fetchResponse({ id: "la-1", currency: "usdt", chain: "tron", address: "TAddr1", destination_address: "0x1" }), + ); + + await bridge.disableFees("cust-1"); + + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining("/virtual_accounts/va-usd"), + expect.objectContaining({ method: "PUT", body: JSON.stringify({ developer_fee_percent: "0.0" }) }), + ); + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining("/liquidation_addresses/la-1"), + expect.objectContaining({ method: "PUT", body: JSON.stringify({ custom_developer_fee_percent: "0.0" }) }), + ); + }); + }); + describe("getCryptoDepositDetails", () => { const account = parse(Address, padHex("0x1", { size: 20 })); @@ -1179,7 +1319,7 @@ describe("bridge utils", () => { network: "TRON", displayName: "TRON", address: "TAddr123", - fee: "0.0", + ...defaultSponsoredFees(), estimatedProcessingTime: "300", }); }); @@ -1201,7 +1341,7 @@ describe("bridge utils", () => { network: "SOLANA", displayName: "SOLANA", address: "SolAddr456", - fee: "0.0", + ...defaultSponsoredFees(), estimatedProcessingTime: "300", }); }); @@ -1229,11 +1369,78 @@ describe("bridge utils", () => { network: "STELLAR", displayName: "STELLAR", address: "StellarAddr789", - fee: "0.0", + ...defaultSponsoredFees(), estimatedProcessingTime: "300", }); }); + it("returns crypto deposit details with partially consumed sponsored fees", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + fetchResponse({ + count: 1, + data: [{ id: "la-1", currency: "usdt", chain: "tron", address: "TAddr123", destination_address: account }], + }), + ); + vi.spyOn(bridge.feeRule, "read").mockResolvedValueOnce({ + result: { trigger: false, volume: 200_000, count: 30 }, + triggered: false, + }); + + await expect(bridge.getCryptoDepositDetails("USDT", "TRON", account, activeCustomer)).resolves.toStrictEqual([ + expect.objectContaining({ + sponsoredFees: { + window: 2_592_000_000, + volume: { available: "1000", threshold: "3000", symbol: "USD" }, + count: { available: "30", threshold: "60" }, + }, + }), + ]); + }); + + it("clamps crypto sponsored fees to zero when thresholds are exceeded", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + fetchResponse({ + count: 1, + data: [ + { id: "la-1", currency: "usdc", chain: "solana", address: "SolAddr456", destination_address: account }, + ], + }), + ); + vi.spyOn(bridge.feeRule, "read").mockResolvedValueOnce({ + result: { trigger: true, volume: 400_000, count: 80 }, + triggered: true, + }); + + await expect(bridge.getCryptoDepositDetails("USDC", "SOLANA", account, activeCustomer)).resolves.toStrictEqual([ + expect.objectContaining({ + sponsoredFees: { + window: 2_592_000_000, + volume: { available: "0", threshold: "3000", symbol: "USD" }, + count: { available: "0", threshold: "60" }, + }, + }), + ]); + }); + + it("returns undefined sponsored fees when fee window read fails", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + fetchResponse({ + count: 1, + data: [{ id: "la-1", currency: "usdt", chain: "tron", address: "TAddr123", destination_address: account }], + }), + ); + const error = new Error("redis connection failed"); + vi.spyOn(bridge.feeRule, "read").mockRejectedValueOnce(error); + + await expect(bridge.getCryptoDepositDetails("USDT", "TRON", account, activeCustomer)).resolves.toStrictEqual([ + expect.objectContaining({ sponsoredFees: undefined }), + ]); + expect(captureException).toHaveBeenCalledWith(error, { + level: "error", + contexts: { bridge: { customerId: activeCustomer.id } }, + }); + }); + it("creates liquidation address when none exists", async () => { vi.spyOn(globalThis, "fetch") .mockResolvedValueOnce(fetchResponse({ count: 0, data: [] })) @@ -1455,6 +1662,17 @@ function blobResponse() { return { ok: true, blob: () => Promise.resolve(new Blob(["img"], { type: "image/jpeg" })) } as Response; } +function defaultSponsoredFees() { + return { + fee: "0.0", + sponsoredFees: { + window: 2_592_000_000, + volume: { available: "3000", threshold: "3000", symbol: "USD" }, + count: { available: "60", threshold: "60" }, + }, + }; +} + const createCustomerPayload = { type: "individual" as const, first_name: "John", diff --git a/server/utils/ramps/bridge.ts b/server/utils/ramps/bridge.ts index 48e9747da..e5278287d 100644 --- a/server/utils/ramps/bridge.ts +++ b/server/utils/ramps/bridge.ts @@ -1,4 +1,4 @@ -import { captureException, withScope } from "@sentry/core"; +import { captureEvent, captureException, withScope } from "@sentry/core"; import { eq } from "drizzle-orm"; import { alpha2ToAlpha3 } from "i18n-iso-countries"; import crypto from "node:crypto"; @@ -30,7 +30,9 @@ import { Address } from "@exactly/common/validation"; import database, { credentials } from "../../database"; import * as persona from "../persona"; +import { queue } from "../redis"; import ServiceError from "../ServiceError"; +import windowRule from "../windowRule"; export const name = "bridge" as const; @@ -419,11 +421,12 @@ export async function getDepositDetails( virtualAccount ??= await createVirtualAccount(customer.id, { source: { currency: CurrencyToBridge[currency] }, - developer_fee_percentage: "0.0", + developer_fee_percent: "0.0", destination: { currency: "usdc", payment_rail: supportedChainId, address: account }, }); - return getDepositDetailsFromVirtualAccount(virtualAccount, account); + const sponsored = await evaluateSponsoredFees(customer.id); + return getDepositDetailsFromVirtualAccount(virtualAccount, account, sponsored); } export async function getCryptoDepositDetails( @@ -461,7 +464,8 @@ export async function getCryptoDepositDetails( }), ); - return getDepositDetailsFromLiquidationAddress(liquidationAddress, account); + const sponsored = await evaluateSponsoredFees(customer.id); + return getDepositDetailsFromLiquidationAddress(liquidationAddress, account, sponsored); } const Endorsements = ["base", "faster_payments", "pix", "sepa", "spei"] as const; // cspell:ignore spei, sepa @@ -782,7 +786,7 @@ const NewCustomer = object({ status: picklist(CustomerStatus), id: string() }); // eslint-disable-next-line @typescript-eslint/no-unused-vars const CreateVirtualAccount = object({ - developer_fee_percentage: optional(string()), + developer_fee_percent: optional(string()), source: object({ currency: picklist(BridgeCurrency), }), @@ -796,7 +800,7 @@ const CreateVirtualAccount = object({ const VirtualAccount = object({ id: string(), status: picklist(VirtualAccountStatus), - developer_fee_percentage: optional(string()), + developer_fee_percent: optional(string()), source_deposit_instructions: variant("currency", [ object({ currency: literal("brl" as const satisfies (typeof BridgeCurrency)[number]), @@ -846,6 +850,7 @@ const VirtualAccount = object({ const VirtualAccounts = object({ count: number(), data: array(VirtualAccount) }); const CreateLiquidationAddress = object({ + custom_developer_fee_percent: optional(string()), currency: picklist(["usdc", "usdt"]), chain: picklist([...CryptoPaymentRail, "evm"]), destination_payment_rail: picklist(BridgeChain), @@ -854,6 +859,7 @@ const CreateLiquidationAddress = object({ }); const LiquidationAddress = object({ + custom_developer_fee_percent: nullish(string()), id: string(), currency: picklist(["usdc", "usdt", "any"]), chain: picklist([...CryptoPaymentRail, "evm"]), @@ -863,6 +869,46 @@ const LiquidationAddress = object({ const LiquidationAddresses = object({ count: number(), data: array(LiquidationAddress) }); +const setFee = (customerId: string, fees?: Record<(typeof BridgeCurrency)[number], string>) => + Promise.all([ + getVirtualAccounts(customerId).then((accounts) => + Promise.all( + accounts.map((account) => + request( + VirtualAccount, + `/customers/${customerId}/virtual_accounts/${account.id}`, + {}, + { developer_fee_percent: fees?.[account.source_deposit_instructions.currency] ?? "0.0" }, + "PUT", + ), + ), + ), + ), + getLiquidationAddresses(customerId).then((addresses) => + Promise.all( + addresses.map((address) => { + switch (address.currency) { + case "usdc": + case "usdt": + return request( + LiquidationAddress, + `/customers/${customerId}/liquidation_addresses/${address.id}`, + {}, + { custom_developer_fee_percent: fees?.[address.currency] ?? "0.0" }, + "PUT", + ); + default: + captureException(new Error("bridge not supported currency"), { + contexts: { details: { currency: address.currency } }, + level: "warning", + }); + return Promise.resolve(); + } + }), + ), + ), + ]); + async function request>( schema: BaseSchema, url: `/${string}`, @@ -905,7 +951,31 @@ async function fetchAndEncodeFile(url: string, fileName: string) { return encodeFile(new File([file], fileName)); } -function getDepositDetailsFromVirtualAccount(virtualAccount: InferOutput, account: string) { +function evaluateSponsoredFees(customerId: string) { + return feeRule + .read(customerId) + .then(({ result }) => ({ + window: feeWindow, + volume: { + available: String(Math.max(0, volumeThreshold - result.volume) / 100), + threshold: String(volumeThreshold / 100), + symbol: "USD", + }, + count: { + available: String(Math.max(0, countThreshold - result.count)), + threshold: String(countThreshold), + }, + })) + .catch((error: unknown): undefined => { + captureException(error, { level: "error", contexts: { bridge: { customerId } } }); + }); +} + +function getDepositDetailsFromVirtualAccount( + virtualAccount: InferOutput, + account: string, + sponsoredFees: Awaited>, +) { if (virtualAccount.destination.address.toLowerCase() !== account.toLowerCase()) { throw new Error(ErrorCodes.INVALID_ACCOUNT); } @@ -921,7 +991,8 @@ function getDepositDetailsFromVirtualAccount(virtualAccount: InferOutput, account: string, + sponsoredFees: Awaited>, ) { if (liquidationAddress.destination_address.toLowerCase() !== account.toLowerCase()) { throw new Error(ErrorCodes.INVALID_ACCOUNT); @@ -1002,7 +1079,8 @@ function getDepositDetailsFromLiquidationAddress( network: "TRON" as const, displayName: "TRON" as const, address: liquidationAddress.address, - fee: "0.0", + fee: liquidationAddress.custom_developer_fee_percent ?? "0.0", + sponsoredFees, estimatedProcessingTime: "300", }, ]; @@ -1012,7 +1090,8 @@ function getDepositDetailsFromLiquidationAddress( network: "SOLANA" as const, displayName: "SOLANA" as const, address: liquidationAddress.address, - fee: "0.0", + fee: liquidationAddress.custom_developer_fee_percent ?? "0.0", + sponsoredFees, estimatedProcessingTime: "300", }, ]; @@ -1022,7 +1101,8 @@ function getDepositDetailsFromLiquidationAddress( network: "STELLAR" as const, displayName: "STELLAR" as const, address: liquidationAddress.address, - fee: "0.0", + fee: liquidationAddress.custom_developer_fee_percent ?? "0.0", + sponsoredFees, estimatedProcessingTime: "300", }, ]; @@ -1048,6 +1128,48 @@ export const ErrorCodes = { NO_SOCIAL_SECURITY_NUMBER: "no social security number", }; +export const fees: Record<(typeof BridgeCurrency)[number], string> = { + usd: "0.4", + eur: "0.7", + mxn: "1.0", + brl: "0.5", + gbp: "1.0", + usdc: "0.3", + usdt: "0.3", +}; + +export const enableFees = (customerId: string) => setFee(customerId, fees); +export const disableFees = (customerId: string) => setFee(customerId); + +export const volumeThreshold = 3000 * 100; +export const countThreshold = 60; +export const feeWindow = 30 * 24 * 60 * 60 * 1000; + +export const feeRule = windowRule( + { + name: "bridge-fees", + schema: object({ amount: number(), bridgeId: string(), eventId: string() }), + window: feeWindow, + partition: (event) => event.bridgeId, + eventId: (event) => event.eventId, + evaluate: (events) => { + const count = events.length; + const volume = events.reduce((sum, event) => sum + event.amount, 0); + return { trigger: volume >= volumeThreshold || count >= countThreshold, volume, count }; + }, + onTrigger: (partition, result) => + enableFees(partition).then(() => + captureEvent({ + message: "bridge fees enabled", + level: "warning", + contexts: { details: { bridgeId: partition, result } }, + }), + ), + onTriggerExpire: (partition) => disableFees(partition), + }, + queue, +); + const BridgeApiErrorCodes = { EMAIL_ALREADY_EXISTS: "A customer with this email already exists", INVALID_PARAMETERS: "invalid_parameters",