From cef88a57d2014587d3ad81fc2cf0283bd3557bc6 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 17 Jul 2026 00:39:02 -0700 Subject: [PATCH 1/2] fix(wallets): never emit unresolvable via types from IBEX transaction mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transaction.initiationVia/settlementVia are NonNull unions resolved via isTypeOf checks that only match intraledger/lightning/onchain. Since #413, IBEX rows with an unrecognized currencyId or transactionTypeId map to { type: "unknown" }, which no union member accepts — graphql-js throws "Abstract type 'InitiationVia' must resolve to an Object type at runtime" and the entire transaction list query fails for the account (home screen and history in the mobile app). - Unknown currencyId: exclude the row from the list (it cannot be represented truthfully) and keep the error log. - Unknown transactionTypeId: amounts and currency are valid, so render as a generic intraledger transaction instead of hiding money movement. - Remove the UnknownTypeTransaction domain type so no code path can emit { type: "unknown" } again. - Add resolveType with a logged intraledger fallback on both unions as defense in depth: one bad row can no longer fail the whole list. Co-Authored-By: Claude Fable 5 --- .../wallets/get-transactions-for-wallet.ts | 49 +++++++------- src/domain/wallets/index.types.d.ts | 11 +--- .../shared/types/abstract/initiation-via.ts | 20 ++++++ .../shared/types/abstract/settlement-via.ts | 20 ++++++ .../get-transactions-for-wallet.spec.ts | 65 +++++++++++++++++-- .../shared/types/abstract/via-unions.spec.ts | 48 ++++++++++++++ 6 files changed, 172 insertions(+), 41 deletions(-) create mode 100644 test/flash/unit/graphql/shared/types/abstract/via-unions.spec.ts diff --git a/src/app/wallets/get-transactions-for-wallet.ts b/src/app/wallets/get-transactions-for-wallet.ts index 3cc6b0cfa..b142fe16d 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,25 @@ 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. return { ...baseTrx, - initiationVia: { type: "unknown" }, - settlementVia: { type: "unknown" }, - } as UnknownTypeTransaction + initiationVia: { + type: "intraledger", + counterPartyWalletId: undefined as unknown as WalletId, + counterPartyUsername: undefined as unknown as Username, + }, + settlementVia: { + type: "intraledger", + counterPartyWalletId: undefined as unknown as WalletId, + counterPartyUsername: null, + }, + } as IntraLedgerTransaction } }) } diff --git a/src/domain/wallets/index.types.d.ts b/src/domain/wallets/index.types.d.ts index 90f86982c..1e3fd1cf3 100644 --- a/src/domain/wallets/index.types.d.ts +++ b/src/domain/wallets/index.types.d.ts @@ -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..d986560e7 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" @@ -44,6 +45,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..5c5387e60 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" @@ -57,6 +58,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() + }) +}) From 68c22a468c675496c126d09b2880f24d4a387ec2 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 17 Jul 2026 08:33:22 -0700 Subject: [PATCH 2/2] fix(wallets): address review findings on via-union fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make the sign caveat for unrecognized IBEX send types an explicit code comment (sign convention is only known for mapped type ids). - Remove now-dead isTypeOf checks on union members: graphql-js ignores isTypeOf when the union defines resolveType, and two parallel resolution mechanisms can drift. - Widen InitiationViaIntraledger/SettlementViaIntraledger counterPartyWalletId (and initiation counterPartyUsername) to allow undefined instead of lying to the compiler with double-casts — the GraphQL fields were already nullable. - Close the unbalanced brace in the transaction-type error log. Co-Authored-By: Claude Fable 5 --- src/app/wallets/get-transactions-for-wallet.ts | 13 ++++++++----- src/domain/wallets/index.types.d.ts | 6 +++--- src/graphql/shared/types/abstract/initiation-via.ts | 3 --- src/graphql/shared/types/abstract/settlement-via.ts | 3 --- 4 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/app/wallets/get-transactions-for-wallet.ts b/src/app/wallets/get-transactions-for-wallet.ts index b142fe16d..2ea096247 100644 --- a/src/app/wallets/get-transactions-for-wallet.ts +++ b/src/app/wallets/get-transactions-for-wallet.ts @@ -109,22 +109,25 @@ export const toWalletTransactions = (ibexResp: GResponse200): IbexTransaction[] } as WalletOnChainSettledTransaction // assuming Ibex only gives us settled default: baseLogger.error( - `Failed to parse Ibex transaction type. Rendering as intraledger. { 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. + // 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: "intraledger", - counterPartyWalletId: undefined as unknown as WalletId, - counterPartyUsername: undefined as unknown as Username, + counterPartyWalletId: undefined, + counterPartyUsername: undefined, }, settlementVia: { type: "intraledger", - counterPartyWalletId: undefined as unknown as WalletId, + 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 1e3fd1cf3..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 } diff --git a/src/graphql/shared/types/abstract/initiation-via.ts b/src/graphql/shared/types/abstract/initiation-via.ts index d986560e7..fa0f4a745 100644 --- a/src/graphql/shared/types/abstract/initiation-via.ts +++ b/src/graphql/shared/types/abstract/initiation-via.ts @@ -10,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), @@ -24,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), @@ -34,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), diff --git a/src/graphql/shared/types/abstract/settlement-via.ts b/src/graphql/shared/types/abstract/settlement-via.ts index 5c5387e60..4cf4fc657 100644 --- a/src/graphql/shared/types/abstract/settlement-via.ts +++ b/src/graphql/shared/types/abstract/settlement-via.ts @@ -16,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), @@ -31,7 +30,6 @@ const SettlementViaIntraLedger = GT.Object({ const SettlementViaLn = GT.Object({ name: "SettlementViaLn", - isTypeOf: (source: SettlementViaLn) => source.type === SettlementMethod.Lightning, fields: () => ({ paymentSecret: { type: LnPaymentSecret, @@ -48,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 },