Skip to content
Open
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
276 changes: 214 additions & 62 deletions src/app/wallets/get-transactions-for-wallet.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -12,47 +21,82 @@ export const getTransactionsForWallets = async ({
wallets: Wallet[]
paginationArgs?: PaginationArgs
}): 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 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<WalletCurrency, DisplayCurrency> = {
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
}
Expand All @@ -63,82 +107,190 @@ 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<WalletCurrency, DisplayCurrency> => {
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')
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
}
14 changes: 8 additions & 6 deletions src/graphql/shared/types/object/btc-wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Wallet>({
name: "BTCWallet",
description:
Expand Down Expand Up @@ -52,11 +54,11 @@ const BtcWallet = GT.Object<Wallet>({
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: {
Expand Down
Loading