Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/app/accounts/create-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/services/ibex/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ const payToLnurl = async (

const getIbexToken = async (): Promise<string | IbexError> => {
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`, {
Expand Down Expand Up @@ -268,7 +268,7 @@ const getIbexToken = async (): Promise<string | IbexError> => {
)
}

return `Bearer ${data.accessToken}`
return data.accessToken
}

const ibexFetch = async <T>(
Expand Down
73 changes: 70 additions & 3 deletions test/flash/unit/app/accounts/create-account.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
>
Expand All @@ -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,
Expand All @@ -54,13 +65,31 @@ describe("createAccountWithPhoneIdentifier", () => {
update: jest.fn().mockResolvedValue({ id: "user-id" }),
} as unknown as ReturnType<typeof UsersRepository>)

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<typeof AccountsRepository>)

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,
Expand Down Expand Up @@ -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")
Expand Down
112 changes: 112 additions & 0 deletions test/flash/unit/services/ibex/client.spec.ts
Original file line number Diff line number Diff line change
@@ -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",
}),
}),
)
})
})