Skip to content
Merged
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
52 changes: 26 additions & 26 deletions src/app/wallets/get-transactions-for-wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,33 +44,18 @@ const currencyFromIbexCurrencyId = (
}

export const toWalletTransactions = (ibexResp: GResponse200): IbexTransaction[] => {
return ibexResp.map((trx) => {
return ibexResp.flatMap((trx) => {
const currency = currencyFromIbexCurrencyId(trx.currencyId)

if (!currency) {
baseLogger.error(
`Failed to parse Ibex transaction currency. { WalletId: ${trx.accountId}, TransactionId: ${trx.id}, currencyId: ${trx.currencyId} }`,
`Failed to parse Ibex transaction currency. Excluding transaction from list. { WalletId: ${trx.accountId}, TransactionId: ${trx.id}, currencyId: ${trx.currencyId} }`,
)
return {
walletId: (trx.accountId || "") as WalletId,
settlementAmount: 0 as Satoshis,
settlementFee: 0 as Satoshis,
settlementCurrency: WalletCurrency.Usd,
settlementDisplayAmount: `${trx.amount}`,
settlementDisplayFee: `${trx.networkFee}`,
settlementDisplayPrice: {
base: 0n,
offset: 0n,
displayCurrency: "USD" as DisplayCurrency,
walletCurrency: WalletCurrency.Usd,
},
createdAt: trx.createdAt ? new Date(trx.createdAt) : new Date(),
id: trx.id || "null",
status: "success" as TxStatus,
memo: null,
initiationVia: { type: "unknown" },
settlementVia: { type: "unknown" },
} as UnknownTypeTransaction
// A row with an unrecognized currency cannot be represented truthfully;
// excluding it beats fabricating a USD row or emitting a source the
// NonNull initiationVia/settlementVia unions cannot resolve (which fails
// the whole transaction list query for the account).
return []
}

const settlementDisplayPrice: WalletMinorUnitDisplayPrice<
Expand Down Expand Up @@ -124,13 +109,28 @@ export const toWalletTransactions = (ibexResp: GResponse200): IbexTransaction[]
} 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}`,
`Failed to parse Ibex transaction type. Rendering as intraledger. { WalletId: ${baseTrx.walletId}, TransactionId: ${trx.id}, transactionTypeId: ${trx.transactionTypeId} }`,
)
// Amounts and currency are valid even when the IBEX type id is not
// recognized; render as a generic intraledger transaction (all union
// fields nullable) rather than hiding money movement or emitting a
// source the unions cannot resolve. Accepted caveat: the sign
// convention (send = negative) is only known for recognized type ids,
// so an unrecognized send type renders positive until its id is
// added to the switch above.
return {
...baseTrx,
initiationVia: { type: "unknown" },
settlementVia: { type: "unknown" },
} as UnknownTypeTransaction
initiationVia: {
type: "intraledger",
counterPartyWalletId: undefined,
counterPartyUsername: undefined,
},
settlementVia: {
type: "intraledger",
counterPartyWalletId: undefined,
counterPartyUsername: null,
},
} as IntraLedgerTransaction
}
})
}
Expand Down
17 changes: 4 additions & 13 deletions src/domain/wallets/index.types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ type WalletType =

type InitiationViaIntraledger = {
readonly type: "intraledger"
readonly counterPartyWalletId: WalletId
readonly counterPartyUsername: Username
readonly counterPartyWalletId: WalletId | undefined
readonly counterPartyUsername: Username | undefined
}

type InitiationViaLn = {
Expand All @@ -32,7 +32,7 @@ type InitiationViaOnChainLegacy = {

type SettlementViaIntraledger = {
readonly type: "intraledger"
readonly counterPartyWalletId: WalletId
readonly counterPartyWalletId: WalletId | undefined
readonly counterPartyUsername: Username | null
}

Expand Down Expand Up @@ -130,15 +130,6 @@ type WalletLnSettledTransaction = BaseWalletTransaction & {
readonly settlementVia: SettlementViaLn
}

type UnknownTypeTransaction = BaseWalletTransaction & {
readonly initiationVia: {
readonly type: "unknown"
}
readonly settlementVia: {
readonly type: "unknown"
}
}

type WalletOnChainTransaction =
| WalletOnChainIntraledgerTransaction
| WalletOnChainSettledTransaction
Expand All @@ -153,9 +144,9 @@ type WalletTransaction =
| WalletLnTransaction

type IbexTransaction =
| IntraLedgerTransaction
| WalletOnChainTransaction
| WalletLnTransaction
| UnknownTypeTransaction

type MemoSharingConfig = {
memoSharingCentsThreshold: UsdCents
Expand Down
23 changes: 20 additions & 3 deletions src/graphql/shared/types/abstract/initiation-via.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { GT } from "@graphql/index"

import { PaymentInitiationMethod } from "@domain/wallets"
import { baseLogger } from "@services/logger"

import WalletId from "../scalar/wallet-id"
import Username from "../scalar/username"
Expand All @@ -9,7 +10,6 @@ import PaymentHash from "../scalar/payment-hash"

const InitiationViaIntraLedger = GT.Object({
name: "InitiationViaIntraLedger",
isTypeOf: (source) => source.type === PaymentInitiationMethod.IntraLedger,
fields: () => ({
counterPartyWalletId: {
// type: GT.NonNull(WalletId),
Expand All @@ -23,7 +23,6 @@ const InitiationViaIntraLedger = GT.Object({

const InitiationViaLn = GT.Object({
name: "InitiationViaLn",
isTypeOf: (source) => source.type === PaymentInitiationMethod.Lightning,
fields: () => ({
paymentHash: {
type: GT.NonNull(PaymentHash),
Expand All @@ -33,7 +32,6 @@ const InitiationViaLn = GT.Object({

const InitiationViaOnChain = GT.Object({
name: "InitiationViaOnChain",
isTypeOf: (source) => source.type === PaymentInitiationMethod.OnChain,
fields: () => ({
address: {
type: GT.NonNull(OnChainAddress),
Expand All @@ -44,6 +42,25 @@ const InitiationViaOnChain = GT.Object({
const InitiationVia = GT.Union({
name: "InitiationVia",
types: () => [InitiationViaIntraLedger, InitiationViaLn, InitiationViaOnChain],
// initiationVia is NonNull on Transaction: a source that fails to resolve
// fails the entire transaction list query for the account. Unrecognized
// sources degrade to the all-nullable IntraLedger member instead.
resolveType: (source) => {
switch (source.type) {
case PaymentInitiationMethod.IntraLedger:
return "InitiationViaIntraLedger"
case PaymentInitiationMethod.Lightning:
return "InitiationViaLn"
case PaymentInitiationMethod.OnChain:
return "InitiationViaOnChain"
default:
baseLogger.error(
{ initiationViaType: source.type },
"Unrecognized initiationVia type; defaulting to InitiationViaIntraLedger",
)
return "InitiationViaIntraLedger"
}
},
})

export default InitiationVia
23 changes: 20 additions & 3 deletions src/graphql/shared/types/abstract/settlement-via.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import dedent from "dedent"
import { GT } from "@graphql/index"

import { SettlementMethod } from "@domain/wallets"
import { baseLogger } from "@services/logger"

import WalletId from "@graphql/shared/types/scalar/wallet-id"

Expand All @@ -15,7 +16,6 @@ import LnPaymentSecret from "../scalar/ln-payment-secret"

const SettlementViaIntraLedger = GT.Object({
name: "SettlementViaIntraLedger",
isTypeOf: (source) => source.type === SettlementMethod.IntraLedger,
fields: () => ({
counterPartyWalletId: {
// type: GT.NonNull(WalletId),
Expand All @@ -30,7 +30,6 @@ const SettlementViaIntraLedger = GT.Object({

const SettlementViaLn = GT.Object({
name: "SettlementViaLn",
isTypeOf: (source: SettlementViaLn) => source.type === SettlementMethod.Lightning,
fields: () => ({
paymentSecret: {
type: LnPaymentSecret,
Expand All @@ -47,7 +46,6 @@ const SettlementViaLn = GT.Object({

const SettlementViaOnChain = GT.Object({
name: "SettlementViaOnChain",
isTypeOf: (source) => source.type === SettlementMethod.OnChain,
fields: () => ({
transactionHash: { type: OnChainTxHash },
vout: { type: GT.Int },
Expand All @@ -57,6 +55,25 @@ const SettlementViaOnChain = GT.Object({
const SettlementVia = GT.Union({
name: "SettlementVia",
types: () => [SettlementViaIntraLedger, SettlementViaLn, SettlementViaOnChain],
// settlementVia is NonNull on Transaction: a source that fails to resolve
// fails the entire transaction list query for the account. Unrecognized
// sources degrade to the all-nullable IntraLedger member instead.
resolveType: (source) => {
switch (source.type) {
case SettlementMethod.IntraLedger:
return "SettlementViaIntraLedger"
case SettlementMethod.Lightning:
return "SettlementViaLn"
case SettlementMethod.OnChain:
return "SettlementViaOnChain"
default:
baseLogger.error(
{ settlementViaType: source.type },
"Unrecognized settlementVia type; defaulting to SettlementViaIntraLedger",
)
return "SettlementViaIntraLedger"
}
},
})

export default SettlementVia
65 changes: 60 additions & 5 deletions test/flash/unit/app/wallets/get-transactions-for-wallet.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,10 @@ describe("toWalletTransactions", () => {
expect(transaction.settlementFee).toBe(12)
})

it("does not silently classify unknown IBEX currency ids as BTC", () => {
const [transaction] = toWalletTransactions([
it("excludes transactions with unknown IBEX currency ids from the list", () => {
const errorSpy = jest.spyOn(baseLogger, "error").mockImplementation()

const transactions = toWalletTransactions([
{
id: "trx-id",
accountId: "wallet-id",
Expand All @@ -168,9 +170,62 @@ describe("toWalletTransactions", () => {
},
] as GResponse200)

expect(transaction.settlementCurrency).not.toBe(WalletCurrency.Btc)
expect(transaction.initiationVia.type).toBe("unknown")
expect(transaction.settlementVia.type).toBe("unknown")
expect(transactions).toHaveLength(0)
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to parse Ibex transaction currency"),
)

errorSpy.mockRestore()
})

it("maps unknown IBEX transaction type ids to intraledger with valid amounts", () => {
const errorSpy = jest.spyOn(baseLogger, "error").mockImplementation()

const [transaction] = toWalletTransactions([
{
id: "trx-id",
accountId: "wallet-id",
amount: 250,
networkFee: 1,
currencyId: 3,
transactionTypeId: 99,
createdAt: "2026-05-13T00:00:00.000Z",
},
] as GResponse200)

expect(transaction.settlementCurrency).toBe(WalletCurrency.Usd)
expect(transaction.settlementAmount).toBe(250)
expect(transaction.settlementFee).toBe(1)
expect(transaction.initiationVia.type).toBe("intraledger")
expect(transaction.settlementVia.type).toBe("intraledger")
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to parse Ibex transaction type"),
)

errorSpy.mockRestore()
})

it("never emits via types outside the GraphQL union members", () => {
const resolvable = ["intraledger", "lightning", "onchain"]

const transactions = toWalletTransactions(
[3, 29].flatMap((currencyId) =>
[1, 2, 3, 4, 10, 99, undefined].map((transactionTypeId, i) => ({
id: `trx-${currencyId}-${i}`,
accountId: "wallet-id",
amount: 10,
currencyId,
transactionTypeId,
createdAt: "2026-05-13T00:00:00.000Z",
})),
) as GResponse200,
)

expect(transactions).toHaveLength(14)
for (const transaction of transactions) {
expect(resolvable).toContain(transaction.initiationVia.type)
expect(resolvable).toContain(transaction.settlementVia.type)
}
})
})

Expand Down
48 changes: 48 additions & 0 deletions test/flash/unit/graphql/shared/types/abstract/via-unions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { GraphQLResolveInfo, GraphQLUnionType } from "graphql"

import InitiationVia from "@graphql/shared/types/abstract/initiation-via"
import SettlementVia from "@graphql/shared/types/abstract/settlement-via"
import { baseLogger } from "@services/logger"

const resolve = (union: GraphQLUnionType, source: { type?: string }) =>
union.resolveType?.(source, {}, {} as GraphQLResolveInfo, union)

describe("InitiationVia union", () => {
it("resolves known initiation methods to their object types", () => {
expect(resolve(InitiationVia, { type: "intraledger" })).toBe(
"InitiationViaIntraLedger",
)
expect(resolve(InitiationVia, { type: "lightning" })).toBe("InitiationViaLn")
expect(resolve(InitiationVia, { type: "onchain" })).toBe("InitiationViaOnChain")
})

it("falls back to InitiationViaIntraLedger for unrecognized sources instead of failing the query", () => {
const errorSpy = jest.spyOn(baseLogger, "error").mockImplementation()

expect(resolve(InitiationVia, { type: "unknown" })).toBe("InitiationViaIntraLedger")
expect(resolve(InitiationVia, {})).toBe("InitiationViaIntraLedger")
expect(errorSpy).toHaveBeenCalled()

errorSpy.mockRestore()
})
})

describe("SettlementVia union", () => {
it("resolves known settlement methods to their object types", () => {
expect(resolve(SettlementVia, { type: "intraledger" })).toBe(
"SettlementViaIntraLedger",
)
expect(resolve(SettlementVia, { type: "lightning" })).toBe("SettlementViaLn")
expect(resolve(SettlementVia, { type: "onchain" })).toBe("SettlementViaOnChain")
})

it("falls back to SettlementViaIntraLedger for unrecognized sources instead of failing the query", () => {
const errorSpy = jest.spyOn(baseLogger, "error").mockImplementation()

expect(resolve(SettlementVia, { type: "unknown" })).toBe("SettlementViaIntraLedger")
expect(resolve(SettlementVia, {})).toBe("SettlementViaIntraLedger")
expect(errorSpy).toHaveBeenCalled()

errorSpy.mockRestore()
})
})
Loading