diff --git a/src/app/wallets/get-transactions-for-wallet.ts b/src/app/wallets/get-transactions-for-wallet.ts index 3cc6b0cfa..2ea096247 100644 --- a/src/app/wallets/get-transactions-for-wallet.ts +++ b/src/app/wallets/get-transactions-for-wallet.ts @@ -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< @@ -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 } }) } diff --git a/src/domain/wallets/index.types.d.ts b/src/domain/wallets/index.types.d.ts index 90f86982c..7c5d6739f 100644 --- a/src/domain/wallets/index.types.d.ts +++ b/src/domain/wallets/index.types.d.ts @@ -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 = { @@ -32,7 +32,7 @@ type InitiationViaOnChainLegacy = { type SettlementViaIntraledger = { readonly type: "intraledger" - readonly counterPartyWalletId: WalletId + readonly counterPartyWalletId: WalletId | undefined readonly counterPartyUsername: Username | null } @@ -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 @@ -153,9 +144,9 @@ type WalletTransaction = | WalletLnTransaction type IbexTransaction = + | IntraLedgerTransaction | WalletOnChainTransaction | WalletLnTransaction - | UnknownTypeTransaction type MemoSharingConfig = { memoSharingCentsThreshold: UsdCents diff --git a/src/graphql/shared/types/abstract/initiation-via.ts b/src/graphql/shared/types/abstract/initiation-via.ts index 33d67a8d6..fa0f4a745 100644 --- a/src/graphql/shared/types/abstract/initiation-via.ts +++ b/src/graphql/shared/types/abstract/initiation-via.ts @@ -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" @@ -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), @@ -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), @@ -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), @@ -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 diff --git a/src/graphql/shared/types/abstract/settlement-via.ts b/src/graphql/shared/types/abstract/settlement-via.ts index 17f77cd51..4cf4fc657 100644 --- a/src/graphql/shared/types/abstract/settlement-via.ts +++ b/src/graphql/shared/types/abstract/settlement-via.ts @@ -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" @@ -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), @@ -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, @@ -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 }, @@ -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 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 index 934097490..9b9ebc614 100644 --- a/test/flash/unit/app/wallets/get-transactions-for-wallet.spec.ts +++ b/test/flash/unit/app/wallets/get-transactions-for-wallet.spec.ts @@ -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", @@ -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) + } }) }) diff --git a/test/flash/unit/graphql/shared/types/abstract/via-unions.spec.ts b/test/flash/unit/graphql/shared/types/abstract/via-unions.spec.ts new file mode 100644 index 000000000..02720a902 --- /dev/null +++ b/test/flash/unit/graphql/shared/types/abstract/via-unions.spec.ts @@ -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() + }) +})