diff --git a/src/app/accounts/create-account.ts b/src/app/accounts/create-account.ts index 17ea2c1f2..a25cfeecb 100644 --- a/src/app/accounts/create-account.ts +++ b/src/app/accounts/create-account.ts @@ -11,12 +11,15 @@ import { import { recordExceptionInCurrentSpan } from "@services/tracing" import { ErrorLevel, WalletCurrency } from "@domain/shared" +import Ibex from "@services/ibex/client" const requiredCashWalletCurrencies: WalletCurrency[] = [ WalletCurrency.Usd, WalletCurrency.Usdt, ] const defaultCashWalletCurrency = WalletCurrency.Usdt +const defaultCashWalletReceiveInfoName = (account: Account) => + account.username || account.id const initializeCreatedAccount = async ({ account, @@ -56,13 +59,25 @@ const initializeCreatedAccount = async ({ } // Set ETH-USDT as the active Cash Wallet while preserving USD for migration. - const defaultWalletId = enabledWallets[defaultCashWalletCurrency]?.id + const defaultWallet = enabledWallets[defaultCashWalletCurrency] + const defaultWalletId = defaultWallet?.id if (defaultWalletId === undefined) { return new ConfigError("NoWalletsEnabledInConfigError") } account.defaultWalletId = defaultWalletId + const defaultCashWalletReceiveOption = await Ibex.getEthereumUsdtOption() + if (defaultCashWalletReceiveOption instanceof Error) + return defaultCashWalletReceiveOption + + const receiveInfo = await Ibex.createCryptoReceiveInfo(defaultWalletId, { + ...defaultCashWalletReceiveOption, + name: defaultCashWalletReceiveInfoName(account), + }) + if (receiveInfo instanceof Error) return receiveInfo + account.bridgeEthereumAddress = receiveInfo.address + // TODO: improve bootstrap process // the script below is to dynamically attribute the editor account at runtime // this is only if editor is set in the config - typically only in test env diff --git a/src/services/ibex/client.ts b/src/services/ibex/client.ts index 2eb0a65fa..c493949a6 100644 --- a/src/services/ibex/client.ts +++ b/src/services/ibex/client.ts @@ -231,7 +231,7 @@ const payToLnurl = async ( const getIbexToken = async (): Promise => { const cached = await Ibex.authentication.storage.getAccessToken() - if (typeof cached === "string") return `Bearer ${cached}` + if (typeof cached === "string") return cached // The SDK uses a single base URL for all calls, but the sandbox auth domain is separate const resp = await fetch(`${IbexConfig.authUrl}/auth/signin`, { @@ -268,7 +268,7 @@ const getIbexToken = async (): Promise => { ) } - return `Bearer ${data.accessToken}` + return data.accessToken } const ibexFetch = async ( diff --git a/test/flash/unit/app/accounts/create-account.spec.ts b/test/flash/unit/app/accounts/create-account.spec.ts index 9f7eb2d35..b2ed447de 100644 --- a/test/flash/unit/app/accounts/create-account.spec.ts +++ b/test/flash/unit/app/accounts/create-account.spec.ts @@ -3,6 +3,8 @@ import { AccountLevel } from "@domain/accounts" import { WalletCurrency } from "@domain/shared" import { PersistError } from "@domain/errors" import { WalletType } from "@domain/wallets" +import Ibex from "@services/ibex/client" +import { IbexError } from "@services/ibex/errors" import { AccountsRepository, UsersRepository, @@ -23,6 +25,14 @@ jest.mock("@services/mongoose", () => ({ WalletsRepository: jest.fn(), })) +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { + getEthereumUsdtOption: jest.fn(), + createCryptoReceiveInfo: jest.fn(), + }, +})) + const mockedAccountsRepository = AccountsRepository as jest.MockedFunction< typeof AccountsRepository > @@ -35,6 +45,7 @@ const mockedWalletsRepository = WalletsRepository as jest.MockedFunction< describe("createAccountWithPhoneIdentifier", () => { let persistNew: jest.Mock + let updateAccount: jest.Mock const account = { id: "account-id" as AccountId, @@ -54,13 +65,31 @@ describe("createAccountWithPhoneIdentifier", () => { update: jest.fn().mockResolvedValue({ id: "user-id" }), } as unknown as ReturnType) + updateAccount = jest + .fn() + .mockImplementation(async (updatedAccount: Account) => updatedAccount) + mockedAccountsRepository.mockReturnValue({ persistNew: jest.fn().mockResolvedValue({ ...account }), - update: jest - .fn() - .mockImplementation(async (updatedAccount: Account) => updatedAccount), + update: updateAccount, } as unknown as ReturnType) + jest.mocked(Ibex.getEthereumUsdtOption).mockResolvedValue({ + id: "eth-usdt-option", + currency: "USDT", + network: "ethereum", + name: "Ethereum USDT", + }) + jest.mocked(Ibex.createCryptoReceiveInfo).mockResolvedValue({ + id: "receive-info-id", + wallet_id: `${WalletCurrency.Usdt}-wallet-id`, + option_id: "eth-usdt-option", + address: "0xeth-usdt-address", + currency: "USDT", + network: "ethereum", + created_at: "2026-05-12T00:00:00Z", + }) + persistNew = jest.fn().mockImplementation(async ({ accountId, type, currency }) => ({ id: `${currency}-wallet-id`, accountId, @@ -98,6 +127,44 @@ describe("createAccountWithPhoneIdentifier", () => { expect((result as Account).defaultWalletId).toBe(`${WalletCurrency.Usdt}-wallet-id`) }) + it("creates one Ethereum USDT receive address for the new USDT cash wallet", async () => { + const result = await createAccountWithPhoneIdentifier({ + newAccountInfo: { + kratosUserId: "kratos-user-id" as UserId, + phone: "+15551234567" as PhoneNumber, + }, + config, + }) + + expect(result).not.toBeInstanceOf(Error) + expect(Ibex.getEthereumUsdtOption).toHaveBeenCalledTimes(1) + expect(Ibex.createCryptoReceiveInfo).toHaveBeenCalledWith( + `${WalletCurrency.Usdt}-wallet-id`, + expect.objectContaining({ name: account.id, network: "ethereum" }), + ) + expect(updateAccount).toHaveBeenCalledWith( + expect.objectContaining({ bridgeEthereumAddress: "0xeth-usdt-address" }), + ) + expect((result as Account).bridgeEthereumAddress).toBe("0xeth-usdt-address") + }) + + it("fails account creation if the required Ethereum USDT receive address cannot be created", async () => { + jest + .mocked(Ibex.createCryptoReceiveInfo) + .mockResolvedValueOnce(new IbexError(new Error("receive-info failed"))) + + const result = await createAccountWithPhoneIdentifier({ + newAccountInfo: { + kratosUserId: "kratos-user-id" as UserId, + phone: "+15551234567" as PhoneNumber, + }, + config, + }) + + expect(result).toBeInstanceOf(Error) + expect(updateAccount).not.toHaveBeenCalled() + }) + it("does not create an account with a USD fallback default if the USDT wallet is missing", async () => { persistNew.mockImplementation(async ({ accountId, type, currency }) => { if (currency === WalletCurrency.Usdt) return new PersistError("USDT wallet failed") diff --git a/test/flash/unit/services/ibex/client.spec.ts b/test/flash/unit/services/ibex/client.spec.ts new file mode 100644 index 000000000..e7a6e038e --- /dev/null +++ b/test/flash/unit/services/ibex/client.spec.ts @@ -0,0 +1,112 @@ +const mockGetAccessToken = jest.fn() +const mockSetAccessToken = jest.fn() +const mockSetRefreshToken = jest.fn() + +jest.mock("@config", () => ({ + IbexConfig: { + url: "https://api-sandbox.poweredbyibex.io", + authUrl: "https://auth.hub.sandbox.poweredbyibex.io", + email: "test@example.com", + password: "password", + webhook: { + uri: "https://example.com/webhook", + port: 4008, + secret: "secret", + }, + }, +})) + +jest.mock("@services/tracing", () => ({ + addAttributesToCurrentSpan: jest.fn(), + wrapAsyncFunctionsToRunInSpan: ({ fns }: { fns: unknown }) => fns, +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }, +})) + +jest.mock("@services/ibex/webhook-server", () => ({ + __esModule: true, + default: { + endpoints: { + onReceive: { + onchain: "https://example.com/onchain", + lnurl: "", + invoice: "", + cashout: "", + zap: "", + }, + onPay: { onchain: "https://example.com/onpay", lnurl: "", invoice: "" }, + cryptoReceive: "https://example.com/crypto-receive", + }, + secret: "secret", + }, +})) + +jest.mock("@services/ibex/cache", () => ({ + Redis: { + get: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + }, +})) + +jest.mock("ibex-client", () => + jest.fn().mockImplementation(() => ({ + authentication: { + storage: { + getAccessToken: mockGetAccessToken, + setAccessToken: mockSetAccessToken, + setRefreshToken: mockSetRefreshToken, + }, + }, + })), +) + +let Ibex: typeof import("@services/ibex/client").default + +describe("Ibex crypto receive info client", () => { + const fetchMock = jest.fn() + + beforeAll(async () => { + Ibex = (await import("@services/ibex/client")).default + }) + + beforeEach(() => { + jest.clearAllMocks() + global.fetch = fetchMock + }) + + it("sends the raw IBEX access token when fetching crypto receive options", async () => { + mockGetAccessToken.mockResolvedValue("access-token") + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + options: [ + { + id: "ethereum-usdt", + name: "Ethereum USDT", + currency: "USDT", + network: "Ethereum", + }, + ], + }), + }) + + const option = await Ibex.getEthereumUsdtOption() + + expect(option).toMatchObject({ id: "ethereum-usdt" }) + expect(fetchMock).toHaveBeenCalledWith( + "https://api-sandbox.poweredbyibex.io/crypto/receive-infos/options", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "access-token", + }), + }), + ) + }) +})