diff --git a/src/app/wallets/get-transactions-for-wallet.ts b/src/app/wallets/get-transactions-for-wallet.ts index e8925dd70..0efa0180d 100644 --- a/src/app/wallets/get-transactions-for-wallet.ts +++ b/src/app/wallets/get-transactions-for-wallet.ts @@ -1,9 +1,18 @@ import { PartialResult } from "@app/partial-result" +import { ExchangeRates } from "@config" +import { + getCurrencyMajorExponent, + SAT_PRICE_PRECISION_OFFSET, + USD_PRICE_PRECISION_OFFSET, + UsdDisplayCurrency, +} from "@domain/fiat" +import { WalletCurrency } from "@domain/shared" import Ibex from "@services/ibex/client" import { IbexError } from "@services/ibex/errors" import { baseLogger } from "@services/logger" +import { AccountsRepository } from "@services/mongoose" import { GResponse200 } from "ibex-client" -import { ConnectionArguments, ConnectionCursor } from "graphql-relay" +import { ConnectionArguments } from "graphql-relay" export const getTransactionsForWallets = async ({ wallets, @@ -12,47 +21,82 @@ export const getTransactionsForWallets = async ({ wallets: Wallet[] paginationArgs?: PaginationArgs }): Promise>> => { - const walletIds = wallets.map((wallet) => wallet.id) - - const ibexCalls = await Promise.all(walletIds - .map(id => Ibex.getAccountTransactions({ - account_id: id, - ...toIbexPaginationArgs(paginationArgs) - })) + const accounts = AccountsRepository() + + const ibexCalls = await Promise.all( + wallets.map(async (wallet) => { + const [account, transactions] = await Promise.all([ + accounts.findById(wallet.accountId), + Ibex.getAccountTransactions({ + account_id: wallet.id, + ...toIbexPaginationArgs(paginationArgs), + }), + ]) + + return { + displayCurrency: + account instanceof Error ? UsdDisplayCurrency : account.displayCurrency, + transactions, + } + }), ) - const transactions = ibexCalls.flatMap(resp => { - if (resp instanceof IbexError) return [] - else return toWalletTransactions(resp) + const transactions = ibexCalls.flatMap(({ displayCurrency, transactions }) => { + if (transactions instanceof IbexError) return [] + else return toWalletTransactions(transactions, 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 ? WalletCurrency.Usd : WalletCurrency.Btc + ) as WalletCurrency - const settlementDisplayPrice: WalletMinorUnitDisplayPrice = { - base: trx.exchangeRateCurrencySats ? BigInt(Math.floor(trx.exchangeRateCurrencySats)) : 0n, - offset: 0n, // what is this? - displayCurrency: "USD" as DisplayCurrency, - walletCurrency: currency - } + const settlementAmount = toSettlementAmount( + trx.amount, + trx.transactionTypeId, + currency, + ) + const settlementFee = toFeeAmount(trx.networkFee, currency) + const settlementDisplayAmount = toSettlementDisplayAmount({ + amount: trx.amount, + transactionTypeId: trx.transactionTypeId, + walletCurrency: currency, + displayCurrency, + exchangeRateCurrencySats: trx.exchangeRateCurrencySats, + }) + const settlementDisplayFee = toSettlementDisplayAmount({ + amount: trx.networkFee, + transactionTypeId: 1, + walletCurrency: currency, + displayCurrency, + exchangeRateCurrencySats: trx.exchangeRateCurrencySats, + }) const baseTrx: BaseWalletTransaction = { - 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, + walletId: (trx.accountId || "") as WalletId, + settlementAmount, + settlementFee, + settlementCurrency: currency, + settlementDisplayAmount, + settlementDisplayFee, + settlementDisplayPrice: settlementDisplayPriceFromAmounts({ + displayAmount: settlementDisplayAmount, + walletAmount: settlementAmount, + walletCurrency: currency, + displayCurrency, + }), createdAt: trx.createdAt ? new Date(trx.createdAt) : new Date(), // should always return - id: trx.id || "null", // "LedgerTransactionId" - this is likely unused + id: trx.id || "null", // "LedgerTransactionId" - this is likely unused status: "success" as TxStatus, // assuming Ibex returns on completed memo: null, // query transaction details } @@ -63,68 +107,176 @@ export const toWalletTransactions = (ibexResp: GResponse200): IbexTransaction[] 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 } + // we need to query the transaction details for each trx individually. + 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 } + // 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 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 JMD_DISPLAY_CURRENCY = WalletCurrency.Jmd as DisplayCurrency + +const amountToNumber = (amount: number | undefined): number => { + if (amount === undefined) { + baseLogger.warn("Ibex did not return transaction amount") + return 0 + } + + return Number(amount) +} + +const isDebit = (transactionTypeId: number | undefined): boolean => + transactionTypeId === 2 || transactionTypeId === 4 + +const toSignedAmount = (amount: number, transactionTypeId: number | undefined): number => + isDebit(transactionTypeId) ? -amount : amount + +const toCurrencyAmount = ( + amount: number, + currency: WalletCurrency, +): Satoshis | UsdCents => { + return currency === WalletCurrency.Usd ? (amount as UsdCents) : (amount as Satoshis) +} + +const toFeeAmount = ( + ibexAmount: number | undefined, + currency: WalletCurrency, +): Satoshis | UsdCents => { + const amount = amountToNumber(ibexAmount) + const minorUnitAmount = + currency === WalletCurrency.Usd ? Math.round(amount * 100) : Math.round(amount) + return toCurrencyAmount(minorUnitAmount, currency) } 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) + const amount = amountToNumber(ibexAmount) + const minorUnitAmount = + currency === WalletCurrency.Usd ? Math.round(amount * 100) : Math.round(amount) + + return toCurrencyAmount(toSignedAmount(minorUnitAmount, transactionTypeId), currency) +} + +const usdMajorToDisplayMajor = ( + usdMajorAmount: number, + displayCurrency: DisplayCurrency, +): number => { + if (displayCurrency === JMD_DISPLAY_CURRENCY) { + return usdMajorAmount * Number(ExchangeRates.jmd.sell.asDollars()) + } + + return usdMajorAmount +} + +const btcMinorToUsdMajor = ( + satsAmount: number, + exchangeRateCurrencySats?: number, +): number => satsAmount * (exchangeRateCurrencySats || 0) + +const formatDisplayMajorAmount = ( + amount: number, + displayCurrency: DisplayCurrency, +): DisplayCurrencyMajorAmount => { + const exponent = getCurrencyMajorExponent(displayCurrency) + return amount.toFixed(exponent) as DisplayCurrencyMajorAmount +} + +const toSettlementDisplayAmount = ({ + amount, + transactionTypeId, + walletCurrency, + displayCurrency, + exchangeRateCurrencySats, +}: { + amount: number | undefined + transactionTypeId: number | undefined + walletCurrency: WalletCurrency + displayCurrency: DisplayCurrency + exchangeRateCurrencySats?: number +}): DisplayCurrencyMajorAmount => { + const signedAmount = toSignedAmount(amountToNumber(amount), transactionTypeId) + const usdMajorAmount = + walletCurrency === WalletCurrency.Usd + ? signedAmount + : btcMinorToUsdMajor(signedAmount, exchangeRateCurrencySats) + + return formatDisplayMajorAmount( + usdMajorToDisplayMajor(usdMajorAmount, displayCurrency), + displayCurrency, + ) +} + +const settlementDisplayPriceFromAmounts = ({ + displayAmount, + walletAmount, + walletCurrency, + displayCurrency, +}: { + displayAmount: DisplayCurrencyMajorAmount + walletAmount: Satoshis | UsdCents + walletCurrency: WalletCurrency + displayCurrency: DisplayCurrency +}): WalletMinorUnitDisplayPrice => { + const offset = + walletCurrency === WalletCurrency.Btc + ? SAT_PRICE_PRECISION_OFFSET + : USD_PRICE_PRECISION_OFFSET + const displayMajorExponent = getCurrencyMajorExponent(displayCurrency) + const displayAmountInMinor = Math.round( + Math.abs(Number(displayAmount)) * 10 ** displayMajorExponent, + ) + const walletAmountAbs = Math.abs(Number(walletAmount)) + const priceInMinorUnit = + walletAmountAbs === 0 ? 0 : displayAmountInMinor / walletAmountAbs + + return { + base: BigInt(Math.round(priceInMinorUnit * 10 ** offset)), + offset: BigInt(offset), + displayCurrency, + walletCurrency, } - // When sending, make negative - 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 // ibex default (0) start at page 0 + limit?: number | undefined // ibex default (0) returns all + sort?: SortOrder | undefined // defaults to SortOrder.RECENT } 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') @@ -132,13 +284,13 @@ export function toIbexPaginationArgs( 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/graphql/shared/types/object/btc-wallet.ts b/src/graphql/shared/types/object/btc-wallet.ts index 3a29738e2..7d8ba0743 100644 --- a/src/graphql/shared/types/object/btc-wallet.ts +++ b/src/graphql/shared/types/object/btc-wallet.ts @@ -12,16 +12,18 @@ import { Wallets } from "@app" import { WalletCurrency as WalletCurrencyDomain } from "@domain/shared" import { WalletType } from "@domain/wallets" +import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fraction" + import IWallet from "../abstract/wallet" import SignedAmount from "../scalar/signed-amount" import WalletCurrency from "../scalar/wallet-currency" import OnChainAddress from "../scalar/on-chain-address" -import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fraction" -import { TransactionConnection } from "./transaction" import Lnurl from "../scalar/lnurl" +import { TransactionConnection } from "./transaction" + const BtcWallet = GT.Object({ name: "BTCWallet", description: @@ -52,11 +54,11 @@ const BtcWallet = GT.Object({ description: "A balance stored in BTC.", resolve: async (source) => { if (source.type === WalletType.External) return null - const balanceSats = await Wallets.getBalanceForWallet({ walletId: source.id }) - if (balanceSats instanceof Error) { - throw mapError(balanceSats) + const balance = await Wallets.getBalanceForWallet({ walletId: source.id }) + if (balance instanceof Error) { + throw mapError(balance) } - return balanceSats + return typeof balance === "number" ? balance : Number(balance.asCents(8)) }, }, pendingIncomingBalance: { diff --git a/src/services/price/index.ts b/src/services/price/index.ts index 847d212cf..9552bbaff 100644 --- a/src/services/price/index.ts +++ b/src/services/price/index.ts @@ -14,7 +14,13 @@ import { WalletCurrency } from "@domain/shared" import { CENTS_PER_USD, UsdDisplayCurrency } from "@domain/fiat" -import { PRICE_HISTORY_HOST, PRICE_HISTORY_PORT, PRICE_HOST, PRICE_PORT } from "@config" +import { + ExchangeRates, + PRICE_HISTORY_HOST, + PRICE_HISTORY_PORT, + PRICE_HOST, + PRICE_PORT, +} from "@config" import { baseLogger } from "../logger" @@ -77,6 +83,17 @@ export const PriceService = (): IPriceService => { } } + if ( + walletCurrency === WalletCurrency.Usd && + displayCurrency === (WalletCurrency.Jmd as DisplayCurrency) + ) { + return { + timestamp: new Date(), + price: Number(ExchangeRates.jmd.sell.asDollars()) / CENTS_PER_USD, + 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/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..51c43bfa4 --- /dev/null +++ b/test/flash/unit/app/wallets/get-transactions-for-wallet.spec.ts @@ -0,0 +1,74 @@ +import { toWalletTransactions } from "@app/wallets/get-transactions-for-wallet" +import { WalletCurrency } from "@domain/shared" + +import { GResponse200 } from "ibex-client" + +const jmdDisplayCurrency = WalletCurrency.Jmd as DisplayCurrency +const usdDisplayCurrency = WalletCurrency.Usd as DisplayCurrency +const jmdPerUsd = 160 + +const ibexTransaction = ({ + amount, + networkFee = 0, + transactionTypeId = 1, +}: { + amount: number + networkFee?: number + transactionTypeId?: number +}) => ({ + id: "tx-id", + createdAt: "2026-05-21T00:00:00.000Z", + accountId: "wallet-id", + amount, + networkFee, + exchangeRateCurrencySats: 0.000001, + currencyId: 3, + transactionTypeId, +}) + +describe("toWalletTransactions", () => { + it.each` + jmdMajor + ${15.15} + ${100.0} + ${12500.01} + `( + "converts historical USD wallet amount to JMD $jmdMajor display amount", + ({ jmdMajor }: { jmdMajor: number }) => { + const [transaction] = toWalletTransactions( + [ibexTransaction({ amount: jmdMajor / jmdPerUsd })] as GResponse200, + jmdDisplayCurrency, + ) + + expect(transaction.settlementDisplayAmount).toBe(jmdMajor.toFixed(2)) + expect(transaction.settlementDisplayPrice.displayCurrency).toBe(jmdDisplayCurrency) + expect(transaction.settlementDisplayPrice.walletCurrency).toBe(WalletCurrency.Usd) + expect(transaction.settlementDisplayPrice.offset).toBe(6n) + }, + ) + + it("keeps USD display amounts and returns settlement amounts in cents", () => { + const [transaction] = toWalletTransactions( + [ibexTransaction({ amount: 8.01, networkFee: 0.03 })] as GResponse200, + usdDisplayCurrency, + ) + + expect(transaction.settlementAmount).toBe(801) + expect(transaction.settlementFee).toBe(3) + expect(transaction.settlementDisplayAmount).toBe("8.01") + expect(transaction.settlementDisplayFee).toBe("0.03") + expect(transaction.settlementDisplayPrice.displayCurrency).toBe(usdDisplayCurrency) + }) + + it("preserves send direction sign for JMD display amounts", () => { + const [transaction] = toWalletTransactions( + [ + ibexTransaction({ amount: 15.15 / jmdPerUsd, transactionTypeId: 2 }), + ] as GResponse200, + jmdDisplayCurrency, + ) + + expect(transaction.settlementAmount).toBe(-9) + expect(transaction.settlementDisplayAmount).toBe("-15.15") + }) +}) diff --git a/test/flash/unit/services/price/index.spec.ts b/test/flash/unit/services/price/index.spec.ts new file mode 100644 index 000000000..85017a175 --- /dev/null +++ b/test/flash/unit/services/price/index.spec.ts @@ -0,0 +1,16 @@ +import { PriceService } from "@services/price" +import { WalletCurrency } from "@domain/shared" + +describe("PriceService", () => { + it("uses the configured JMD sell rate for USD-cent realtime prices", async () => { + const price = await PriceService().getUsdCentRealTimePrice({ + displayCurrency: WalletCurrency.Jmd as DisplayCurrency, + }) + + expect(price).not.toBeInstanceOf(Error) + if (price instanceof Error) throw price + + expect(price.currency).toBe(WalletCurrency.Jmd) + expect(price.price).toBe(1.6) + }) +})