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
192 changes: 130 additions & 62 deletions src/app/wallets/get-transactions-for-wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,140 +5,208 @@ 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<PartialResult<PaginatedArray<IbexTransaction>>> => {
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<WalletCurrency, DisplayCurrency> = {
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) {
case 1:
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
}
1 change: 1 addition & 0 deletions src/domain/fiat/primitives.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export const UsdDisplayCurrency = "USD" as DisplayCurrency
export const JmdDisplayCurrency = "JMD" as DisplayCurrency
38 changes: 36 additions & 2 deletions src/services/price/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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()
Expand Down
Loading