diff --git a/.changeset/brave-melons-burn.md b/.changeset/brave-melons-burn.md new file mode 100644 index 0000000000..0d887c8ba6 --- /dev/null +++ b/.changeset/brave-melons-burn.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🔥 remove crypto ramp and liquidation addresses diff --git a/.changeset/lucky-otters-drop.md b/.changeset/lucky-otters-drop.md new file mode 100644 index 0000000000..b346617a27 --- /dev/null +++ b/.changeset/lucky-otters-drop.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ drop crypto currencies for new customers diff --git a/server/api/ramp.ts b/server/api/ramp.ts index 20d7e31868..da83497b1f 100644 --- a/server/api/ramp.ts +++ b/server/api/ramp.ts @@ -39,10 +39,10 @@ const ErrorCodes = { EXTERNAL_ACCOUNT_NOT_FOUND: "external account not found", EXTERNAL_ACCOUNT_NOT_SUPPORTED: "external account not supported", INVALID_BANK_NAME: "invalid bank name", - INVALID_DEPOSIT_ADDRESS: "invalid deposit address", NO_CREDENTIAL: "no credential", NOT_APPROVED: "not approved", NOT_STARTED: "not started", + NOT_SUPPORTED: "not supported", POSTAL_CODE_REQUIRED: "postal code required", WITHDRAWAL_IN_PROGRESS: "withdrawal in progress", }; @@ -122,35 +122,6 @@ export default new Hono() 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")), @@ -212,29 +183,6 @@ export default new Hono() 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, - ), - } satisfies InferOutput, - 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( { @@ -264,22 +212,7 @@ export default new Hono() throw error; } } - - if ("network" in query) { - return c.json( - { - quote, - depositInfo: await bridge.getCryptoDepositDetails( - query.currency, - query.network, - credential.account, - bridgeUser, - ), - } satisfies InferOutput, - 200, - ); - } - + if ("network" in query) return c.json({ code: ErrorCodes.NOT_SUPPORTED }, 400); return c.json( { quote, diff --git a/server/test/api/ramp.test.ts b/server/test/api/ramp.test.ts index 173ef3f7bc..a9dec80128 100644 --- a/server/test/api/ramp.test.ts +++ b/server/test/api/ramp.test.ts @@ -542,133 +542,16 @@ describe("ramp api", () => { }); }); - it("returns deposit info with undefined quote for bridge USDT/TRON", async () => { + it("returns 400 not supported for crypto onramp", async () => { vi.spyOn(bridge, "getCustomer").mockResolvedValue(bridgeCustomer); - vi.spyOn(bridge, "getCryptoDepositDetails").mockResolvedValue([ - { - network: "TRON" as const, - displayName: "TRON" as const, - address: "TXyz123456789", - fee: "0.0", - estimatedProcessingTime: "300", - }, - ]); - - const response = await appClient.quote.$get( - { query: { provider: "bridge", currency: "USDT", network: "TRON" } }, - { headers: { "test-credential-id": "ramp-bridge" } }, - ); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toStrictEqual({ - depositInfo: [ - { - network: "TRON", - displayName: "TRON", - address: "TXyz123456789", - fee: "0.0", - estimatedProcessingTime: "300", - }, - ], - }); - }); - - it("returns deposit info with default quote for bridge USDC/SOLANA", async () => { - vi.spyOn(bridge, "getCustomer").mockResolvedValue(bridgeCustomer); - vi.spyOn(bridge, "getCryptoDepositDetails").mockResolvedValue([ - { - network: "SOLANA" as const, - displayName: "SOLANA" as const, - address: "So1anaAddress123", - fee: "0.0", - estimatedProcessingTime: "300", - }, - ]); - - const response = await appClient.quote.$get( - { query: { provider: "bridge", currency: "USDC", network: "SOLANA" } }, - { headers: { "test-credential-id": "ramp-bridge" } }, - ); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toStrictEqual({ - quote: { buyRate: "1.0", sellRate: "1.0" }, - depositInfo: [ - { - network: "SOLANA", - displayName: "SOLANA", - address: "So1anaAddress123", - fee: "0.0", - estimatedProcessingTime: "300", - }, - ], - }); - }); - - it("returns deposit info with default quote for bridge USDC/STELLAR", async () => { - vi.spyOn(bridge, "getCustomer").mockResolvedValue(bridgeCustomer); - vi.spyOn(bridge, "getCryptoDepositDetails").mockResolvedValue([ - { - network: "STELLAR" as const, - displayName: "STELLAR" as const, - address: "STELLAR123456", - fee: "0.0", - estimatedProcessingTime: "300", - memo: "789012", - }, - ]); - - const response = await appClient.quote.$get( - { query: { provider: "bridge", currency: "USDC", network: "STELLAR" } }, - { headers: { "test-credential-id": "ramp-bridge" } }, - ); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toStrictEqual({ - quote: { buyRate: "1.0", sellRate: "1.0" }, - depositInfo: [ - { - network: "STELLAR", - displayName: "STELLAR", - address: "STELLAR123456", - fee: "0.0", - estimatedProcessingTime: "300", - memo: "789012", - }, - ], - }); - }); - - it("returns deposit info with default quote for bridge USDC/BASE", async () => { - vi.spyOn(bridge, "getCustomer").mockResolvedValue(bridgeCustomer); - vi.spyOn(bridge, "getCryptoDepositDetails").mockResolvedValue([ - { - network: "BASE" as const, - displayName: "BASE" as const, - address: deposit, - fee: "0.0", - estimatedProcessingTime: "300", - }, - ]); const response = await appClient.quote.$get( { query: { provider: "bridge", currency: "USDC", network: "BASE" } }, { headers: { "test-credential-id": "ramp-bridge" } }, ); - expect(response.status).toBe(200); - await expect(response.json()).resolves.toStrictEqual({ - quote: { buyRate: "1.0", sellRate: "1.0" }, - depositInfo: [ - { - network: "BASE", - displayName: "BASE", - address: deposit, - fee: "0.0", - estimatedProcessingTime: "300", - }, - ], - }); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "not supported" }); }); describe("offramp", () => { @@ -796,69 +679,6 @@ describe("ramp api", () => { "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(); - }); }); }); }); @@ -1685,6 +1505,7 @@ const mantecaUser = { const bridgeCustomer = { id: "bridge-customer-123", + created_at: "2026-07-01T00:00:00.000Z", email: "test@example.com", status: "active" as const, endorsements: [], diff --git a/server/test/hooks/bridge.test.ts b/server/test/hooks/bridge.test.ts index 8b05f14275..5bf6ca0cfb 100644 --- a/server/test/hooks/bridge.test.ts +++ b/server/test/hooks/bridge.test.ts @@ -259,6 +259,7 @@ describe("bridge hook", () => { vi.spyOn(bridge, "getCustomer").mockResolvedValue({ id: "fallback-bridge-id", email: "fallback@example.com", + created_at: "2026-07-01T00:00:00.000Z", status: "active", endorsements: [], }); @@ -307,6 +308,7 @@ describe("bridge hook", () => { vi.spyOn(bridge, "getCustomer").mockResolvedValue({ id: "conflict-bridge-id", email: "conflict@example.com", + created_at: "2026-07-01T00:00:00.000Z", status: "active", endorsements: [], }); @@ -329,6 +331,7 @@ describe("bridge hook", () => { vi.spyOn(bridge, "getCustomer").mockResolvedValue({ id: "multi-bridge-id", email: "multi@example.com", + created_at: "2026-07-01T00:00:00.000Z", status: "active", endorsements: [], }); @@ -357,6 +360,7 @@ describe("bridge hook", () => { vi.spyOn(bridge, "getCustomer").mockResolvedValue({ id: "empty-bridge-id", email: "empty@example.com", + created_at: "2026-07-01T00:00:00.000Z", status: "active", endorsements: [], }); @@ -382,6 +386,7 @@ describe("bridge hook", () => { vi.spyOn(bridge, "getCustomer").mockResolvedValue({ id: "orphan-bridge-id", email: "orphan@example.com", + created_at: "2026-07-01T00:00:00.000Z", status: "active", endorsements: [], }); diff --git a/server/test/utils/bridge.test.ts b/server/test/utils/bridge.test.ts index cb805efa00..14bced4c70 100644 --- a/server/test/utils/bridge.test.ts +++ b/server/test/utils/bridge.test.ts @@ -77,6 +77,14 @@ describe("bridge utils", () => { expect(result).toBeUndefined(); }); + it("throws when created_at is not a valid date", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + fetchResponse({ ...activeCustomer, created_at: "not-a-date" }), + ); + + await expect(bridge.getCustomer("cust-123")).rejects.toThrow("invalid created_at"); + }); + it("throws on other errors", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(fetchError(500, "internal error")); @@ -245,6 +253,25 @@ describe("bridge utils", () => { ); }); + it("returns ONBOARDING without crypto currencies when customer is created after the cutoff", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + fetchResponse({ ...activeCustomer, created_at: "2026-07-02T00:00:00.000Z", status: "rejected" }), + ); + + const result = await bridge.getProvider({ credentialId: "cred-1", customerId: "cust-1" }); + + expect(result).toStrictEqual({ + status: "ONBOARDING", + onramp: { currencies: ["USD"] }, + offramp: { currencies: ["USD"] }, + kycLink: undefined, + }); + expect(captureException).toHaveBeenCalledWith( + expect.objectContaining({ message: "bridge user onboarding" }), + expect.objectContaining({ level: "warning" }), + ); + }); + it("returns ONBOARDING when customer is paused", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(fetchResponse({ ...activeCustomer, status: "paused" })); @@ -1052,6 +1079,23 @@ describe("bridge utils", () => { }); }); + it("returns ACTIVE without crypto currencies when customer is created after the cutoff", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + fetchResponse({ + ...activeCustomer, + created_at: "2026-07-02T00:00:00.000Z", + endorsements: [endorsement("base", "approved"), endorsement("sepa", "approved")], + }), + ); + + await expect(bridge.getProvider({ credentialId: "cred-1", customerId: "cust-1" })).resolves.toStrictEqual({ + status: "ACTIVE", + onramp: { currencies: ["USD", "EUR"] }, + offramp: { currencies: ["USD", "EUR"] }, + futureRequirement: undefined, + }); + }); + it("returns the four crypto offramp options regardless of endorsements", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(fetchResponse({ ...activeCustomer, endorsements: [] })); @@ -2360,293 +2404,6 @@ describe("bridge utils", () => { }); }); - describe("getLiquidationAddresses", () => { - it("paginates when count exceeds first page", async () => { - const page1 = Array.from({ length: 20 }, (_, index) => ({ - id: `la-${String(index)}`, - currency: "usdt" as const, - chain: "tron" as const, - address: `TAddr${String(index)}`, - destination_address: padHex(`0x${(index + 1).toString(16)}`, { size: 20 }), - })); - const page2 = [ - { - id: "la-20", - currency: "usdt" as const, - chain: "tron" as const, - address: "TAddr20", - destination_address: padHex("0x15", { size: 20 }), - }, - ]; - vi.spyOn(globalThis, "fetch") - .mockResolvedValueOnce(fetchResponse({ count: 21, data: page1 })) - .mockResolvedValueOnce(fetchResponse({ count: 21, data: page2 })); - - const result = await bridge.getLiquidationAddresses("cust-1"); - - expect(result).toHaveLength(21); - expect(captureException).toHaveBeenCalledWith( - expect.objectContaining({ message: "bridge liquidation addresses pagination" }), - expect.objectContaining({ level: "warning" }), - ); - }); - - it("does not paginate when all results fit in first page", async () => { - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( - fetchResponse({ - count: 1, - data: [ - { - id: "la-1", - currency: "usdt", - chain: "tron", - address: "TAddr1", - destination_address: padHex("0x1", { size: 20 }), - }, - ], - }), - ); - - const result = await bridge.getLiquidationAddresses("cust-1"); - - expect(result).toHaveLength(1); - expect(fetchSpy).toHaveBeenCalledOnce(); - }); - - it("stops paginating and warns when a subsequent page returns empty data", async () => { - const fetchSpy = vi - .spyOn(globalThis, "fetch") - .mockResolvedValueOnce( - fetchResponse({ - count: 5, - data: [ - { - id: "la-1", - currency: "usdt", - chain: "tron", - address: "TAddr1", - destination_address: padHex("0x1", { size: 20 }), - }, - ], - }), - ) - .mockResolvedValueOnce(fetchResponse({ count: 5, data: [] })); - - const result = await bridge.getLiquidationAddresses("cust-1"); - - expect(result).toHaveLength(1); - expect(fetchSpy).toHaveBeenCalledTimes(2); - expect(captureException).toHaveBeenCalledWith( - expect.objectContaining({ message: "bridge liquidation addresses empty page" }), - { level: "warning", contexts: { bridge: { customerId: "cust-1", count: 5, fetched: 1 } } }, - ); - }); - }); - - describe("getCryptoDepositDetails", () => { - const account = parse(Address, padHex("0x1", { size: 20 })); - const deposit = parse(Address, padHex("0xde9", { size: 20 })); - - it("throws NOT_SUPPORTED_CHAIN_ID for unsupported chain", async () => { - chainMock.id = 1; - - await expect(bridge.getCryptoDepositDetails("USDT", "TRON", account, activeCustomer)).rejects.toThrow( - bridge.ErrorCodes.NOT_SUPPORTED_CHAIN_ID, - ); - }); - - it("throws NOT_ACTIVE_CUSTOMER when customer is not active", async () => { - await expect( - bridge.getCryptoDepositDetails("USDT", "TRON", account, { ...activeCustomer, status: "rejected" }), - ).rejects.toThrow(bridge.ErrorCodes.NOT_ACTIVE_CUSTOMER); - }); - - it("throws NOT_AVAILABLE_CRYPTO_PAYMENT_RAIL for invalid combination", async () => { - await expect(bridge.getCryptoDepositDetails("USDC", "TRON", account, activeCustomer)).rejects.toThrow( - bridge.ErrorCodes.NOT_AVAILABLE_CRYPTO_PAYMENT_RAIL, - ); - }); - - it("returns TRON deposit details from existing liquidation address", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( - fetchResponse({ - count: 1, - data: [{ id: "la-1", currency: "usdt", chain: "tron", address: "TAddr123", destination_address: account }], - }), - ); - - const result = await bridge.getCryptoDepositDetails("USDT", "TRON", account, activeCustomer); - - expect(result).toHaveLength(1); - expect(result[0]).toStrictEqual({ - network: "TRON", - displayName: "TRON", - address: "TAddr123", - fee: "0.0", - estimatedProcessingTime: "300", - }); - }); - - it("returns SOLANA deposit details", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( - fetchResponse({ - count: 1, - data: [ - { id: "la-2", currency: "usdc", chain: "solana", address: "SolAddr456", destination_address: account }, - ], - }), - ); - - const result = await bridge.getCryptoDepositDetails("USDC", "SOLANA", account, activeCustomer); - - expect(result).toHaveLength(1); - expect(result[0]).toStrictEqual({ - network: "SOLANA", - displayName: "SOLANA", - address: "SolAddr456", - fee: "0.0", - estimatedProcessingTime: "300", - }); - }); - - it("returns STELLAR deposit details", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( - fetchResponse({ - count: 1, - data: [ - { - id: "la-3", - currency: "usdc", - chain: "stellar", - address: "StellarAddr789", - destination_address: account, - blockchain_memo: "123456", - }, - ], - }), - ); - - const result = await bridge.getCryptoDepositDetails("USDC", "STELLAR", account, activeCustomer); - - expect(result).toHaveLength(1); - expect(result[0]).toStrictEqual({ - network: "STELLAR", - displayName: "STELLAR", - address: "StellarAddr789", - fee: "0.0", - estimatedProcessingTime: "300", - memo: "123456", - }); - }); - - it("throws when STELLAR liquidation address has no memo", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( - fetchResponse({ - count: 1, - data: [ - { - id: "la-3", - currency: "usdc", - chain: "stellar", - address: "StellarAddr789", - destination_address: account, - }, - ], - }), - ); - - await expect(bridge.getCryptoDepositDetails("USDC", "STELLAR", account, activeCustomer)).rejects.toThrow( - "missing stellar memo", - ); - }); - - it("creates liquidation address when none exists", async () => { - vi.spyOn(globalThis, "fetch") - .mockResolvedValueOnce(fetchResponse({ count: 0, data: [] })) - .mockResolvedValueOnce( - fetchResponse({ - id: "la-new", - currency: "usdt", - chain: "tron", - address: "TNewAddr", - destination_address: account, - }), - ); - - const result = await bridge.getCryptoDepositDetails("USDT", "TRON", account, activeCustomer); - - expect(result).toHaveLength(1); - expect(result[0]).toStrictEqual(expect.objectContaining({ address: "TNewAddr" })); - }); - - it("throws INVALID_ACCOUNT when liquidation address destination does not match", async () => { - const wrongAccount = parse(Address, padHex("0x999", { size: 20 })); - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( - fetchResponse({ - count: 1, - data: [ - { id: "la-1", currency: "usdt", chain: "tron", address: "TAddr123", destination_address: wrongAccount }, - ], - }), - ); - - await expect(bridge.getCryptoDepositDetails("USDT", "TRON", account, activeCustomer)).rejects.toThrow( - bridge.ErrorCodes.INVALID_ACCOUNT, - ); - }); - - it("returns BASE deposit details from existing evm liquidation address", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( - fetchResponse({ - count: 1, - data: [{ id: "la-evm", currency: "usdc", chain: "evm", address: deposit, destination_address: account }], - }), - ); - - const result = await bridge.getCryptoDepositDetails("USDC", "BASE", account, activeCustomer); - - expect(result).toHaveLength(1); - expect(result[0]).toStrictEqual({ - network: "BASE", - displayName: "BASE", - address: deposit, - fee: "0.0", - estimatedProcessingTime: "300", - }); - }); - - it("creates evm liquidation address when none exists for BASE", async () => { - vi.spyOn(globalThis, "fetch") - .mockResolvedValueOnce(fetchResponse({ count: 0, data: [] })) - .mockResolvedValueOnce( - fetchResponse({ - id: "la-evm-new", - currency: "usdc", - chain: "evm", - address: deposit, - destination_address: account, - }), - ); - - const result = await bridge.getCryptoDepositDetails("USDC", "BASE", account, activeCustomer); - - expect(result).toHaveLength(1); - expect(result[0]).toStrictEqual({ - network: "BASE", - displayName: "BASE", - address: deposit, - fee: "0.0", - estimatedProcessingTime: "300", - }); - }); - - it("throws NOT_AVAILABLE_CRYPTO_PAYMENT_RAIL for USDT on BASE", async () => { - await expect(bridge.getCryptoDepositDetails("USDT", "BASE", account, activeCustomer)).rejects.toThrow( - bridge.ErrorCodes.NOT_AVAILABLE_CRYPTO_PAYMENT_RAIL, - ); - }); - }); - describe("getExternalAccount", () => { it("returns external account when found", async () => { const fetchSpy = vi @@ -2935,181 +2692,6 @@ describe("bridge utils", () => { }); }); - 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" }; const nonUsAddress = { @@ -4325,6 +3907,7 @@ const onboardingCurrencies = [...baseCurrencies, "USD"]; const activeCustomer = { id: "cust-123", + created_at: "2026-07-01T00:00:00.000Z", email: "test@example.com", status: "active" as const, endorsements: [] as ReturnType[], diff --git a/server/utils/ramps/bridge.ts b/server/utils/ramps/bridge.ts index 5e336f8309..1c518d60b8 100644 --- a/server/utils/ramps/bridge.ts +++ b/server/utils/ramps/bridge.ts @@ -46,8 +46,6 @@ import ServiceError from "../ServiceError"; 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; @@ -153,35 +151,6 @@ export async function getVirtualAccounts(customerId: string) { return all; } -export async function createLiquidationAddress(customerId: string, data: InferInput) { - return await request(LiquidationAddress, `/customers/${customerId}/liquidation_addresses`, {}, data, "POST"); -} - -export async function getLiquidationAddresses(customerId: string) { - const path = `/customers/${customerId}/liquidation_addresses` as const; - const first = await request(LiquidationAddresses, `${path}?limit=20`); - const all = [...first.data]; - if (first.data.length < first.count) - captureException(new Error("bridge liquidation addresses pagination"), { - level: "warning", - contexts: { bridge: { customerId, count: first.count } }, - }); - while (all.length < first.count) { - const last = all.at(-1); - if (!last) break; - const page = await request(LiquidationAddresses, `${path}?limit=20&starting_after=${last.id}`); - if (page.data.length === 0) { - captureException(new Error("bridge liquidation addresses empty page"), { - level: "warning", - contexts: { bridge: { customerId, count: first.count, fetched: all.length } }, - }); - break; - } - all.push(...page.data); - } - return all; -} - export async function createTransfer(data: InferInput, idempotencyKey?: string) { return await request(Transfer, "/transfers", {}, data, "POST", 15_000, idempotencyKey); } @@ -586,6 +555,10 @@ export async function getProvider(params: { if (params.customerId) { const bridgeUser = await getCustomer(params.customerId); if (!bridgeUser) throw new Error(ErrorCodes.BAD_BRIDGE_ID); + if (new Date(bridgeUser.created_at) >= new Date("2026-07-02T00:00:00.000Z")) { + currencies.onramp = []; + currencies.offramp = []; + } switch (bridgeUser.status) { case "offboarded": captureException(new Error("bridge user not available"), { contexts: { bridgeUser }, level: "warning" }); @@ -876,48 +849,6 @@ export async function getDepositDetails( return getDepositDetailsFromVirtualAccount(virtualAccount, account); } -export async function getCryptoDepositDetails( - currency: "USDC" | "USDT", - network: (typeof Network)[number], - account: string, - customer: InferOutput, -) { - const supportedChainId = Supported[chain.id]; - if (!supportedChainId) { - captureException(new Error("bridge not supported chain id"), { contexts: { chain }, level: "error" }); - throw new Error(ErrorCodes.NOT_SUPPORTED_CHAIN_ID); - } - if (customer.status !== "active") throw new Error(ErrorCodes.NOT_ACTIVE_CUSTOMER); - - const paymentRail = NetworkToCryptoPaymentRail[network]; - if (!CurrencyByPaymentRail[paymentRail].includes(currency)) { - throw new Error(ErrorCodes.NOT_AVAILABLE_CRYPTO_PAYMENT_RAIL); - } - - const liquidationAddresses = await getLiquidationAddresses(customer.id); - let liquidationAddress = liquidationAddresses.find( - ({ chain: bridgeChain, currency: bridgeCurrency }) => - bridgeChain === paymentRail && CurrencyToBridge[currency] === bridgeCurrency, - ); - - liquidationAddress ??= await createLiquidationAddress( - customer.id, - parse(CreateLiquidationAddress, { - destination_address: account, - destination_currency: "usdc", - destination_payment_rail: supportedChainId, - currency: CurrencyToBridge[currency], - chain: paymentRail, - }), - ); - - return getDepositDetailsFromLiquidationAddress( - liquidationAddress, - account, - paymentRail === "evm" ? parse(picklist(EVMNetwork), network) : undefined, - ); -} - export async function getOfframpDepositDetails( externalAccountId: string, account: string, @@ -969,60 +900,6 @@ export async function getOfframpDepositDetails( ]; } -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), @@ -1140,31 +1017,8 @@ export const CurrencyByEndorsement: Record<(typeof Endorsements)[number], (typeo spei: ["MXN"], }; -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"], - tron: ["USDT"], -}; - -const NetworkToCryptoPaymentRail: Record<(typeof Network)[number], (typeof CryptoPaymentRail)[number]> = { - BASE: "evm", - SOLANA: "solana", - STELLAR: "stellar", - 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", @@ -1345,6 +1199,10 @@ const AgreementLinkResponse = object({ url: string() }); const CustomerResponse = object({ id: string(), + created_at: pipe( + string(), + check((value) => !Number.isNaN(new Date(value).getTime()), "invalid created_at"), + ), email: string(), status: picklist(CustomerStatus), endorsements: array( @@ -1500,25 +1358,6 @@ const VirtualAccount = object({ }); const VirtualAccounts = object({ count: number(), data: array(VirtualAccount) }); -const CreateLiquidationAddress = object({ - currency: picklist(["usdc", "usdt"]), - chain: picklist([...CryptoPaymentRail]), - destination_payment_rail: picklist(BridgeChain), - destination_currency: picklist(["usdc"]), - destination_address: Address, -}); - -const LiquidationAddress = object({ - id: string(), - currency: picklist(["usdc", "usdt", "any"]), - chain: picklist([...CryptoPaymentRail]), - address: string(), - destination_address: Address, - blockchain_memo: optional(string()), -}); - -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")); @@ -2097,63 +1936,6 @@ function getDepositDetailsFromVirtualAccount(virtualAccount: InferOutput, - account: string, - evmNetwork?: (typeof EVMNetwork)[number], -) { - if (liquidationAddress.destination_address.toLowerCase() !== account.toLowerCase()) { - throw new Error(ErrorCodes.INVALID_ACCOUNT); - } - - switch (liquidationAddress.chain) { - case "evm": - if (!evmNetwork) throw new Error(ErrorCodes.NOT_AVAILABLE_EVM_NETWORK); - return [ - { - network: evmNetwork, - displayName: evmNetwork, - address: parse(Address, liquidationAddress.address), - fee: "0.0", - estimatedProcessingTime: "300", - }, - ]; - case "tron": - return [ - { - network: "TRON" as const, - displayName: "TRON" as const, - address: liquidationAddress.address, - fee: "0.0", - estimatedProcessingTime: "300", - }, - ]; - case "solana": - return [ - { - network: "SOLANA" as const, - displayName: "SOLANA" as const, - address: liquidationAddress.address, - fee: "0.0", - estimatedProcessingTime: "300", - }, - ]; - case "stellar": - if (!liquidationAddress.blockchain_memo) throw new Error(ErrorCodes.MISSING_STELLAR_MEMO); - return [ - { - network: "STELLAR" as const, - displayName: "STELLAR" as const, - address: liquidationAddress.address, - fee: "0.0", - estimatedProcessingTime: "300", - memo: liquidationAddress.blockchain_memo, - }, - ]; - } - throw new Error(ErrorCodes.NOT_AVAILABLE_CRYPTO_PAYMENT_RAIL); -} - export const ErrorCodes = { ALREADY_ONBOARDED: "already onboarded", BAD_BRIDGE_ID: "bad bridge id", @@ -2165,13 +1947,9 @@ export const ErrorCodes = { INVALID_ACCOUNT: "invalid destination account", INVALID_ADDRESS: "invalid address", INVALID_BANK_NAME: "invalid bank name", - 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", - NOT_AVAILABLE_EVM_NETWORK: "not available evm network", NOT_ENABLED: "not enabled", NOT_FOUND_IDENTIFICATION_CLASS: "not found identification class", NOT_SUPPORTED_CHAIN_ID: "not supported chain id",