@flashnet/sdk 0.5.6 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2093,8 +2093,16 @@ class FlashnetClient {
2093
2093
  const assetOutAddress = quote.tokenIsAssetA
2094
2094
  ? pool.assetBAddress
2095
2095
  : pool.assetAAddress;
2096
- // Calculate min amount out with slippage protection
2097
- const minBtcOut = this.calculateMinAmountOut(quote.btcAmountRequired, maxSlippageBps);
2096
+ const effectiveMaxLightningFee = maxLightningFeeSats ?? quote.estimatedLightningFee;
2097
+ // Floor minAmountOut at invoiceAmount + fee so the swap never returns
2098
+ // less BTC than the lightning payment requires.
2099
+ const slippageMin = this.calculateMinAmountOut(quote.btcAmountRequired, maxSlippageBps);
2100
+ const baseBtcNeeded = !quote.isZeroAmountInvoice
2101
+ ? BigInt(quote.invoiceAmountSats) + BigInt(effectiveMaxLightningFee)
2102
+ : 0n;
2103
+ const minBtcOut = BigInt(slippageMin) >= baseBtcNeeded
2104
+ ? slippageMin
2105
+ : baseBtcNeeded.toString();
2098
2106
  // Execute the swap
2099
2107
  const swapResponse = await this.executeSwap({
2100
2108
  poolId: quote.poolId,
@@ -2119,138 +2127,158 @@ class FlashnetClient {
2119
2127
  error: swapResponse.error || "Swap was not accepted",
2120
2128
  };
2121
2129
  }
2122
- // Step 5: Wait for transfer (skip useExistingBtcBalance for zero-amount invoices)
2123
- let canPayImmediately = false;
2124
- if (!quote.isZeroAmountInvoice && useExistingBtcBalance) {
2125
- const invoiceAmountSats = await this.decodeInvoiceAmount(invoice);
2126
- const effectiveMaxLightningFee = maxLightningFeeSats ?? quote.estimatedLightningFee;
2127
- const btcNeededForPayment = invoiceAmountSats + effectiveMaxLightningFee;
2128
- const balance = await this.getBalance();
2129
- canPayImmediately = balance.balance >= safeBigInt(btcNeededForPayment);
2130
- }
2131
- if (!canPayImmediately) {
2132
- const transferComplete = await this.waitForTransferCompletion(swapResponse.outboundTransferId, transferTimeoutMs);
2133
- if (!transferComplete) {
2134
- return {
2135
- success: false,
2136
- poolId: quote.poolId,
2137
- tokenAmountSpent: quote.tokenAmountRequired,
2138
- btcAmountReceived: swapResponse.amountOut || "0",
2139
- swapTransferId: swapResponse.outboundTransferId,
2140
- ammFeePaid: quote.estimatedAmmFee,
2141
- sparkTokenTransferId: swapResponse.inboundSparkTransferId,
2142
- error: "Transfer did not complete within timeout",
2143
- };
2144
- }
2145
- }
2146
- // Step 6: Calculate Lightning fee and payment amount
2147
- const effectiveMaxLightningFee = maxLightningFeeSats ?? quote.estimatedLightningFee;
2148
- const btcReceived = swapResponse.amountOut || quote.btcAmountRequired;
2149
- // Step 7: Pay the Lightning invoice
2130
+ // Step 5: Claim the swap output and refresh wallet state.
2131
+ // Suppress leaf optimization for the entire claim-to-pay window so
2132
+ // the SSP cannot swap away the leaves we need for lightning payment.
2133
+ const restoreOptimization = this.suppressOptimization();
2150
2134
  try {
2151
- let lightningPayment;
2152
- let invoiceAmountPaid;
2153
- if (quote.isZeroAmountInvoice) {
2154
- // Zero-amount invoice: pay whatever BTC we received minus lightning fee
2155
- const actualBtc = safeBigInt(btcReceived);
2156
- const lnFee = safeBigInt(effectiveMaxLightningFee);
2157
- const amountToPay = actualBtc - lnFee;
2158
- if (amountToPay <= 0n) {
2135
+ let canPayImmediately = false;
2136
+ if (!quote.isZeroAmountInvoice && useExistingBtcBalance) {
2137
+ const invoiceAmountSats = await this.decodeInvoiceAmount(invoice);
2138
+ const btcNeededForPayment = invoiceAmountSats + effectiveMaxLightningFee;
2139
+ const balance = await this.getBalance();
2140
+ canPayImmediately =
2141
+ balance.balance >= safeBigInt(btcNeededForPayment);
2142
+ }
2143
+ if (!canPayImmediately) {
2144
+ const claimed = await this.instaClaimTransfer(swapResponse.outboundTransferId, transferTimeoutMs);
2145
+ if (!claimed) {
2159
2146
  return {
2160
2147
  success: false,
2161
2148
  poolId: quote.poolId,
2162
2149
  tokenAmountSpent: quote.tokenAmountRequired,
2163
- btcAmountReceived: btcReceived,
2150
+ btcAmountReceived: swapResponse.amountOut || "0",
2164
2151
  swapTransferId: swapResponse.outboundTransferId,
2165
2152
  ammFeePaid: quote.estimatedAmmFee,
2166
2153
  sparkTokenTransferId: swapResponse.inboundSparkTransferId,
2167
- error: `BTC received (${btcReceived} sats) is not enough to cover lightning fee (${effectiveMaxLightningFee} sats).`,
2154
+ error: "Transfer did not complete within timeout",
2168
2155
  };
2169
2156
  }
2170
- invoiceAmountPaid = Number(amountToPay);
2171
- lightningPayment = await this._wallet.payLightningInvoice({
2172
- invoice,
2173
- amountSats: invoiceAmountPaid,
2174
- maxFeeSats: effectiveMaxLightningFee,
2175
- preferSpark,
2176
- });
2177
2157
  }
2178
- else {
2179
- // Standard invoice: pay the specified amount
2180
- lightningPayment = await this._wallet.payLightningInvoice({
2181
- invoice,
2182
- maxFeeSats: effectiveMaxLightningFee,
2183
- preferSpark,
2184
- });
2158
+ // Step 6: Calculate payment amount
2159
+ const requestedMaxLightningFee = effectiveMaxLightningFee;
2160
+ const btcReceived = swapResponse.amountOut || quote.btcAmountRequired;
2161
+ // Cap the lightning fee budget to what the wallet can actually cover.
2162
+ // The swap output may be slightly less than quoted due to rounding or
2163
+ // price movement between quote and execution. The Spark SDK requires
2164
+ // invoiceAmount + maxFeeSats <= balance, so we adjust maxFeeSats down
2165
+ // when the actual BTC received is less than expected.
2166
+ let cappedMaxLightningFee = requestedMaxLightningFee;
2167
+ if (!quote.isZeroAmountInvoice) {
2168
+ const actualBtc = safeBigInt(btcReceived);
2169
+ const invoiceAmount = safeBigInt(quote.invoiceAmountSats);
2170
+ const available = actualBtc - invoiceAmount;
2171
+ if (available > 0n && available < safeBigInt(cappedMaxLightningFee)) {
2172
+ cappedMaxLightningFee = Number(available);
2173
+ }
2185
2174
  }
2186
- // Extract the Spark transfer ID from the lightning payment result.
2187
- // payLightningInvoice returns LightningSendRequest | WalletTransfer:
2188
- // - LightningSendRequest has .transfer?.sparkId (the Sparkscan-visible transfer ID)
2189
- // - WalletTransfer (Spark-to-Spark) has .id directly as the transfer ID
2190
- // Note: lightningPayment.id (the SSP request ID) is already returned as lightningPaymentId
2191
- const sparkLightningTransferId = lightningPayment.transfer?.sparkId;
2192
- return {
2193
- success: true,
2194
- poolId: quote.poolId,
2195
- tokenAmountSpent: quote.tokenAmountRequired,
2196
- btcAmountReceived: btcReceived,
2197
- swapTransferId: swapResponse.outboundTransferId,
2198
- lightningPaymentId: lightningPayment.id,
2199
- ammFeePaid: quote.estimatedAmmFee,
2200
- lightningFeePaid: effectiveMaxLightningFee,
2201
- invoiceAmountPaid,
2202
- sparkTokenTransferId: swapResponse.inboundSparkTransferId,
2203
- sparkLightningTransferId,
2204
- };
2205
- }
2206
- catch (lightningError) {
2207
- // Lightning payment failed after swap succeeded
2208
- const lightningErrorMessage = lightningError instanceof Error
2209
- ? lightningError.message
2210
- : String(lightningError);
2211
- // Attempt rollback if requested
2212
- if (rollbackOnFailure) {
2213
- try {
2214
- const rollbackResult = await this.rollbackSwap(quote.poolId, btcReceived, tokenAddress, maxSlippageBps);
2215
- if (rollbackResult.success) {
2175
+ // Step 7: Pay the Lightning invoice
2176
+ try {
2177
+ let lightningPayment;
2178
+ let invoiceAmountPaid;
2179
+ if (quote.isZeroAmountInvoice) {
2180
+ const actualBtc = safeBigInt(btcReceived);
2181
+ const lnFee = safeBigInt(cappedMaxLightningFee);
2182
+ const amountToPay = actualBtc - lnFee;
2183
+ if (amountToPay <= 0n) {
2216
2184
  return {
2217
2185
  success: false,
2218
2186
  poolId: quote.poolId,
2219
- tokenAmountSpent: "0", // Rolled back
2220
- btcAmountReceived: "0",
2187
+ tokenAmountSpent: quote.tokenAmountRequired,
2188
+ btcAmountReceived: btcReceived,
2221
2189
  swapTransferId: swapResponse.outboundTransferId,
2222
2190
  ammFeePaid: quote.estimatedAmmFee,
2223
2191
  sparkTokenTransferId: swapResponse.inboundSparkTransferId,
2224
- error: `Lightning payment failed: ${lightningErrorMessage}. Funds rolled back to ${rollbackResult.tokenAmount} tokens.`,
2192
+ error: `BTC received (${btcReceived} sats) is not enough to cover lightning fee (${cappedMaxLightningFee} sats).`,
2225
2193
  };
2226
2194
  }
2195
+ invoiceAmountPaid = Number(amountToPay);
2196
+ lightningPayment = await this._wallet.payLightningInvoice({
2197
+ invoice,
2198
+ amountSats: invoiceAmountPaid,
2199
+ maxFeeSats: cappedMaxLightningFee,
2200
+ preferSpark,
2201
+ });
2227
2202
  }
2228
- catch (rollbackError) {
2229
- const rollbackErrorMessage = rollbackError instanceof Error
2230
- ? rollbackError.message
2231
- : String(rollbackError);
2232
- return {
2233
- success: false,
2234
- poolId: quote.poolId,
2235
- tokenAmountSpent: quote.tokenAmountRequired,
2236
- btcAmountReceived: btcReceived,
2237
- swapTransferId: swapResponse.outboundTransferId,
2238
- ammFeePaid: quote.estimatedAmmFee,
2239
- sparkTokenTransferId: swapResponse.inboundSparkTransferId,
2240
- error: `Lightning payment failed: ${lightningErrorMessage}. Rollback also failed: ${rollbackErrorMessage}. BTC remains in wallet.`,
2241
- };
2203
+ else {
2204
+ lightningPayment = await this._wallet.payLightningInvoice({
2205
+ invoice,
2206
+ maxFeeSats: cappedMaxLightningFee,
2207
+ preferSpark,
2208
+ });
2242
2209
  }
2210
+ // Extract the Spark transfer ID from the lightning payment result.
2211
+ // payLightningInvoice returns LightningSendRequest | WalletTransfer:
2212
+ // - LightningSendRequest has .transfer?.sparkId (the Sparkscan-visible transfer ID)
2213
+ // - WalletTransfer (Spark-to-Spark) has .id directly as the transfer ID
2214
+ // Note: lightningPayment.id (the SSP request ID) is already returned as lightningPaymentId
2215
+ const sparkLightningTransferId = lightningPayment.transfer?.sparkId;
2216
+ return {
2217
+ success: true,
2218
+ poolId: quote.poolId,
2219
+ tokenAmountSpent: quote.tokenAmountRequired,
2220
+ btcAmountReceived: btcReceived,
2221
+ swapTransferId: swapResponse.outboundTransferId,
2222
+ lightningPaymentId: lightningPayment.id,
2223
+ ammFeePaid: quote.estimatedAmmFee,
2224
+ lightningFeePaid: cappedMaxLightningFee,
2225
+ invoiceAmountPaid,
2226
+ sparkTokenTransferId: swapResponse.inboundSparkTransferId,
2227
+ sparkLightningTransferId,
2228
+ };
2243
2229
  }
2244
- return {
2245
- success: false,
2246
- poolId: quote.poolId,
2247
- tokenAmountSpent: quote.tokenAmountRequired,
2248
- btcAmountReceived: btcReceived,
2249
- swapTransferId: swapResponse.outboundTransferId,
2250
- ammFeePaid: quote.estimatedAmmFee,
2251
- sparkTokenTransferId: swapResponse.inboundSparkTransferId,
2252
- error: `Lightning payment failed: ${lightningErrorMessage}. BTC (${btcReceived} sats) remains in wallet.`,
2253
- };
2230
+ catch (lightningError) {
2231
+ // Lightning payment failed after swap succeeded
2232
+ const lightningErrorMessage = lightningError instanceof Error
2233
+ ? lightningError.message
2234
+ : String(lightningError);
2235
+ // Attempt rollback if requested
2236
+ if (rollbackOnFailure) {
2237
+ try {
2238
+ const rollbackResult = await this.rollbackSwap(quote.poolId, btcReceived, tokenAddress, maxSlippageBps);
2239
+ if (rollbackResult.success) {
2240
+ return {
2241
+ success: false,
2242
+ poolId: quote.poolId,
2243
+ tokenAmountSpent: "0", // Rolled back
2244
+ btcAmountReceived: "0",
2245
+ swapTransferId: swapResponse.outboundTransferId,
2246
+ ammFeePaid: quote.estimatedAmmFee,
2247
+ sparkTokenTransferId: swapResponse.inboundSparkTransferId,
2248
+ error: `Lightning payment failed: ${lightningErrorMessage}. Funds rolled back to ${rollbackResult.tokenAmount} tokens.`,
2249
+ };
2250
+ }
2251
+ }
2252
+ catch (rollbackError) {
2253
+ const rollbackErrorMessage = rollbackError instanceof Error
2254
+ ? rollbackError.message
2255
+ : String(rollbackError);
2256
+ return {
2257
+ success: false,
2258
+ poolId: quote.poolId,
2259
+ tokenAmountSpent: quote.tokenAmountRequired,
2260
+ btcAmountReceived: btcReceived,
2261
+ swapTransferId: swapResponse.outboundTransferId,
2262
+ ammFeePaid: quote.estimatedAmmFee,
2263
+ sparkTokenTransferId: swapResponse.inboundSparkTransferId,
2264
+ error: `Lightning payment failed: ${lightningErrorMessage}. Rollback also failed: ${rollbackErrorMessage}. BTC remains in wallet.`,
2265
+ };
2266
+ }
2267
+ }
2268
+ return {
2269
+ success: false,
2270
+ poolId: quote.poolId,
2271
+ tokenAmountSpent: quote.tokenAmountRequired,
2272
+ btcAmountReceived: btcReceived,
2273
+ swapTransferId: swapResponse.outboundTransferId,
2274
+ ammFeePaid: quote.estimatedAmmFee,
2275
+ sparkTokenTransferId: swapResponse.inboundSparkTransferId,
2276
+ error: `Lightning payment failed: ${lightningErrorMessage}. BTC (${btcReceived} sats) remains in wallet.`,
2277
+ };
2278
+ }
2279
+ }
2280
+ finally {
2281
+ restoreOptimization();
2254
2282
  }
2255
2283
  }
2256
2284
  catch (error) {
@@ -2771,6 +2799,73 @@ class FlashnetClient {
2771
2799
  }
2772
2800
  return false;
2773
2801
  }
2802
+ /**
2803
+ * Suppress leaf optimization on the wallet. Sets the internal
2804
+ * optimizationInProgress flag so optimizeLeaves() returns immediately.
2805
+ * Returns a restore function that clears the flag.
2806
+ * @private
2807
+ */
2808
+ suppressOptimization() {
2809
+ const w = this._wallet;
2810
+ const was = w.optimizationInProgress;
2811
+ w.optimizationInProgress = true;
2812
+ return () => {
2813
+ w.optimizationInProgress = was;
2814
+ };
2815
+ }
2816
+ /**
2817
+ * Insta-claim: listen for the wallet's stream event that fires when
2818
+ * the coordinator broadcasts the transfer. The stream auto-claims
2819
+ * incoming transfers, so no polling is needed.
2820
+ *
2821
+ * After claim, refreshes the leaf cache from the coordinator to
2822
+ * ensure the balance is current.
2823
+ *
2824
+ * Caller is responsible for suppressing optimization around this call
2825
+ * if the claimed leaves must not be swapped before spending.
2826
+ * @private
2827
+ */
2828
+ async instaClaimTransfer(transferId, timeoutMs) {
2829
+ const w = this._wallet;
2830
+ const claimed = await new Promise((resolve) => {
2831
+ let done = false;
2832
+ const finish = (value) => {
2833
+ if (done) {
2834
+ return;
2835
+ }
2836
+ done = true;
2837
+ clearTimeout(timer);
2838
+ try {
2839
+ w.removeListener?.("transfer:claimed", handler);
2840
+ }
2841
+ catch {
2842
+ // Ignore
2843
+ }
2844
+ resolve(value);
2845
+ };
2846
+ const timer = setTimeout(() => finish(false), timeoutMs);
2847
+ const handler = (claimedId) => {
2848
+ if (claimedId === transferId) {
2849
+ finish(true);
2850
+ }
2851
+ };
2852
+ // The wallet's background gRPC stream auto-claims transfers.
2853
+ // We just listen for the event.
2854
+ if (typeof w.on === "function") {
2855
+ w.on("transfer:claimed", handler);
2856
+ }
2857
+ else {
2858
+ // No event support, fall back to passive polling
2859
+ clearTimeout(timer);
2860
+ this.pollForTransferCompletion(transferId, timeoutMs).then(resolve);
2861
+ }
2862
+ });
2863
+ if (claimed) {
2864
+ const leaves = await this._wallet.getLeaves(true);
2865
+ w.leaves = leaves;
2866
+ }
2867
+ return claimed;
2868
+ }
2774
2869
  /**
2775
2870
  * Get Lightning fee estimate for an invoice
2776
2871
  * @private