Skip to content

Commit 2c738cc

Browse files
committed
✨ server: add bridge fee window tracking
1 parent f478f78 commit 2c738cc

8 files changed

Lines changed: 562 additions & 39 deletions

File tree

.changeset/swift-foxes-track.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@exactly/server": patch
3+
---
4+
5+
✨ add bridge fee window tracking

server/api/ramp.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Hono } from "hono";
55
import {
66
array,
77
literal,
8+
number,
89
object,
910
optional,
1011
parse,
@@ -371,6 +372,13 @@ const ProviderInfo = variant("provider", [
371372
]),
372373
]),
373374
),
375+
sponsoredFees: optional(
376+
object({
377+
windowMs: number(),
378+
volume: object({ available: string(), threshold: string(), symbol: string() }),
379+
count: object({ available: string(), threshold: string() }),
380+
}),
381+
),
374382
}),
375383
status: ProviderStatus,
376384
tosLink: optional(string()),

server/hooks/bridge.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ import { and, DrizzleQueryError, eq, isNull } from "drizzle-orm";
55
import { Hono } from "hono";
66
import { validator } from "hono/validator";
77
import { createHash, createVerify } from "node:crypto";
8-
import { literal, object, parse, picklist, string, unknown, variant } from "valibot";
8+
import { check, literal, object, parse, picklist, pipe, string, unknown, variant } from "valibot";
99

1010
import { Address } from "@exactly/common/validation";
1111

1212
import database, { credentials } from "../database";
1313
import { sendPushNotification } from "../utils/onesignal";
1414
import { searchAccounts } from "../utils/persona";
15-
import { BridgeCurrency, getCustomer, publicKey } from "../utils/ramps/bridge";
15+
import { BridgeCurrency, feeWindow, getCustomer, publicKey } from "../utils/ramps/bridge";
1616
import { track } from "../utils/segment";
1717
import validatorHook from "../utils/validatorHook";
1818

