Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/app/bridge/send-withdrawal-notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export const sendBridgeWithdrawalNotification = async ({
data: {
type: `bridge_withdrawal_${outcome}`,
amount,
currency,
currency: currency == "usdt" ? "USD" : currency.toUpperCase(),
...(failureReason ? { failureReason } : {}),
},
})
Expand Down
3 changes: 2 additions & 1 deletion src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,8 +660,9 @@ export const configSchema = {
kyc: { type: "string" },
deposit: { type: "string" },
transfer: { type: "string" },
external_account: { type: "string" },
},
required: ["kyc", "deposit", "transfer"],
required: ["kyc", "deposit", "transfer", "external_account"],
},
timestampSkewMs: { type: "integer" },
replaySecret: { type: "string" },
Expand Down
1 change: 1 addition & 0 deletions src/config/schema.types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type BridgeWebhookPublicKeys = {
kyc: string
deposit: string
transfer: string
external_account: string
}

type BridgeWebhook = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { GT } from "@graphql/index"
import { mapAndParseErrorForGqlResponse } from "@graphql/error-map"
import IError from "@graphql/shared/types/abstract/error"
import BridgeExternalAccount from "@graphql/public/types/object/bridge-external-account"
import BridgeExternalAccountLink from "@graphql/public/types/object/bridge-external-account-link"
import { BridgeConfig } from "@config"
import BridgeService from "@services/bridge"
import { BridgeDisabledError, BridgeAccountLevelError } from "@services/bridge/errors"
Expand All @@ -10,7 +10,7 @@ const BridgeAddExternalAccountPayload = GT.Object({
name: "BridgeAddExternalAccountPayload",
fields: () => ({
errors: { type: GT.NonNullList(IError) },
externalAccount: { type: BridgeExternalAccount },
externalAccount: { type: BridgeExternalAccountLink },
}),
})

Expand Down
11 changes: 11 additions & 0 deletions src/graphql/public/types/object/bridge-external-account-link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { GT } from "@graphql/index"

const BridgeExternalAccountLink = GT.Object({
name: "BridgeExternalAccountLink",
fields: () => ({
linkUrl: { type: GT.NonNull(GT.String) },
expiresAt: { type: GT.NonNull(GT.String) },
}),
})

