Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/swift-foxes-track.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

✨ add bridge fee window tracking
16 changes: 16 additions & 0 deletions server/api/ramp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Hono } from "hono";
import {
array,
literal,
number,
object,
optional,
parse,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -265,6 +272,7 @@ const DepositDetails = variant("network", [
displayName: literal("PIX BR"),
beneficiaryName: string(),
fee: string(),
sponsoredFees: optional(SponsoredFees),
estimatedProcessingTime: string(),
}),
object({
Expand All @@ -277,6 +285,7 @@ const DepositDetails = variant("network", [
bankAddress: string(),
beneficiaryAddress: string(),
fee: string(),
sponsoredFees: optional(SponsoredFees),
estimatedProcessingTime: string(),
}),
object({
Expand All @@ -289,6 +298,7 @@ const DepositDetails = variant("network", [
bankName: string(),
beneficiaryAddress: string(),
fee: string(),
sponsoredFees: optional(SponsoredFees),
estimatedProcessingTime: string(),
}),
object({
Expand All @@ -297,6 +307,7 @@ const DepositDetails = variant("network", [
beneficiaryName: string(),
iban: string(), // cspell:ignore iban
fee: string(),
sponsoredFees: optional(SponsoredFees),
estimatedProcessingTime: string(),
}),
object({
Expand All @@ -305,27 +316,31 @@ const DepositDetails = variant("network", [
beneficiaryName: string(),
clabe: string(), // cspell:ignore clabe
fee: string(),
sponsoredFees: optional(SponsoredFees),
estimatedProcessingTime: string(),
}),
object({
network: literal("TRON"),
displayName: literal("TRON"),
address: string(),
fee: string(),
sponsoredFees: optional(SponsoredFees),
estimatedProcessingTime: string(),
}),
object({
network: literal("SOLANA"),
displayName: literal("SOLANA"),
address: string(),
fee: string(),
sponsoredFees: optional(SponsoredFees),
estimatedProcessingTime: string(),
}),
object({
network: literal("STELLAR"),
displayName: literal("STELLAR"),
address: string(),
fee: string(),
sponsoredFees: optional(SponsoredFees),
estimatedProcessingTime: string(),
}),
object({
Expand All @@ -337,6 +352,7 @@ const DepositDetails = variant("network", [
bankName: string(),
bankAddress: string(),
fee: string(),
sponsoredFees: optional(SponsoredFees),
estimatedProcessingTime: string(),
}),
]);
Expand Down
43 changes: 38 additions & 5 deletions server/hooks/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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(),
Expand All @@ -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(),
Expand Down Expand Up @@ -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",
Expand All @@ -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" },
Expand All @@ -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);
}
}
},
);
Expand Down
6 changes: 4 additions & 2 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
Comment on lines +326 to 329
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The server shutdown sequence can hang indefinitely if a BullMQ job is stuck, preventing Redis connections from being closed.
Severity: HIGH

Suggested Fix

Wrap the feeRule.stop() call in a Promise.race with a timeout. Alternatively, use BullMQ's force-close options to ensure the worker is terminated after a reasonable period, allowing the shutdown sequence to proceed and close all connections.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: server/index.ts#L326-L329

Potential issue: The `feeRule.stop()` method calls `worker.close()` without a timeout.
Since `worker.close()` waits indefinitely for active jobs to finish, a single hanging
job (e.g., waiting on an external API) will block the promise from resolving. This
prevents the `Promise.allSettled` in the main shutdown sequence from completing, meaning
`closeRedis()` is never called. This results in a resource leak and prevents a graceful
server shutdown.

else if (results.some((result) => result.status === "rejected")) reject(new Error("closing services failed"));
else resolve(null);
Expand Down
Loading