diff --git a/__mocks__/@breeztech/breez-sdk-spark-react-native.js b/__mocks__/@breeztech/breez-sdk-spark-react-native.js index 8ad338939..cfc743b19 100644 --- a/__mocks__/@breeztech/breez-sdk-spark-react-native.js +++ b/__mocks__/@breeztech/breez-sdk-spark-react-native.js @@ -1,9 +1,21 @@ +/* eslint-disable camelcase */ +// The *_Tags identifiers mirror the SDK's generated enum names verbatim. +// Full member set verified against the 0.22.3 generated d.ts +// (breez_sdk_spark.d.ts, `export declare enum SdkEvent_Tags`). Keep this +// complete: a missing member reads as `undefined`, every tag comparison goes +// silently false, and a test passes green while asserting the wrong branch. const SdkEvent_Tags = { - PaymentPending: "PaymentPending", + Synced: "Synced", + UnclaimedDeposits: "UnclaimedDeposits", + ClaimedDeposits: "ClaimedDeposits", PaymentSucceeded: "PaymentSucceeded", + PaymentPending: "PaymentPending", PaymentFailed: "PaymentFailed", - Synced: "Synced", - Optimization: "Optimization", + // 0.17.0 renamed the variant (OptimizationEvent -> AutoOptimizationEvent); + // verified against the 0.22.3 generated d.ts. + AutoOptimization: "AutoOptimization", + LightningAddressChanged: "LightningAddressChanged", + NewDeposits: "NewDeposits", } // Every enum below must mirror the real SDK's generated numbering @@ -18,6 +30,23 @@ const SdkEvent_Tags = { // values under a "Complete" key); PaymentType did too. Copy the generated // values when adding a new enum here — do not invent readable strings. // +// 0.22.x: prepareSendPayment takes a tagged-union PaymentRequest; app code +// constructs `new PaymentRequest.Input({ input })`. Mirror of the generated +// class shape — tests only ever read `.tag` and `.inner.input`. +const PaymentRequest_Tags = { + Input: "Input", + CrossChain: "CrossChain", +} +const PaymentRequest = { + Input: class { + constructor(inner) { + this.tag = PaymentRequest_Tags.Input + this.inner = Object.freeze({ ...inner }) + } + }, + instanceOf: (obj) => Boolean(obj && obj.tag && obj.inner), +} + // generated: PaymentType — Send = 0, Receive = 1 const PaymentType = { Send: 0, @@ -44,6 +73,8 @@ class BitcoinAddress { } module.exports = { + PaymentRequest, + PaymentRequest_Tags, __esModule: true, BitcoinAddress, Bolt11Invoice, diff --git a/__tests__/mocks/breez-sdk-spark-mock-parity.spec.ts b/__tests__/mocks/breez-sdk-spark-mock-parity.spec.ts new file mode 100644 index 000000000..604b65072 --- /dev/null +++ b/__tests__/mocks/breez-sdk-spark-mock-parity.spec.ts @@ -0,0 +1,61 @@ +import * as fs from "fs" +import * as path from "path" + +// The manual mock at __mocks__/@breeztech/breez-sdk-spark-react-native.js +// claims its *_Tags objects mirror the SDK's generated enums verbatim. That +// claim has silently drifted before: a member missing from the mock reads as +// `undefined`, every tag comparison against it goes false, and a test passes +// green while asserting the wrong branch. This spec pins the claim to the +// installed package's generated d.ts so drift fails loudly on SDK bumps. +// eslint-disable-next-line @typescript-eslint/no-var-requires +const mock = require("../../__mocks__/@breeztech/breez-sdk-spark-react-native.js") + +const generatedDts = fs.readFileSync( + path.join( + __dirname, + "../../node_modules/@breeztech/breez-sdk-spark-react-native/lib/typescript/module/src/generated/breez_sdk_spark.d.ts", + ), + "utf8", +) + +const realEnumMembers = (enumName: string): Record => { + const enumBlock = generatedDts.match( + new RegExp(`export declare enum ${enumName} \\{([\\s\\S]*?)\\}`), + ) + if (!enumBlock) { + throw new Error(`enum ${enumName} not found in generated d.ts`) + } + const members: Record = {} + // Matches both string enums (`Synced = "Synced"`) and numeric enums + // (`Completed = 0`) — the numeric ones (PaymentStatus, PaymentType) are the + // ones that drifted destructively before (string values under a "Complete" + // key), so both shapes must be pinned. + for (const member of enumBlock[1].matchAll(/(\w+) = ("[^"]+"|\d+)/g)) { + const rawValue = member[2] + members[member[1]] = rawValue.startsWith('"') + ? rawValue.slice(1, -1) + : Number(rawValue) + } + if (Object.keys(members).length === 0) { + throw new Error(`enum ${enumName} matched no members in generated d.ts`) + } + return members +} + +describe("breez-sdk-spark manual mock parity with the generated SDK enums", () => { + it("SdkEvent_Tags mirrors the generated enum exactly — members and values", () => { + expect(mock.SdkEvent_Tags).toEqual(realEnumMembers("SdkEvent_Tags")) + }) + + it("PaymentRequest_Tags mirrors the generated enum exactly — members and values", () => { + expect(mock.PaymentRequest_Tags).toEqual(realEnumMembers("PaymentRequest_Tags")) + }) + + it("PaymentStatus mirrors the generated numeric enum exactly — members and values", () => { + expect(mock.PaymentStatus).toEqual(realEnumMembers("PaymentStatus")) + }) + + it("PaymentType mirrors the generated numeric enum exactly — members and values", () => { + expect(mock.PaymentType).toEqual(realEnumMembers("PaymentType")) + }) +}) diff --git a/__tests__/utils/breez-fee-extraction.spec.ts b/__tests__/utils/breez-fee-extraction.spec.ts index 3eef521df..21cac0cb9 100644 --- a/__tests__/utils/breez-fee-extraction.spec.ts +++ b/__tests__/utils/breez-fee-extraction.spec.ts @@ -64,10 +64,18 @@ describe("extractFeeFromPaymentMethod", () => { ).toEqual(BigInt(4)) }) - it("returns 0 for unknown methods", () => { - expect(extractFeeFromPaymentMethod({ tag: "SomethingNew" }, "fast")).toEqual( - BigInt(0), + it("throws on unrecognized payment method tags — never a silent 0-sat fee", () => { + // 0.22.x added CrossChainAddress to the SendPaymentMethod union; any tag + // this module doesn't understand must not render as a free send. + expect(() => + extractFeeFromPaymentMethod({ tag: "CrossChainAddress", inner: {} }, "fast"), + ).toThrow(/unrecognized payment method.*CrossChainAddress/i) + expect(() => extractFeeFromPaymentMethod({ tag: "SomethingNew" }, "fast")).toThrow( + /unrecognized payment method.*SomethingNew/i, ) + }) + + it("returns 0 when no payment method is passed at all", () => { expect(extractFeeFromPaymentMethod(undefined, "fast")).toEqual(BigInt(0)) }) }) diff --git a/app/components/home-screen/Transactions.tsx b/app/components/home-screen/Transactions.tsx index 9e04891a0..9c415037e 100644 --- a/app/components/home-screen/Transactions.tsx +++ b/app/components/home-screen/Transactions.tsx @@ -84,7 +84,8 @@ const Transactions: React.FC = ({ const addBreezEventListener = async () => { const listenerId = await addEventListener((e: SdkEvent) => { - if (e.tag !== SdkEvent_Tags.Synced && e.tag !== SdkEvent_Tags.Optimization) { + // eslint-disable-next-line camelcase -- mirrors the SDK's generated enum name + if (e.tag !== SdkEvent_Tags.Synced && e.tag !== SdkEvent_Tags.AutoOptimization) { fetchPaymentsBreez() } }) diff --git a/app/utils/breez-sdk/fee-extraction.ts b/app/utils/breez-sdk/fee-extraction.ts index d52fcc6a1..bfa5ac548 100644 --- a/app/utils/breez-sdk/fee-extraction.ts +++ b/app/utils/breez-sdk/fee-extraction.ts @@ -13,17 +13,22 @@ type SpeedFee = { } // Structural view of the SDK's SendPaymentMethod union — only the fields fee -// extraction reads. Optional everywhere so any variant assigns to it. +// extraction reads. `inner` is opaque at the type level because 0.22.x added +// variants (CrossChainAddress) whose inner shares NO properties with the fee +// fields, which breaks structural assignability of the union as a whole; each +// tag branch below narrows to exactly the fields that variant carries. export type SendPaymentMethodLike = { tag: string - inner?: { - lightningFeeSats?: bigint - fee?: bigint - feeQuote?: { - speedFast: SpeedFee - speedMedium: SpeedFee - speedSlow: SpeedFee - } + inner?: object +} + +type FeeFields = { + lightningFeeSats?: bigint + fee?: bigint + feeQuote?: { + speedFast: SpeedFee + speedMedium: SpeedFee + speedSlow: SpeedFee } } @@ -31,11 +36,12 @@ export const extractFeeFromPaymentMethod = ( paymentMethod: SendPaymentMethodLike | undefined, selectedFeeType?: OnchainFeeSpeed, ): bigint => { + const inner = paymentMethod?.inner as FeeFields | undefined if (paymentMethod?.tag === "Bolt11Invoice") { - return paymentMethod.inner?.lightningFeeSats ?? BigInt(0) + return inner?.lightningFeeSats ?? BigInt(0) } if (paymentMethod?.tag === "BitcoinAddress") { - const feeQuote = paymentMethod.inner?.feeQuote + const feeQuote = inner?.feeQuote if (!feeQuote) { // No quote means no knowable fee. Throwing (classified upstream as an // "sdk" fee error) beats the old fall-through to 0, which displayed a @@ -54,7 +60,19 @@ export const extractFeeFromPaymentMethod = ( return feeQuote.speedMedium.userFeeSat + feeQuote.speedMedium.l1BroadcastFeeSat } if (paymentMethod?.tag === "SparkAddress" || paymentMethod?.tag === "SparkInvoice") { - return paymentMethod.inner?.fee ?? BigInt(0) + return inner?.fee ?? BigInt(0) + } + if (paymentMethod === undefined) { + // Defensive only: the SDK types paymentMethod as required on the prepare + // response, so callers never actually hit this. Kept as 0 for direct + // callers passing nothing. + return BigInt(0) } - return BigInt(0) + // Any other tag is a variant this module doesn't understand — 0.22.x added + // SendPaymentMethod.CrossChainAddress to the union, and future SDK versions + // may add more. Rendering an unknown variant as a 0-sat fee would display a + // free send and skip the amount+fee balance check (the same failure mode the + // BitcoinAddress no-quote branch above throws on). Throwing is classified + // upstream as an "sdk" fee error via classifyBreezSdkError. + throw new Error(`Unrecognized payment method in prepare response: ${paymentMethod.tag}`) } diff --git a/app/utils/breez-sdk/spark.ts b/app/utils/breez-sdk/spark.ts index 54364d2bd..b7f2cd8a1 100644 --- a/app/utils/breez-sdk/spark.ts +++ b/app/utils/breez-sdk/spark.ts @@ -3,6 +3,7 @@ import RNFS from "react-native-fs" import { PaymentType } from "@galoymoney/client" import * as Keychain from "react-native-keychain" import { + PaymentRequest, defaultConfig, Network, ReceivePaymentMethod, @@ -245,7 +246,9 @@ export const fetchBreezFee = async ({ if (paymentType === "lightning") { const prepareResponse = await sdk.prepareSendPayment({ - paymentRequest, + // 0.22.x: paymentRequest is a tagged union; Input wraps the raw string + // (bolt11 / address / BIP-21) exactly as the SDK parsed it before. + paymentRequest: new PaymentRequest.Input({ input: paymentRequest }), amount: BigInt(amountSats), tokenIdentifier: undefined, conversionOptions: undefined, @@ -257,7 +260,9 @@ export const fetchBreezFee = async ({ if (paymentType === "onchain") { const prepareResponse = await sdk.prepareSendPayment({ - paymentRequest, + // 0.22.x: paymentRequest is a tagged union; Input wraps the raw string + // (bolt11 / address / BIP-21) exactly as the SDK parsed it before. + paymentRequest: new PaymentRequest.Input({ input: paymentRequest }), amount: BigInt(amountSats), tokenIdentifier: undefined, conversionOptions: undefined, @@ -389,7 +394,9 @@ export const payLightningBreez = async ( const sdk = getSDKInstance() const prepareResponse = await sdk.prepareSendPayment({ - paymentRequest, + // 0.22.x: paymentRequest is a tagged union; Input wraps the raw string + // (bolt11 / address / BIP-21) exactly as the SDK parsed it before. + paymentRequest: new PaymentRequest.Input({ input: paymentRequest }), amount: amountSats !== undefined ? BigInt(amountSats) : undefined, tokenIdentifier: undefined, conversionOptions: undefined, @@ -424,7 +431,9 @@ export const payOnchainBreez = async ( const sdk = getSDKInstance() const prepareResponse = await sdk.prepareSendPayment({ - paymentRequest, + // 0.22.x: paymentRequest is a tagged union; Input wraps the raw string + // (bolt11 / address / BIP-21) exactly as the SDK parsed it before. + paymentRequest: new PaymentRequest.Input({ input: paymentRequest }), amount: BigInt(amountSats), tokenIdentifier: undefined, conversionOptions: undefined, diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 68e0cfbd2..39b50e7c8 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -10,7 +10,7 @@ PODS: - breez_sdk_liquidFFI (0.11.13) - BreezSDKLiquid (0.11.13): - breez_sdk_liquidFFI (= 0.11.13) - - BreezSdkSparkReactNative (0.13.6): + - BreezSdkSparkReactNative (0.22.3): - DoubleConversion - glog - hermes-engine @@ -3088,7 +3088,7 @@ SPEC CHECKSUMS: breez_sdk_liquid: e05cd39b19b9e029ecf50195e4e7df0aefb745cd breez_sdk_liquidFFI: f05fadc0611126ade76d1fe6761ed8b020aabefb BreezSDKLiquid: ee6bf5a57f1b2533dc3c14c24c9773496f17b756 - BreezSdkSparkReactNative: 7f843c40572498538808eb6f6b399fe5e0bc7839 + BreezSdkSparkReactNative: fe0a5d91aeca17fed9a9972847b60ca3cd9d82c7 BVLinearGradient: cb006ba232a1f3e4f341bb62c42d1098c284da70 DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6 diff --git a/package.json b/package.json index 579db1c98..73c048163 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "dependencies": { "@apollo/client": "3.9.0-alpha.5", "@bitcoinerlab/secp256k1": "^1.0.5", - "@breeztech/breez-sdk-spark-react-native": "^0.13.4", + "@breeztech/breez-sdk-spark-react-native": "0.22.3", "@breeztech/react-native-breez-sdk-liquid": "^0.11.13", "@flash/client": "git+https://github.com/lnflash/flash-client.git", "@formatjs/intl-getcanonicallocales": "^2.3.0", diff --git a/yarn.lock b/yarn.lock index 77ac0210c..cbb1e6715 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1347,10 +1347,10 @@ dependencies: "@noble/curves" "^1.7.0" -"@breeztech/breez-sdk-spark-react-native@^0.13.4": - version "0.13.6" - resolved "https://registry.yarnpkg.com/@breeztech/breez-sdk-spark-react-native/-/breez-sdk-spark-react-native-0.13.6.tgz#5f4d9ab71826a277c406250a2aac3ad1304b4dd4" - integrity sha512-HFGN+AFuLnt02JdW+7/6LfhWZPeUB2lnEQHqm0xJCSGh/kqsUxFX+7O8OdQtFOGx9bbTAX8WK1JsUQLoxnEgww== +"@breeztech/breez-sdk-spark-react-native@0.22.3": + version "0.22.3" + resolved "https://registry.yarnpkg.com/@breeztech/breez-sdk-spark-react-native/-/breez-sdk-spark-react-native-0.22.3.tgz#37a8efe6296501355361029ce856d1bb52fdae35" + integrity sha512-QqOfzs5BeI+JbP/vp6+B51LnSwT465unXwrlT+xB+DzU6/gkZTK+KWOMKKJ58TjTDPB92GDVSOm3ts+BKZfJhA== dependencies: uniffi-bindgen-react-native "^0.29.3-1"