@@ -52,6 +52,10 @@ export default new Hono().post(
5252
object({
5353
event_type: literal("liquidation_address.drain.updated.status_transitioned"),
5454
event_object: object({
55+
created_at: pipe(
56+
string(),
57+
check((v) => !Number.isNaN(new Date(v).getTime()), "invalid date"),
58+
),
5559
currency: picklist(BridgeCurrency),
5660
customer_id: string(),
5761
id: string(),
@@ -77,6 +81,10 @@ export default new Hono().post(
7781
receipt: object({ initial_amount: string(), final_amount: string() }),
7882
}),
7983
object({
84+
created_at: pipe(
85+
string(),
86+
check((v) => !Number.isNaN(new Date(v).getTime()), "invalid date"),
87+
),
8088
customer_id: string(),
8189
currency: picklist(BridgeCurrency),
8290
id: string(),
@@ -181,6 +189,10 @@ export default new Hono().post(
181189
return c.json({ code: "ok" }, 200);
182190
case "virtual_account.activity.created":
183191
if (payload.event_object.type === "payment_submitted") {
192+
await feeWindow.report(
193+
{ bridgeId, eventId: payload.event_object.id, amount: payload.event_object.receipt.initial_amount },
194+
new Date(payload.event_object.created_at),
195+
);
184196
sendPushNotification({
185197
userId: account,
186198
headings: { en: "Deposited funds" },
@@ -205,6 +217,10 @@ export default new Hono().post(
205217
return c.json({ code: "ok" }, 200);
206218
case "liquidation_address.drain.updated.status_transitioned":
207219
if (payload.event_object.state !== "payment_submitted") return c.json({ code: "ok" }, 200);
220+
await feeWindow.report(
221+
{ bridgeId, eventId: payload.event_object.id, amount: payload.event_object.receipt.outgoing_amount },
222+
new Date(payload.event_object.created_at),
223+
);
208224
sendPushNotification({
209225
userId: account,
210226
headings: { en: "Deposited funds" },

server/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import panda from "./hooks/panda";
2020
import persona from "./hooks/persona";
2121
import androidFingerprints from "./utils/android/fingerprints";
2222
import appOrigin from "./utils/appOrigin";
23+
import { feeWindow } from "./utils/ramps/bridge";
2324
import { close as closeRedis } from "./utils/redis";
2425
import { closeAndFlush as closeSegment } from "./utils/segment";
2526

@@ -322,8 +323,9 @@ const server = serve(app);
322323
export async function close() {
323324
return new Promise((resolve, reject) => {
324325
server.close((error) => {
325-
Promise.allSettled([closeSentry(), closeRedis(), closeSegment(), database.$client.end()])
326-
.then((results) => {
326+
Promise.allSettled([feeWindow.stop(), closeSentry(), closeSegment(), database.$client.end()])
327+
.then(async (results) => {
328+
await closeRedis();
327329
if (error) reject(error);
328330
else if (results.some((result) => result.status === "rejected")) reject(new Error("closing services failed"));
329331
else resolve(null);

server/test/api/ramp.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { HTTPException } from "hono/http-exception";
77
import { testClient } from "hono/testing";
88
import { hexToBytes, padHex, zeroHash } from "viem";
99
import { privateKeyToAddress } from "viem/accounts";
10-
import { afterEach, beforeAll, describe, expect, inject, it, vi } from "vitest";
10+
import { afterAll, afterEach, beforeAll, describe, expect, inject, it, vi } from "vitest";
1111

1212
import deriveAddress from "@exactly/common/deriveAddress";
1313

@@ -16,6 +16,7 @@ import database, { credentials } from "../../database";
1616
import * as persona from "../../utils/persona";
1717
import * as bridge from "../../utils/ramps/bridge";
1818
import * as manteca from "../../utils/ramps/manteca";
19+
import { close as closeRedis } from "../../utils/redis";
1920

2021
const appClient = testClient(app);
2122

@@ -38,6 +39,11 @@ describe("ramp api", () => {
3839
]);
3940
});
4041

42+
afterAll(async () => {
43+
await bridge.feeWindow.stop();
44+
await closeRedis();
45+
});
46+
4147
afterEach(() => {
4248
vi.clearAllMocks();
4349
vi.restoreAllMocks();

server/test/hooks/bridge.test.ts

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { testClient } from "hono/testing";
77
import { createHash, createPrivateKey, createSign, generateKeyPairSync } from "node:crypto";
88
import { hexToBytes, padHex, zeroHash } from "viem";
99
import { privateKeyToAddress } from "viem/accounts";
10-
import { afterEach, beforeAll, describe, expect, inject, it, vi } from "vitest";
10+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, inject, it, vi } from "vitest";
1111

1212
import deriveAddress from "@exactly/common/deriveAddress";
1313

@@ -16,6 +16,8 @@ import app from "../../hooks/bridge";
1616
import * as onesignal from "../../utils/onesignal";
1717
import * as persona from "../../utils/persona";
1818
import * as bridge from "../../utils/ramps/bridge";
19+
import { feeWindow } from "../../utils/ramps/bridge";
20+
import redis, { close as closeRedis } from "../../utils/redis";
1921
import * as segment from "../../utils/segment";
2022

2123
const appClient = testClient(app);
@@ -54,11 +56,21 @@ describe("bridge hook", () => {
5456
]);
5557
});
5658

59+
beforeEach(async () => {
60+
const keys = await redis.keys("wr:bridge-fees:*");
61+
if (keys.length > 0) await redis.del(...keys);
62+
});
63+
5764
afterEach(() => {
5865
vi.clearAllMocks();
5966
vi.restoreAllMocks();
6067
});
6168

69+
afterAll(async () => {
70+
await feeWindow.stop();
71+
await closeRedis();
72+
});
73+
6274
it("returns 200 with valid signature and payload", async () => {
6375
vi.spyOn(segment, "track").mockReturnValue();
6476
const response = await appClient.index.$post({
@@ -138,6 +150,39 @@ describe("bridge hook", () => {
138150
await expect(response.json()).resolves.toMatchObject({ code: "bad bridge" });
139151
});
140152

153+
it("rejects payment_submitted with invalid created_at", async () => {
154+
const payload = {
155+
...paymentSubmitted,
156+
event_object: { ...paymentSubmitted.event_object, created_at: "not-a-date" },
157+
};
158+
const response = await appClient.index.$post({
159+
header: { "x-webhook-signature": createSignature(payload) },
160+
json: payload as never,
161+
});
162+
163+
expect(response.status).toBe(200);
164+
await expect(response.json()).resolves.toStrictEqual({
165+
code: "bad bridge",
166+
legacy: "bad bridge",
167+
message: expect.arrayContaining([expect.stringContaining("invalid date")]), // eslint-disable-line @typescript-eslint/no-unsafe-assignment
168+
});
169+
});
170+
171+
it("rejects drain with invalid created_at", async () => {
172+
const payload = { ...drain, event_object: { ...drain.event_object, created_at: "invalid" } };
173+
const response = await appClient.index.$post({
174+
header: { "x-webhook-signature": createSignature(payload) },
175+
json: payload as never,
176+
});
177+
178+
expect(response.status).toBe(200);
179+
await expect(response.json()).resolves.toStrictEqual({
180+
code: "bad bridge",
181+
legacy: "bad bridge",
182+
message: expect.arrayContaining([expect.stringContaining("invalid date")]), // eslint-disable-line @typescript-eslint/no-unsafe-assignment
183+
});
184+
});
185+
141186
it("returns 200 without side effects for non-payment virtual account types", async () => {
142187
vi.spyOn(segment, "track").mockReturnValue();
143188
const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification");
@@ -153,6 +198,36 @@ describe("bridge hook", () => {
153198
expect(captureException).not.toHaveBeenCalled();
154199
});
155200

201+
it("returns 500 when feeWindow.report fails on payment_submitted", async () => {
202+
vi.spyOn(segment, "track").mockReturnValue();
203+
const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification");
204+
vi.spyOn(feeWindow, "report").mockRejectedValue(new Error("redis down"));
205+
const response = await appClient.index.$post({
206+
header: { "x-webhook-signature": createSignature(paymentSubmitted) },
207+
json: paymentSubmitted as never,
208+
});
209+
210+
expect(response.status).toBe(500);
211+
expect(sendPushNotification).not.toHaveBeenCalled();
212+
expect(segment.track).not.toHaveBeenCalled();
213+
expect(captureException).not.toHaveBeenCalled();
214+
});
215+
216+
it("returns 500 when feeWindow.report fails on drain", async () => {
217+
vi.spyOn(segment, "track").mockReturnValue();
218+
const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification");
219+
vi.spyOn(feeWindow, "report").mockRejectedValue(new Error("redis down"));
220+
const response = await appClient.index.$post({
221+
header: { "x-webhook-signature": createSignature(drain) },
222+
json: drain as never,
223+
});
224+
225+
expect(response.status).toBe(500);
226+
expect(sendPushNotification).not.toHaveBeenCalled();
227+
expect(segment.track).not.toHaveBeenCalled();
228+
expect(captureException).not.toHaveBeenCalled();
229+
});
230+
156231
it("does not track onramp for payment_submitted virtual account", async () => {
157232
vi.spyOn(segment, "track").mockReturnValue();
158233
const response = await appClient.index.$post({
@@ -185,6 +260,7 @@ describe("bridge hook", () => {
185260
it("tracks onramp for payment_processed virtual account", async () => {
186261
vi.spyOn(segment, "track").mockReturnValue();
187262
const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification");
263+
const reportSpy = vi.spyOn(feeWindow, "report");
188264
const response = await appClient.index.$post({
189265
header: { "x-webhook-signature": createSignature(paymentProcessed) },
190266
json: paymentProcessed as never,
@@ -198,6 +274,7 @@ describe("bridge hook", () => {
198274
properties: { currency: "usd", amount: 1000, provider: "bridge", source: null, usdcAmount: 995 },
199275
});
200276
expect(sendPushNotification).not.toHaveBeenCalled();
277+
expect(reportSpy).not.toHaveBeenCalled();
201278
expect(captureException).not.toHaveBeenCalled();
202279
});
203280

@@ -600,6 +677,78 @@ describe("bridge hook", () => {
600677
expect(sendPushNotification).not.toHaveBeenCalled();
601678
expect(captureException).not.toHaveBeenCalled();
602679
});
680+
681+
it("tracks ramp amount via feeWindow.report on payment_submitted", async () => {
682+
vi.spyOn(segment, "track").mockReturnValue();
683+
const reportSpy = vi.spyOn(feeWindow, "report");
684+
await appClient.index.$post({
685+
header: { "x-webhook-signature": createSignature(paymentSubmitted) },
686+
json: paymentSubmitted as never,
687+
});
688+
689+
expect(reportSpy).toHaveBeenCalledExactlyOnceWith(
690+
{ amount: "1000", bridgeId: "bridgeCustomerId", eventId: "evt_123" },
691+
new Date("2026-03-01T00:00:00.000Z"),
692+
);
693+
});
694+
695+
it("tracks ramp amount via feeWindow.report on drain payment_submitted", async () => {
696+
vi.spyOn(segment, "track").mockReturnValue();
697+
const reportSpy = vi.spyOn(feeWindow, "report");
698+
await appClient.index.$post({
699+
header: { "x-webhook-signature": createSignature(drain) },
700+
json: drain as never,
701+
});
702+
703+
expect(reportSpy).toHaveBeenCalledExactlyOnceWith(
704+
{ amount: "500", bridgeId: "bridgeCustomerId", eventId: "drain_123" },
705+
new Date("2026-03-01T00:00:00.000Z"),
706+
);
707+
});
708+
709+
it("passes fractional amount to feeWindow.report on payment_submitted", async () => {
710+
vi.spyOn(segment, "track").mockReturnValue();
711+
const reportSpy = vi.spyOn(feeWindow, "report");
712+
const payload = {
713+
...paymentSubmitted,
714+
event_object: {
715+
...paymentSubmitted.event_object,
716+
id: "evt_frac_1",
717+
receipt: { initial_amount: "99.999", final_amount: "99.999" },
718+
},
719+
};
720+
await appClient.index.$post({
721+
header: { "x-webhook-signature": createSignature(payload) },
722+
json: payload as never,
723+
});
724+
725+
expect(reportSpy).toHaveBeenCalledExactlyOnceWith(
726+
{ amount: "99.999", bridgeId: "bridgeCustomerId", eventId: "evt_frac_1" },
727+
expect.any(Date),
728+
);
729+
});
730+
731+
it("passes fractional amount to feeWindow.report on drain", async () => {
732+
vi.spyOn(segment, "track").mockReturnValue();
733+
const reportSpy = vi.spyOn(feeWindow, "report");
734+
const payload = {
735+
...drain,
736+
event_object: {
737+
...drain.event_object,
738+
id: "drain_frac_1",
739+
receipt: { initial_amount: "49.991", outgoing_amount: "49.991" },
740+
},
741+
};
742+
await appClient.index.$post({
743+
header: { "x-webhook-signature": createSignature(payload) },
744+
json: payload as never,
745+
});
746+
747+
expect(reportSpy).toHaveBeenCalledExactlyOnceWith(
748+
{ amount: "49.991", bridgeId: "bridgeCustomerId", eventId: "drain_frac_1" },
749+
expect.any(Date),
750+
);
751+
});
603752
});
604753

605754
const testSigningKey = createPrivateKey(`-----BEGIN PRIVATE KEY-----
@@ -651,6 +800,7 @@ const fundsReceived = {
651800
const paymentSubmitted = {
652801
event_type: "virtual_account.activity.created",
653802
event_object: {
803+
created_at: "2026-03-01T00:00:00.000Z",
654804
id: "evt_123",
655805
type: "payment_submitted",
656806
currency: "usd",
@@ -678,6 +828,7 @@ const statusTransitioned = {
678828
const drain = {
679829
event_type: "liquidation_address.drain.updated.status_transitioned",
680830
event_object: {
831+
created_at: "2026-03-01T00:00:00.000Z",
681832
id: "drain_123",
682833
state: "payment_submitted",
683834
currency: "usdc",
@@ -687,3 +838,8 @@ const drain = {
687838
};
688839

689840
vi.mock("@sentry/core", { spy: true });
841+
vi.mock("../../utils/ramps/bridge", async (importOriginal) => ({
842+
...(await importOriginal()),
843+
enableFees: vi.fn(),
844+
disableFees: vi.fn(),
845+
}));

0 commit comments

Comments
 (0)