export default BridgeExternalAccountLink
2 changes: 1 addition & 1 deletion src/graphql/public/types/object/bridge-external-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { GT } from "@graphql/index"
const BridgeExternalAccount = GT.Object({
name: "BridgeExternalAccount",
fields: () => ({
id: { type: GT.NonNullID },
id: { type: GT.NonNullID, resolve: (obj) => obj.bridgeExternalAccountId },
bankName: { type: GT.NonNull(GT.String) },
accountNumberLast4: { type: GT.NonNull(GT.String) },
status: { type: GT.NonNull(GT.String) },
Expand Down
2 changes: 2 additions & 0 deletions src/services/bridge/webhook-server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { verifyBridgeSignature } from "./middleware/verify-signature"
import { kycHandler } from "./routes/kyc"
import { depositHandler } from "./routes/deposit"
import { transferHandler } from "./routes/transfer"
import { externalAccountHandler } from "./routes/external-account"
import { replayAuthMiddleware, replayHandler } from "./routes/replay"

type RawBodyRequest = express.Request & { rawBody?: string }
Expand Down Expand Up @@ -43,6 +44,7 @@ export const startBridgeWebhookServer = () => {
app.post("/kyc", verifyBridgeSignature("kyc"), kycHandler)
app.post("/deposit", verifyBridgeSignature("deposit"), depositHandler)
app.post("/transfer", verifyBridgeSignature("transfer"), transferHandler)
app.post("/external-account", verifyBridgeSignature("external_account"), externalAccountHandler)
app.post("/internal/replay", replayAuthMiddleware, replayHandler)

if (!BridgeConfig.webhook.replaySecret && !process.env.BRIDGE_WEBHOOK_REPLAY_SECRET) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { baseLogger } from "@services/logger"

type RawBodyRequest = Request & { rawBody?: string }

export const verifyBridgeSignature = (publicKeyType: "kyc" | "deposit" | "transfer") => {
export const verifyBridgeSignature = (publicKeyType: "kyc" | "deposit" | "transfer" | "external_account") => {
return (req: Request, res: Response, next: NextFunction) => {
const signature = req.headers["x-webhook-signature"] as string

Expand Down
80 changes: 80 additions & 0 deletions src/services/bridge/webhook-server/routes/external-account.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* Bridge External Account Webhook Handler
* Handles external_account.created and external_account.updated events from Bridge.xyz
*
* Fires after a user completes the Plaid bank-linking flow.
* Persists the linked account to MongoDB so it appears in bridgeExternalAccounts queries.
*
* Status mapping:
* active: true → "verified"
* active: false → "failed"
*/

import { Request, Response } from "express"
import { AccountsRepository } from "@services/mongoose/accounts"
import { LockService } from "@services/lock"
import { baseLogger } from "@services/logger"
import { toBridgeCustomerId } from "@domain/primitives/bridge"
import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts"

const toStatus = (active: boolean | undefined): "pending" | "verified" | "failed" => {
if (active === true) return "verified"
if (active === false) return "failed"
return "pending"
}

export const externalAccountHandler = async (req: Request, res: Response) => {
const { event_id, event_object } = req.body
const { id, customer_id, bank_name, last_4, active } = event_object ?? {}

if (!id || !customer_id || !event_id) {
return res.status(400).json({ error: "Invalid payload" })
}

try {
const bridgeCustomerId = toBridgeCustomerId(customer_id)
const account = await AccountsRepository().findByBridgeCustomerId(bridgeCustomerId)
if (account instanceof Error) {
baseLogger.warn(
{ customer_id, event_id },
"Account not found for Bridge customer — may be a timing issue, Bridge will retry",
)
return res.status(503).json({ error: "Account not ready" })
}

const lockKey = `bridge-external-account:${event_id}`
const lockResult = await LockService().lockIdempotencyKey(lockKey as IdempotencyKey)
if (lockResult instanceof Error) {
baseLogger.info({ customer_id, event_id, id }, "Duplicate Bridge external account webhook")
return res.status(200).json({ status: "already_processed" })
}

const status = toStatus(active)

const result = await BridgeAccountsRepo.createExternalAccount({
accountId: String(account.id),
bridgeExternalAccountId: id,
bankName: bank_name ?? "Unknown",
accountNumberLast4: last_4 ?? "0000",
status,
})

if (result instanceof Error) {
baseLogger.error(
{ accountId: account.id, event_id, id, error: result },
"Failed to persist Bridge external account",
)
return res.status(500).json({ error: "Failed to persist external account" })
}

baseLogger.info(
{ accountId: account.id, bridgeExternalAccountId: id, status },
"Bridge external account persisted",
)

return res.status(200).json({ status: "success" })
} catch (error) {
baseLogger.error({ error, customer_id, event_id }, "Error processing Bridge external account webhook")
return res.status(500).json({ error: "Internal server error" })
}
}
6 changes: 5 additions & 1 deletion src/services/bridge/webhook-server/routes/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,16 @@ import {
} from "../transfer-direction"

import { depositHandler } from "./deposit"
import { externalAccountHandler } from "./external-account"
import { kycHandler } from "./kyc"
import { transferHandler } from "./transfer"
type RouteKey = "kyc" | "deposit" | "transfer"
type RouteKey = "kyc" | "deposit" | "transfer" | "external_account"

const HANDLERS: Record<RouteKey, (req: Request, res: Response) => Promise<Response>> = {
kyc: kycHandler,
deposit: depositHandler,
transfer: transferHandler,
external_account: externalAccountHandler,
}

const DEPOSIT_EVENT_TYPES = new Set([
Expand All @@ -39,6 +41,7 @@ const DEPOSIT_EVENT_TYPES = new Set([
const toRouteKey = (bridgeEventType: string): RouteKey | null => {
if (bridgeEventType.startsWith("kyc")) return "kyc"
if (bridgeEventType.startsWith("transfer")) return "transfer"
if (bridgeEventType.startsWith("external_account")) return "external_account"
if (DEPOSIT_EVENT_TYPES.has(bridgeEventType)) return "deposit"
return null
}
Expand Down Expand Up @@ -114,6 +117,7 @@ const toHandlerBody = ({
event_id: eventId,
event_object: eventObject,
}

}

export const replayAuthMiddleware = (req: Request, res: Response, next: () => void) => {
Expand Down
8 changes: 3 additions & 5 deletions src/services/bridge/webhook-server/routes/transfer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Bridge Transfer Webhook Handler
* Handles transfer.completed and transfer.failed events from Bridge.xyz
* Handles transfer webhook events from Bridge.xyz (transfer.completed, transfer.updated.status_transitioned)
*/

import { Request, Response } from "express"
Expand Down Expand Up @@ -65,7 +65,7 @@ export const transferHandler = async (req: Request, res: Response) => {
event === "transfer.payment_processed" ||
state === "payment_processed"

const isFailure = event === "transfer.failed" || TERMINAL_FAILURE_STATES.has(state)
const isFailure = TERMINAL_FAILURE_STATES.has(state)

if (!isCompletion && !isFailure) {
baseLogger.info({ transfer_id, state, event }, "Bridge transfer event not handled")
Expand Down Expand Up @@ -118,9 +118,7 @@ export const transferHandler = async (req: Request, res: Response) => {
const failureReason =
state === "refund_failed"
? (return_reason as string | undefined)
: event === "transfer.failed"
? (reason as string | undefined)
: ((reason as string | undefined) ?? (return_reason as string | undefined))
: ((reason as string | undefined) ?? (return_reason as string | undefined))

const result = await BridgeAccountsRepo.updateWithdrawalStatus(
bridgeTransferId,
Expand Down
7 changes: 6 additions & 1 deletion src/services/mongoose/bridge-accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,12 @@ export const createExternalAccount = async (data: {
status?: "pending" | "verified" | "failed"
}) => {
try {
const record = await BridgeExternalAccount.create(data)
const { bridgeExternalAccountId, accountId, status, ...immutable } = data
const record = await BridgeExternalAccount.findOneAndUpdate(
{ bridgeExternalAccountId, accountId },
{ $setOnInsert: { bridgeExternalAccountId, accountId, ...immutable }, $set: { status: status ?? "pending" } },
{ upsert: true, new: true, setDefaultsOnInsert: true },
)
return record
} catch (error) {
return new RepositoryError(String(error))
Expand Down