diff --git a/src/app/wallets/get-transactions-for-wallet.ts b/src/app/wallets/get-transactions-for-wallet.ts index e8925dd70..73251d852 100644 --- a/src/app/wallets/get-transactions-for-wallet.ts +++ b/src/app/wallets/get-transactions-for-wallet.ts @@ -5,56 +5,111 @@ import { baseLogger } from "@services/logger" import { GResponse200 } from "ibex-client" import { ConnectionArguments, ConnectionCursor } from "graphql-relay" +import { ExchangeRates } from "@config" +import { + SAT_PRICE_PRECISION_OFFSET, + USD_PRICE_PRECISION_OFFSET, + UsdDisplayCurrency, + JmdDisplayCurrency, +} from "@domain/fiat" +import { BigIntConversionError, WalletCurrency } from "@domain/shared" + export const getTransactionsForWallets = async ({ wallets, paginationArgs, + displayCurrency = UsdDisplayCurrency, }: { wallets: Wallet[] paginationArgs?: PaginationArgs + displayCurrency?: DisplayCurrency }): Promise>> => { const walletIds = wallets.map((wallet) => wallet.id) - - const ibexCalls = await Promise.all(walletIds - .map(id => Ibex.getAccountTransactions({ - account_id: id, - ...toIbexPaginationArgs(paginationArgs) - })) + + const ibexCalls = await Promise.all( + walletIds.map((id) => + Ibex.getAccountTransactions({ + account_id: id, + ...toIbexPaginationArgs(paginationArgs), + }), + ), ) - const transactions = ibexCalls.flatMap(resp => { - if (resp instanceof IbexError) return [] - else return toWalletTransactions(resp) + const transactions = ibexCalls.flatMap((resp) => { + if (resp instanceof IbexError) return [] + else return toWalletTransactions(resp, displayCurrency) }) return PartialResult.ok({ slice: transactions, - total: transactions.length + total: transactions.length, }) } -export const toWalletTransactions = (ibexResp: GResponse200): IbexTransaction[] => { - return ibexResp.map(trx => { - const currency = (trx.currencyId === 3 ? "USD" : "BTC") as WalletCurrency // WalletCurrency: "USD" | "BTC", +export const toWalletTransactions = ( + ibexResp: GResponse200, + displayCurrency: DisplayCurrency = UsdDisplayCurrency, +): IbexTransaction[] => { + return ibexResp.map((trx) => { + const currency = (trx.currencyId === 3 ? "USD" : "BTC") as WalletCurrency + + // Determine the correct price precision offset based on wallet currency + const priceOffset = + currency === WalletCurrency.Btc + ? SAT_PRICE_PRECISION_OFFSET + : USD_PRICE_PRECISION_OFFSET + + // Round instead of floor to avoid truncating small exchange rates to 0 + const exchangeRateBase = trx.exchangeRateCurrencySats + ? BigInt(Math.round(trx.exchangeRateCurrencySats * 10 ** priceOffset)) + : 0n + + const settlementDisplayPrice: WalletMinorUnitDisplayPrice< + WalletCurrency, + DisplayCurrency + > = { + base: exchangeRateBase, + offset: BigInt(priceOffset), + displayCurrency, + walletCurrency: currency, + } + + // For JMD display currency, convert settlement amounts using the static rate + let settlementDisplayAmount = `${trx.amount}` + let settlementDisplayFee = `${trx.networkFee}` + + if ( + displayCurrency === JmdDisplayCurrency && + trx.amount !== undefined + ) { + const sellRate = ExchangeRates.jmd.sell + if (!(sellRate instanceof BigIntConversionError)) { + const usdCents = trx.amount + const jmdCents = Number(sellRate.asCents()) / 100 * usdCents + settlementDisplayAmount = String(Math.round(jmdCents)) - const settlementDisplayPrice: WalletMinorUnitDisplayPrice = { - base: trx.exchangeRateCurrencySats ? BigInt(Math.floor(trx.exchangeRateCurrencySats)) : 0n, - offset: 0n, // what is this? - displayCurrency: "USD" as DisplayCurrency, - walletCurrency: currency + if (trx.networkFee !== undefined) { + const feeJmdCents = Number(sellRate.asCents()) / 100 * trx.networkFee + settlementDisplayFee = String(Math.round(feeJmdCents)) + } + } } const baseTrx: BaseWalletTransaction = { - walletId: (trx.accountId || "") as WalletId, - settlementAmount: toSettlementAmount(trx.amount, trx.transactionTypeId, currency), + walletId: (trx.accountId || "") as WalletId, + settlementAmount: toSettlementAmount( + trx.amount, + trx.transactionTypeId, + currency, + ), settlementFee: asCurrency(trx.networkFee, currency), - settlementCurrency: currency, - settlementDisplayAmount: `${trx.amount}`, - settlementDisplayFee: `${trx.networkFee}`, - settlementDisplayPrice: settlementDisplayPrice, - createdAt: trx.createdAt ? new Date(trx.createdAt) : new Date(), // should always return - id: trx.id || "null", // "LedgerTransactionId" - this is likely unused - status: "success" as TxStatus, // assuming Ibex returns on completed - memo: null, // query transaction details + settlementCurrency: currency, + settlementDisplayAmount, + settlementDisplayFee, + settlementDisplayPrice, + createdAt: trx.createdAt ? new Date(trx.createdAt) : new Date(), + id: trx.id || "null", + status: "success" as TxStatus, + memo: null, } switch (trx.transactionTypeId) { @@ -62,83 +117,96 @@ export const toWalletTransactions = (ibexResp: GResponse200): IbexTransaction[] case 2: return { ...baseTrx, - // Ibex does not provide paymentHash, pubkey and preimage in transactions endpoint. To get these fields, - // we need to query the transaction details for each trx individually. - initiationVia: { type: 'lightning', paymentHash: "", pubkey: "" }, - settlementVia: { type: 'lightning', revealedPreImage: undefined } + initiationVia: { + type: "lightning", + paymentHash: "", + pubkey: "", + }, + settlementVia: { + type: "lightning", + revealedPreImage: undefined, + }, } as WalletLnSettledTransaction case 3: case 4: return { ...baseTrx, - // Ibex does not provide paymentHash, pubkey and preimage in transactions endpoint. To get these fields, - // we need to query the transaction details for each trx individually. - initiationVia: { type: 'onchain', address: "" }, - settlementVia: { type: 'onchain', transactionHash: '', vout: undefined } - } as WalletOnChainSettledTransaction // assuming Ibex only gives us settled + initiationVia: { type: "onchain", address: "" }, + settlementVia: { + type: "onchain", + transactionHash: "", + vout: undefined, + }, + } as WalletOnChainSettledTransaction default: - baseLogger.error(`Failed to parse Ibex transaction type. { WalletId: ${baseTrx.walletId}, TransactionId: ${trx.id}, transactionTypeId: ${trx.transactionTypeId}`) - return { + baseLogger.error( + `Failed to parse Ibex transaction type. { WalletId: ${baseTrx.walletId}, TransactionId: ${trx.id}, transactionTypeId: ${trx.transactionTypeId}`, + ) + return { ...baseTrx, - initiationVia: { type: 'unknown' }, - settlementVia: { type: 'unknown' } + initiationVia: { type: "unknown" }, + settlementVia: { type: "unknown" }, } as UnknownTypeTransaction } }) } -const asCurrency = (amount: number | undefined, currency: WalletCurrency): Satoshis | UsdCents => { - return currency === "USD" ? amount as UsdCents : amount as Satoshis +const asCurrency = ( + amount: number | undefined, + currency: WalletCurrency, +): Satoshis | UsdCents => { + return currency === "USD" + ? (amount as UsdCents) + : (amount as Satoshis) } const toSettlementAmount = ( - ibexAmount: number | undefined, - transactionTypeId: number | undefined, - currency: WalletCurrency + ibexAmount: number | undefined, + transactionTypeId: number | undefined, + currency: WalletCurrency, ): Satoshis | UsdCents => { if (ibexAmount === undefined) { baseLogger.warn("Ibex did not return transaction amount") - return asCurrency(ibexAmount, currency) + return asCurrency(ibexAmount, currency) } - // When sending, make negative - const amt = (transactionTypeId === 2 || transactionTypeId === 4) - ? -1 * ibexAmount - : ibexAmount + const amt = + transactionTypeId === 2 || transactionTypeId === 4 + ? -1 * ibexAmount + : ibexAmount return asCurrency(amt, currency) } enum SortOrder { RECENT = "settledAt", - OLDEST = "-settledAt" + OLDEST = "-settledAt", } type IbexPaginationArgs = { - page?: number | undefined; // ibex default (0) start at page 0 - limit?: number | undefined; // ibex default (0) returns all - sort?: SortOrder | undefined; // defaults to SortOrder.RECENT + page?: number | undefined + limit?: number | undefined + sort?: SortOrder | undefined } export function toIbexPaginationArgs( - args: ConnectionArguments | undefined + args: ConnectionArguments | undefined, ): IbexPaginationArgs { const DEFAULTS = { - page: 0, - limit: 0, - sort: SortOrder.RECENT, + page: 0, + limit: 0, + sort: SortOrder.RECENT, } - // Prefer 'first' over 'last') if (args && args.first != null) { return { ...DEFAULTS, limit: args.first, - sort: SortOrder.RECENT, + sort: SortOrder.RECENT, } } else if (args && args.last != null) { return { ...DEFAULTS, limit: args.last, - sort: SortOrder.OLDEST, + sort: SortOrder.OLDEST, } } else return DEFAULTS } diff --git a/src/domain/fiat/primitives.ts b/src/domain/fiat/primitives.ts index 2df016199..70466845b 100644 --- a/src/domain/fiat/primitives.ts +++ b/src/domain/fiat/primitives.ts @@ -1 +1,2 @@ export const UsdDisplayCurrency = "USD" as DisplayCurrency +export const JmdDisplayCurrency = "JMD" as DisplayCurrency diff --git a/src/services/price/index.ts b/src/services/price/index.ts index 847d212cf..e7592bf8f 100644 --- a/src/services/price/index.ts +++ b/src/services/price/index.ts @@ -12,9 +12,15 @@ import { SATS_PER_BTC } from "@domain/bitcoin" import { WalletCurrency } from "@domain/shared" -import { CENTS_PER_USD, UsdDisplayCurrency } from "@domain/fiat" +import { CENTS_PER_USD, UsdDisplayCurrency, JmdDisplayCurrency } from "@domain/fiat" -import { PRICE_HISTORY_HOST, PRICE_HISTORY_PORT, PRICE_HOST, PRICE_PORT } from "@config" +import { + PRICE_HISTORY_HOST, + PRICE_HISTORY_PORT, + PRICE_HOST, + PRICE_PORT, + ExchangeRates, +} from "@config" import { baseLogger } from "../logger" @@ -77,6 +83,34 @@ export const PriceService = (): IPriceService => { } } + // For JMD display currency, use the static exchange rate from config + // instead of triangulating through BTC via the price server. + // This avoids compounding float precision errors across two gRPC calls. + if (displayCurrency === JmdDisplayCurrency) { + const jmdSellRate = ExchangeRates.jmd.sell + if (jmdSellRate instanceof Error) return new PriceNotAvailableError() + + let displayCurrencyPrice: number + if (walletCurrency === WalletCurrency.Btc) { + // JMD per sat: JMD cents per USD cent * (USD/BTC) / SATS_PER_BTC / CENTS_PER_USD + const usdPrice = await getPrice({ currency: UsdDisplayCurrency }) + if (!usdPrice.price) return new PriceNotAvailableError() + displayCurrencyPrice = + (Number(jmdSellRate.asDollars()) * usdPrice.price) / + SATS_PER_BTC / + CENTS_PER_USD + } else { + // JMD per USD cent: static rate in dollars / CENTS_PER_USD + displayCurrencyPrice = Number(jmdSellRate.asDollars()) / CENTS_PER_USD + } + + return { + timestamp: new Date(), + price: displayCurrencyPrice, + currency: displayCurrency, + } + } + // FIXME: price server should return CentsPerSat directly and timestamp const { price } = await getPrice({ currency: displayCurrency }) if (!price) return new PriceNotAvailableError() diff --git a/src/services/price/protos/price.proto b/src/services/price/protos/price.proto index aa03f93a7..5f5a00ebd 100644 --- a/src/services/price/protos/price.proto +++ b/src/services/price/protos/price.proto @@ -6,7 +6,7 @@ service PriceFeed { } message PriceResponse { - float price = 1; + double price = 1; } message PriceQuery { diff --git a/src/services/price/protos/price_history.proto b/src/services/price/protos/price_history.proto index 41df871a1..6c6d2524e 100644 --- a/src/services/price/protos/price_history.proto +++ b/src/services/price/protos/price_history.proto @@ -22,5 +22,5 @@ message PriceHistoryResponse { message Tick { uint64 timestamp = 1; - float price = 2; + double price = 2; } diff --git a/test/flash/unit/app/wallets/get-transactions-for-wallet.spec.ts b/test/flash/unit/app/wallets/get-transactions-for-wallet.spec.ts new file mode 100644 index 000000000..5702393e6 --- /dev/null +++ b/test/flash/unit/app/wallets/get-transactions-for-wallet.spec.ts @@ -0,0 +1,251 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** + * Test suite for toWalletTransactions and getTransactionsForWallets + * + * These tests focus on the Ibex API response transformation logic: + * - Correct price offset based on wallet currency (BTC → 12, USD → 6) + * - Math.round() instead of Math.floor() - prevents truncating small rates to 0 + * - JMD display currency conversion using static ExchangeRates config + * - Correct settlementDisplayPrice construction + */ + +import { toWalletTransactions } from "@app/wallets/get-transactions-for-wallet" +import { UsdDisplayCurrency, JmdDisplayCurrency } from "@domain/fiat" + +// Mock Ibex client to break the complex dependency chain (mongoose, etc.) +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: {}, +})) + +// Mock the config ExchangeRates for JMD conversion tests +jest.mock("@config", () => { + // Create a minimal JMDAmount mock + const JMDAmount = { + dollars: jest.fn((d: number) => ({ + asCents: () => String(Math.round(d * 100)), + asDollars: () => d.toFixed(2), + })), + } + + return { + ExchangeRates: { + jmd: { + sell: JMDAmount.dollars(160), // 1 USD = 160 JMD + }, + }, + } +}) + +jest.mock("@services/logger", () => ({ + baseLogger: { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + child: jest.fn(() => ({ + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + })), + }, +})) + +describe("toWalletTransactions", () => { + describe("USD wallet currency (currencyId === 3)", () => { + const usdTx = { + accountId: "acc1", + amount: 1000, + currencyId: 3, // USD + transactionTypeId: 1, // Lightning receive + exchangeRateCurrencySats: 0.001, // 1 sat = $0.001 = 0.1 USD cents + networkFee: 5, + id: "tx1", + createdAt: "2024-01-01T00:00:00Z", + } + + it("uses USD_PRICE_PRECISION_OFFSET (6) for USD wallet transactions", () => { + const result = toWalletTransactions([usdTx as any], UsdDisplayCurrency) + expect(result[0].settlementDisplayPrice.offset).toBe(6n) + expect(result[0].settlementDisplayPrice.walletCurrency).toBe("USD") + }) + + it("scales the exchange rate base by 10^6 for USD wallet", () => { + // 0.001 * 10^6 = 1000 + const result = toWalletTransactions([usdTx as any], UsdDisplayCurrency) + expect(result[0].settlementDisplayPrice.base).toBe(1000n) + }) + + it("uses the provided displayCurrency instead of hardcoded USD", () => { + const result = toWalletTransactions( + [usdTx as any], + UsdDisplayCurrency, + ) + expect(result[0].settlementDisplayPrice.displayCurrency).toBe("USD") + }) + }) + + describe("BTC wallet currency (currencyId !== 3)", () => { + const btcTx = { + accountId: "acc2", + amount: 100000, + currencyId: 1, // BTC + transactionTypeId: 1, + exchangeRateCurrencySats: 0.00007, // 1 sat = 0.00007 USD + networkFee: 50, + id: "tx2", + createdAt: "2024-01-01T00:00:00Z", + } + + it("uses SAT_PRICE_PRECISION_OFFSET (12) for BTC wallet transactions", () => { + const result = toWalletTransactions([btcTx as any], UsdDisplayCurrency) + expect(result[0].settlementDisplayPrice.offset).toBe(12n) + expect(result[0].settlementDisplayPrice.walletCurrency).toBe("BTC") + }) + + it("scales the exchange rate base by 10^12 for BTC wallet", () => { + // 0.00007 * 10^12 = 70000000 + const result = toWalletTransactions([btcTx as any], UsdDisplayCurrency) + expect(result[0].settlementDisplayPrice.base).toBe(70000000n) + }) + }) + + describe("Math.floor vs Math.round fix", () => { + it("uses Math.round() so small exchange rates don't truncate to 0", () => { + // A very small exchange rate: 0.00000007 USD per sat + // Math.floor(0.00000007 * 10^12) = 0 - BUG! + // Math.round(0.00000007 * 10^12) = 70000 - CORRECT + const tx = { + accountId: "acc3", + amount: 5000, + currencyId: 1, // BTC + transactionTypeId: 1, + exchangeRateCurrencySats: 0.00000007, + createdAt: "2024-01-01T00:00:00Z", + id: "tx3", + } + const result = toWalletTransactions([tx as any], UsdDisplayCurrency) + // With Math.floor: base would be 0n + // With Math.round: base should be 70000n + expect(result[0].settlementDisplayPrice.base).toBeGreaterThan(0n) + expect(result[0].settlementDisplayPrice.base).toBe(70000n) + }) + + it("handles undefined exchangeRateCurrencySats gracefully", () => { + const tx = { + accountId: "acc4", + amount: 1000, + currencyId: 3, // USD + transactionTypeId: 1, + createdAt: "2024-01-01T00:00:00Z", + id: "tx4", + } + const result = toWalletTransactions([tx as any], UsdDisplayCurrency) + expect(result[0].settlementDisplayPrice.base).toBe(0n) + expect(result[0].settlementDisplayPrice.offset).toBe(6n) + }) + }) + + describe("JMD display currency conversion", () => { + const usdTx = { + accountId: "acc5", + amount: 1000, // 1000 USD cents = $10.00 + currencyId: 3, // USD + transactionTypeId: 1, + exchangeRateCurrencySats: 0.001, + networkFee: 5, + id: "tx5", + createdAt: "2024-01-01T00:00:00Z", + } + + it("converts settlementDisplayAmount to JMD when displayCurrency is JMD", () => { + // 1000 USD cents = $10.00 USD + // 1 USD = 160 JMD → $10.00 USD = 1600 JMD dollars = 160000 JMD cents + const result = toWalletTransactions([usdTx as any], JmdDisplayCurrency) + expect(result[0].settlementDisplayAmount).toBe("160000") + }) + + it("converts settlementDisplayFee to JMD when displayCurrency is JMD", () => { + // 5 USD cents × 160 JMD/USD = 800 JMD cents + const result = toWalletTransactions([usdTx as any], JmdDisplayCurrency) + expect(result[0].settlementDisplayFee).toBe("800") + }) + + it("does not convert to JMD when displayCurrency is USD (default)", () => { + const result = toWalletTransactions([usdTx as any], UsdDisplayCurrency) + // Amount should pass through as-is (raw string from Ibex) + expect(result[0].settlementDisplayAmount).toBe("1000") + expect(result[0].settlementDisplayFee).toBe("5") + }) + }) + + describe("Transaction type parsing", () => { + it("maps transactionTypeId 1 to Lightning receive", () => { + const tx = { + accountId: "acc6", + amount: 500, + currencyId: 1, + transactionTypeId: 1, // Lightning receive + createdAt: "2024-01-01T00:00:00Z", + id: "tx6", + } + const result = toWalletTransactions([tx as any], UsdDisplayCurrency) + expect(result[0].initiationVia).toMatchObject({ type: "lightning" }) + expect(result[0].settlementVia).toMatchObject({ type: "lightning" }) + }) + + it("maps transactionTypeId 3 to Onchain receive", () => { + const tx = { + accountId: "acc7", + amount: 50000, + currencyId: 1, + transactionTypeId: 3, // Onchain receive + createdAt: "2024-01-01T00:00:00Z", + id: "tx7", + } + const result = toWalletTransactions([tx as any], UsdDisplayCurrency) + expect(result[0].initiationVia).toMatchObject({ type: "onchain" }) + expect(result[0].settlementVia).toMatchObject({ type: "onchain" }) + }) + }) + + describe("Settlement amounts", () => { + it("makes receive amounts positive", () => { + const tx = { + accountId: "acc8", + amount: 2000, + currencyId: 3, // USD + transactionTypeId: 1, // Lightning receive + createdAt: "2024-01-01T00:00:00Z", + id: "tx8", + } + const result = toWalletTransactions([tx as any], UsdDisplayCurrency) + expect(result[0].settlementAmount).toBe(2000) // positive + }) + + it("makes send amounts negative", () => { + const tx = { + accountId: "acc9", + amount: 1500, + currencyId: 3, // USD + transactionTypeId: 2, // Lightning send + createdAt: "2024-01-01T00:00:00Z", + id: "tx9", + } + const result = toWalletTransactions([tx as any], UsdDisplayCurrency) + expect(result[0].settlementAmount).toBe(-1500) // negative + }) + + it("handles missing amount gracefully", () => { + const tx = { + accountId: "acc10", + currencyId: 3, + transactionTypeId: 1, + createdAt: "2024-01-01T00:00:00Z", + id: "tx10", + } + const result = toWalletTransactions([tx as any], UsdDisplayCurrency) + expect(result[0].settlementAmount).toBeUndefined() + }) + }) +})