diff --git a/.changeset/red-cases-know.md b/.changeset/red-cases-know.md new file mode 100644 index 000000000..32e8feb14 --- /dev/null +++ b/.changeset/red-cases-know.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🗃️ add bridge id index to credentials diff --git a/.changeset/thin-pumas-brush.md b/.changeset/thin-pumas-brush.md new file mode 100644 index 000000000..7858891dc --- /dev/null +++ b/.changeset/thin-pumas-brush.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ handle bridge webhook events diff --git a/.do/app.yaml b/.do/app.yaml index 28204aac2..a82ea1324 100644 --- a/.do/app.yaml +++ b/.do/app.yaml @@ -78,6 +78,9 @@ services: - key: BRIDGE_API_URL scope: RUN_TIME value: ${{ env.BRIDGE_API_URL }} + - key: BRIDGE_WEBHOOK_PUBLIC_KEY + scope: RUN_TIME + value: ${{ env.BRIDGE_WEBHOOK_PUBLIC_KEY }} - key: DEBUG scope: RUN_TIME value: ${{ env.DEBUG }} diff --git a/server/database/schema.ts b/server/database/schema.ts index 919b2c58e..1829cc5bb 100644 --- a/server/database/schema.ts +++ b/server/database/schema.ts @@ -35,7 +35,7 @@ export const credentials = pgTable( bridgeId: text("bridge_id"), source: text("source"), }, - ({ account }) => [uniqueIndex("account_index").on(account)], + ({ account, bridgeId }) => [uniqueIndex("account_index").on(account), index("bridge_id_index").on(bridgeId)], ); export const cards = pgTable( diff --git a/server/hooks/bridge.ts b/server/hooks/bridge.ts new file mode 100644 index 000000000..790c3a659 --- /dev/null +++ b/server/hooks/bridge.ts @@ -0,0 +1,191 @@ +import { vValidator } from "@hono/valibot-validator"; +import { captureException, setUser } from "@sentry/core"; +import createDebug from "debug"; +import { eq } 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 { Address } from "@exactly/common/validation"; + +import database, { credentials } from "../database"; +import { sendPushNotification } from "../utils/onesignal"; +import { BridgeCurrency, publicKey } from "../utils/ramps/bridge"; +import { track } from "../utils/segment"; +import validatorHook from "../utils/validatorHook"; + +const debug = createDebug("exa:bridge"); +Object.assign(debug, { inspectOpts: { depth: undefined } }); + +export default new Hono().post( + "/", + headerValidator(publicKey), + vValidator( + "json", + variant("event_type", [ + object({ event_type: literal("customer.created"), event_object: unknown() }), + object({ + event_type: literal("customer.updated.status_transitioned"), + event_object: object({ + id: string(), + status: picklist([ + "active", + "awaiting_questionnaire", + "awaiting_ubo", + "incomplete", + "not_started", + "offboarded", + "paused", + "rejected", + "under_review", + ]), + }), + }), + object({ event_type: literal("customer.updated"), event_object: unknown() }), + object({ event_type: literal("liquidation_address.drain.created"), event_object: unknown() }), + object({ + event_type: literal("liquidation_address.drain.updated"), + event_object: unknown(), + }), + object({ + event_type: literal("liquidation_address.drain.updated.status_transitioned"), + event_object: object({ + currency: picklist(BridgeCurrency), + customer_id: string(), + id: string(), + state: picklist(["funds_received", "funds_scheduled", "payment_submitted", "payment_processed"]), + receipt: object({ initial_amount: string(), outgoing_amount: string() }), + }), + }), + object({ + event_type: literal("virtual_account.activity.created"), + event_object: variant("type", [ + object({ type: literal("account_update"), id: string(), customer_id: string() }), + object({ type: literal("activation"), id: string(), customer_id: string() }), + object({ type: literal("deactivation"), id: string(), customer_id: string() }), + object({ type: literal("funds_received"), id: string(), customer_id: string() }), + object({ type: literal("funds_scheduled"), id: string(), customer_id: string() }), + object({ type: literal("in_review"), id: string(), customer_id: string() }), + object({ type: literal("microdeposit"), id: string(), customer_id: string() }), // cspell:ignore microdeposit + object({ type: literal("payment_processed"), id: string(), customer_id: string() }), + object({ + customer_id: string(), + currency: picklist(BridgeCurrency), + id: string(), + type: literal("payment_submitted"), + receipt: object({ initial_amount: string(), final_amount: string() }), + }), + object({ type: literal("refund"), id: string(), customer_id: string() }), + ]), + }), + object({ event_type: literal("virtual_account.activity.updated"), event_object: unknown() }), + ]), + validatorHook({ code: "bad bridge", status: 200, debug }), + ), + async (c) => { + const payload = c.req.valid("json"); + switch (payload.event_type) { + case "customer.created": + case "customer.updated": + case "liquidation_address.drain.created": + case "liquidation_address.drain.updated": + case "virtual_account.activity.updated": + return c.json({ code: "ok" }, 200); + } + + const bridgeId = + payload.event_type === "customer.updated.status_transitioned" + ? payload.event_object.id + : payload.event_object.customer_id; + const credential = await database.query.credentials.findFirst({ + columns: { account: true, source: true }, + where: eq(credentials.bridgeId, bridgeId), + }); + if (!credential) { + captureException(new Error("credential not found"), { + level: "error", + contexts: { details: { bridgeId } }, + }); + return c.json({ code: "credential not found" }, 200); + } + const account = parse(Address, credential.account); + setUser({ id: account }); + + switch (payload.event_type) { + case "customer.updated.status_transitioned": + if (payload.event_object.status !== "active") return c.json({ code: "ok" }, 200); + track({ + userId: account, + event: "RampAccount", + properties: { provider: "bridge", source: credential.source }, + }); + sendPushNotification({ + userId: account, + headings: { en: "Fiat onramp activated" }, + contents: { en: "Your fiat onramp account has been activated" }, + }).catch((error: unknown) => captureException(error, { level: "error" })); + return c.json({ code: "ok" }, 200); + case "virtual_account.activity.created": + if (payload.event_object.type !== "payment_submitted") return c.json({ code: "ok" }, 200); + sendPushNotification({ + userId: account, + headings: { en: "Deposited funds" }, + contents: { + en: `${payload.event_object.receipt.initial_amount} ${payload.event_object.currency.toUpperCase()} deposited`, + }, + }).catch((error: unknown) => captureException(error, { level: "error" })); + track({ + userId: account, + event: "Onramp", + properties: { + currency: payload.event_object.currency, + amount: Number(payload.event_object.receipt.initial_amount), + provider: "bridge", + source: credential.source, + usdcAmount: Number(payload.event_object.receipt.final_amount), + }, + }); + return c.json({ code: "ok" }, 200); + case "liquidation_address.drain.updated.status_transitioned": + if (payload.event_object.state !== "payment_submitted") return c.json({ code: "ok" }, 200); + sendPushNotification({ + userId: account, + headings: { en: "Deposited funds" }, + contents: { + en: `${payload.event_object.receipt.initial_amount} ${payload.event_object.currency.toUpperCase()} deposited`, + }, + }).catch((error: unknown) => captureException(error, { level: "error" })); + track({ + userId: account, + event: "Onramp", + properties: { + currency: payload.event_object.currency, + amount: Number(payload.event_object.receipt.initial_amount), + provider: "bridge", + source: credential.source, + usdcAmount: Number(payload.event_object.receipt.outgoing_amount), + }, + }); + return c.json({ code: "ok" }, 200); + } + }, +); + +function headerValidator(key: string) { + return validator("header", async ({ "x-webhook-signature": signature }, c) => { + if (typeof signature !== "string") return c.json({ code: "unauthorized" }, 401); + const match = /^t=(\d+),v0=(.+)$/.exec(signature); + if (!match) return c.json({ code: "unauthorized" }, 401); + const [, timestamp, base64Signature] = match; + if (!timestamp || !base64Signature) return c.json({ code: "unauthorized" }, 401); + if (Math.abs(Date.now() - Number(timestamp)) > 600_000) return c.json({ code: "unauthorized" }, 401); + const body = Buffer.from(await c.req.arrayBuffer()).toString("utf8"); + const digest = createHash("sha256").update(`${timestamp}.${body}`).digest(); + const verifier = createVerify("RSA-SHA256"); + verifier.update(digest); + if (!verifier.verify(key, Buffer.from(base64Signature, "base64"))) { + return c.json({ code: "unauthorized" }, 401); + } + }); +} diff --git a/server/hooks/manteca.ts b/server/hooks/manteca.ts index e02dd70c4..cc07ef0be 100644 --- a/server/hooks/manteca.ts +++ b/server/hooks/manteca.ts @@ -201,7 +201,7 @@ export default new Hono().post( event: "Onramp", properties: { currency: payload.data.against, - fiatAmount: Number(payload.data.assetAmount) * Number(payload.data.effectivePrice), + amount: Number(payload.data.assetAmount) * Number(payload.data.effectivePrice), provider: "manteca", source: credential.source, usdcAmount: Number(payload.data.assetAmount), diff --git a/server/index.ts b/server/index.ts index e65940c38..b3b999717 100644 --- a/server/index.ts +++ b/server/index.ts @@ -14,6 +14,7 @@ import api from "./api"; import database from "./database"; import activityHook from "./hooks/activity"; import block from "./hooks/block"; +import bridge from "./hooks/bridge"; import manteca from "./hooks/manteca"; import panda from "./hooks/panda"; import persona from "./hooks/persona"; @@ -30,6 +31,7 @@ app.route("/api", api); app.route("/hooks/activity", activityHook); app.route("/hooks/block", block); +app.route("/hooks/bridge", bridge); app.route("/hooks/manteca", manteca); app.route("/hooks/panda", panda); app.route("/hooks/persona", persona); diff --git a/server/test/hooks/bridge.test.ts b/server/test/hooks/bridge.test.ts new file mode 100644 index 000000000..efaf00585 --- /dev/null +++ b/server/test/hooks/bridge.test.ts @@ -0,0 +1,494 @@ +import "../mocks/onesignal"; +import "../mocks/sentry"; + +import { captureException } from "@sentry/core"; +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 deriveAddress from "@exactly/common/deriveAddress"; + +import database, { credentials } from "../../database"; +import app from "../../hooks/bridge"; +import * as onesignal from "../../utils/onesignal"; +import * as segment from "../../utils/segment"; + +const appClient = testClient(app); + +describe("bridge hook", () => { + const owner = privateKeyToAddress(padHex("0xb1e")); + const factory = inject("ExaAccountFactory"); + const account = deriveAddress(factory, { x: padHex(owner), y: zeroHash }); + + beforeAll(async () => { + await database.insert(credentials).values([ + { + id: "bridge-test", + publicKey: new Uint8Array(hexToBytes(owner)), + account, + factory, + pandaId: "bridgePandaId", + bridgeId: "bridgeCustomerId", + }, + ]); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + it("returns 200 with valid signature and payload", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const response = await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(paymentSubmitted) }, + json: paymentSubmitted as never, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + }); + + it("returns 401 with missing signature header", async () => { + const response = await appClient.index.$post({ + header: {}, + json: fundsReceived as never, + }); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toStrictEqual({ code: "unauthorized" }); + }); + + it("returns 401 with invalid signature", async () => { + const { privateKey: wrongKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const timestamp = Date.now(); + const digest = createHash("sha256") + .update(`${timestamp}.${JSON.stringify(fundsReceived)}`) + .digest(); + const signer = createSign("RSA-SHA256"); + signer.update(digest); + const response = await appClient.index.$post({ + header: { "x-webhook-signature": `t=${timestamp},v0=${signer.sign(wrongKey, "base64")}` }, + json: fundsReceived as never, + }); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toStrictEqual({ code: "unauthorized" }); + }); + + it("returns 401 with expired timestamp", async () => { + const response = await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(fundsReceived, Date.now() - 600_001) }, + json: fundsReceived as never, + }); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toStrictEqual({ code: "unauthorized" }); + }); + + it("returns 401 with malformed signature header missing t=", async () => { + const response = await appClient.index.$post({ + header: { "x-webhook-signature": "v0=abc123" }, + json: fundsReceived as never, + }); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toStrictEqual({ code: "unauthorized" }); + }); + + it("returns 401 with malformed signature header missing v0=", async () => { + const response = await appClient.index.$post({ + header: { "x-webhook-signature": `t=${Date.now()}` }, + json: fundsReceived as never, + }); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toStrictEqual({ code: "unauthorized" }); + }); + + it("returns 200 with bad payload schema", async () => { + const payload = { invalid: true }; + 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.toMatchObject({ code: "bad bridge" }); + }); + + it("returns 200 without tracking for non-payment_submitted virtual account types", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + const response = await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(fundsReceived) }, + json: fundsReceived as never, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(segment.track).not.toHaveBeenCalled(); + expect(sendPushNotification).not.toHaveBeenCalled(); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("returns 200 and tracks onramp for payment_submitted virtual account", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const response = await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(paymentSubmitted) }, + json: paymentSubmitted as never, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(segment.track).toHaveBeenCalledWith({ + userId: account, + event: "Onramp", + properties: { currency: "usd", amount: 1000, provider: "bridge", source: null, usdcAmount: 1000 }, + }); + }); + + it("sends push notification on payment_submitted virtual account", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + const response = await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(paymentSubmitted) }, + json: paymentSubmitted as never, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(sendPushNotification).toHaveBeenCalledWith({ + userId: account, + headings: { en: "Deposited funds" }, + contents: { en: "1000 USD deposited" }, + }); + }); + + it("returns 200 with credential not found when bridgeId does not match", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + const payload = { + ...fundsReceived, + event_object: { ...fundsReceived.event_object, customer_id: "unknown-customer" }, + }; + 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: "credential not found" }); + expect(segment.track).not.toHaveBeenCalled(); + expect(sendPushNotification).not.toHaveBeenCalled(); + }); + + it("captures sentry exception when credential not found", async () => { + const payload = { + ...fundsReceived, + event_object: { ...fundsReceived.event_object, customer_id: "unknown-customer" }, + }; + const response = await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(payload) }, + json: payload as never, + }); + + expect(response.status).toBe(200); + expect(captureException).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: "credential not found" }), + { level: "error", contexts: { details: { bridgeId: "unknown-customer" } } }, + ); + }); + + it("tracks RampAccount and sends notification on status_transitioned to active", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + const response = await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(statusTransitioned) }, + json: statusTransitioned as never, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(segment.track).toHaveBeenCalledWith({ + userId: account, + event: "RampAccount", + properties: { provider: "bridge", source: null }, + }); + expect(sendPushNotification).toHaveBeenCalledWith({ + userId: account, + headings: { en: "Fiat onramp activated" }, + contents: { en: "Your fiat onramp account has been activated" }, + }); + }); + + it("returns 200 without tracking for status_transitioned to non-active", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + const payload = { + ...statusTransitioned, + event_object: { ...statusTransitioned.event_object, status: "incomplete" }, + }; + 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: "ok" }); + expect(segment.track).not.toHaveBeenCalled(); + expect(sendPushNotification).not.toHaveBeenCalled(); + }); + + it("returns 200 without tracking for customer.updated events", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + const payload = { event_type: "customer.updated", event_object: { id: "bridgeCustomerId", status: "active" } }; + 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: "ok" }); + expect(segment.track).not.toHaveBeenCalled(); + expect(sendPushNotification).not.toHaveBeenCalled(); + }); + + it("captures sentry exception when status_transitioned credential not found", async () => { + const payload = { + ...statusTransitioned, + event_object: { ...statusTransitioned.event_object, id: "unknown-customer" }, + }; + 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: "credential not found" }); + expect(captureException).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: "credential not found" }), + { level: "error", contexts: { details: { bridgeId: "unknown-customer" } } }, + ); + }); + + it("returns 200 and tracks onramp for drain payment_submitted", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const response = await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(drain) }, + json: drain as never, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(segment.track).toHaveBeenCalledWith({ + userId: account, + event: "Onramp", + properties: { currency: "usdc", amount: 500, provider: "bridge", source: null, usdcAmount: 500 }, + }); + }); + + it("sends push notification on drain payment_submitted", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + const response = await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(drain) }, + json: drain as never, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(sendPushNotification).toHaveBeenCalledWith({ + userId: account, + headings: { en: "Deposited funds" }, + contents: { en: "500 USDC deposited" }, + }); + }); + + it("returns 200 with credential not found for drain with unknown customer", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + const payload = { ...drain, event_object: { ...drain.event_object, customer_id: "unknown-customer" } }; + 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: "credential not found" }); + expect(segment.track).not.toHaveBeenCalled(); + expect(sendPushNotification).not.toHaveBeenCalled(); + }); + + it("captures sentry exception when drain credential not found", async () => { + const payload = { ...drain, event_object: { ...drain.event_object, customer_id: "unknown-customer" } }; + const response = await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(payload) }, + json: payload as never, + }); + + expect(response.status).toBe(200); + expect(captureException).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: "credential not found" }), + { level: "error", contexts: { details: { bridgeId: "unknown-customer" } } }, + ); + }); + + it("returns 200 without tracking for liquidation_address.drain.updated events", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + const payload = { event_type: "liquidation_address.drain.updated", event_object: { id: "drain_123" } }; + 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: "ok" }); + expect(segment.track).not.toHaveBeenCalled(); + expect(sendPushNotification).not.toHaveBeenCalled(); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("returns 200 without tracking for customer.created events", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + const payload = { event_type: "customer.created", event_object: { id: "bridgeCustomerId" } }; + 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: "ok" }); + expect(segment.track).not.toHaveBeenCalled(); + expect(sendPushNotification).not.toHaveBeenCalled(); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("returns 200 without tracking for liquidation_address.drain.created events", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + const payload = { event_type: "liquidation_address.drain.created", event_object: { id: "drain_123" } }; + 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: "ok" }); + expect(segment.track).not.toHaveBeenCalled(); + expect(sendPushNotification).not.toHaveBeenCalled(); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("returns 200 without tracking for virtual_account.activity.updated events", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + const payload = { event_type: "virtual_account.activity.updated", event_object: { id: "evt_123" } }; + 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: "ok" }); + expect(segment.track).not.toHaveBeenCalled(); + expect(sendPushNotification).not.toHaveBeenCalled(); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("returns 200 without tracking for drain non-payment_submitted state", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + const payload = { ...drain, event_object: { ...drain.event_object, state: "funds_received" } }; + 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: "ok" }); + expect(segment.track).not.toHaveBeenCalled(); + expect(sendPushNotification).not.toHaveBeenCalled(); + expect(captureException).not.toHaveBeenCalled(); + }); +}); + +const testSigningKey = createPrivateKey(`-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDh/1AC4d9nGevP +7Fe6a+bdoegChtT5oBKMGfR3RRUpvm0YB3vrn4hzunJARZzGOMAfXFD+VV2mDSfL +RCtZlJUUhZmbiMS3SBr9taIH/kWdKT04cRjDIi/ORQentVl/Y/Ea5PcsbG2T/K/+ +wydmUTadSS48BVq3Hi3owDr6O+MsANPcuHdgjOV/zsZ3w92h9jjzhLpgm17ImRPu +e18j5L/hfIXNA3tvWxJ0sFIKy6v6NzcyJvS1JBKvyZR/1MatwHnJEaMQ/tMmAyHs +98iYRuqVfWHLwnuPt2lhjEZdlxRC5Mv752731D3LEb/SgWsT0gvCphklJw6VwC6V +8gKR68lhAgMBAAECggEANITelSzke9M8R7+Gy53TsuGzRxMKX1BhvwkxFJ6LQn4s +YA8tLx6N2UcU0fbbbf02OJN9hv1TnAkmnEglQtYSpwg9IDXycR1imF8jXnQqvVEe +FwXBWWeScH7+Pm0YdVBGcZeQEVTJSkDIrY2wlEh/RqIBCpW79R4gURyLGCferRS8 +W8qkeDstuc+sf43vlYkmUyqfsDAsSo8QCX5cjKJq+XjF62pXs8Si9IDKnMB4wg/t +925lX+gppQ/0w8K5yl4Y1lG4qS3ZcieqYyR8G00brIvye4RkDIVAnEpSt/IGlVBd +Y9RJx2YuxPxJnwKmM6lPegNvdrua5A+pfRgjKkbl+QKBgQDxT5StHoVHvfa3jw3G +ZXlOW8Tumcwp2RYRO/PX8MEC0G6W5QRDJuIa2im//vIaEv0svlvMfdiSHjRsjFjR +L0rKKMJi7FsgVqoofNtX9XgkVt1bdl29HCIc/PTczOO2F3Gj2hP8zSVotuT2yppm +DMobWvIPWhLkdWdXv85zI8AyRwKBgQDvwRif4n5jjE0CcyRwZlRxXIg+s9FRD7qA +7E/Sln9rx84rne6x1+oLp4GkvtjFdswK7wfS44bpmplT3F0a9aMMxjib+NOpIMBX +FW3XaMvmhgkqAtjVbkiHoqJjcTjJyqXobEuAfkHw9kVZiEA4l4/4r7sj8fToAXFj +R3iTUrQTFwKBgQCMXRcFUDCEl5nwEdUYZyQVkUnO5EUevniYk7/2BsOuiGEbgqFl +EjQJHIeWd4yJ4CvGIAAzxav46nrh/Q0YuKKPTwArHIKxH9ggbugDlPRKZwChWAuU +mc26AOXJnaCC5cYjYhGoRggRjflHGHiRDbVuDgupJGLC4wu2vgovbUc5twKBgQCN +wmCq+KK+fZBzKF2dUAQR2yJ74JqdEW23GQLBg1boBYXz6DfgU8gBCBPxsx4881cG +B/taSEnXCiAqo5sxe5fiz7ldD60mzUSsuPDvcvlM3mfAvVo0KDcea50UqzdmqTmb +yZyC5yRaM2Mh4xwF2ie4ZT+Dq2ahX2kJyJKUmUv8FQKBgEkrv3xpZhKCADrNIyhW +LSumwgofMoFHkyDYZUAbdyj9kY3UHErmD0TZQkHEkL721prfPaEFrjHRVfJ1E6eM +WVPBXOPA2xRr1i4K3aETJd42XMrx0PNe3k05Lf/bCLdjaOEvPkdvDJ3s+vltFA+w +wjKVfw0300Nahs8Jfru7MLNR +-----END PRIVATE KEY-----`); // gitleaks:allow test private key + +function createSignature(payload: object, timestamp = Date.now()) { + const body = JSON.stringify(payload); + const digest = createHash("sha256").update(`${timestamp}.${body}`).digest(); + const signer = createSign("RSA-SHA256"); + signer.update(digest); + return `t=${timestamp},v0=${signer.sign(testSigningKey, "base64")}`; +} + +const fundsReceived = { + event_type: "virtual_account.activity.created", + event_object: { + id: "evt_123", + type: "funds_received", + customer_id: "bridgeCustomerId", + }, +}; + +const paymentSubmitted = { + event_type: "virtual_account.activity.created", + event_object: { + id: "evt_123", + type: "payment_submitted", + currency: "usd", + customer_id: "bridgeCustomerId", + receipt: { initial_amount: "1000", final_amount: "1000" }, + }, +}; + +const statusTransitioned = { + event_type: "customer.updated.status_transitioned", + event_object: { id: "bridgeCustomerId", status: "active" }, +}; + +const drain = { + event_type: "liquidation_address.drain.updated.status_transitioned", + event_object: { + id: "drain_123", + state: "payment_submitted", + currency: "usdc", + customer_id: "bridgeCustomerId", + receipt: { initial_amount: "500", outgoing_amount: "500" }, + }, +}; + +vi.mock("@sentry/core", { spy: true }); diff --git a/server/test/hooks/manteca.test.ts b/server/test/hooks/manteca.test.ts index 02c3d1c8c..52911aa14 100644 --- a/server/test/hooks/manteca.test.ts +++ b/server/test/hooks/manteca.test.ts @@ -346,7 +346,7 @@ describe("manteca hook", () => { expect(segment.track).toHaveBeenCalledWith({ userId: account, event: "Onramp", - properties: { currency: "ARS", fiatAmount: 100_000, provider: "manteca", source: null, usdcAmount: 100 }, + properties: { currency: "ARS", amount: 100_000, provider: "manteca", source: null, usdcAmount: 100 }, }); expect(manteca.withdrawBalance).toHaveBeenCalledWith("456", "USDC", account); }); diff --git a/server/utils/ramps/bridge.ts b/server/utils/ramps/bridge.ts index f93a34160..bd8bdb1fb 100644 --- a/server/utils/ramps/bridge.ts +++ b/server/utils/ramps/bridge.ts @@ -23,6 +23,7 @@ import { } from "valibot"; import { optimism, optimismSepolia } from "viem/chains"; +import domain from "@exactly/common/domain"; import chain from "@exactly/common/generated/chain"; import { Address } from "@exactly/common/validation"; @@ -447,7 +448,7 @@ export async function getCryptoDepositDetails( } const Endorsements = ["base", "faster_payments", "pix", "sepa", "spei"] as const; // cspell:ignore spei, sepa -const BridgeCurrency = ["brl", "eur", "gbp", "mxn", "usd", "usdc", "usdt"] as const; +export const BridgeCurrency = ["brl", "eur", "gbp", "mxn", "usd", "usdc", "usdt"] as const; export const PaymentRail = ["ach_push", "faster_payments", "pix", "sepa", "spei", "wire"] as const; const VirtualAccountStatus = ["activated", "deactivated"] as const; @@ -1031,3 +1032,37 @@ const BridgeApiErrorCodes = { INVALID_PARAMETERS: "invalid_parameters", NOT_FOUND: "not_found", } as const; + +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing -- ignore empty string */ +export const publicKey = + process.env.BRIDGE_WEBHOOK_PUBLIC_KEY || + { + "web.exactly.app": `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3iaPv91f5xNeSu41hSi/ +cMIvCPmrezsW/ZTzE8CxOTBTd+jFokCoOm5PCd6FKRz/So/gUeQP4ejvK81CVXTX +gAnsg/+By1XUc0HFs6X8F8iQEgzpLlT47ulh1yIiTTop14QPwApG7b8YafvNZgdB +LW/SeDREQ9RqxJCpCPboRrZGiD2JZzisrrk6uPuDLq4yy59uWg+EoIop/qSKjbe+ +ZNEUuNgaDl+kjNq7kDXsvyoKWeS05dtxpWljhxMCsBVTawiCWhg3wTEMPa+Ui8Gg +PBs4homDyXrVIA3aw7JYEZJLtJkmWKgSyQtDc8yZnUPyBj+pNmWBqqq1IIeYJ4QF +1QIDAQAB +-----END PUBLIC KEY-----`, + "sandbox.exactly.app": `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxrV+s8CvC0+s1W6vZG52 +5eozo6W6HzkTcLQMWDoEzQX+ulEoYH2fPuXeupi11MdVLpEqNqYas8LD3BIf/c9H +kK54V8vnXNwoHa5ROp/Gjp3B17q3wGfjLa8bQJoJZFWd9W+e3TjUohCDNpeD/qv+ +bkY2y3b1QixmXKK3REw35sfiEe5NkGMU4aEfXhZieIZ1mKXLsIgsgrIpv9BFwQr5 ++h3R7Vv3hGKVgSZHnRMa9F1/go8v5Au8gj+9w0LxxRJikoJCubI6igaTCivibxuo +QXWfFylw6m7eQTvZDQz70pnUEakofRlvKasetbyKmvLzMhuRHeqsxgi8C4ZCx7MP +dwIDAQAB +-----END PUBLIC KEY-----`, + }[domain] || + `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxrV+s8CvC0+s1W6vZG52 +5eozo6W6HzkTcLQMWDoEzQX+ulEoYH2fPuXeupi11MdVLpEqNqYas8LD3BIf/c9H +kK54V8vnXNwoHa5ROp/Gjp3B17q3wGfjLa8bQJoJZFWd9W+e3TjUohCDNpeD/qv+ +bkY2y3b1QixmXKK3REw35sfiEe5NkGMU4aEfXhZieIZ1mKXLsIgsgrIpv9BFwQr5 ++h3R7Vv3hGKVgSZHnRMa9F1/go8v5Au8gj+9w0LxxRJikoJCubI6igaTCivibxuo +QXWfFylw6m7eQTvZDQz70pnUEakofRlvKasetbyKmvLzMhuRHeqsxgi8C4ZCx7MP +dwIDAQAB +-----END PUBLIC KEY-----`; +/* eslint-enable @typescript-eslint/prefer-nullish-coalescing */ diff --git a/server/utils/segment.ts b/server/utils/segment.ts index b9d531c6f..7276b0774 100644 --- a/server/utils/segment.ts +++ b/server/utils/segment.ts @@ -42,8 +42,8 @@ export function track( | { event: "Onramp"; properties: { + amount: number; currency: string; - fiatAmount: number; provider: "bridge" | "manteca"; source: null | string; usdcAmount: number; diff --git a/server/vitest.config.mts b/server/vitest.config.mts index 9b1893b71..bca929b42 100644 --- a/server/vitest.config.mts +++ b/server/vitest.config.mts @@ -48,6 +48,15 @@ VuNOZKwaXFtqgA== PAX_ASSOCIATE_ID_KEY: "pax", PERSONA_API_KEY: "persona", PERSONA_URL: "https://persona.test", + BRIDGE_WEBHOOK_PUBLIC_KEY: `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4f9QAuHfZxnrz+xXumvm +3aHoAobU+aASjBn0d0UVKb5tGAd765+Ic7pyQEWcxjjAH1xQ/lVdpg0ny0QrWZSV +FIWZm4jEt0ga/bWiB/5FnSk9OHEYwyIvzkUHp7VZf2PxGuT3LGxtk/yv/sMnZlE2 +nUkuPAVatx4t6MA6+jvjLADT3Lh3YIzlf87Gd8PdofY484S6YJteyJkT7ntfI+S/ +4XyFzQN7b1sSdLBSCsur+jc3Mib0tSQSr8mUf9TGrcB5yRGjEP7TJgMh7PfImEbq +lX1hy8J7j7dpYYxGXZcUQuTL++du99Q9yxG/0oFrE9ILwqYZJScOlcAulfICkevJ +YQIDAQAB +-----END PUBLIC KEY-----`, PERSONA_WEBHOOK_SECRET: "persona", POSTGRES_URL: "postgres://postgres:postgres@localhost:8432/postgres?sslmode=disable", // cspell:ignore sslmode REDIS_URL: "redis",