diff --git a/.changeset/swift-bridges-cross.md b/.changeset/swift-bridges-cross.md new file mode 100644 index 000000000..a4a030ebb --- /dev/null +++ b/.changeset/swift-bridges-cross.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ add bridge offramp diff --git a/server/api/ramp.ts b/server/api/ramp.ts index bc84ee2e6..836a343e6 100644 --- a/server/api/ramp.ts +++ b/server/api/ramp.ts @@ -34,8 +34,14 @@ import * as manteca from "../utils/ramps/manteca"; import validatorHook from "../utils/validatorHook"; const ErrorCodes = { + EXTERNAL_ACCOUNT_CURRENCY_MISMATCH: "external account currency mismatch", + EXTERNAL_ACCOUNT_NOT_FOUND: "external account not found", + EXTERNAL_ACCOUNT_NOT_SUPPORTED: "external account not supported", + INVALID_DEPOSIT_ADDRESS: "invalid deposit address", NO_CREDENTIAL: "no credential", + NOT_APPROVED: "not approved", NOT_STARTED: "not started", + WITHDRAWAL_IN_PROGRESS: "withdrawal in progress", }; export default new Hono() @@ -74,7 +80,11 @@ export default new Hono() }) .catch((error: unknown) => { captureException(error, { level: "error", contexts: { credential, params: { countryCode } } }); - return { onramp: { currencies: [] }, status: "NOT_AVAILABLE" as const }; + return { + onramp: { currencies: [] }, + offramp: { currencies: [] }, + status: "NOT_AVAILABLE" as const, + }; }), ]); @@ -93,13 +103,62 @@ export default new Hono() vValidator( "query", variant("provider", [ - object({ provider: literal("manteca"), currency: picklist(manteca.Currency) }), - object({ provider: literal("bridge"), currency: picklist(bridge.FiatCurrency) }), - object({ provider: literal("bridge"), currency: literal("USDT"), network: literal("TRON") }), object({ + currency: picklist(manteca.Currency), + direction: optional(literal("onramp")), + provider: literal("manteca"), + }), + object({ + currency: picklist(bridge.FiatCurrency), + direction: optional(literal("onramp")), + provider: literal("bridge"), + }), + object({ + currency: picklist(bridge.FiatCurrency), + direction: literal("offramp"), + externalAccountId: string(), + provider: literal("bridge"), + }), + object({ + address: Address, + currency: literal("USDC"), + direction: literal("offramp"), + network: literal("BASE"), + provider: literal("bridge"), + }), + object({ + address: string(), + currency: literal("USDC"), + direction: literal("offramp"), + network: literal("SOLANA"), + provider: literal("bridge"), + }), + object({ + address: string(), + currency: literal("USDC"), + direction: literal("offramp"), + memo: string(), + network: literal("STELLAR"), + provider: literal("bridge"), + }), + object({ + address: string(), + currency: literal("USDT"), + direction: literal("offramp"), + network: literal("TRON"), + provider: literal("bridge"), + }), + object({ + currency: literal("USDT"), + direction: optional(literal("onramp")), + network: literal("TRON"), provider: literal("bridge"), + }), + object({ currency: literal("USDC"), + direction: optional(literal("onramp")), network: picklist([...bridge.EVMNetwork, "SOLANA", "STELLAR"]), + provider: literal("bridge"), }), ]), validatorHook(), @@ -115,11 +174,11 @@ export default new Hono() const account = parse(Address, credential.account); setUser({ id: account }); - let depositInfo: InferOutput[]; switch (query.provider) { case "manteca": { const mantecaUser = await manteca.getUser(account); if (!mantecaUser) return c.json({ code: ErrorCodes.NOT_STARTED }, 400); + let depositInfo: InferOutput[]; try { depositInfo = manteca.getDepositDetails(query.currency, mantecaUser.exchange); } catch (error) { @@ -144,18 +203,80 @@ export default new Hono() if (!credential.bridgeId) return c.json({ code: ErrorCodes.NOT_STARTED }, 400); const bridgeUser = await bridge.getCustomer(credential.bridgeId); if (!bridgeUser) return c.json({ code: ErrorCodes.NOT_STARTED }, 400); + if (bridgeUser.status !== "active") return c.json({ code: ErrorCodes.NOT_APPROVED }, 400); + const quote = (await bridge.getQuote("USD", query.currency)) satisfies QuoteResponse; + + if (query.direction === "offramp") { + if ("network" in query) { + try { + return c.json( + { + quote, + depositInfo: await bridge.getCryptoOfframpDepositDetails( + query.currency, + query.network, + query.address, + parse(Address, credential.account), + bridgeUser, + query.network === "STELLAR" ? query.memo : undefined, + ), + }, + 200, + ); + } catch (error) { + if (error instanceof Error && error.message === bridge.ErrorCodes.INVALID_DEPOSIT_ADDRESS) { + return c.json({ code: ErrorCodes.INVALID_DEPOSIT_ADDRESS }, 400); + } + throw error; + } + } + try { + return c.json( + { + quote, + depositInfo: await bridge.getOfframpDepositDetails( + query.externalAccountId, + credential.account, + bridgeUser, + query.currency, + ), + }, + 200, + ); + } catch (error) { + if (error instanceof Error && error.message === bridge.ErrorCodes.EXTERNAL_ACCOUNT_NOT_FOUND) { + return c.json({ code: ErrorCodes.EXTERNAL_ACCOUNT_NOT_FOUND }, 400); + } + if (error instanceof Error && error.message === bridge.ErrorCodes.NOT_AVAILABLE_CURRENCY) { + return c.json({ code: ErrorCodes.EXTERNAL_ACCOUNT_NOT_SUPPORTED }, 400); + } + if (error instanceof Error && error.message === bridge.ErrorCodes.EXTERNAL_ACCOUNT_CURRENCY_MISMATCH) { + return c.json({ code: ErrorCodes.EXTERNAL_ACCOUNT_CURRENCY_MISMATCH }, 400); + } + if (error instanceof Error && error.message === bridge.ErrorCodes.TRANSFER_IN_USE) { + return c.json({ code: ErrorCodes.WITHDRAWAL_IN_PROGRESS }, 400); + } + throw error; + } + } - depositInfo = await ("currency" in query && "network" in query - ? bridge.getCryptoDepositDetails(query.currency, query.network, credential.account, bridgeUser) - : bridge.getDepositDetails(query.currency, credential.account, bridgeUser)); + if ("network" in query) { + return c.json( + { + quote, + depositInfo: await bridge.getCryptoDepositDetails( + query.currency, + query.network, + credential.account, + bridgeUser, + ), + }, + 200, + ); + } return c.json( - { - quote: ("currency" in query && "network" in query - ? undefined - : await bridge.getQuote("USD", query.currency)) satisfies QuoteResponse, - depositInfo, - }, + { quote, depositInfo: await bridge.getDepositDetails(query.currency, credential.account, bridgeUser) }, 200, ); } @@ -231,6 +352,108 @@ export default new Hono() } return c.json({ code: "ok" }, 200); }, + ) + .post("/external-account", auth(), vValidator("json", bridge.ExternalAccountInput, validatorHook()), async (c) => { + const { credentialId } = c.req.valid("cookie"); + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, credentialId), + columns: { account: true, bridgeId: true }, + }); + if (!credential) return c.json({ code: ErrorCodes.NO_CREDENTIAL }, 400); + if (!credential.bridgeId) return c.json({ code: ErrorCodes.NOT_STARTED }, 400); + setUser({ id: parse(Address, credential.account) }); + + const bridgeUser = await bridge.getCustomer(credential.bridgeId); + if (!bridgeUser) return c.json({ code: ErrorCodes.NOT_STARTED }, 400); + if (bridgeUser.status !== "active") return c.json({ code: ErrorCodes.NOT_APPROVED }, 400); + + try { + return c.json(await bridge.createExternalAccount(bridgeUser, c.req.valid("json")), 200); + } catch (error) { + if (error instanceof Error && error.message === bridge.ErrorCodes.NO_ENDORSEMENT) { + return c.json({ code: ErrorCodes.NOT_APPROVED }, 400); + } + throw error; + } + }) + .get("/external-account", auth(), async (c) => { + const { credentialId } = c.req.valid("cookie"); + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, credentialId), + columns: { account: true, bridgeId: true }, + }); + if (!credential) return c.json({ code: ErrorCodes.NO_CREDENTIAL }, 400); + if (!credential.bridgeId) return c.json({ code: ErrorCodes.NOT_STARTED }, 400); + setUser({ id: parse(Address, credential.account) }); + + const bridgeUser = await bridge.getCustomer(credential.bridgeId); + if (!bridgeUser) return c.json({ code: ErrorCodes.NOT_STARTED }, 400); + if (bridgeUser.status !== "active") return c.json({ code: ErrorCodes.NOT_APPROVED }, 400); + + return c.json(await bridge.listExternalAccounts(credential.bridgeId), 200); + }) + .patch( + "/external-account/:id", + auth(), + vValidator("param", object({ id: string() }), validatorHook()), + vValidator("json", bridge.UpdateExternalAccountInput, validatorHook()), + async (c) => { + const { credentialId } = c.req.valid("cookie"); + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, credentialId), + columns: { account: true, bridgeId: true }, + }); + if (!credential) return c.json({ code: ErrorCodes.NO_CREDENTIAL }, 400); + if (!credential.bridgeId) return c.json({ code: ErrorCodes.NOT_STARTED }, 400); + setUser({ id: parse(Address, credential.account) }); + + const bridgeUser = await bridge.getCustomer(credential.bridgeId); + if (!bridgeUser) return c.json({ code: ErrorCodes.NOT_STARTED }, 400); + if (bridgeUser.status !== "active") return c.json({ code: ErrorCodes.NOT_APPROVED }, 400); + try { + return c.json( + await bridge.updateExternalAccount(bridgeUser, c.req.valid("param").id, c.req.valid("json")), + 200, + ); + } catch (error) { + if (error instanceof Error && error.message === bridge.ErrorCodes.EXTERNAL_ACCOUNT_NOT_FOUND) { + return c.json({ code: ErrorCodes.EXTERNAL_ACCOUNT_NOT_FOUND }, 400); + } + throw error; + } + }, + ) + .delete( + "/external-account/:id", + auth(), + vValidator("param", object({ id: string() }), validatorHook()), + async (c) => { + const { credentialId } = c.req.valid("cookie"); + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, credentialId), + columns: { account: true, bridgeId: true }, + }); + if (!credential) return c.json({ code: ErrorCodes.NO_CREDENTIAL }, 400); + if (!credential.bridgeId) return c.json({ code: ErrorCodes.NOT_STARTED }, 400); + setUser({ id: parse(Address, credential.account) }); + + const bridgeUser = await bridge.getCustomer(credential.bridgeId); + if (!bridgeUser) return c.json({ code: ErrorCodes.NOT_STARTED }, 400); + if (bridgeUser.status !== "active") return c.json({ code: ErrorCodes.NOT_APPROVED }, 400); + + try { + await bridge.removeExternalAccount(bridgeUser, c.req.valid("param").id); + } catch (error) { + if (error instanceof Error && error.message === bridge.ErrorCodes.EXTERNAL_ACCOUNT_NOT_FOUND) { + return c.json({ code: ErrorCodes.EXTERNAL_ACCOUNT_NOT_FOUND }, 400); + } + if (error instanceof Error && error.message === bridge.ErrorCodes.TRANSFER_IN_USE) { + return c.json({ code: ErrorCodes.WITHDRAWAL_IN_PROGRESS }, 400); + } + throw error; + } + return c.json({ code: "ok" }, 200); + }, ); async function getOrCreateInquiry(credentialId: string, template: string) { @@ -325,6 +548,13 @@ const DepositDetails = variant("network", [ estimatedProcessingTime: string(), }), ), + object({ + network: literal("OPTIMISM"), + displayName: literal("Optimism"), + address: Address, + fee: string(), + estimatedProcessingTime: string(), + }), object({ network: literal("TRON"), displayName: literal("TRON"), @@ -379,6 +609,17 @@ const ProviderInfo = variant("provider", [ }), object({ provider: literal("bridge"), + offramp: object({ + currencies: array( + union([ + picklist(bridge.FiatCurrency), + variant("currency", [ + object({ currency: literal("USDT"), network: literal("TRON") }), + object({ currency: literal("USDC"), network: picklist(["BASE", "SOLANA", "STELLAR"]) }), + ]), + ]), + ), + }), onramp: object({ currencies: array( union([ diff --git a/server/hooks/bridge.ts b/server/hooks/bridge.ts index 2d15cc29f..99f15c447 100644 --- a/server/hooks/bridge.ts +++ b/server/hooks/bridge.ts @@ -5,7 +5,7 @@ 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 { literal, nullish, object, optional, parse, picklist, string, unknown, variant } from "valibot"; import { Address } from "@exactly/common/validation"; @@ -88,6 +88,33 @@ export default new Hono().post( ]), }), object({ event_type: literal("virtual_account.activity.updated"), event_object: unknown() }), + object({ event_type: literal("external_account.created"), event_object: unknown() }), + object({ event_type: literal("external_account.updated"), event_object: unknown() }), + object({ event_type: literal("transfer.created"), event_object: unknown() }), + object({ event_type: literal("transfer.updated"), event_object: unknown() }), + object({ + event_type: literal("transfer.updated.status_transitioned"), + event_object: object({ + currency: picklist(BridgeCurrency), + destination: object({ external_account_id: nullish(string()) }), + id: string(), + on_behalf_of: string(), + receipt: optional(object({ initial_amount: string(), final_amount: string() })), + state: picklist([ + "awaiting_funds", + "canceled", + "funds_received", + "in_review", + "payment_processed", + "payment_submitted", + "refund_failed", + "refund_in_flight", + "refunded", + "returned", + "undeliverable", + ]), + }), + }), ]), validatorHook({ code: "bad bridge", status: 200, debug }), ), @@ -99,13 +126,19 @@ export default new Hono().post( case "liquidation_address.drain.created": case "liquidation_address.drain.updated": case "virtual_account.activity.updated": + case "external_account.created": + case "external_account.updated": + case "transfer.created": + case "transfer.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; + : payload.event_type === "transfer.updated.status_transitioned" + ? payload.event_object.on_behalf_of + : payload.event_object.customer_id; let credential = await database.query.credentials.findFirst({ columns: { account: true, source: true }, where: eq(credentials.bridgeId, bridgeId), @@ -227,6 +260,41 @@ export default new Hono().post( }, }); return c.json({ code: "ok" }, 200); + case "transfer.updated.status_transitioned": + if (!payload.event_object.destination.external_account_id) return c.json({ code: "ok" }, 200); + switch (payload.event_object.state) { + case "funds_received": + sendPushNotification({ + userId: account, + headings: t("Withdrawal in progress"), + contents: t("Your funds are on the way to your bank"), + }).catch((error: unknown) => captureException(error, { level: "error" })); + return c.json({ code: "ok" }, 200); + case "payment_processed": + if (!payload.event_object.receipt) return c.json({ code: "ok" }, 200); + sendPushNotification({ + userId: account, + headings: t("Withdraw completed"), + contents: t("{{amount}} {{asset}} withdrawn", { + amount: f(payload.event_object.receipt.final_amount), + asset: payload.event_object.currency.toUpperCase(), + }), + }).catch((error: unknown) => captureException(error, { level: "error" })); + track({ + userId: account, + event: "Offramp", + properties: { + currency: payload.event_object.currency, + amount: Number(payload.event_object.receipt.final_amount), + provider: "bridge", + source: credential.source, + usdcAmount: Number(payload.event_object.receipt.initial_amount), + }, + }); + return c.json({ code: "ok" }, 200); + default: + return c.json({ code: "ok" }, 200); + } } }, ); diff --git a/server/i18n/es.json b/server/i18n/es.json index a6def25e0..00641bcdd 100644 --- a/server/i18n/es.json +++ b/server/i18n/es.json @@ -11,7 +11,9 @@ "Refund processed": "Reembolso procesado", "Transaction at {{merchantName}} for {{amount}} rejected: {{reason}}": "Transacción en {{merchantName}} por {{amount}} rechazada: {{reason}}", "Withdraw completed": "Retiro completado", + "Withdrawal in progress": "Retiro en curso", "Your fiat onramp account has been activated": "Tu cuenta para depositar dinero fiat ha sido activada", + "Your funds are on the way to your bank": "Tus fondos están en camino a tu banco", "frozen card": "tarjeta bloqueada", "insufficient funds": "fondos insuficientes", "merchant blocked": "comercio bloqueado", @@ -21,6 +23,7 @@ "{{amount}} received": "{{amount}} recibidos", "{{amount}} received and instantly started earning yield": "{{amount}} recibidos y empezaron a generar rendimiento", "{{amount}} {{asset}} deposited": "{{amount}} {{asset}} depositados", + "{{amount}} {{asset}} withdrawn": "{{amount}} {{asset}} retirados", "{{amount}} {{symbol}} sent to {{recipient}}": "{{amount}} {{symbol}} enviados a {{recipient}}", "{{refundAmount}} USDC from {{merchantName}} have been refunded to your account": "{{refundAmount}} USDC de {{merchantName}} fueron reembolsados a tu cuenta" } diff --git a/server/i18n/pt.json b/server/i18n/pt.json index a7ef55769..3f1e8993b 100644 --- a/server/i18n/pt.json +++ b/server/i18n/pt.json @@ -11,7 +11,9 @@ "Refund processed": "Reembolso processado", "Transaction at {{merchantName}} for {{amount}} rejected: {{reason}}": "Transação em {{merchantName}} de {{amount}} recusada: {{reason}}", "Withdraw completed": "Saque concluído", + "Withdrawal in progress": "Saque em andamento", "Your fiat onramp account has been activated": "Sua conta para depositar dinheiro fiat foi ativada", + "Your funds are on the way to your bank": "Seus fundos estão a caminho do seu banco", "frozen card": "cartão bloqueado", "insufficient funds": "fundos insuficientes", "merchant blocked": "estabelecimento bloqueado", @@ -21,6 +23,7 @@ "{{amount}} received": "{{amount}} recebidos", "{{amount}} received and instantly started earning yield": "{{amount}} recebidos e começaram a render", "{{amount}} {{asset}} deposited": "{{amount}} {{asset}} depositados", + "{{amount}} {{asset}} withdrawn": "{{amount}} {{asset}} sacados", "{{amount}} {{symbol}} sent to {{recipient}}": "{{amount}} {{symbol}} enviados para {{recipient}}", "{{refundAmount}} USDC from {{merchantName}} have been refunded to your account": "{{refundAmount}} USDC de {{merchantName}} foram reembolsados para sua conta" } diff --git a/server/test/api/ramp.test.ts b/server/test/api/ramp.test.ts index aa88aeb49..ad36b8176 100644 --- a/server/test/api/ramp.test.ts +++ b/server/test/api/ramp.test.ts @@ -1,4 +1,4 @@ -// cspell:ignore SEPA, SPEI +// cspell:ignore SEPA, SPEI, GABCDEFGHIJ import "../mocks/auth"; import "../mocks/deployments"; import "../mocks/sentry"; @@ -61,6 +61,7 @@ describe("ramp api", () => { }); vi.spyOn(bridge, "getProvider").mockResolvedValue({ onramp: { currencies: [] }, + offramp: { currencies: [] }, status: "NOT_AVAILABLE", }); @@ -74,7 +75,12 @@ describe("ramp api", () => { onramp: { currencies: ["ARS", "USD"] }, status: "NOT_STARTED", }, - bridge: { provider: "bridge", onramp: { currencies: [] }, status: "NOT_AVAILABLE" }, + bridge: { + provider: "bridge", + onramp: { currencies: [] }, + offramp: { currencies: [] }, + status: "NOT_AVAILABLE", + }, }); }); @@ -82,6 +88,7 @@ describe("ramp api", () => { vi.spyOn(manteca, "getProvider").mockRejectedValue(new Error("manteca error")); vi.spyOn(bridge, "getProvider").mockResolvedValue({ onramp: { currencies: [] }, + offramp: { currencies: [] }, status: "NOT_AVAILABLE", }); @@ -91,7 +98,12 @@ describe("ramp api", () => { const json = await response.json(); expect(json).toStrictEqual({ manteca: { provider: "manteca", onramp: { currencies: [] }, status: "NOT_AVAILABLE" }, - bridge: { provider: "bridge", onramp: { currencies: [] }, status: "NOT_AVAILABLE" }, + bridge: { + provider: "bridge", + onramp: { currencies: [] }, + offramp: { currencies: [] }, + status: "NOT_AVAILABLE", + }, }); }); @@ -108,7 +120,12 @@ describe("ramp api", () => { const json = await response.json(); expect(json).toStrictEqual({ manteca: { provider: "manteca", onramp: { currencies: ["ARS"] }, status: "ACTIVE" }, - bridge: { provider: "bridge", onramp: { currencies: [] }, status: "NOT_AVAILABLE" }, + bridge: { + provider: "bridge", + onramp: { currencies: [] }, + offramp: { currencies: [] }, + status: "NOT_AVAILABLE", + }, }); }); @@ -119,6 +136,7 @@ describe("ramp api", () => { }); const bridgeSpy = vi.spyOn(bridge, "getProvider").mockResolvedValue({ onramp: { currencies: [] }, + offramp: { currencies: [] }, status: "NOT_AVAILABLE", }); @@ -555,7 +573,7 @@ describe("ramp api", () => { }); }); - it("returns deposit info with undefined quote for bridge USDC/SOLANA", async () => { + it("returns deposit info with default quote for bridge USDC/SOLANA", async () => { vi.spyOn(bridge, "getCustomer").mockResolvedValue(bridgeCustomer); vi.spyOn(bridge, "getCryptoDepositDetails").mockResolvedValue([ { @@ -574,6 +592,7 @@ describe("ramp api", () => { expect(response.status).toBe(200); await expect(response.json()).resolves.toStrictEqual({ + quote: { buyRate: "1.0", sellRate: "1.0" }, depositInfo: [ { network: "SOLANA", @@ -586,7 +605,7 @@ describe("ramp api", () => { }); }); - it("returns deposit info with undefined quote for bridge USDC/STELLAR", async () => { + it("returns deposit info with default quote for bridge USDC/STELLAR", async () => { vi.spyOn(bridge, "getCustomer").mockResolvedValue(bridgeCustomer); vi.spyOn(bridge, "getCryptoDepositDetails").mockResolvedValue([ { @@ -606,6 +625,7 @@ describe("ramp api", () => { expect(response.status).toBe(200); await expect(response.json()).resolves.toStrictEqual({ + quote: { buyRate: "1.0", sellRate: "1.0" }, depositInfo: [ { network: "STELLAR", @@ -619,7 +639,7 @@ describe("ramp api", () => { }); }); - it("returns deposit info with undefined quote for bridge USDC/BASE", async () => { + it("returns deposit info with default quote for bridge USDC/BASE", async () => { vi.spyOn(bridge, "getCustomer").mockResolvedValue(bridgeCustomer); vi.spyOn(bridge, "getCryptoDepositDetails").mockResolvedValue([ { @@ -638,6 +658,7 @@ describe("ramp api", () => { expect(response.status).toBe(200); await expect(response.json()).resolves.toStrictEqual({ + quote: { buyRate: "1.0", sellRate: "1.0" }, depositInfo: [ { network: "BASE", @@ -649,6 +670,196 @@ describe("ramp api", () => { ], }); }); + + describe("offramp", () => { + it("returns 400 when bridgeId is missing", async () => { + const response = await appClient.quote.$get( + { + query: { + provider: "bridge", + currency: "USD", + direction: "offramp", + externalAccountId: "ext-acc-1", + }, + }, + { headers: { "test-credential-id": "ramp-test" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "not started" }); + }); + + it("returns 400 when bridge customer not found", async () => { + vi.spyOn(bridge, "getCustomer").mockResolvedValue(undefined); // eslint-disable-line unicorn/no-useless-undefined + + const response = await appClient.quote.$get( + { + query: { + provider: "bridge", + currency: "USD", + direction: "offramp", + externalAccountId: "ext-acc-1", + }, + }, + { headers: { "test-credential-id": "ramp-bridge" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "not started" }); + }); + + it("returns 400 when external account is not found", async () => { + vi.spyOn(bridge, "getCustomer").mockResolvedValue(bridgeCustomer); + vi.spyOn(bridge, "getQuote").mockResolvedValue({ buyRate: "1.00", sellRate: "1.00" }); + vi.spyOn(bridge, "getOfframpDepositDetails").mockRejectedValue( + new Error(bridge.ErrorCodes.EXTERNAL_ACCOUNT_NOT_FOUND), + ); + + const response = await appClient.quote.$get( + { + query: { + provider: "bridge", + currency: "USD", + direction: "offramp", + externalAccountId: "ext-acc-missing", + }, + }, + { headers: { "test-credential-id": "ramp-bridge" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "external account not found" }); + }); + + it("returns 500 when bridge util throws an unexpected error", async () => { + vi.spyOn(bridge, "getCustomer").mockResolvedValue(bridgeCustomer); + vi.spyOn(bridge, "getQuote").mockResolvedValue({ buyRate: "1.00", sellRate: "1.00" }); + vi.spyOn(bridge, "getOfframpDepositDetails").mockRejectedValue(new Error("unexpected")); + + const response = await appClient.quote.$get( + { + query: { + provider: "bridge", + currency: "USD", + direction: "offramp", + externalAccountId: "ext-acc-1", + }, + }, + { headers: { "test-credential-id": "ramp-bridge" } }, + ); + + expect(response.status).toBe(500); + }); + + it("returns quote and deposit info for USD offramp", async () => { + vi.spyOn(bridge, "getCustomer").mockResolvedValue(bridgeCustomer); + vi.spyOn(bridge, "getQuote").mockResolvedValue({ buyRate: "1.00", sellRate: "1.00" }); + vi.spyOn(bridge, "getOfframpDepositDetails").mockResolvedValue([ + { + network: "OPTIMISM" as const, + displayName: "Optimism" as const, + address: deposit, + fee: "0.0", + estimatedProcessingTime: "300", + }, + ]); + + const response = await appClient.quote.$get( + { + query: { + provider: "bridge", + currency: "USD", + direction: "offramp", + externalAccountId: "ext-acc-1", + }, + }, + { 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: "OPTIMISM", + displayName: "Optimism", + address: deposit, + fee: "0.0", + estimatedProcessingTime: "300", + }, + ], + }); + expect(bridge.getOfframpDepositDetails).toHaveBeenCalledWith( + "ext-acc-1", + expect.any(String), + bridgeCustomer, + "USD", + ); + }); + + it("returns 400 when crypto offramp to_address is invalid", async () => { + vi.spyOn(bridge, "getCustomer").mockResolvedValue(bridgeCustomer); + vi.spyOn(bridge, "getCryptoOfframpDepositDetails").mockRejectedValue( + new Error(bridge.ErrorCodes.INVALID_DEPOSIT_ADDRESS), + ); + + const response = await appClient.quote.$get( + { + query: { + provider: "bridge", + currency: "USDT", + direction: "offramp", + network: "TRON", + address: "not-a-tron-address", + }, + }, + { headers: { "test-credential-id": "ramp-bridge" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "invalid deposit address" }); + }); + + it("returns 500 when crypto offramp util throws an unexpected error", async () => { + vi.spyOn(bridge, "getCustomer").mockResolvedValue(bridgeCustomer); + vi.spyOn(bridge, "getCryptoOfframpDepositDetails").mockRejectedValue(new Error("unexpected")); + + const response = await appClient.quote.$get( + { + query: { + provider: "bridge", + currency: "USDT", + direction: "offramp", + network: "TRON", + address: "TXyz", + }, + }, + { headers: { "test-credential-id": "ramp-bridge" } }, + ); + + expect(response.status).toBe(500); + }); + + it("returns 400 when STELLAR offramp is missing the memo", async () => { + const cryptoSpy = vi.spyOn(bridge, "getCryptoOfframpDepositDetails"); + + const response = await appClient.quote.$get( + { + query: { + provider: "bridge", + currency: "USDC", + direction: "offramp", + network: "STELLAR", + address: "GABCDEFGHIJ", + } as never, + }, + { headers: { "test-credential-id": "ramp-bridge" } }, + ); + + expect(response.status).toBe(400); + expect(cryptoSpy).not.toHaveBeenCalled(); + }); + }); }); }); @@ -984,6 +1195,97 @@ describe("ramp api", () => { }); }); }); + + describe("delete external account", () => { + it("returns 400 for no credential", async () => { + const response = await appClient["external-account"][":id"].$delete( + { param: { id: "ext-acc-1" } }, + { headers: { "test-credential-id": "non-existent" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "no credential" }); + }); + + it("returns 400 when bridgeId is missing", async () => { + const response = await appClient["external-account"][":id"].$delete( + { param: { id: "ext-acc-1" } }, + { headers: { "test-credential-id": "ramp-test" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "not started" }); + }); + + it("returns 400 when bridge customer not found", async () => { + vi.spyOn(bridge, "getCustomer").mockResolvedValue(undefined); // eslint-disable-line unicorn/no-useless-undefined + const removeSpy = vi.spyOn(bridge, "removeExternalAccount"); + + const response = await appClient["external-account"][":id"].$delete( + { param: { id: "ext-acc-1" } }, + { headers: { "test-credential-id": "ramp-bridge" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "not started" }); + expect(removeSpy).not.toHaveBeenCalled(); + }); + + it("returns 400 when customer is not active", async () => { + vi.spyOn(bridge, "getCustomer").mockResolvedValue({ ...bridgeCustomer, status: "under_review" }); + const removeSpy = vi.spyOn(bridge, "removeExternalAccount"); + + const response = await appClient["external-account"][":id"].$delete( + { param: { id: "ext-acc-1" } }, + { headers: { "test-credential-id": "ramp-bridge" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "not approved" }); + expect(removeSpy).not.toHaveBeenCalled(); + }); + + it("returns 400 when external account is not found", async () => { + vi.spyOn(bridge, "getCustomer").mockResolvedValue(bridgeCustomer); + vi.spyOn(bridge, "removeExternalAccount").mockRejectedValue( + new Error(bridge.ErrorCodes.EXTERNAL_ACCOUNT_NOT_FOUND), + ); + + const response = await appClient["external-account"][":id"].$delete( + { param: { id: "ext-acc-missing" } }, + { headers: { "test-credential-id": "ramp-bridge" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "external account not found" }); + }); + + it("returns 500 when bridge util throws an unexpected error", async () => { + vi.spyOn(bridge, "getCustomer").mockResolvedValue(bridgeCustomer); + vi.spyOn(bridge, "removeExternalAccount").mockRejectedValue(new Error("unexpected")); + + const response = await appClient["external-account"][":id"].$delete( + { param: { id: "ext-acc-1" } }, + { headers: { "test-credential-id": "ramp-bridge" } }, + ); + + expect(response.status).toBe(500); + }); + + it("delegates to removeExternalAccount and returns ok", async () => { + vi.spyOn(bridge, "getCustomer").mockResolvedValue(bridgeCustomer); + const removeSpy = vi.spyOn(bridge, "removeExternalAccount").mockResolvedValue(); + + const response = await appClient["external-account"][":id"].$delete( + { param: { id: "ext-acc-1" } }, + { headers: { "test-credential-id": "ramp-bridge" } }, + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(removeSpy).toHaveBeenCalledWith(bridgeCustomer, "ext-acc-1"); + }); + }); }); const mantecaUser = { diff --git a/server/test/hooks/bridge.test.ts b/server/test/hooks/bridge.test.ts index 2363d78a6..8b05f1427 100644 --- a/server/test/hooks/bridge.test.ts +++ b/server/test/hooks/bridge.test.ts @@ -649,6 +649,230 @@ describe("bridge hook", () => { expect(sendPushNotification).not.toHaveBeenCalled(); expect(captureException).not.toHaveBeenCalled(); }); + + it("returns 200 without tracking for transfer.created events", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + const payload = { event_type: "transfer.created", event_object: { id: "tr_1" } }; + 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 transfer.updated events", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + const payload = { event_type: "transfer.updated", event_object: { id: "tr_1" } }; + 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 external_account.created events", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + const payload = { event_type: "external_account.created", event_object: { id: "ext_1" } }; + 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 external_account.updated events", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + const payload = { event_type: "external_account.updated", event_object: { id: "ext_1" } }; + 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("tracks offramp and notifies on transfer payment_processed", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + const response = await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(transferProcessed) }, + json: transferProcessed as never, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(segment.track).toHaveBeenCalledExactlyOnceWith({ + userId: account, + event: "Offramp", + properties: { currency: "usd", amount: 2.97, provider: "bridge", source: null, usdcAmount: 3 }, + }); + expect(sendPushNotification).toHaveBeenCalledExactlyOnceWith({ + userId: account, + headings: t("Withdraw completed"), + contents: t("{{amount}} {{asset}} withdrawn", { amount: f("2.97"), asset: "USD" }), + }); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("notifies on transfer funds_received without tracking", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + const response = await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(transferFundsReceived) }, + json: transferFundsReceived as never, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(sendPushNotification).toHaveBeenCalledExactlyOnceWith({ + userId: account, + headings: t("Withdrawal in progress"), + contents: t("Your funds are on the way to your bank"), + }); + expect(segment.track).not.toHaveBeenCalled(); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("captures transfer funds_received notification errors", async () => { + const error = new Error("push failed"); + vi.spyOn(segment, "track").mockReturnValue(); + vi.spyOn(onesignal, "sendPushNotification").mockRejectedValueOnce(error); + const response = await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(transferFundsReceived) }, + json: transferFundsReceived as never, + }); + + await vi.waitUntil(() => vi.mocked(captureException).mock.calls.some(([captured]) => captured === error)); + + expect(captureException).toHaveBeenCalledWith(error, { level: "error" }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(segment.track).not.toHaveBeenCalled(); + }); + + it("captures transfer payment_processed notification errors", async () => { + const error = new Error("push failed"); + vi.spyOn(segment, "track").mockReturnValue(); + vi.spyOn(onesignal, "sendPushNotification").mockRejectedValueOnce(error); + const response = await appClient.index.$post({ + header: { "x-webhook-signature": createSignature(transferProcessed) }, + json: transferProcessed as never, + }); + + await vi.waitUntil(() => vi.mocked(captureException).mock.calls.some(([captured]) => captured === error)); + + expect(captureException).toHaveBeenCalledWith(error, { level: "error" }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok" }); + expect(segment.track).toHaveBeenCalledExactlyOnceWith({ + userId: account, + event: "Offramp", + properties: { currency: "usd", amount: 2.97, provider: "bridge", source: null, usdcAmount: 3 }, + }); + }); + + it("returns 200 without tracking for transfer non-payment_processed state", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + const payload = { + ...transferProcessed, + event_object: { ...transferProcessed.event_object, state: "payment_submitted" }, + }; + 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 transfer without external_account_id", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + const payload = { + ...transferProcessed, + event_object: { + ...transferProcessed.event_object, + destination: { ...transferProcessed.event_object.destination, external_account_id: null }, + }, + }; + 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 transfer payment_processed without receipt", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + const { receipt: _receipt, ...rest } = transferProcessed.event_object; + const payload = { ...transferProcessed, event_object: rest }; + 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 with credential not found for transfer with unknown on_behalf_of", async () => { + vi.spyOn(segment, "track").mockReturnValue(); + const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); + const payload = { + ...transferProcessed, + event_object: { ...transferProcessed.event_object, on_behalf_of: "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(); + expect(captureException).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: "credential not found" }), + { level: "error", contexts: { details: { bridgeId: "unknown-customer" } } }, + ); + }); }); const testSigningKey = createPrivateKey(`-----BEGIN PRIVATE KEY----- @@ -735,4 +959,27 @@ const drain = { }, }; +const transferProcessed = { + event_type: "transfer.updated.status_transitioned", + event_object: { + id: "tr_123", + on_behalf_of: "bridgeCustomerId", + state: "payment_processed", + currency: "usd", + destination: { payment_rail: "ach", external_account_id: "ext_123" }, + receipt: { initial_amount: "3", final_amount: "2.97" }, + }, +}; + +const transferFundsReceived = { + event_type: "transfer.updated.status_transitioned", + event_object: { + id: "tr_123", + on_behalf_of: "bridgeCustomerId", + state: "funds_received", + currency: "usd", + destination: { payment_rail: "ach", external_account_id: "ext_123" }, + }, +}; + vi.mock("@sentry/core", { spy: true }); diff --git a/server/test/utils/bridge.test.ts b/server/test/utils/bridge.test.ts index 2221e8ecd..7e832ede9 100644 --- a/server/test/utils/bridge.test.ts +++ b/server/test/utils/bridge.test.ts @@ -1,9 +1,9 @@ -// cspell:ignore cust midmarket sepa spei iban COBADEFFXXX +// cspell:ignore cust midmarket sepa spei iban COBADEFFXXX Anytown Joao Zdestination Adestination GABCDEFGHIJKLMNOPQRSTUVWXYZSTELLARDESTINATION import "../mocks/sentry"; import { captureException } from "@sentry/core"; import { eq } from "drizzle-orm"; -import { parse } from "valibot"; +import { parse, safeParse } from "valibot"; import { hexToBytes, padHex, zeroHash } from "viem"; import { privateKeyToAddress } from "viem/accounts"; import { optimism, optimismSepolia } from "viem/chains"; @@ -192,7 +192,11 @@ describe("bridge utils", () => { const result = await bridge.getProvider({ credentialId: "cred-1" }); - expect(result).toStrictEqual({ status: "NOT_AVAILABLE", onramp: { currencies: [] } }); + expect(result).toStrictEqual({ + status: "NOT_AVAILABLE", + onramp: { currencies: [] }, + offramp: { currencies: [] }, + }); expect(captureException).toHaveBeenCalledWith( expect.objectContaining({ message: "bridge not supported chain id" }), expect.objectContaining({ level: "error" }), @@ -213,7 +217,11 @@ describe("bridge utils", () => { const result = await bridge.getProvider({ credentialId: "cred-1", customerId: "cust-1" }); - expect(result).toStrictEqual({ status: "NOT_AVAILABLE", onramp: { currencies: [] } }); + expect(result).toStrictEqual({ + status: "NOT_AVAILABLE", + onramp: { currencies: [] }, + offramp: { currencies: [] }, + }); expect(captureException).toHaveBeenCalledWith( expect.objectContaining({ message: "bridge user not available" }), expect.objectContaining({ level: "warning" }), @@ -228,6 +236,7 @@ describe("bridge utils", () => { expect(result).toStrictEqual({ status: "ONBOARDING", onramp: { currencies: onboardingCurrencies }, + offramp: { currencies: [...baseCurrencies, "USD"] }, kycLink: undefined, }); expect(captureException).toHaveBeenCalledWith( @@ -244,6 +253,7 @@ describe("bridge utils", () => { expect(result).toStrictEqual({ status: "ONBOARDING", onramp: { currencies: onboardingCurrencies }, + offramp: { currencies: [...baseCurrencies, "USD"] }, kycLink: undefined, }); expect(captureException).toHaveBeenCalledWith( @@ -262,6 +272,7 @@ describe("bridge utils", () => { expect(result).toStrictEqual({ status: "ONBOARDING", onramp: { currencies: onboardingCurrencies }, + offramp: { currencies: [...baseCurrencies, "USD"] }, kycLink: undefined, }); }); @@ -274,6 +285,7 @@ describe("bridge utils", () => { expect(result).toStrictEqual({ status: "ONBOARDING", onramp: { currencies: onboardingCurrencies }, + offramp: { currencies: [...baseCurrencies, "USD"] }, kycLink: undefined, }); }); @@ -288,6 +300,7 @@ describe("bridge utils", () => { expect(result).toStrictEqual({ status: "ONBOARDING", onramp: { currencies: onboardingCurrencies }, + offramp: { currencies: [...baseCurrencies, "USD"] }, kycLink: undefined, }); expect(fetchSpy).toHaveBeenCalledOnce(); @@ -305,6 +318,7 @@ describe("bridge utils", () => { expect(result).toStrictEqual({ status: "ONBOARDING", onramp: { currencies: onboardingCurrencies }, + offramp: { currencies: [...baseCurrencies, "USD"] }, kycLink: undefined, }); expect(fetchSpy).toHaveBeenCalledOnce(); @@ -330,6 +344,7 @@ describe("bridge utils", () => { expect(result).toStrictEqual({ status: "ONBOARDING", onramp: { currencies: onboardingCurrencies }, + offramp: { currencies: [...baseCurrencies, "USD"] }, kycLink: undefined, }); expect(fetchSpy).toHaveBeenCalledOnce(); @@ -355,6 +370,7 @@ describe("bridge utils", () => { expect(result).toStrictEqual({ status: "ONBOARDING", onramp: { currencies: onboardingCurrencies }, + offramp: { currencies: [...baseCurrencies, "USD"] }, kycLink: undefined, }); expect(fetchSpy).toHaveBeenCalledOnce(); @@ -380,6 +396,7 @@ describe("bridge utils", () => { expect(result).toStrictEqual({ status: "ONBOARDING", onramp: { currencies: onboardingCurrencies }, + offramp: { currencies: [...baseCurrencies, "USD"] }, kycLink: undefined, }); expect(fetchSpy).toHaveBeenCalledOnce(); @@ -405,6 +422,7 @@ describe("bridge utils", () => { expect(result).toStrictEqual({ status: "ONBOARDING", onramp: { currencies: onboardingCurrencies }, + offramp: { currencies: [...baseCurrencies, "USD"] }, kycLink: undefined, }); expect(fetchSpy).toHaveBeenCalledOnce(); @@ -430,6 +448,7 @@ describe("bridge utils", () => { expect(result).toStrictEqual({ status: "ONBOARDING", onramp: { currencies: onboardingCurrencies }, + offramp: { currencies: [...baseCurrencies, "USD"] }, kycLink: undefined, }); expect(fetchSpy).toHaveBeenCalledOnce(); @@ -455,6 +474,7 @@ describe("bridge utils", () => { expect(result).toStrictEqual({ status: "ONBOARDING", onramp: { currencies: onboardingCurrencies }, + offramp: { currencies: [...baseCurrencies, "USD"] }, kycLink: undefined, }); expect(fetchSpy).toHaveBeenCalledOnce(); @@ -480,6 +500,7 @@ describe("bridge utils", () => { expect(result).toStrictEqual({ status: "ONBOARDING", onramp: { currencies: onboardingCurrencies }, + offramp: { currencies: [...baseCurrencies, "USD"] }, kycLink: undefined, }); expect(fetchSpy).toHaveBeenCalledOnce(); @@ -510,6 +531,7 @@ describe("bridge utils", () => { expect(result).toStrictEqual({ status: "ONBOARDING", onramp: { currencies: onboardingCurrencies }, + offramp: { currencies: [...baseCurrencies, "USD"] }, kycLink: undefined, }); expect(fetchSpy).toHaveBeenCalledOnce(); @@ -540,6 +562,7 @@ describe("bridge utils", () => { await expect(bridge.getProvider({ credentialId: "cred-1", customerId: "cust-1" })).resolves.toStrictEqual({ status: "ONBOARDING", onramp: { currencies: onboardingCurrencies }, + offramp: { currencies: [...baseCurrencies, "USD"] }, kycLink: "https://kyc.bridge.xyz/link", }); }); @@ -564,6 +587,7 @@ describe("bridge utils", () => { await expect(bridge.getProvider({ credentialId: "cred-1", customerId: "cust-1" })).resolves.toStrictEqual({ status: "ONBOARDING", onramp: { currencies: onboardingCurrencies }, + offramp: { currencies: [...baseCurrencies, "USD"] }, kycLink: "https://kyc.bridge.xyz/link", }); }); @@ -593,6 +617,7 @@ describe("bridge utils", () => { await expect(bridge.getProvider({ credentialId: "cred-1", customerId: "cust-1" })).resolves.toStrictEqual({ status: "ONBOARDING", onramp: { currencies: onboardingCurrencies }, + offramp: { currencies: [...baseCurrencies, "USD"] }, kycLink: "https://kyc.bridge.xyz/link", }); }); @@ -622,6 +647,7 @@ describe("bridge utils", () => { await expect(bridge.getProvider({ credentialId: "cred-1", customerId: "cust-1" })).resolves.toStrictEqual({ status: "ONBOARDING", onramp: { currencies: onboardingCurrencies }, + offramp: { currencies: [...baseCurrencies, "USD"] }, kycLink: "https://kyc.bridge.xyz/link", }); }); @@ -651,6 +677,7 @@ describe("bridge utils", () => { await expect(bridge.getProvider({ credentialId: "cred-1", customerId: "cust-1" })).resolves.toStrictEqual({ status: "ONBOARDING", onramp: { currencies: onboardingCurrencies }, + offramp: { currencies: [...baseCurrencies, "USD"] }, kycLink: "https://kyc.bridge.xyz/link", }); }); @@ -680,6 +707,7 @@ describe("bridge utils", () => { await expect(bridge.getProvider({ credentialId: "cred-1", customerId: "cust-1" })).resolves.toStrictEqual({ status: "ONBOARDING", onramp: { currencies: onboardingCurrencies }, + offramp: { currencies: [...baseCurrencies, "USD"] }, kycLink: "https://kyc.bridge.xyz/link", }); }); @@ -711,6 +739,7 @@ describe("bridge utils", () => { await expect(bridge.getProvider({ credentialId: "cred-1", customerId: "cust-1" })).resolves.toStrictEqual({ status: "ONBOARDING", onramp: { currencies: onboardingCurrencies }, + offramp: { currencies: [...baseCurrencies, "USD"] }, kycLink: "https://kyc.bridge.xyz/link", }); }); @@ -794,6 +823,7 @@ describe("bridge utils", () => { expect(result).toStrictEqual({ status: "ONBOARDING", onramp: { currencies: onboardingCurrencies }, + offramp: { currencies: [...baseCurrencies, "USD"] }, kycLink: undefined, }); expect(captureException).toHaveBeenCalledWith( @@ -827,6 +857,7 @@ describe("bridge utils", () => { expect(result).toStrictEqual({ status: "ONBOARDING", onramp: { currencies: onboardingCurrencies }, + offramp: { currencies: [...baseCurrencies, "USD"] }, kycLink: undefined, }); expect(fetchSpy).toHaveBeenCalledOnce(); @@ -854,6 +885,7 @@ describe("bridge utils", () => { expect(result).toStrictEqual({ status: "ONBOARDING", onramp: { currencies: onboardingCurrencies }, + offramp: { currencies: [...baseCurrencies, "USD"] }, kycLink: undefined, }); expect(captureException).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ level: "error" })); @@ -911,6 +943,7 @@ describe("bridge utils", () => { expect(result).toStrictEqual({ status: "ONBOARDING", onramp: { currencies: onboardingCurrencies }, + offramp: { currencies: [...baseCurrencies, "USD"] }, kycLink: undefined, }); expect(fetchSpy).toHaveBeenCalledOnce(); @@ -941,6 +974,7 @@ describe("bridge utils", () => { expect(result).toStrictEqual({ status: "ONBOARDING", onramp: { currencies: onboardingCurrencies }, + offramp: { currencies: [...baseCurrencies, "USD"] }, kycLink: undefined, }); expect(fetchSpy).toHaveBeenCalledOnce(); @@ -981,6 +1015,7 @@ describe("bridge utils", () => { await expect(bridge.getProvider({ credentialId: "cred-1", customerId: "cust-1" })).resolves.toStrictEqual({ status: "ONBOARDING", onramp: { currencies: onboardingCurrencies }, + offramp: { currencies: [...baseCurrencies, "USD"] }, kycLink: "https://kyc.bridge.xyz/link", }); }); @@ -996,6 +1031,7 @@ describe("bridge utils", () => { await expect(bridge.getProvider({ credentialId: "cred-1", customerId: "cust-1" })).resolves.toStrictEqual({ status: "ACTIVE", onramp: { currencies: [...baseCurrencies, "USD", "GBP"] }, + offramp: { currencies: [...baseCurrencies, "USD", "GBP"] }, }); }); @@ -1010,9 +1046,23 @@ describe("bridge utils", () => { await expect(bridge.getProvider({ credentialId: "cred-1", customerId: "cust-1" })).resolves.toStrictEqual({ status: "ACTIVE", onramp: { currencies: [...baseCurrencies, "USD", "EUR"] }, + offramp: { currencies: [...baseCurrencies, "USD", "EUR"] }, }); }); + it("returns the four crypto offramp options regardless of endorsements", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(fetchResponse({ ...activeCustomer, endorsements: [] })); + + const result = await bridge.getProvider({ credentialId: "cred-1", customerId: "cust-1" }); + + expect(result.offramp.currencies).toStrictEqual([ + { currency: "USDC", network: "BASE" }, + { currency: "USDC", network: "SOLANA" }, + { currency: "USDC", network: "STELLAR" }, + { currency: "USDT", network: "TRON" }, + ]); + }); + it("skips non-approved endorsements and continues collecting", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( fetchResponse({ @@ -1028,6 +1078,7 @@ describe("bridge utils", () => { await expect(bridge.getProvider({ credentialId: "cred-1", customerId: "cust-1" })).resolves.toStrictEqual({ status: "ACTIVE", onramp: { currencies: [...baseCurrencies, "USD", "BRL"] }, + offramp: { currencies: [...baseCurrencies, "USD", "BRL"] }, }); expect(captureException).toHaveBeenCalledWith( expect.objectContaining({ message: "endorsement not approved" }), @@ -1045,7 +1096,11 @@ describe("bridge utils", () => { const result = await bridge.getProvider({ credentialId: "cred-1", customerId: "cust-1" }); - expect(result).toStrictEqual({ status: "ACTIVE", onramp: { currencies: [...baseCurrencies, "USD"] } }); + expect(result).toStrictEqual({ + status: "ACTIVE", + onramp: { currencies: [...baseCurrencies, "USD"] }, + offramp: { currencies: [...baseCurrencies, "USD"] }, + }); expect(captureException).toHaveBeenCalledWith( expect.objectContaining({ message: "additional requirements" }), expect.objectContaining({ level: "warning" }), @@ -1067,7 +1122,11 @@ describe("bridge utils", () => { const result = await bridge.getProvider({ credentialId: "cred-1", customerId: "cust-1" }); - expect(result).toStrictEqual({ status: "ACTIVE", onramp: { currencies: [...baseCurrencies, "USD"] } }); + expect(result).toStrictEqual({ + status: "ACTIVE", + onramp: { currencies: [...baseCurrencies, "USD"] }, + offramp: { currencies: [...baseCurrencies, "USD"] }, + }); expect(captureException).toHaveBeenCalledWith( expect.objectContaining({ message: "requirements missing" }), expect.objectContaining({ level: "warning" }), @@ -1100,7 +1159,11 @@ describe("bridge utils", () => { const result = await bridge.getProvider({ credentialId: "cred-1" }); - expect(result).toStrictEqual({ onramp: { currencies: [] }, status: "NOT_AVAILABLE" }); + expect(result).toStrictEqual({ + onramp: { currencies: [] }, + offramp: { currencies: [] }, + status: "NOT_AVAILABLE", + }); expect(captureException).toHaveBeenCalledWith( expect.objectContaining({ message: "bridge not found identification class" }), expect.objectContaining({ @@ -1119,7 +1182,11 @@ describe("bridge utils", () => { const result = await bridge.getProvider({ credentialId: "cred-1" }); - expect(result).toStrictEqual({ onramp: { currencies: [] }, status: "NOT_AVAILABLE" }); + expect(result).toStrictEqual({ + onramp: { currencies: [] }, + offramp: { currencies: [] }, + status: "NOT_AVAILABLE", + }); expect(captureException).toHaveBeenCalledWith( expect.objectContaining({ message: "bridge not found identification class" }), expect.objectContaining({ @@ -1159,6 +1226,7 @@ describe("bridge utils", () => { status: "NOT_STARTED", tosLink: "https://tos.link/agree", onramp: { currencies: [...baseCurrencies, "USD", "EUR"] }, + offramp: { currencies: [...baseCurrencies, "USD", "EUR"] }, }); }); @@ -1176,6 +1244,7 @@ describe("bridge utils", () => { status: "NOT_STARTED", tosLink: "https://tos.link/agree", onramp: { currencies: [...baseCurrencies, "USD", "EUR", "MXN"] }, + offramp: { currencies: [...baseCurrencies, "USD", "EUR", "MXN"] }, }); }); @@ -1193,6 +1262,7 @@ describe("bridge utils", () => { status: "NOT_STARTED", tosLink: "https://tos.link/agree", onramp: { currencies: [...baseCurrencies, "USD", "EUR", "BRL"] }, + offramp: { currencies: [...baseCurrencies, "USD", "EUR", "BRL"] }, }); }); @@ -1210,6 +1280,7 @@ describe("bridge utils", () => { status: "NOT_STARTED", tosLink: "https://tos.link/agree", onramp: { currencies: [...baseCurrencies, "USD", "EUR", "GBP"] }, + offramp: { currencies: [...baseCurrencies, "USD", "EUR", "GBP"] }, }); }); @@ -1258,6 +1329,7 @@ describe("bridge utils", () => { status: "NOT_STARTED", tosLink: "https://tos.link/agree", onramp: { currencies: [...baseCurrencies, "USD", "EUR"] }, + offramp: { currencies: [...baseCurrencies, "USD", "EUR"] }, }); }); }); @@ -2061,7 +2133,1247 @@ describe("bridge utils", () => { ); }); }); -}); + + describe("getExternalAccount", () => { + it("returns external account when found", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))); + + const result = await bridge.getExternalAccount("cust-123", "ext-acc-1"); + + expect(result).toStrictEqual(externalAccountResponse("usd")); + expect(fetchSpy).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining("/customers/cust-123/external_accounts/ext-acc-1"), + expect.objectContaining({ method: "GET" }), + ); + }); + + it("returns undefined when not found", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(fetchError(404, "not_found")); + + const result = await bridge.getExternalAccount("cust-123", "ext-acc-missing"); + + expect(result).toBeUndefined(); + }); + + it("throws on other errors", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(fetchError(500, "internal error")); + + await expect(bridge.getExternalAccount("cust-123", "ext-acc-1")).rejects.toThrow("internal error"); + }); + }); + + describe("getOfframpDepositDetails", () => { + const account = parse(Address, padHex("0x1", { size: 20 })); + const deposit = parse(Address, padHex("0xde9", { size: 20 })); + + it("throws NOT_ACTIVE_CUSTOMER when customer is not active", async () => { + await expect( + bridge.getOfframpDepositDetails("ext-acc-1", account, { ...activeCustomer, status: "under_review" }, "USD"), + ).rejects.toThrow(bridge.ErrorCodes.NOT_ACTIVE_CUSTOMER); + }); + + it("throws NOT_SUPPORTED_CHAIN_ID for unsupported chain", async () => { + chainMock.id = 1; + + await expect(bridge.getOfframpDepositDetails("ext-acc-1", account, activeCustomer, "USD")).rejects.toThrow( + bridge.ErrorCodes.NOT_SUPPORTED_CHAIN_ID, + ); + expect(captureException).toHaveBeenCalledWith( + expect.objectContaining({ message: "bridge not supported chain id" }), + expect.objectContaining({ level: "error" }), + ); + }); + + it("throws EXTERNAL_ACCOUNT_NOT_FOUND when external account is missing", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(fetchError(404, "not_found")); + + await expect(bridge.getOfframpDepositDetails("ext-acc-missing", account, activeCustomer, "USD")).rejects.toThrow( + bridge.ErrorCodes.EXTERNAL_ACCOUNT_NOT_FOUND, + ); + }); + + it("throws EXTERNAL_ACCOUNT_CURRENCY_MISMATCH when query currency does not match the external account", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))); + + await expect(bridge.getOfframpDepositDetails("ext-acc-1", account, activeCustomer, "EUR")).rejects.toThrow( + bridge.ErrorCodes.EXTERNAL_ACCOUNT_CURRENCY_MISMATCH, + ); + }); + + it("returns deposit details from existing static template", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))) + .mockResolvedValueOnce( + fetchResponse({ + count: 1, + data: [staticTemplate({ externalAccountId: "ext-acc-1", currency: "usd", toAddress: deposit })], + }), + ); + + const result = await bridge.getOfframpDepositDetails("ext-acc-1", account, activeCustomer, "USD"); + + expect(result).toStrictEqual([ + { network: "OPTIMISM", displayName: "Optimism", address: deposit, fee: "0.0", estimatedProcessingTime: "300" }, + ]); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it("matches static template on optimism for optimism sepolia chain", async () => { + chainMock.id = optimismSepolia.id; + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("eur"))) + .mockResolvedValueOnce( + fetchResponse({ + count: 1, + data: [staticTemplate({ externalAccountId: "ext-acc-1", currency: "eur", toAddress: deposit })], + }), + ); + + const result = await bridge.getOfframpDepositDetails("ext-acc-1", account, activeCustomer, "EUR"); + + expect(result).toStrictEqual([ + { network: "OPTIMISM", displayName: "Optimism", address: deposit, fee: "0.0", estimatedProcessingTime: "300" }, + ]); + }); + + it("creates a transfer when no matching static template exists", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))) + .mockResolvedValueOnce(fetchResponse({ count: 0, data: [] })) + .mockResolvedValueOnce( + fetchResponse(staticTemplate({ externalAccountId: "ext-acc-1", currency: "usd", toAddress: deposit })), + ); + + const result = await bridge.getOfframpDepositDetails("ext-acc-1", account, activeCustomer, "USD"); + + expect(result).toStrictEqual([ + { network: "OPTIMISM", displayName: "Optimism", address: deposit, fee: "0.0", estimatedProcessingTime: "300" }, + ]); + expect(fetchSpy).toHaveBeenCalledTimes(3); + const transferCall = fetchSpy.mock.calls[2]; + expect(transferCall?.[0]).toContain("/transfers"); + expect(JSON.parse(transferCall?.[1]?.body as string)).toStrictEqual({ + on_behalf_of: activeCustomer.id, + client_reference_id: account, + source: { currency: "usdc", payment_rail: "optimism" }, + destination: { currency: "usd", payment_rail: "ach", external_account_id: "ext-acc-1" }, + features: { flexible_amount: true, static_template: true, allow_any_from_address: true }, + }); + }); + + it("creates a transfer with the eur payment rail for an eur external account", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("eur"))) + .mockResolvedValueOnce(fetchResponse({ count: 0, data: [] })) + .mockResolvedValueOnce( + fetchResponse(staticTemplate({ externalAccountId: "ext-acc-1", currency: "eur", toAddress: deposit })), + ); + + await bridge.getOfframpDepositDetails("ext-acc-1", account, activeCustomer, "EUR"); + + expect(JSON.parse(fetchSpy.mock.calls[2]?.[1]?.body as string)).toStrictEqual({ + on_behalf_of: activeCustomer.id, + client_reference_id: account, + source: { currency: "usdc", payment_rail: "optimism" }, + destination: { currency: "eur", payment_rail: "sepa", external_account_id: "ext-acc-1" }, + features: { flexible_amount: true, static_template: true, allow_any_from_address: true }, + }); + }); + + it("ignores static templates with mismatched source payment rail", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))) + .mockResolvedValueOnce( + fetchResponse({ + count: 1, + data: [ + staticTemplate({ + externalAccountId: "ext-acc-1", + currency: "usd", + toAddress: deposit, + sourcePaymentRail: "base", + }), + ], + }), + ) + .mockResolvedValueOnce( + fetchResponse(staticTemplate({ externalAccountId: "ext-acc-1", currency: "usd", toAddress: deposit })), + ); + + await bridge.getOfframpDepositDetails("ext-acc-1", account, activeCustomer, "USD"); + + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it("ignores static templates for a different external account", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))) + .mockResolvedValueOnce( + fetchResponse({ + count: 1, + data: [staticTemplate({ externalAccountId: "ext-acc-other", currency: "usd", toAddress: deposit })], + }), + ) + .mockResolvedValueOnce( + fetchResponse(staticTemplate({ externalAccountId: "ext-acc-1", currency: "usd", toAddress: deposit })), + ); + + await bridge.getOfframpDepositDetails("ext-acc-1", account, activeCustomer, "USD"); + + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it("throws on an invalid to_address", async () => { + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))) + .mockResolvedValueOnce( + fetchResponse({ + count: 1, + data: [staticTemplate({ externalAccountId: "ext-acc-1", currency: "usd", toAddress: "not-an-address" })], + }), + ); + + await expect(bridge.getOfframpDepositDetails("ext-acc-1", account, activeCustomer, "USD")).rejects.toThrow( + "bad address", + ); + }); + + it("throws on a null to_address", async () => { + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))) + .mockResolvedValueOnce( + fetchResponse({ + count: 1, + data: [staticTemplate({ externalAccountId: "ext-acc-1", currency: "usd", toAddress: null })], + }), + ); + + await expect(bridge.getOfframpDepositDetails("ext-acc-1", account, activeCustomer, "USD")).rejects.toThrow( + "bad address", + ); + }); + + it("throws NOT_AVAILABLE_CURRENCY when bridge currency has no payment rail mapping", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(fetchResponse(externalAccountResponse("usdc"))); + + await expect(bridge.getOfframpDepositDetails("ext-acc-usdc", account, activeCustomer, "USDC")).rejects.toThrow( + bridge.ErrorCodes.NOT_AVAILABLE_CURRENCY, + ); + }); + + it("throws TRANSFER_IN_USE when an existing template is not in awaiting_funds state", async () => { + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))) + .mockResolvedValueOnce( + fetchResponse({ + count: 1, + data: [ + { + ...staticTemplate({ externalAccountId: "ext-acc-1", currency: "usd", toAddress: deposit }), + state: "funds_received", + }, + ], + }), + ); + + await expect(bridge.getOfframpDepositDetails("ext-acc-1", account, activeCustomer, "USD")).rejects.toThrow( + bridge.ErrorCodes.TRANSFER_IN_USE, + ); + }); + + it("ignores canceled templates and creates a new transfer", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))) + .mockResolvedValueOnce( + fetchResponse({ + count: 1, + data: [ + { + ...staticTemplate({ externalAccountId: "ext-acc-1", currency: "usd", toAddress: deposit }), + state: "canceled", + }, + ], + }), + ) + .mockResolvedValueOnce( + fetchResponse(staticTemplate({ externalAccountId: "ext-acc-1", currency: "usd", toAddress: deposit })), + ); + + const result = await bridge.getOfframpDepositDetails("ext-acc-1", account, activeCustomer, "USD"); + + expect(result).toStrictEqual([ + { network: "OPTIMISM", displayName: "Optimism", address: deposit, fee: "0.0", estimatedProcessingTime: "300" }, + ]); + expect(fetchSpy).toHaveBeenCalledTimes(3); + expect(fetchSpy.mock.calls[2]?.[0] as string).toContain("/transfers"); + }); + }); + + describe("getCryptoOfframpDepositDetails", () => { + const account = parse(Address, padHex("0x1", { size: 20 })); + const deposit = parse(Address, padHex("0xde9", { size: 20 })); + const tronAddress = "TXYZdestinationTRONAddress"; + + it("throws NOT_ACTIVE_CUSTOMER when customer is not active", async () => { + await expect( + bridge.getCryptoOfframpDepositDetails("USDT", "TRON", tronAddress, account, { + ...activeCustomer, + status: "under_review", + }), + ).rejects.toThrow(bridge.ErrorCodes.NOT_ACTIVE_CUSTOMER); + }); + + it("throws NOT_SUPPORTED_CHAIN_ID for unsupported chain", async () => { + chainMock.id = 1; + + await expect( + bridge.getCryptoOfframpDepositDetails("USDT", "TRON", tronAddress, account, activeCustomer), + ).rejects.toThrow(bridge.ErrorCodes.NOT_SUPPORTED_CHAIN_ID); + expect(captureException).toHaveBeenCalledWith( + expect.objectContaining({ message: "bridge not supported chain id" }), + expect.objectContaining({ level: "error" }), + ); + }); + + it("throws NOT_AVAILABLE_CRYPTO_PAYMENT_RAIL when currency is not supported on the payment rail", async () => { + await expect( + bridge.getCryptoOfframpDepositDetails("USDC", "TRON", tronAddress, account, activeCustomer), + ).rejects.toThrow(bridge.ErrorCodes.NOT_AVAILABLE_CRYPTO_PAYMENT_RAIL); + }); + + it("creates a transfer to the TRON address and returns the Optimism deposit details", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + fetchResponse({ + id: "tr-tron-1", + state: "awaiting_funds", + on_behalf_of: activeCustomer.id, + source: { payment_rail: "optimism", currency: "usdc" }, + destination: { payment_rail: "tron", currency: "usdt", to_address: tronAddress }, + source_deposit_instructions: { payment_rail: "optimism", currency: "usdc", to_address: deposit }, + }), + ); + + const result = await bridge.getCryptoOfframpDepositDetails("USDT", "TRON", tronAddress, account, activeCustomer); + + expect(result).toStrictEqual([ + { network: "OPTIMISM", displayName: "Optimism", address: deposit, fee: "0.0", estimatedProcessingTime: "300" }, + ]); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0]?.[0] as string).toContain("/transfers"); + expect(JSON.parse(fetchSpy.mock.calls[0]?.[1]?.body as string)).toStrictEqual({ + on_behalf_of: activeCustomer.id, + client_reference_id: account, + source: { currency: "usdc", payment_rail: "optimism" }, + destination: { currency: "usdt", payment_rail: "tron", to_address: tronAddress }, + features: { flexible_amount: true, allow_any_from_address: true }, + }); + }); + + it("throws on a bad source deposit to_address", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + fetchResponse({ + id: "tr-tron-1", + state: "awaiting_funds", + on_behalf_of: activeCustomer.id, + source: { payment_rail: "optimism", currency: "usdc" }, + destination: { payment_rail: "tron", currency: "usdt", to_address: tronAddress }, + source_deposit_instructions: { payment_rail: "optimism", currency: "usdc", to_address: "not-an-address" }, + }), + ); + + await expect( + bridge.getCryptoOfframpDepositDetails("USDT", "TRON", tronAddress, account, activeCustomer), + ).rejects.toThrow("bad address"); + }); + + it("throws INVALID_DEPOSIT_ADDRESS when bridge rejects to_address", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + fetchError( + 400, + '{"code":"invalid_parameters","message":"Please resubmit","source":{"location":"body","key":{"to_address":"blockchain address format not valid for tron"}}}', + ), + ); + + await expect( + bridge.getCryptoOfframpDepositDetails("USDT", "TRON", tronAddress, account, activeCustomer), + ).rejects.toThrow(bridge.ErrorCodes.INVALID_DEPOSIT_ADDRESS); + }); + + it("rethrows other bridge errors when creating a transfer", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(fetchError(500, "internal error")); + + await expect( + bridge.getCryptoOfframpDepositDetails("USDT", "TRON", tronAddress, account, activeCustomer), + ).rejects.toThrow("internal error"); + }); + + it("creates a USDC transfer on BASE", async () => { + const baseAddress = parse(Address, padHex("0xba5e", { size: 20 })); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + fetchResponse({ + id: "tr-base-1", + state: "awaiting_funds", + on_behalf_of: activeCustomer.id, + source: { payment_rail: "optimism", currency: "usdc" }, + destination: { payment_rail: "base", currency: "usdc", to_address: baseAddress }, + source_deposit_instructions: { payment_rail: "optimism", currency: "usdc", to_address: deposit }, + }), + ); + + const result = await bridge.getCryptoOfframpDepositDetails("USDC", "BASE", baseAddress, account, activeCustomer); + + expect(result).toStrictEqual([ + { network: "OPTIMISM", displayName: "Optimism", address: deposit, fee: "0.0", estimatedProcessingTime: "300" }, + ]); + expect(JSON.parse(fetchSpy.mock.calls[0]?.[1]?.body as string)).toStrictEqual({ + on_behalf_of: activeCustomer.id, + client_reference_id: account, + source: { currency: "usdc", payment_rail: "optimism" }, + destination: { currency: "usdc", payment_rail: "base", to_address: baseAddress }, + features: { flexible_amount: true, allow_any_from_address: true }, + }); + }); + + it("creates a USDC transfer on SOLANA", async () => { + const solanaAddress = "SoLAnAdestinationAddress11111111111111111111"; + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + fetchResponse({ + id: "tr-sol-1", + state: "awaiting_funds", + on_behalf_of: activeCustomer.id, + source: { payment_rail: "optimism", currency: "usdc" }, + destination: { payment_rail: "solana", currency: "usdc", to_address: solanaAddress }, + source_deposit_instructions: { payment_rail: "optimism", currency: "usdc", to_address: deposit }, + }), + ); + + await bridge.getCryptoOfframpDepositDetails("USDC", "SOLANA", solanaAddress, account, activeCustomer); + + expect(JSON.parse(fetchSpy.mock.calls[0]?.[1]?.body as string)).toStrictEqual({ + on_behalf_of: activeCustomer.id, + client_reference_id: account, + source: { currency: "usdc", payment_rail: "optimism" }, + destination: { currency: "usdc", payment_rail: "solana", to_address: solanaAddress }, + features: { flexible_amount: true, allow_any_from_address: true }, + }); + }); + + it("creates a USDC transfer on STELLAR forwarding the memo as blockchain_memo", async () => { + const stellarAddress = "GABCDEFGHIJKLMNOPQRSTUVWXYZSTELLARDESTINATION"; + const memo = "12345"; + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + fetchResponse({ + id: "tr-stellar-1", + state: "awaiting_funds", + on_behalf_of: activeCustomer.id, + source: { payment_rail: "optimism", currency: "usdc" }, + destination: { payment_rail: "stellar", currency: "usdc", to_address: stellarAddress }, + source_deposit_instructions: { payment_rail: "optimism", currency: "usdc", to_address: deposit }, + }), + ); + + await bridge.getCryptoOfframpDepositDetails("USDC", "STELLAR", stellarAddress, account, activeCustomer, memo); + + expect(JSON.parse(fetchSpy.mock.calls[0]?.[1]?.body as string)).toStrictEqual({ + on_behalf_of: activeCustomer.id, + client_reference_id: account, + source: { currency: "usdc", payment_rail: "optimism" }, + destination: { currency: "usdc", payment_rail: "stellar", to_address: stellarAddress, blockchain_memo: memo }, + features: { flexible_amount: true, allow_any_from_address: true }, + }); + }); + }); + + describe("createExternalAccount", () => { + const usdAddress = { streetLine1: "123 Main St", city: "Anytown", state: "CA", country: "USA" }; + + it("rejects USD input without state at the schema level", () => { + const result = safeParse(bridge.ExternalAccountInput, { + currency: "USD", + accountOwnerName: "John Doe", + accountNumber: "1210002481111", + routingNumber: "121000248", + address: { streetLine1: usdAddress.streetLine1, city: usdAddress.city, country: usdAddress.country }, + }); + expect(result.success).toBe(false); + expect(result.issues?.some((issue) => issue.path?.at(-1)?.key === "state")).toBe(true); + }); + + it("accepts non-USD input without state at the schema level", () => { + const result = safeParse(bridge.ExternalAccountInput, { + currency: "EUR", + accountOwnerName: "Jane Doe", + accountOwnerType: "individual", + firstName: "Jane", + lastName: "Doe", + accountNumber: "DE89370400440532013000", + country: "DEU", + }); + expect(result.success).toBe(true); + }); + + it("throws NO_ENDORSEMENT when the customer lacks the required endorsement", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + await expect( + bridge.createExternalAccount(activeCustomer, { + currency: "USD", + accountOwnerName: "John Doe", + accountNumber: "1210002481111", + routingNumber: "121000248", + address: usdAddress, + }), + ).rejects.toThrow(bridge.ErrorCodes.NO_ENDORSEMENT); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("posts a US bank account to bridge and returns the ExternalAccount shape", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))); + + const result = await bridge.createExternalAccount(activeCustomerWithBaseEndorsement, { + currency: "USD", + accountOwnerName: "John Doe", + accountNumber: "1210002481111", + routingNumber: "121000248", + checkingOrSavings: "checking", + bankName: "Test Bank", + address: usdAddress, + }); + + expect(result).toStrictEqual({ + addressValid: true, + bankName: "Test Bank", + currency: "USD", + id: "ext-acc-1", + ownerName: "John Doe", + }); + expect(fetchSpy).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining("/customers/cust-123/external_accounts"), + expect.objectContaining({ method: "POST" }), + ); + expect(JSON.parse(fetchSpy.mock.calls[0]?.[1]?.body as string)).toStrictEqual({ + account_type: "us", + currency: "usd", + account_owner_name: "John Doe", + bank_name: "Test Bank", + account: { account_number: "1210002481111", routing_number: "121000248", checking_or_savings: "checking" }, + address: { city: "Anytown", country: "USA", state: "CA", street_line_1: "123 Main St" }, + }); + }); + + it("returns bankName and ownerName from the bridge response, not the request input", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + fetchResponse({ + ...externalAccountResponse("usd"), + account_owner_name: "JOHN DOE", + bank_name: "Test Bank Normalized", + }), + ); + + const result = await bridge.createExternalAccount(activeCustomerWithBaseEndorsement, { + currency: "USD", + accountOwnerName: " John Doe ", + accountNumber: "1210002481111", + routingNumber: "121000248", + bankName: "test bank", + address: usdAddress, + }); + + expect(result).toStrictEqual({ + addressValid: true, + bankName: "Test Bank Normalized", + currency: "USD", + id: "ext-acc-1", + ownerName: "JOHN DOE", + }); + }); + + it("posts an EUR IBAN account for an individual", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("eur"))); + + await bridge.createExternalAccount(activeCustomerWithSepaEndorsement, { + currency: "EUR", + accountOwnerName: "Jane Doe", + accountOwnerType: "individual", + firstName: "Jane", + lastName: "Doe", + accountNumber: "DE89370400440532013000", + country: "DEU", + }); + + expect(JSON.parse(fetchSpy.mock.calls[0]?.[1]?.body as string)).toStrictEqual({ + account_type: "iban", + currency: "eur", + account_owner_name: "Jane Doe", + account_owner_type: "individual", + first_name: "Jane", + last_name: "Doe", + iban: { account_number: "DE89370400440532013000", country: "DEU" }, + }); + }); + + it("posts an EUR IBAN account for a business", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("eur"))); + + await bridge.createExternalAccount(activeCustomerWithSepaEndorsement, { + currency: "EUR", + accountOwnerName: "Acme GmbH", + accountOwnerType: "business", + businessName: "Acme GmbH", + accountNumber: "DE89370400440532013000", + country: "DEU", + }); + + expect(JSON.parse(fetchSpy.mock.calls[0]?.[1]?.body as string)).toStrictEqual({ + account_type: "iban", + currency: "eur", + account_owner_name: "Acme GmbH", + account_owner_type: "business", + business_name: "Acme GmbH", + iban: { account_number: "DE89370400440532013000", country: "DEU" }, + }); + }); + + it("forwards the IBAN bic when provided", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("eur"))); + + await bridge.createExternalAccount(activeCustomerWithSepaEndorsement, { + currency: "EUR", + accountOwnerName: "Jane Doe", + accountOwnerType: "individual", + firstName: "Jane", + lastName: "Doe", + accountNumber: "DE89370400440532013000", + bic: "COBADEFFXXX", + country: "DEU", + }); + + expect(JSON.parse(fetchSpy.mock.calls[0]?.[1]?.body as string)).toStrictEqual( + expect.objectContaining({ + iban: { account_number: "DE89370400440532013000", bic: "COBADEFFXXX", country: "DEU" }, + }), + ); + }); + + it("posts an MXN CLABE account", async () => { + const customer = { ...activeCustomer, endorsements: [endorsement("spei", "approved")] }; + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("mxn"))); + + await bridge.createExternalAccount(customer, { + currency: "MXN", + accountOwnerName: "Juan Perez", + clabe: "646180171800000178", + }); + + expect(JSON.parse(fetchSpy.mock.calls[0]?.[1]?.body as string)).toStrictEqual({ + account_type: "clabe", + currency: "mxn", + account_owner_name: "Juan Perez", + clabe: { account_number: "646180171800000178" }, + }); + }); + + it("posts a BRL Pix key account", async () => { + const customer = { ...activeCustomer, endorsements: [endorsement("pix", "approved")] }; + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("brl"))); + + await bridge.createExternalAccount(customer, { + currency: "BRL", + accountOwnerName: "Joao Silva", + account: { pixKey: "12345678901", documentNumber: "12345678901" }, + }); + + expect(JSON.parse(fetchSpy.mock.calls[0]?.[1]?.body as string)).toStrictEqual({ + account_type: "pix", + currency: "brl", + account_owner_name: "Joao Silva", + pix_key: { pix_key: "12345678901", document_number: "12345678901" }, + }); + }); + + it("posts a BRL Pix BR Code account", async () => { + const customer = { ...activeCustomer, endorsements: [endorsement("pix", "approved")] }; + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("brl"))); + + await bridge.createExternalAccount(customer, { + currency: "BRL", + accountOwnerName: "Joao Silva", + account: { brCode: "00020126580014br.gov.bcb.pix", documentNumber: "12345678901" }, + }); + + expect(JSON.parse(fetchSpy.mock.calls[0]?.[1]?.body as string)).toStrictEqual({ + account_type: "pix", + currency: "brl", + account_owner_name: "Joao Silva", + br_code: { br_code: "00020126580014br.gov.bcb.pix", document_number: "12345678901" }, + }); + }); + + it("posts a GBP Faster Payments account for an individual", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("gbp"))); + + await bridge.createExternalAccount(activeCustomerWithFasterPaymentsEndorsement, { + currency: "GBP", + accountOwnerName: "Holly Smith", + accountOwnerType: "individual", + firstName: "Holly", + lastName: "Smith", + accountNumber: "12345678", + sortCode: "123456", + }); + + expect(JSON.parse(fetchSpy.mock.calls[0]?.[1]?.body as string)).toStrictEqual({ + account_type: "gb", + currency: "gbp", + account_owner_name: "Holly Smith", + account_owner_type: "individual", + first_name: "Holly", + last_name: "Smith", + account: { account_number: "12345678", sort_code: "123456" }, + }); + }); + + it("posts a GBP Faster Payments account for a business", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("gbp"))); + + await bridge.createExternalAccount(activeCustomerWithFasterPaymentsEndorsement, { + currency: "GBP", + accountOwnerName: "Acme Ltd", + accountOwnerType: "business", + businessName: "Acme Ltd", + accountNumber: "12345678", + sortCode: "123456", + }); + + expect(JSON.parse(fetchSpy.mock.calls[0]?.[1]?.body as string)).toStrictEqual({ + account_type: "gb", + currency: "gbp", + account_owner_name: "Acme Ltd", + account_owner_type: "business", + business_name: "Acme Ltd", + account: { account_number: "12345678", sort_code: "123456" }, + }); + }); + + it("posts a GBP Faster Payments account without an owner type", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("gbp"))); + + await bridge.createExternalAccount(activeCustomerWithFasterPaymentsEndorsement, { + currency: "GBP", + accountOwnerName: "Holly Smith", + accountNumber: "12345678", + sortCode: "123456", + }); + + expect(JSON.parse(fetchSpy.mock.calls[0]?.[1]?.body as string)).toStrictEqual({ + account_type: "gb", + currency: "gbp", + account_owner_name: "Holly Smith", + account: { account_number: "12345678", sort_code: "123456" }, + }); + }); + }); + + describe("updateExternalAccount", () => { + const address = { streetLine1: "10 Downing St", city: "London", state: "ENG", country: "GBR", postalCode: "SW1A" }; + + it("sends a PUT with address and account and returns the ExternalAccount shape", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))); + + const result = await bridge.updateExternalAccount(activeCustomer, "ext-acc-1", { + address, + account: { routingNumber: "121000248", checkingOrSavings: "savings" }, + }); + + expect(result).toStrictEqual({ + addressValid: true, + bankName: "Test Bank", + currency: "USD", + id: "ext-acc-1", + ownerName: "John Doe", + }); + expect(fetchSpy).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining("/customers/cust-123/external_accounts/ext-acc-1"), + expect.objectContaining({ method: "PUT" }), + ); + expect(JSON.parse(fetchSpy.mock.calls[0]?.[1]?.body as string)).toStrictEqual({ + address: { + street_line_1: "10 Downing St", + city: "London", + state: "ENG", + country: "GBR", + postal_code: "SW1A", + }, + account: { routing_number: "121000248", checking_or_savings: "savings" }, + }); + }); + + it("omits address when not provided", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))); + + await bridge.updateExternalAccount(activeCustomer, "ext-acc-1", { + account: { routingNumber: "121000248" }, + }); + + expect(JSON.parse(fetchSpy.mock.calls[0]?.[1]?.body as string)).toStrictEqual({ + account: { routing_number: "121000248" }, + }); + }); + + it("omits account when not provided", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("eur"))); + + await bridge.updateExternalAccount(activeCustomer, "ext-acc-1", { address }); + + expect(JSON.parse(fetchSpy.mock.calls[0]?.[1]?.body as string)).toStrictEqual({ + address: { + street_line_1: "10 Downing St", + city: "London", + state: "ENG", + country: "GBR", + postal_code: "SW1A", + }, + }); + }); + + it("rejects empty update payloads at the schema level", () => { + const result = safeParse(bridge.UpdateExternalAccountInput, {}); + expect(result.success).toBe(false); + expect(result.issues?.[0]?.message).toBe("address or account is required"); + }); + + it("rejects account-only updates with no fields at the schema level", () => { + const result = safeParse(bridge.UpdateExternalAccountInput, { account: {} }); + expect(result.success).toBe(false); + expect(result.issues?.[0]?.message).toBe("account requires at least one field"); + }); + + it("normalizes bridge 404 into EXTERNAL_ACCOUNT_NOT_FOUND", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(fetchError(404, "not_found")); + + await expect(bridge.updateExternalAccount(activeCustomer, "ext-acc-missing", { address })).rejects.toThrow( + bridge.ErrorCodes.EXTERNAL_ACCOUNT_NOT_FOUND, + ); + }); + + it("propagates non-404 bridge errors", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(fetchError(500, "internal error")); + + await expect(bridge.updateExternalAccount(activeCustomer, "ext-acc-1", { address })).rejects.toThrow( + "internal error", + ); + }); + }); + + describe("listExternalAccounts", () => { + it("returns mapped fiat accounts on a single page", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse({ count: 1, data: [externalAccountResponse("usd")] })); + + const result = await bridge.listExternalAccounts("cust-123"); + + expect(result).toStrictEqual([ + { addressValid: true, bankName: "Test Bank", currency: "USD", id: "ext-acc-1", ownerName: "John Doe" }, + ]); + expect(fetchSpy).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining("/customers/cust-123/external_accounts?limit=20"), + expect.objectContaining({ method: "GET" }), + ); + }); + + it("filters out non-fiat currencies", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + fetchResponse({ count: 2, data: [externalAccountResponse("usd"), externalAccountResponse("usdc")] }), + ); + + const result = await bridge.listExternalAccounts("cust-123"); + + expect(result).toStrictEqual([ + { addressValid: true, bankName: "Test Bank", currency: "USD", id: "ext-acc-1", ownerName: "John Doe" }, + ]); + }); + + it("filters out inactive accounts", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + fetchResponse({ + count: 2, + data: [ + { ...externalAccountResponse("usd"), id: "ext-acc-active" }, + { ...externalAccountResponse("eur"), id: "ext-acc-inactive", active: false }, + ], + }), + ); + + const result = await bridge.listExternalAccounts("cust-123"); + + expect(result).toStrictEqual([ + { addressValid: true, bankName: "Test Bank", currency: "USD", id: "ext-acc-active", ownerName: "John Doe" }, + ]); + }); + + it("paginates and reports pagination via captureException", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + fetchResponse({ count: 2, data: [{ ...externalAccountResponse("usd"), id: "ext-acc-1" }] }), + ) + .mockResolvedValueOnce( + fetchResponse({ count: 2, data: [{ ...externalAccountResponse("eur"), id: "ext-acc-2" }] }), + ); + + const result = await bridge.listExternalAccounts("cust-123"); + + expect(result).toHaveLength(2); + expect(result.map((account) => account.id)).toStrictEqual(["ext-acc-1", "ext-acc-2"]); + expect(fetchSpy.mock.calls[1]?.[0] as string).toContain("starting_after=ext-acc-1"); + expect(captureException).toHaveBeenCalledWith( + expect.objectContaining({ message: "bridge external accounts pagination" }), + { level: "warning", contexts: { bridge: { customerId: "cust-123", count: 2 } } }, + ); + }); + + it("stops paginating when a subsequent page returns empty data", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + fetchResponse({ count: 5, data: [{ ...externalAccountResponse("usd"), id: "ext-acc-1" }] }), + ) + .mockResolvedValueOnce(fetchResponse({ count: 5, data: [] })); + + const result = await bridge.listExternalAccounts("cust-123"); + + expect(result.map((account) => account.id)).toStrictEqual(["ext-acc-1"]); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it("returns an empty list when no accounts exist", async () => { + vi.mocked(captureException).mockClear(); + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(fetchResponse({ count: 0, data: [] })); + + const result = await bridge.listExternalAccounts("cust-123"); + + expect(result).toStrictEqual([]); + expect(captureException).not.toHaveBeenCalled(); + }); + }); + + describe("removeExternalAccount", () => { + it("throws EXTERNAL_ACCOUNT_NOT_FOUND when external account is missing", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchError(404, "not_found")) + .mockResolvedValueOnce(fetchResponse({ count: 0, data: [] })); + + await expect(bridge.removeExternalAccount(activeCustomer, "ext-acc-missing")).rejects.toThrow( + bridge.ErrorCodes.EXTERNAL_ACCOUNT_NOT_FOUND, + ); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it("deletes the awaiting_funds static template before the external account", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))) + .mockResolvedValueOnce( + fetchResponse({ + count: 1, + data: [staticTemplate({ externalAccountId: "ext-acc-1", currency: "usd", toAddress: null })], + }), + ) + .mockResolvedValueOnce( + fetchResponse(staticTemplate({ externalAccountId: "ext-acc-1", currency: "usd", toAddress: null })), + ) + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))); + + await bridge.removeExternalAccount(activeCustomer, "ext-acc-1"); + + expect(fetchSpy).toHaveBeenCalledTimes(4); + expect(fetchSpy.mock.calls[2]?.[0] as string).toContain("/transfers/tr-ext-acc-1-usd"); + expect(fetchSpy.mock.calls[2]?.[1]?.method).toBe("DELETE"); + expect(fetchSpy.mock.calls[3]?.[0] as string).toContain("/customers/cust-123/external_accounts/ext-acc-1"); + expect(fetchSpy.mock.calls[3]?.[1]?.method).toBe("DELETE"); + }); + + it("aborts without deleting the external account when transfer deletion fails", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))) + .mockResolvedValueOnce( + fetchResponse({ + count: 1, + data: [staticTemplate({ externalAccountId: "ext-acc-1", currency: "usd", toAddress: null })], + }), + ) + .mockResolvedValueOnce(fetchError(500, "transfer delete failed")); + + await expect(bridge.removeExternalAccount(activeCustomer, "ext-acc-1")).rejects.toThrow("transfer delete failed"); + + const transferDeleteCalls = fetchSpy.mock.calls.filter( + ([url, init]) => init?.method === "DELETE" && (url as string).includes("/transfers/tr-ext-acc-1-usd"), + ); + expect(transferDeleteCalls).toHaveLength(1); + const accountDeleteCalls = fetchSpy.mock.calls.filter( + ([url, init]) => init?.method === "DELETE" && (url as string).includes("/external_accounts/ext-acc-1"), + ); + expect(accountDeleteCalls).toHaveLength(0); + }); + + it("deletes the external account on a follow-up call when the transfer is already gone", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))) + .mockResolvedValueOnce(fetchResponse({ count: 0, data: [] })) + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))); + + await bridge.removeExternalAccount(activeCustomer, "ext-acc-1"); + + const transferDeleteCalls = fetchSpy.mock.calls.filter( + ([url, init]) => init?.method === "DELETE" && (url as string).includes("/transfers/"), + ); + expect(transferDeleteCalls).toHaveLength(0); + const accountDeleteCalls = fetchSpy.mock.calls.filter( + ([url, init]) => init?.method === "DELETE" && (url as string).includes("/external_accounts/ext-acc-1"), + ); + expect(accountDeleteCalls).toHaveLength(1); + }); + + it("deletes only the external account when no templates exist", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))) + .mockResolvedValueOnce(fetchResponse({ count: 0, data: [] })) + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))); + + await bridge.removeExternalAccount(activeCustomer, "ext-acc-1"); + + const deleteCalls = fetchSpy.mock.calls.filter(([, init]) => init?.method === "DELETE"); + expect(deleteCalls).toHaveLength(1); + expect(deleteCalls[0]?.[0] as string).toContain("/customers/cust-123/external_accounts/ext-acc-1"); + }); + + it("throws TRANSFER_IN_USE when matching template is not in awaiting_funds state", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))) + .mockResolvedValueOnce( + fetchResponse({ + count: 1, + data: [ + { + ...staticTemplate({ externalAccountId: "ext-acc-1", currency: "usd", toAddress: null }), + state: "funds_received", + }, + ], + }), + ); + + await expect(bridge.removeExternalAccount(activeCustomer, "ext-acc-1")).rejects.toThrow( + bridge.ErrorCodes.TRANSFER_IN_USE, + ); + + const deleteCalls = fetchSpy.mock.calls.filter(([, init]) => init?.method === "DELETE"); + expect(deleteCalls).toHaveLength(0); + }); + + it("skips canceled templates and deletes only the external account", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))) + .mockResolvedValueOnce( + fetchResponse({ + count: 1, + data: [ + { + ...staticTemplate({ externalAccountId: "ext-acc-1", currency: "usd", toAddress: null }), + state: "canceled", + }, + ], + }), + ) + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))); + + await bridge.removeExternalAccount(activeCustomer, "ext-acc-1"); + + const deleteCalls = fetchSpy.mock.calls.filter(([, init]) => init?.method === "DELETE"); + expect(deleteCalls).toHaveLength(1); + expect(deleteCalls[0]?.[0] as string).toContain("/customers/cust-123/external_accounts/ext-acc-1"); + }); + + it("skips template deletion when template belongs to a different external account", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))) + .mockResolvedValueOnce( + fetchResponse({ + count: 1, + data: [staticTemplate({ externalAccountId: "ext-acc-other", currency: "usd", toAddress: null })], + }), + ) + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))); + + await bridge.removeExternalAccount(activeCustomer, "ext-acc-1"); + + const deleteCalls = fetchSpy.mock.calls.filter(([, init]) => init?.method === "DELETE"); + expect(deleteCalls).toHaveLength(1); + expect(deleteCalls[0]?.[0] as string).toContain("/customers/cust-123/external_accounts/ext-acc-1"); + }); + + it("deletes every awaiting_funds template for the external account in parallel", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))) + .mockResolvedValueOnce( + fetchResponse({ + count: 2, + data: [ + { ...staticTemplate({ externalAccountId: "ext-acc-1", currency: "usd", toAddress: null }), id: "tr-a" }, + { ...staticTemplate({ externalAccountId: "ext-acc-1", currency: "usd", toAddress: null }), id: "tr-b" }, + ], + }), + ) + .mockResolvedValueOnce(fetchResponse({})) + .mockResolvedValueOnce(fetchResponse({})) + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))); + + await bridge.removeExternalAccount(activeCustomer, "ext-acc-1"); + + const deleteCalls = fetchSpy.mock.calls.filter(([, init]) => init?.method === "DELETE"); + expect(deleteCalls).toHaveLength(3); + const urls = deleteCalls.map(([url]) => url as string); + expect(urls.some((url) => url.includes("/transfers/tr-a"))).toBe(true); + expect(urls.some((url) => url.includes("/transfers/tr-b"))).toBe(true); + expect(urls.some((url) => url.includes("/customers/cust-123/external_accounts/ext-acc-1"))).toBe(true); + }); + + it("throws TRANSFER_IN_USE when any of the matching templates is not awaiting_funds", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))) + .mockResolvedValueOnce( + fetchResponse({ + count: 2, + data: [ + { ...staticTemplate({ externalAccountId: "ext-acc-1", currency: "usd", toAddress: null }), id: "tr-a" }, + { + ...staticTemplate({ externalAccountId: "ext-acc-1", currency: "usd", toAddress: null }), + id: "tr-b", + state: "funds_received", + }, + ], + }), + ); + + await expect(bridge.removeExternalAccount(activeCustomer, "ext-acc-1")).rejects.toThrow( + bridge.ErrorCodes.TRANSFER_IN_USE, + ); + + const deleteCalls = fetchSpy.mock.calls.filter(([, init]) => init?.method === "DELETE"); + expect(deleteCalls).toHaveLength(0); + }); + + it("ignores canceled templates and deletes only the awaiting_funds ones", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))) + .mockResolvedValueOnce( + fetchResponse({ + count: 2, + data: [ + { + ...staticTemplate({ externalAccountId: "ext-acc-1", currency: "usd", toAddress: null }), + id: "tr-canceled", + state: "canceled", + }, + { + ...staticTemplate({ externalAccountId: "ext-acc-1", currency: "usd", toAddress: null }), + id: "tr-live", + }, + ], + }), + ) + .mockResolvedValueOnce(fetchResponse({})) + .mockResolvedValueOnce(fetchResponse(externalAccountResponse("usd"))); + + await bridge.removeExternalAccount(activeCustomer, "ext-acc-1"); + + const deleteCalls = fetchSpy.mock.calls.filter(([, init]) => init?.method === "DELETE"); + expect(deleteCalls).toHaveLength(2); + const urls = deleteCalls.map(([url]) => url as string); + expect(urls.some((url) => url.includes("/transfers/tr-live"))).toBe(true); + expect(urls.every((url) => !url.includes("/transfers/tr-canceled"))).toBe(true); + expect(urls.some((url) => url.includes("/external_accounts/ext-acc-1"))).toBe(true); + }); + }); +}); + +function externalAccountResponse(currency: "brl" | "eur" | "gbp" | "mxn" | "usd" | "usdc") { + return { + id: "ext-acc-1", + customer_id: "cust-123", + account_type: currency === "usd" ? "us" : "iban", + currency, + account_owner_name: "John Doe", + bank_name: "Test Bank", + active: true, + beneficiary_address_valid: true, + }; +} + +function staticTemplate({ + externalAccountId, + currency, + toAddress, + sourcePaymentRail = "optimism", +}: { + currency: "brl" | "eur" | "gbp" | "mxn" | "usd"; + externalAccountId: string; + sourcePaymentRail?: "base" | "optimism"; + toAddress: null | string; +}) { + const destinationPaymentRail = { brl: "pix", eur: "sepa", gbp: "faster_payments", mxn: "spei", usd: "ach" }[currency]; + return { + id: `tr-${externalAccountId}-${currency}`, + state: "awaiting_funds" as const, + on_behalf_of: "cust-123", + source: { payment_rail: sourcePaymentRail, currency: "usdc" }, + destination: { payment_rail: destinationPaymentRail, currency, external_account_id: externalAccountId }, + source_deposit_instructions: { payment_rail: sourcePaymentRail, currency: "usdc", to_address: toAddress }, + }; +} const identityDocument = { id_class: { value: "pp" }, diff --git a/server/utils/ramps/bridge.ts b/server/utils/ramps/bridge.ts index 0ee74618e..5add76514 100644 --- a/server/utils/ramps/bridge.ts +++ b/server/utils/ramps/bridge.ts @@ -1,10 +1,16 @@ -import { captureException, withScope } from "@sentry/core"; +import { captureException, setContext, withScope } from "@sentry/core"; import { eq } from "drizzle-orm"; import { alpha2ToAlpha3 } from "i18n-iso-countries"; import crypto from "node:crypto"; import { array, + boolean, + check, + flatten, + length, literal, + maxLength, + minLength, nullish, number, object, @@ -12,11 +18,13 @@ import { parse, picklist, pipe, + regex, safeParse, string, union, unknown, url as urlValidator, + ValiError, variant, type BaseIssue, type BaseSchema, @@ -38,6 +46,7 @@ export const name = "bridge" as const; export const EVMNetwork = ["BASE"] as const; export const Network = [...EVMNetwork, "SOLANA", "STELLAR", "TRON"] as const; +export const OfframpNetwork = ["BASE", "SOLANA", "STELLAR", "TRON"]; if (!process.env.BRIDGE_API_URL) throw new Error("missing bridge api url"); const baseURL = process.env.BRIDGE_API_URL; @@ -162,6 +171,34 @@ export async function getLiquidationAddresses(customerId: string) { return all; } +export async function createTransfer(data: InferInput, idempotencyKey?: string) { + return await request(Transfer, "/transfers", {}, data, "POST", 15_000, idempotencyKey); +} + +export async function getTransfers(customerId: string) { + return await request(Transfers, `/customers/${customerId}/transfers`, {}, undefined, "GET"); +} + +export async function getStaticTemplates(customerId: string) { + const path = `/customers/${customerId}/transfers/static_templates` as const; + const first = await request(StaticTemplates, `${path}?limit=50`); + const all = [...first.data]; + const paginated = all.length < first.count; + while (all.length < first.count) { + const last = all.at(-1); + if (!last) break; + const page = await request(StaticTemplates, `${path}?limit=50&starting_after=${last.id}`); + if (page.data.length === 0) break; + all.push(...page.data); + } + if (paginated) + captureException(new Error("bridge static templates pagination"), { + level: "warning", + contexts: { bridge: { customerId, count: first.count } }, + }); + return all; +} + export function getKYCLink(customerId: string, redirectUri?: string, endorsement?: (typeof Endorsements)[number]) { const params = new URLSearchParams(); if (endorsement) params.set("endorsement", endorsement); @@ -172,6 +209,240 @@ export function getKYCLink(customerId: string, redirectUri?: string, endorsement ).then((result) => result.url); } +export async function createExternalAccount( + customer: InferOutput, + externalAccount: InferInput, +) { + const approved = customer.endorsements.some( + (endorsement) => + endorsement.status === "approved" && CurrencyByEndorsement[endorsement.name].includes(externalAccount.currency), + ); + if (!approved) throw new Error(ErrorCodes.NO_ENDORSEMENT); + return await request( + BridgeExternalAccount, + `/customers/${customer.id}/external_accounts`, + {}, + ((): InferInput => { + switch (externalAccount.currency) { + case "USD": + return { + account_type: "us", + currency: "usd", + account_owner_name: externalAccount.accountOwnerName, + bank_name: externalAccount.bankName, + account: { + account_number: externalAccount.accountNumber, + routing_number: externalAccount.routingNumber, + checking_or_savings: externalAccount.checkingOrSavings, + }, + address: { + city: externalAccount.address.city, + country: externalAccount.address.country, + street_line_1: externalAccount.address.streetLine1, + street_line_2: externalAccount.address.streetLine2, + state: externalAccount.address.state, + postal_code: externalAccount.address.postalCode, + }, + }; + case "EUR": + return externalAccount.accountOwnerType === "individual" + ? { + account_type: "iban", + currency: "eur", + account_owner_name: externalAccount.accountOwnerName, + account_owner_type: "individual", + first_name: externalAccount.firstName, + last_name: externalAccount.lastName, + bank_name: externalAccount.bankName, + iban: { + account_number: externalAccount.accountNumber, + bic: externalAccount.bic, + country: externalAccount.country, + }, + } + : { + account_type: "iban", + currency: "eur", + account_owner_name: externalAccount.accountOwnerName, + account_owner_type: "business", + business_name: externalAccount.businessName, + bank_name: externalAccount.bankName, + iban: { + account_number: externalAccount.accountNumber, + bic: externalAccount.bic, + country: externalAccount.country, + }, + }; + case "MXN": + return { + account_type: "clabe", + currency: "mxn", + account_owner_name: externalAccount.accountOwnerName, + bank_name: externalAccount.bankName, + clabe: { account_number: externalAccount.clabe }, + }; + case "BRL": + return "pixKey" in externalAccount.account + ? { + account_type: "pix", + currency: "brl", + account_owner_name: externalAccount.accountOwnerName, + bank_name: externalAccount.bankName, + pix_key: { + pix_key: externalAccount.account.pixKey, + document_number: externalAccount.account.documentNumber, + }, + } + : { + account_type: "pix", + currency: "brl", + account_owner_name: externalAccount.accountOwnerName, + bank_name: externalAccount.bankName, + br_code: { + br_code: externalAccount.account.brCode, + document_number: externalAccount.account.documentNumber, + }, + }; + case "GBP": + if (!("accountOwnerType" in externalAccount)) { + return { + account_type: "gb", + currency: "gbp", + account_owner_name: externalAccount.accountOwnerName, + bank_name: externalAccount.bankName, + account: { account_number: externalAccount.accountNumber, sort_code: externalAccount.sortCode }, + }; + } + return externalAccount.accountOwnerType === "individual" + ? { + account_type: "gb", + currency: "gbp", + account_owner_name: externalAccount.accountOwnerName, + account_owner_type: "individual", + first_name: externalAccount.firstName, + last_name: externalAccount.lastName, + bank_name: externalAccount.bankName, + account: { account_number: externalAccount.accountNumber, sort_code: externalAccount.sortCode }, + } + : { + account_type: "gb", + currency: "gbp", + account_owner_name: externalAccount.accountOwnerName, + account_owner_type: "business", + business_name: externalAccount.businessName, + bank_name: externalAccount.bankName, + account: { account_number: externalAccount.accountNumber, sort_code: externalAccount.sortCode }, + }; + } + })(), + "POST", + ).then( + ({ beneficiary_address_valid, bank_name, currency, id, account_owner_name }) => + ({ + addressValid: beneficiary_address_valid, + bankName: bank_name, + currency: parse(picklist(FiatCurrency), FiatByBridgeCurrency[currency]), + id, + ownerName: account_owner_name, + }) satisfies InferOutput, + ); +} + +export function updateExternalAccount( + customer: InferOutput, + externalAccountId: string, + update: InferInput, +) { + return request( + BridgeExternalAccount, + `/customers/${customer.id}/external_accounts/${externalAccountId}`, + {}, + { + address: update.address && { + street_line_1: update.address.streetLine1, + street_line_2: update.address.streetLine2, + city: update.address.city, + state: update.address.state, + postal_code: update.address.postalCode, + country: update.address.country, + }, + account: update.account && { + checking_or_savings: update.account.checkingOrSavings, + routing_number: update.account.routingNumber, + }, + } satisfies InferInput, + "PUT", + ) + .catch((error: unknown) => { + if ( + error instanceof ServiceError && + typeof error.cause === "string" && + error.cause.includes(BridgeApiErrorCodes.NOT_FOUND) + ) { + throw new Error(ErrorCodes.EXTERNAL_ACCOUNT_NOT_FOUND); + } + throw error; + }) + .then( + (externalAccount) => + ({ + addressValid: externalAccount.beneficiary_address_valid, + bankName: externalAccount.bank_name, + currency: parse(picklist(FiatCurrency), FiatByBridgeCurrency[externalAccount.currency]), + id: externalAccount.id, + ownerName: externalAccount.account_owner_name, + }) satisfies InferOutput, + ); +} + +export async function getExternalAccount(customerId: string, externalAccountId: string) { + return await request(BridgeExternalAccount, `/customers/${customerId}/external_accounts/${externalAccountId}`).catch( + (error: unknown) => { + if ( + error instanceof ServiceError && + typeof error.cause === "string" && + error.cause.includes(BridgeApiErrorCodes.NOT_FOUND) + ) { + return; + } + throw error; + }, + ); +} + +export async function listExternalAccounts(customerId: string) { + const path = `/customers/${customerId}/external_accounts` as const; + const first = await request(ExternalAccounts, `${path}?limit=20`); + const accounts = [...first.data]; + const paginated = accounts.length < first.count; + while (accounts.length < first.count) { + const last = accounts.at(-1); + if (!last) break; + const page = await request(ExternalAccounts, `${path}?limit=20&starting_after=${last.id}`); + if (page.data.length === 0) break; + accounts.push(...page.data); + } + if (paginated) + captureException(new Error("bridge external accounts pagination"), { + level: "warning", + contexts: { bridge: { customerId, count: first.count } }, + }); + return accounts.flatMap((account) => { + const currency = FiatByBridgeCurrency[account.currency]; + if (!currency) return []; + if (!account.active) return []; + return [ + { + addressValid: account.beneficiary_address_valid, + bankName: account.bank_name, + currency, + id: account.id, + ownerName: account.account_owner_name, + } satisfies InferOutput, + ]; + }); +} + export async function getProvider(params: { countryCode?: string; credentialId: string; @@ -180,15 +451,23 @@ export async function getProvider(params: { }) { if (!Supported[chain.id]) { captureException(new Error("bridge not supported chain id"), { contexts: { chain }, level: "error" }); - return { onramp: { currencies: [] }, status: "NOT_AVAILABLE" as const }; + return { onramp: { currencies: [] }, offramp: { currencies: [] }, status: "NOT_AVAILABLE" as const }; } - const currencies = [ - ...EVMNetwork.map((network) => ({ currency: "USDC" as const, network })), - { currency: "USDC" as const, network: "SOLANA" as const }, - { currency: "USDC" as const, network: "STELLAR" as const }, - { currency: "USDT" as const, network: "TRON" as const }, - ]; + const currencies = { + onramp: [ + ...EVMNetwork.map((network) => ({ currency: "USDC" as const, network })), + { currency: "USDC" as const, network: "SOLANA" as const }, + { currency: "USDC" as const, network: "STELLAR" as const }, + { currency: "USDT" as const, network: "TRON" as const }, + ], + offramp: [ + { currency: "USDC" as const, network: "BASE" as const }, + { currency: "USDC" as const, network: "SOLANA" as const }, + { currency: "USDC" as const, network: "STELLAR" as const }, + { currency: "USDT" as const, network: "TRON" as const }, + ], + }; if (params.customerId) { const bridgeUser = await getCustomer(params.customerId); @@ -196,7 +475,7 @@ export async function getProvider(params: { switch (bridgeUser.status) { case "offboarded": captureException(new Error("bridge user not available"), { contexts: { bridgeUser }, level: "warning" }); - return { status: "NOT_AVAILABLE" as const, onramp: { currencies: [] } }; + return { status: "NOT_AVAILABLE" as const, onramp: { currencies: [] }, offramp: { currencies: [] } }; case "paused": case "rejected": case "under_review": @@ -208,7 +487,10 @@ export async function getProvider(params: { return { status: "ONBOARDING" as const, onramp: { - currencies: [...currencies, ...CurrencyByEndorsement.base], + currencies: [...currencies.onramp, ...CurrencyByEndorsement.base], + }, + offramp: { + currencies: [...currencies.offramp, ...CurrencyByEndorsement.base], }, kycLink: await maybeKYCLink( bridgeUser, @@ -224,40 +506,38 @@ export async function getProvider(params: { break; } + const approvedCurrencies = bridgeUser.endorsements.flatMap((endorsement) => { + if (endorsement.status !== "approved") { + // TODO handle pending tasks + captureException(new Error("endorsement not approved"), { + contexts: { bridge: { bridgeId: params.customerId, endorsement } }, + level: "warning", + }); + return []; + } + + if (endorsement.additional_requirements?.length) { + // TODO handle additional requirements + captureException(new Error("additional requirements"), { + contexts: { bridge: { bridgeId: params.customerId, endorsement } }, + level: "warning", + }); + } + + if (endorsement.requirements.missing) { + captureException(new Error("requirements missing"), { + contexts: { bridge: { bridgeId: params.customerId, endorsement } }, + level: "warning", + }); + } + + return CurrencyByEndorsement[endorsement.name]; + }); + return { status: "ACTIVE" as const, - onramp: { - currencies: [ - ...currencies, - ...bridgeUser.endorsements.flatMap((endorsement) => { - if (endorsement.status !== "approved") { - // TODO handle pending tasks - captureException(new Error("endorsement not approved"), { - contexts: { bridge: { bridgeId: params.customerId, endorsement } }, - level: "warning", - }); - return []; - } - - if (endorsement.additional_requirements?.length) { - // TODO handle additional requirements - captureException(new Error("additional requirements"), { - contexts: { bridge: { bridgeId: params.customerId, endorsement } }, - level: "warning", - }); - } - - if (endorsement.requirements.missing) { - captureException(new Error("requirements missing"), { - contexts: { bridge: { bridgeId: params.customerId, endorsement } }, - level: "warning", - }); - } - - return CurrencyByEndorsement[endorsement.name]; - }), - ], - }, + onramp: { currencies: [...currencies.onramp, ...approvedCurrencies] }, + offramp: { currencies: [...currencies.offramp, ...approvedCurrencies] }, }; } @@ -274,7 +554,7 @@ export async function getProvider(params: { contexts: { bridge: { credentialId: params.credentialId, idClass: validDocument.id_class.value } }, level: "warning", }); - return { onramp: { currencies: [] }, status: "NOT_AVAILABLE" as const }; + return { onramp: { currencies: [] }, offramp: { currencies: [] }, status: "NOT_AVAILABLE" as const }; } const country = alpha2ToAlpha3(countryCode); @@ -303,7 +583,10 @@ export async function getProvider(params: { })(), ), onramp: { - currencies: [...currencies, ...endorsements.flatMap((endorsement) => CurrencyByEndorsement[endorsement])], + currencies: [...currencies.onramp, ...endorsements.flatMap((endorsement) => CurrencyByEndorsement[endorsement])], + }, + offramp: { + currencies: [...currencies.offramp, ...endorsements.flatMap((endorsement) => CurrencyByEndorsement[endorsement])], }, }; } @@ -480,6 +763,136 @@ export async function getCryptoDepositDetails( ); } +export async function getOfframpDepositDetails( + externalAccountId: string, + account: string, + customer: InferOutput, + currency: (typeof SupportedCurrency)[number], +) { + if (customer.status !== "active") throw new Error(ErrorCodes.NOT_ACTIVE_CUSTOMER); + const supportedChain = Supported[chain.id]; + if (!supportedChain) { + captureException(new Error("bridge not supported chain id"), { contexts: { chain }, level: "error" }); + throw new Error(ErrorCodes.NOT_SUPPORTED_CHAIN_ID); + } + + const externalAccount = await getExternalAccount(customer.id, externalAccountId); + if (!externalAccount) throw new Error(ErrorCodes.EXTERNAL_ACCOUNT_NOT_FOUND); + if (externalAccount.currency !== CurrencyToBridge[currency]) { + throw new Error(ErrorCodes.EXTERNAL_ACCOUNT_CURRENCY_MISMATCH); + } + const paymentRail = PaymentRailByBridgeCurrency[externalAccount.currency]; + if (!paymentRail) throw new Error(ErrorCodes.NOT_AVAILABLE_CURRENCY); + const templates = await getStaticTemplates(customer.id); + let transfer = templates.find( + ({ destination, source, state }) => + destination.external_account_id === externalAccountId && + source.payment_rail === supportedChain && + source.currency === "usdc" && + state !== "canceled", + ); + if (transfer && transfer.state !== "awaiting_funds") throw new Error(ErrorCodes.TRANSFER_IN_USE); + transfer ??= await createTransfer({ + on_behalf_of: customer.id, + client_reference_id: account, + source: { currency: "usdc", payment_rail: supportedChain }, + destination: { + currency: externalAccount.currency, + payment_rail: paymentRail, + external_account_id: externalAccountId, + }, + features: { flexible_amount: true, static_template: true, allow_any_from_address: true }, + }); + + return [ + { + network: "OPTIMISM" as const, + displayName: "Optimism" as const, + address: parse(Address, transfer.source_deposit_instructions.to_address), + fee: "0.0", + estimatedProcessingTime: "300", + }, + ]; +} + +export async function getCryptoOfframpDepositDetails( + currency: "USDC" | "USDT", + network: (typeof Network)[number], + toAddress: string, + account: Address, + customer: InferOutput, + memo?: string, +) { + if (customer.status !== "active") throw new Error(ErrorCodes.NOT_ACTIVE_CUSTOMER); + const supportedChain = Supported[chain.id]; + if (!supportedChain) { + captureException(new Error("bridge not supported chain id"), { contexts: { chain }, level: "error" }); + throw new Error(ErrorCodes.NOT_SUPPORTED_CHAIN_ID); + } + + const paymentRail = parse(picklist(["solana", "stellar", "tron", "base"]), NetworkToOfframpRail[network]); + if (!CurrencyByPaymentRail[paymentRail].includes(currency)) { + throw new Error(ErrorCodes.NOT_AVAILABLE_CRYPTO_PAYMENT_RAIL); + } + + const transfer = await createTransfer({ + on_behalf_of: customer.id, + client_reference_id: account, + source: { currency: "usdc", payment_rail: supportedChain }, + destination: { + currency: CurrencyToBridge[currency], + payment_rail: paymentRail, + to_address: toAddress, + blockchain_memo: memo, + }, + features: { flexible_amount: true, allow_any_from_address: true }, + }).catch((error: unknown) => { + if ( + error instanceof ServiceError && + typeof error.cause === "string" && + error.cause.includes(BridgeApiErrorCodes.INVALID_PARAMETERS) && + error.cause.includes("to_address") + ) { + throw new Error(ErrorCodes.INVALID_DEPOSIT_ADDRESS); + } + throw error; + }); + + return [ + { + network: "OPTIMISM" as const, + displayName: "Optimism" as const, + address: parse(Address, transfer.source_deposit_instructions.to_address), + fee: "0.0", + estimatedProcessingTime: "300", + }, + ]; +} + +export async function removeExternalAccount(customer: InferOutput, externalAccountId: string) { + const [externalAccount, templates] = await Promise.all([ + getExternalAccount(customer.id, externalAccountId), + getStaticTemplates(customer.id), + ]); + if (!externalAccount) throw new Error(ErrorCodes.EXTERNAL_ACCOUNT_NOT_FOUND); + const transfers = templates.filter( + ({ destination, state }) => destination.external_account_id === externalAccountId && state !== "canceled", + ); + if (transfers.some(({ state }) => state !== "awaiting_funds")) throw new Error(ErrorCodes.TRANSFER_IN_USE); + await Promise.all(transfers.map(({ id }) => request(unknown(), `/transfers/${id}`, {}, undefined, "DELETE"))); + await request(unknown(), `/customers/${customer.id}/external_accounts/${externalAccountId}`, {}, undefined, "DELETE"); +} + +const PaymentRailByBridgeCurrency: Partial< + Record<(typeof BridgeCurrency)[number], (typeof TransferPaymentRail)[number]> +> = { + usd: "ach", + eur: "sepa", + mxn: "spei", + brl: "pix", + gbp: "faster_payments", +}; + const missing = new Set(["tax_identification_number", "source_of_funds_questionnaire"]); const issues = new Set(["government_id_verification_failed"]); @@ -546,7 +959,7 @@ const CurrencyToBridge: Record<(typeof SupportedCurrency)[number], (typeof Bridg USDT: "usdt", } as const; -const CurrencyByEndorsement: Record<(typeof Endorsements)[number], (typeof FiatCurrency)[number][]> = { +export const CurrencyByEndorsement: Record<(typeof Endorsements)[number], (typeof FiatCurrency)[number][]> = { base: ["USD"], faster_payments: ["GBP"], pix: ["BRL"], @@ -554,10 +967,11 @@ const CurrencyByEndorsement: Record<(typeof Endorsements)[number], (typeof FiatC spei: ["MXN"], }; -export const CryptoPaymentRail = ["evm", "solana", "stellar", "tron"] as const; +export const CryptoPaymentRail = ["evm", "solana", "stellar", "tron", "base"] as const; export const BridgeChain = ["optimism"] as const; const CurrencyByPaymentRail: Record<(typeof CryptoPaymentRail)[number], (typeof CryptoCurrency)[number][]> = { + base: ["USDC"], evm: ["USDC"], solana: ["USDC"], stellar: ["USDC"], @@ -571,6 +985,13 @@ const NetworkToCryptoPaymentRail: Record<(typeof Network)[number], (typeof Crypt TRON: "tron", } as const; +const NetworkToOfframpRail: Record<(typeof OfframpNetwork)[number], (typeof CryptoPaymentRail)[number]> = { + BASE: "base", + SOLANA: "solana", + STELLAR: "stellar", + TRON: "tron", +} as const; + const Supported: Record = { [optimism.id]: "optimism", [optimismSepolia.id]: "optimism", @@ -901,12 +1322,370 @@ const LiquidationAddress = object({ const LiquidationAddresses = object({ count: number(), data: array(LiquidationAddress) }); +const AccountOwnerName = pipe(string(), minLength(1), maxLength(256)); +const BankName = pipe(string(), minLength(1), maxLength(256)); +const RoutingNumber = pipe(string(), regex(/^\d{9}$/, "9 digits")); +const DocumentNumber = pipe(string(), regex(/^\d+$/, "digits only")); +const CountryCode = pipe(string(), length(3, "3-letter iso 3166-1 code")); + +const AddressInput = object({ + streetLine1: pipe(string(), minLength(4), maxLength(35)), + streetLine2: optional(pipe(string(), maxLength(35))), + city: pipe(string(), minLength(1)), + state: optional(pipe(string(), minLength(1), maxLength(3, "1-3 character iso 3166-2 code"))), + postalCode: optional(pipe(string(), minLength(1))), + country: CountryCode, +}); + +export const ExternalAccountInput = variant("currency", [ + object({ + currency: literal("USD"), + accountOwnerName: AccountOwnerName, + accountNumber: pipe(string(), minLength(1)), + routingNumber: RoutingNumber, + checkingOrSavings: optional(picklist(["checking", "savings"])), + bankName: optional(BankName), + address: object({ + ...AddressInput.entries, + state: pipe(string(), minLength(1), maxLength(3, "1-3 character iso 3166-2 code")), + }), + }), + object({ + currency: literal("EUR"), + accountOwnerName: AccountOwnerName, + accountOwnerType: literal("individual"), + firstName: pipe(string(), minLength(1)), + lastName: pipe(string(), minLength(1)), + accountNumber: pipe(string(), minLength(1)), + bic: optional(pipe(string(), minLength(1))), + country: CountryCode, + bankName: optional(BankName), + }), + object({ + currency: literal("EUR"), + accountOwnerName: AccountOwnerName, + accountOwnerType: literal("business"), + businessName: pipe(string(), minLength(1)), + accountNumber: pipe(string(), minLength(1)), + bic: optional(pipe(string(), minLength(1))), + country: CountryCode, + bankName: optional(BankName), + }), + object({ + currency: literal("MXN"), + accountOwnerName: AccountOwnerName, + clabe: pipe(string(), regex(/^\d{18}$/, "18 digits")), + bankName: optional(BankName), + }), + object({ + currency: literal("BRL"), + accountOwnerName: AccountOwnerName, + account: union([ + object({ pixKey: pipe(string(), minLength(1)), documentNumber: optional(DocumentNumber) }), + object({ brCode: pipe(string(), minLength(1)), documentNumber: optional(DocumentNumber) }), + ]), + bankName: optional(BankName), + }), + object({ + currency: literal("GBP"), + accountOwnerName: AccountOwnerName, + accountOwnerType: literal("individual"), + firstName: pipe(string(), minLength(1)), + lastName: pipe(string(), minLength(1)), + accountNumber: pipe(string(), regex(/^\d{8}$/, "8 digits")), + sortCode: pipe(string(), regex(/^\d{6}$/, "6 digits, no hyphens")), + bankName: optional(BankName), + }), + object({ + currency: literal("GBP"), + accountOwnerName: AccountOwnerName, + accountOwnerType: literal("business"), + businessName: pipe(string(), minLength(1)), + accountNumber: pipe(string(), regex(/^\d{8}$/, "8 digits")), + sortCode: pipe(string(), regex(/^\d{6}$/, "6 digits, no hyphens")), + bankName: optional(BankName), + }), + object({ + currency: literal("GBP"), + accountOwnerName: AccountOwnerName, + accountNumber: pipe(string(), regex(/^\d{8}$/, "8 digits")), + sortCode: pipe(string(), regex(/^\d{6}$/, "6 digits, no hyphens")), + bankName: optional(BankName), + }), +]); + +export const UpdateExternalAccountInput = pipe( + object({ + address: optional(AddressInput), + account: optional( + pipe( + object({ + checkingOrSavings: optional(picklist(["checking", "savings"])), + routingNumber: optional(RoutingNumber), + }), + check( + ({ checkingOrSavings, routingNumber }) => checkingOrSavings !== undefined || routingNumber !== undefined, + "account requires at least one field", + ), + ), + ), + }), + check(({ address, account }) => address !== undefined || account !== undefined, "address or account is required"), +); + +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- type-only usage +const BridgeCreateExternalAccount = variant("account_type", [ + object({ + account_type: literal("us"), + currency: literal("usd"), + account_owner_name: string(), + bank_name: optional(string()), + account: object({ + account_number: string(), + routing_number: string(), + checking_or_savings: optional(picklist(["checking", "savings"])), + }), + address: object({ + city: string(), + country: string(), + postal_code: optional(string()), + state: optional(string()), + street_line_1: string(), + street_line_2: optional(string()), + }), + }), + object({ + account_type: literal("iban"), + currency: literal("eur"), + account_owner_name: string(), + account_owner_type: literal("individual"), + first_name: string(), + last_name: string(), + bank_name: optional(string()), + iban: object({ account_number: string(), bic: optional(string()), country: string() }), + }), + object({ + account_type: literal("iban"), + currency: literal("eur"), + account_owner_name: string(), + account_owner_type: literal("business"), + business_name: string(), + bank_name: optional(string()), + iban: object({ account_number: string(), bic: optional(string()), country: string() }), + }), + object({ + account_type: literal("clabe"), + currency: literal("mxn"), + account_owner_name: string(), + bank_name: optional(string()), + clabe: object({ account_number: string() }), + }), + object({ + account_type: literal("pix"), + currency: literal("brl"), + account_owner_name: string(), + bank_name: optional(string()), + pix_key: object({ pix_key: string(), document_number: optional(string()) }), + }), + object({ + account_type: literal("pix"), + currency: literal("brl"), + account_owner_name: string(), + bank_name: optional(string()), + br_code: object({ br_code: string(), document_number: optional(string()) }), + }), + object({ + account_type: literal("gb"), + currency: literal("gbp"), + account_owner_name: string(), + account_owner_type: literal("individual"), + first_name: string(), + last_name: string(), + bank_name: optional(string()), + account: object({ account_number: string(), sort_code: string() }), + }), + object({ + account_type: literal("gb"), + currency: literal("gbp"), + account_owner_name: string(), + account_owner_type: literal("business"), + business_name: string(), + bank_name: optional(string()), + account: object({ account_number: string(), sort_code: string() }), + }), + object({ + account_type: literal("gb"), + currency: literal("gbp"), + account_owner_name: string(), + bank_name: optional(string()), + account: object({ account_number: string(), sort_code: string() }), + }), +]); + +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- type-only usage +const BridgeUpdateExternalAccount = object({ + address: optional( + object({ + street_line_1: string(), + street_line_2: optional(string()), + city: string(), + state: optional(string()), + postal_code: optional(string()), + country: string(), + }), + ), + account: optional( + object({ checking_or_savings: optional(picklist(["checking", "savings"])), routing_number: optional(string()) }), + ), +}); + +const BridgeExternalAccount = object({ + id: string(), + customer_id: string(), + account_type: string(), + currency: picklist(BridgeCurrency), + account_owner_name: string(), + bank_name: optional(string()), + active: boolean(), + beneficiary_address_valid: boolean(), +}); + +const ExternalAccounts = object({ count: number(), data: array(BridgeExternalAccount) }); + +export const ExternalAccount = object({ + addressValid: boolean(), + bankName: optional(string()), + currency: picklist(FiatCurrency), + id: string(), + ownerName: string(), +}); + +const FiatByBridgeCurrency: Partial> = { + brl: "BRL", + eur: "EUR", + gbp: "GBP", + mxn: "MXN", + usd: "USD", +}; + +const TransferCurrency = [ + "brl", + "cop", + "dai", + "eur", + "eurc", // cspell:ignore eurc + "gbp", + "mxn", + "pyusd", // cspell:ignore pyusd + "usd", + "usdb", // cspell:ignore usdb + "usdc", + "usdt", +] as const; + +const TransferPaymentRail = [ + "ach", + "ach_push", + "ach_same_day", + "arbitrum", + "avalanche_c_chain", + "base", + "bre_b", // cspell:ignore bre_b + "bridge_wallet", + "celo", + "co_bank_transfer", + "ethereum", + "faster_payments", + "fiat_deposit_return", + "optimism", + "pix", + "polygon", + "sepa", + "solana", + "spei", + "stellar", + "swift", + "tempo", + "tron", + "wire", +] as const; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const CreateTransfer = object({ + on_behalf_of: string(), + client_reference_id: optional(string()), + amount: optional(string()), + developer_fee: optional(string()), + developer_fee_percent: optional(string()), + features: optional( + object({ + flexible_amount: optional(boolean()), + static_template: optional(boolean()), + allow_any_from_address: optional(boolean()), + }), + ), + source: object({ + currency: picklist(TransferCurrency), + payment_rail: picklist(TransferPaymentRail), + from_address: optional(Address), + bridge_wallet_id: optional(string()), + }), + destination: object({ + currency: picklist(TransferCurrency), + payment_rail: picklist(TransferPaymentRail), + external_account_id: optional(string()), + bridge_wallet_id: optional(string()), + to_address: optional(string()), + amount: optional(string()), + wire_message: optional(string()), + sepa_reference: optional(string()), + swift_reference: optional(string()), + spei_reference: optional(string()), + ach_reference: optional(string()), + blockchain_memo: optional(string()), + swift_charges: optional(picklist(["ben", "our", "sha"])), + }), +}); + +const Transfer = object({ + id: string(), + client_reference_id: optional(string()), + state: string(), + amount: nullish(string()), + currency: optional(picklist(TransferCurrency)), + developer_fee: optional(string()), + developer_fee_percent: optional(string()), + on_behalf_of: string(), + source: object({ + payment_rail: picklist(TransferPaymentRail), + currency: picklist(TransferCurrency), + from_address: nullish(string()), + }), + destination: object({ + blockchain_memo: nullish(string()), + currency: picklist(TransferCurrency), + external_account_id: nullish(string()), + payment_rail: picklist(TransferPaymentRail), + to_address: nullish(string()), + }), + source_deposit_instructions: object({ + payment_rail: picklist(TransferPaymentRail), + currency: picklist(TransferCurrency), + from_address: nullish(string()), + to_address: nullish(string()), + }), +}); + +const Transfers = object({ count: number(), data: array(Transfer) }); + +const StaticTemplates = object({ count: number(), data: array(Transfer) }); + async function request>( schema: BaseSchema, url: `/${string}`, headers = {}, body?: unknown, - method: "GET" | "PATCH" | "POST" | "PUT" = body === undefined ? "GET" : "POST", + method: "DELETE" | "GET" | "PATCH" | "POST" | "PUT" = body === undefined ? "GET" : "POST", timeout = 10_000, idempotencyKey?: string, ) { @@ -926,7 +1705,12 @@ async function request>( if (!response.ok) throw new ServiceError("Bridge", response.status, await response.text()); const rawBody = await response.arrayBuffer(); if (rawBody.byteLength === 0) return parse(schema, {}); - return parse(schema, JSON.parse(new TextDecoder().decode(rawBody))); + const result = safeParse(schema, JSON.parse(new TextDecoder().decode(rawBody))); + if (!result.success) { + setContext("validation", { ...result, flatten: flatten(result.issues) }); + throw new ValiError(result.issues); + } + return result.output; } async function encodeFile(file: File) { @@ -1086,9 +1870,13 @@ export const ErrorCodes = { ALREADY_ONBOARDED: "already onboarded", BAD_BRIDGE_ID: "bad bridge id", EMAIL_ALREADY_EXISTS: "email already exists", + EXTERNAL_ACCOUNT_CURRENCY_MISMATCH: "external account currency mismatch", + EXTERNAL_ACCOUNT_NOT_FOUND: "external account not found", INVALID_ACCOUNT: "invalid destination account", INVALID_ADDRESS: "invalid address", + INVALID_DEPOSIT_ADDRESS: "invalid deposit address", NOT_ACTIVE_CUSTOMER: "not active customer", + NO_ENDORSEMENT: "no endorsement", NOT_AVAILABLE_CRYPTO_PAYMENT_RAIL: "not available crypto payment rail", NOT_AVAILABLE_CURRENCY: "not available currency", MISSING_STELLAR_MEMO: "missing stellar memo", @@ -1100,6 +1888,7 @@ export const ErrorCodes = { NO_DOCUMENT_FILE: "no document file", NO_PERSONA_ACCOUNT: "no persona account", NO_SOCIAL_SECURITY_NUMBER: "no social security number", + TRANSFER_IN_USE: "transfer in use", }; const BridgeApiErrorCodes = { diff --git a/server/utils/segment.ts b/server/utils/segment.ts index 7276b0774..c208e8e24 100644 --- a/server/utils/segment.ts +++ b/server/utils/segment.ts @@ -39,6 +39,16 @@ export function track( | { event: "CardFrozen"; properties: SourceProperty } | { event: "CardIssued"; properties: SourceProperty & { productId: string } } | { event: "CardUnfrozen"; properties: SourceProperty } + | { + event: "Offramp"; + properties: { + amount: number; + currency: string; + provider: "bridge" | "manteca"; + source: null | string; + usdcAmount: number; + }; + } | { event: "Onramp"; properties: {