From 70b14f07fd1229d625e1005e2de7e8c9133986de Mon Sep 17 00:00:00 2001 From: Blake Kaufman <68204898+BlakeKaufman@users.noreply.github.com> Date: Thu, 14 May 2026 19:56:44 -0400 Subject: [PATCH] Migrate ln to orchestra (#831) * adding new translations * migrate USD > BTC LN to orchestra * adding orchestra tests * updating call locations * add exponential backoff + resubmit swap quote if first time failed * show failed Tx and add support button * minor fixes * bump timeout to 1 min * making sure usd quote doesn't block btc --- .../spark/orchestraLightning.test.js | 81 +++++ .../contacts/sendAndRequestPage.js | 3 + .../sendBitcoin/functions/decodeSendAdress.js | 2 +- .../functions/processBolt11Invoice.js | 26 +- .../sendBitcoin/functions/processLNUrlPay.js | 26 +- .../sendBitcoin/sendPaymentScreen.js | 3 + .../sendBitcoin/stablecoinSendScreen.js | 1 + app/functions/combinedTransactionsSpark.js | 135 ++++----- app/functions/spark/flashnet.js | 284 +++++++++++------- app/functions/spark/orchestraLightning.js | 105 +++++++ app/functions/spark/payments.js | 88 ++---- app/functions/spark/restore.js | 49 ++- app/screens/inAccount/confirmTxPage.js | 7 +- app/screens/inAccount/expandedTxPage.js | 109 ++++--- locales/de-DE/translation.json | 6 +- locales/en/translation.json | 6 +- locales/es/translation.json | 6 +- locales/fr/translation.json | 6 +- locales/it/translation.json | 6 +- locales/pt-BR/translation.json | 6 +- locales/ru/translation.json | 6 +- locales/sv/translation.json | 6 +- 22 files changed, 644 insertions(+), 323 deletions(-) create mode 100644 __tests__/functions/spark/orchestraLightning.test.js create mode 100644 app/functions/spark/orchestraLightning.js diff --git a/__tests__/functions/spark/orchestraLightning.test.js b/__tests__/functions/spark/orchestraLightning.test.js new file mode 100644 index 00000000..8b7cefdf --- /dev/null +++ b/__tests__/functions/spark/orchestraLightning.test.js @@ -0,0 +1,81 @@ +import { + getLightningInvoiceAmountSats, + mapOrchestraQuoteToLightningQuote, + normalizeOrchestraBackendError, +} from '../../../app/functions/spark/orchestraLightning'; + +const FIXED_AMOUNT_INVOICE = + 'lnbc20u1pvjluezhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqfppqw508d6qejxtdg4y5r3zarvary0c5xw7kxqrrsssp5m6kmam774klwlh4dhmhaatd7al02m0h0m6kmam774klwlh4dhmhs9qypqqqcqpf3cwux5979a8j28d4ydwahx00saa68wq3az7v9jdgzkghtxnkf3z5t7q5suyq2dl9tqwsap8j0wptc82cpyvey9gf6zyylzrm60qtcqsq7egtsq'; + +describe('orchestraLightning helpers', () => { + test('decodes fixed-amount BOLT11 invoice sats', () => { + expect(getLightningInvoiceAmountSats(FIXED_AMOUNT_INVOICE)).toBe(2000); + }); + + test('uses explicit amount override before decoding invoice', () => { + expect(getLightningInvoiceAmountSats('not-an-invoice', 1234)).toBe(1234); + }); + + test('maps Orchestra quote response to legacy Lightning quote shape', () => { + const expiresAt = Date.now() + 120000; + const quote = mapOrchestraQuoteToLightningQuote( + { + quoteId: 'quote_123', + depositAddress: 'spark1deposit', + amountIn: '2500000', + estimatedOut: '2000', + expiresAt, + fee: 12500, + }, + 2000, + ); + + expect(quote).toEqual( + expect.objectContaining({ + invoiceAmountSats: 2000, + estimatedLightningFee: 0, + btcAmountRequired: 2000, + tokenAmountRequired: 2500000, + estimatedAmmFee: 12500, + poolId: 'quote_123', + quoteId: 'quote_123', + depositAddress: 'spark1deposit', + expiresAt, + estimatedOut: '2000', + orchestra: true, + }), + ); + }); + + test('rejects malformed Orchestra quote responses', () => { + expect(() => + mapOrchestraQuoteToLightningQuote( + { + quoteId: 'quote_123', + amountIn: '2500000', + expiresAt: Date.now() + 120000, + }, + 2000, + ), + ).toThrow('Missing Orchestra deposit address'); + }); + + test('preserves backend error code and minimum sats', () => { + expect( + normalizeOrchestraBackendError( + { + error: { + code: 'amount_too_small', + message: 'Minimum is 5000 sats', + minimumSats: '5000', + }, + }, + 'fallback', + ), + ).toEqual({ + code: 'amount_too_small', + message: 'Minimum is 5000 sats', + minimumSats: '5000', + }); + }); +}); diff --git a/app/components/admin/homeComponents/contacts/sendAndRequestPage.js b/app/components/admin/homeComponents/contacts/sendAndRequestPage.js index 10d5f100..57c9b955 100644 --- a/app/components/admin/homeComponents/contacts/sendAndRequestPage.js +++ b/app/components/admin/homeComponents/contacts/sendAndRequestPage.js @@ -216,6 +216,9 @@ export default function SendAndRequestPage(props) { currentWalletMnemoinc, invoiceResponse.pr, USD_ASSET_ADDRESS, + undefined, + undefined, + { amountSats: amount }, ); if (!quote.didWork) throw new Error(quote.error || 'Fee quote failed'); diff --git a/app/components/admin/homeComponents/sendBitcoin/functions/decodeSendAdress.js b/app/components/admin/homeComponents/sendBitcoin/functions/decodeSendAdress.js index a5223a20..8624208b 100644 --- a/app/components/admin/homeComponents/sendBitcoin/functions/decodeSendAdress.js +++ b/app/components/admin/homeComponents/sendBitcoin/functions/decodeSendAdress.js @@ -440,7 +440,7 @@ function withTimeout(promise, t) { reject( new Error(t('wallet.sendPages.handlingAddressErrors.timeoutError')), ), - 20000, + 60000, ), ), ]); diff --git a/app/components/admin/homeComponents/sendBitcoin/functions/processBolt11Invoice.js b/app/components/admin/homeComponents/sendBitcoin/functions/processBolt11Invoice.js index 2886da13..be04adfd 100644 --- a/app/components/admin/homeComponents/sendBitcoin/functions/processBolt11Invoice.js +++ b/app/components/admin/homeComponents/sendBitcoin/functions/processBolt11Invoice.js @@ -98,13 +98,29 @@ export default async function processBolt11Invoice(input, context) { if (needUsdFee && !hasUsdQuote) { usdPromiseIndex = promises.length; - promises.push( - getLightningPaymentQuote( - currentWalletMnemoinc, - input.data.address, - USD_ASSET_ADDRESS, + const bolt11QuoteTimeout = new Promise(resolve => + setTimeout( + () => + resolve({ + didWork: false, + error: 'Lightning payment quote timed out', + }), + 25000, ), ); + promises.push( + Promise.race([ + getLightningPaymentQuote( + currentWalletMnemoinc, + input.data.address, + USD_ASSET_ADDRESS, + undefined, + undefined, + { amountSats: amountSat }, + ), + bolt11QuoteTimeout, + ]), + ); } if (needBtcFee && !hasBtcFee) { diff --git a/app/components/admin/homeComponents/sendBitcoin/functions/processLNUrlPay.js b/app/components/admin/homeComponents/sendBitcoin/functions/processLNUrlPay.js index a6d7fadd..dc6c9e2e 100644 --- a/app/components/admin/homeComponents/sendBitcoin/functions/processLNUrlPay.js +++ b/app/components/admin/homeComponents/sendBitcoin/functions/processLNUrlPay.js @@ -186,13 +186,29 @@ export default async function processLNUrlPay(input, context) { if (needUsdFee && !hasUsdQuote) { usdPromiseIndex = promises.length; - promises.push( - getLightningPaymentQuote( - currentWalletMnemoinc, - invoice, - USD_ASSET_ADDRESS, + const lnurlQuoteTimeout = new Promise(resolve => + setTimeout( + () => + resolve({ + didWork: false, + error: 'Lightning payment quote timed out', + }), + 25000, ), ); + promises.push( + Promise.race([ + getLightningPaymentQuote( + currentWalletMnemoinc, + invoice, + USD_ASSET_ADDRESS, + undefined, + undefined, + { amountSats: amountSat }, + ), + lnurlQuoteTimeout, + ]), + ); } if (needBtcFee && !hasBtcFee) { diff --git a/app/components/admin/homeComponents/sendBitcoin/sendPaymentScreen.js b/app/components/admin/homeComponents/sendBitcoin/sendPaymentScreen.js index eb21915f..8fa52e21 100644 --- a/app/components/admin/homeComponents/sendBitcoin/sendPaymentScreen.js +++ b/app/components/admin/homeComponents/sendBitcoin/sendPaymentScreen.js @@ -512,6 +512,9 @@ export default function SendPaymentScreen(props) { currentWalletMnemoinc, invoice, USD_ASSET_ADDRESS, + undefined, + undefined, + { amountSats: amount }, ); if (!quote.didWork) throw new Error(quote.error || 'Fee quote failed'); diff --git a/app/components/admin/homeComponents/sendBitcoin/stablecoinSendScreen.js b/app/components/admin/homeComponents/sendBitcoin/stablecoinSendScreen.js index 7edafea2..afc08965 100644 --- a/app/components/admin/homeComponents/sendBitcoin/stablecoinSendScreen.js +++ b/app/components/admin/homeComponents/sendBitcoin/stablecoinSendScreen.js @@ -419,6 +419,7 @@ export default function StablecoinSendScreen() { supportFee: 0, description: description || '', address: quote.depositAddress, + sourceSparkAddress: sparkInformation.sparkAddress, time: Date.now(), createdAt: Date.now(), direction: 'OUTGOING', diff --git a/app/functions/combinedTransactionsSpark.js b/app/functions/combinedTransactionsSpark.js index 38ccb35b..43e7faa4 100644 --- a/app/functions/combinedTransactionsSpark.js +++ b/app/functions/combinedTransactionsSpark.js @@ -24,6 +24,7 @@ import { isFlashnetTransfer } from './spark/handleFlashnetTransferIds'; import { satsToDollars } from './spark/flashnet'; import ThemeIcon from './CustomElements/themeIcon'; import { HIDDEN_OPACITY, INSET_WINDOW_WIDTH } from '../constants/theme'; +import { isOrchestraSwapFailed } from './spark/orchestraLightning'; // Constants to avoid re-creating objects const TRANSACTION_CONSTANTS = { @@ -164,9 +165,10 @@ const getTxIconName = ( isFailedPayment, isReceive, ) => { + if (isFailedPayment) return { icon: 'CircleX', bg: null }; + // Default: Lightning / Bitcoin / Spark — directional arrows return { icon: isReceive ? 'ArrowDown' : 'ArrowUp', bg: null }; - if (isFailedPayment) return { icon: 'CircleX', bg: null }; // Pending / swap pending if (showSwapConversion) return { icon: 'Clock', bg: null }; @@ -350,7 +352,10 @@ export default function getFormattedHomepageTxsForSpark(props) { const paymentDate = new Date(paymentDetails.time).getTime(); const uniuqeIDFromTx = currentTransaction.sparkID; - const isFailedPayment = paymentStatus === TRANSACTION_CONSTANTS.FAILED; + const showLNOrchestraAsFailed = isOrchestraSwapFailed(currentTransaction); + const isFailedPayment = + paymentStatus === TRANSACTION_CONSTANTS.FAILED || + showLNOrchestraAsFailed; // Calculate time difference once // const timeDifferenceInDays = @@ -601,37 +606,21 @@ export const UserTransaction = memo(function UserTransaction({ const descriptionTextStyle = useMemo( () => ({ ...styles.descriptionText, - color: isFailedPayment - ? theme && darkModeType - ? COLORS.darkModeText - : COLORS.failedTransaction - : theme - ? COLORS.darkModeText - : COLORS.lightModeText, - fontStyle: isFailedPayment ? 'italic' : 'normal', + color: theme ? COLORS.darkModeText : COLORS.lightModeText, }), - [isFailedPayment, theme, darkModeType], + [theme, darkModeType], ); const dateTextStyle = useMemo( () => ({ ...styles.dateText, - fontWeight: isFailedPayment ? 400 : 300, - color: isFailedPayment - ? theme && darkModeType - ? COLORS.darkModeText - : COLORS.failedTransaction - : theme - ? COLORS.darkModeText - : COLORS.lightModeText, - fontStyle: isFailedPayment ? 'italic' : 'normal', + color: theme ? COLORS.darkModeText : COLORS.lightModeText, }), - [isFailedPayment, theme, darkModeType], + [theme, darkModeType], ); // Pre-calculate description content const descriptionContent = useMemo(() => { - if (isFailedPayment) return t('transactionLabelText.notSent'); // if (userBalanceDenomination === 'hidden') return HIDDEN_BALANCE_TEXT; if (isDefaultDescription || !paymentDescription) { return transaction.details.direction === TRANSACTION_CONSTANTS.OUTGOING @@ -640,7 +629,6 @@ export const UserTransaction = memo(function UserTransaction({ } return paymentDescription; }, [ - isFailedPayment, userBalanceDenomination, isDefaultDescription, paymentDescription, @@ -700,59 +688,54 @@ export const UserTransaction = memo(function UserTransaction({ )} /> - {!isFailedPayment && ( - - {showSwapConversion ? ( - - ) : ( - - )} - - )} + + {showSwapConversion ? ( + + ) : ( + + )} + + {!isLastItem && ( )} diff --git a/app/functions/spark/flashnet.js b/app/functions/spark/flashnet.js index 7d417a88..ffc845e8 100644 --- a/app/functions/spark/flashnet.js +++ b/app/functions/spark/flashnet.js @@ -11,12 +11,15 @@ import { } from '@flashnet/sdk'; import { getFlashnetClient, + getSparkAddress, getSingleTxDetails, getSparkLightningPaymentStatus, initializeFlashnet, selectSparkRuntime, + sendSparkTokens, validateWebViewResponse, } from '.'; +import { getPublicKey } from 'nostr-tools'; import i18next from 'i18next'; import { OPERATION_TYPES, @@ -37,6 +40,14 @@ import { getSingleSparkLightningRequest, } from './transactions'; import { decode } from 'bolt11'; +import fetchBackend from '../../../db/handleBackend'; +import { privateKeyFromSeedWords } from '../nostrCompatability'; +import { + getLightningInvoiceAmountSats, + isUsableOrchestraQuote, + mapOrchestraQuoteToLightningQuote, + normalizeOrchestraBackendError, +} from './orchestraLightning'; // ============================================ // CONSTANTS & PURE UTILITIES @@ -719,62 +730,68 @@ export const getLightningPaymentQuote = async ( mnemonic, invoice, tokenAddress, - integratorFeeRateBps = INTEGRATOR_FEE_BPS, - maxSlippageBps = DEFAULT_MAX_SLIPPAGE_BPS, + _integratorFeeRateBps = INTEGRATOR_FEE_BPS, + _maxSlippageBps = DEFAULT_MAX_SLIPPAGE_BPS, + options = {}, ) => { try { - const runtime = await selectSparkRuntime(mnemonic); - if (runtime === 'webview') { - const response = await sendWebViewRequestGlobal( - OPERATION_TYPES.getLightningPaymentQuote, - { - mnemonic, - invoice, - tokenAddress, - integratorFeeRateBps, - maxSlippageBps, - }, - ); - return validateWebViewResponse( - response, - 'Not able to getLightningPaymentQuote', - ); - } else { - const client = getFlashnetClient(mnemonic); - - const quote = await client.getPayLightningWithTokenQuote( - invoice, - tokenAddress, - { - integratorFeeRateBps, - maxSlippageBps, - }, - ); - - return { - didWork: true, - quote: { - invoiceAmountSats: quote.invoiceAmountSats, - estimatedLightningFee: quote.estimatedLightningFee, - btcAmountRequired: quote.btcAmountRequired, - tokenAmountRequired: quote.tokenAmountRequired, - estimatedAmmFee: quote.estimatedAmmFee, - executionPrice: quote.executionPrice, - priceImpact: quote.priceImpactPct, - poolId: quote.poolId, - fee: quote.btcAmountRequired - quote.invoiceAmountSats, - }, - }; + if (tokenAddress !== USD_ASSET_ADDRESS) { + throw new Error('Only USDB Lightning payments are supported'); } - } catch (error) { - console.warn( - 'Get Lightning quote error:', - formatError(error, 'getLightningPaymentQuote'), + + const invoiceAmountSats = getLightningInvoiceAmountSats( + invoice, + options.amountSats, ); + const privateKey = + options.contactsPrivateKey || (await privateKeyFromSeedWords(mnemonic)); + const publicKey = options.publicKey || getPublicKey(privateKey); + const sparkAddressResponse = options.refundAddress + ? { didWork: true, response: options.refundAddress } + : await getSparkAddress(mnemonic); + + if (!sparkAddressResponse.didWork || !sparkAddressResponse.response) { + throw new Error( + sparkAddressResponse.error || 'Unable to derive Spark refund address', + ); + } + + const result = await fetchBackend( + 'createFlashnetStablecoinQuoteV2', + { + recipientAddress: invoice, + destinationChain: 'lightning', + destinationAsset: 'BTC', + amountSats: invoiceAmountSats, + sourceMethod: 'usdb', + refundAddress: sparkAddressResponse.response, + }, + privateKey, + publicKey, + ); + + const quote = mapOrchestraQuoteToLightningQuote(result, invoiceAmountSats); + return { didWork: true, quote }; + } catch (error) { + const backendError = normalizeOrchestraBackendError( + error, + 'Unable to get Lightning quote', + ); + console.warn('Get Lightning quote error:', { + operation: 'getLightningPaymentQuote', + message: backendError.message, + code: backendError.code, + minimumSats: backendError.minimumSats, + }); return { didWork: false, - error: error.message, - details: formatError(error, 'getLightningPaymentQuote'), + error: backendError.message, + details: { + operation: 'getLightningPaymentQuote', + message: backendError.message, + code: backendError.code, + minimumSats: backendError.minimumSats, + }, }; } }; @@ -791,85 +808,122 @@ export const payLightningWithToken = async ( invoice, tokenAddress, maxSlippageBps = DEFAULT_MAX_SLIPPAGE_BPS, - maxLightningFeeSats = null, - rollbackOnFailure = true, - useExistingBtcBalance = false, integratorFeeRateBps = INTEGRATOR_FEE_BPS, + quote = null, + amountSats, + contactsPrivateKey, + publicKey, + refundAddress, }, ) => { try { - const runtime = await selectSparkRuntime(mnemonic); - if (runtime === 'webview') { - const response = await sendWebViewRequestGlobal( - OPERATION_TYPES.payLightningWithToken, - { - mnemonic, - invoice, - tokenAddress, - maxSlippageBps, - maxLightningFeeSats, - rollbackOnFailure, - useExistingBtcBalance, - integratorFeeRateBps, - }, - ); - return validateWebViewResponse( - response, - 'Not able to payLightningWithToken', - ); - } else { - const client = getFlashnetClient(mnemonic); + if (tokenAddress !== USD_ASSET_ADDRESS) { + throw new Error('Only USDB Lightning payments are supported'); + } - const result = await client.payLightningWithToken({ + const privateKey = + contactsPrivateKey || (await privateKeyFromSeedWords(mnemonic)); + const resolvedPublicKey = publicKey || getPublicKey(privateKey); + const sparkAddressResponse = refundAddress + ? { didWork: true, response: refundAddress } + : await getSparkAddress(mnemonic); + + if (!sparkAddressResponse.didWork || !sparkAddressResponse.response) { + throw new Error( + sparkAddressResponse.error || 'Unable to derive Spark source address', + ); + } + + let paymentQuote = quote; + if (!isUsableOrchestraQuote(paymentQuote)) { + const quoteResponse = await getLightningPaymentQuote( + mnemonic, invoice, tokenAddress, - maxSlippageBps, - maxLightningFeeSats: maxLightningFeeSats || undefined, - rollbackOnFailure, - useExistingBtcBalance, integratorFeeRateBps, - integratorPublicKey: process.env.BLITZ_SPARK_PUBLICKEY, - }); + maxSlippageBps, + { + amountSats, + contactsPrivateKey: privateKey, + publicKey: resolvedPublicKey, + refundAddress: sparkAddressResponse.response, + }, + ); - console.log('token lightning payment response:', result); - - if (result.success) { - return { - didWork: true, - result: { - success: true, - lightningPaymentId: result.lightningPaymentId, - tokenAmountSpent: result.tokenAmountSpent, - btcAmountReceived: result.btcAmountReceived, - swapTransferId: result.swapTransferId, - ammFeePaid: result.ammFeePaid, - lightningFeePaid: result.lightningFeePaid, - poolId: result.poolId, - }, - }; - } else { - return { - didWork: false, - error: result.error, - result: { - success: false, - error: result.error, - poolId: result.poolId, - tokenAmountSpent: result.tokenAmountSpent, - btcAmountReceived: result.btcAmountReceived, - }, - }; + if (!quoteResponse.didWork) { + throw new Error(quoteResponse.error || 'Unable to create quote'); } + paymentQuote = quoteResponse.quote; } - } catch (error) { - console.warn( - 'Pay Lightning with token error:', - formatError(error, 'payLightningWithToken'), + + const tokenAmount = Number(paymentQuote.tokenAmountRequired); + if (!Number.isFinite(tokenAmount) || tokenAmount <= 0) { + throw new Error('Invalid Orchestra token amount'); + } + + const tokenPayment = await sendSparkTokens({ + tokenIdentifier: USDB_TOKEN_ID, + tokenAmount: Number(Math.ceil(tokenAmount)), + receiverSparkAddress: paymentQuote.depositAddress, + mnemonic, + }); + + if (!tokenPayment.didWork) { + throw new Error(tokenPayment.error || 'Unable to send USDB deposit'); + } + + const sparkTxHash = tokenPayment.response; + await fetchBackend( + 'submitFlashnetStablecoinOrder', + { + quoteId: paymentQuote.quoteId, + sparkTxHash, + sourceSparkAddress: sparkAddressResponse.response, + }, + privateKey, + resolvedPublicKey, ); + + return { + didWork: true, + result: { + success: true, + tokenAmountSpent: tokenAmount, + btcAmountReceived: + Number(paymentQuote.estimatedOut) || + Number(paymentQuote.invoiceAmountSats) || + 0, + swapTransferId: sparkTxHash, + ammFeePaid: Number(paymentQuote.estimatedAmmFee || 0), + lightningFeePaid: 0, + poolId: paymentQuote.quoteId, + quoteId: paymentQuote.quoteId, + depositAddress: paymentQuote.depositAddress, + expiresAt: paymentQuote.expiresAt, + orchestra: true, + sourceSparkAddress: sparkAddressResponse.response, + }, + }; + } catch (error) { + const backendError = normalizeOrchestraBackendError( + error, + 'Unable to pay Lightning invoice with USDB', + ); + console.warn('Pay Lightning with token error:', { + operation: 'payLightningWithToken', + message: backendError.message, + code: backendError.code, + minimumSats: backendError.minimumSats, + }); return { didWork: false, - error: error.message, - details: formatError(error, 'payLightningWithToken'), + error: backendError.message, + details: { + operation: 'payLightningWithToken', + message: backendError.message, + code: backendError.code, + minimumSats: backendError.minimumSats, + }, }; } }; diff --git a/app/functions/spark/orchestraLightning.js b/app/functions/spark/orchestraLightning.js new file mode 100644 index 00000000..aea36fcc --- /dev/null +++ b/app/functions/spark/orchestraLightning.js @@ -0,0 +1,105 @@ +import { decode } from 'bolt11'; + +export function getLightningInvoiceAmountSats(invoice, amountSats) { + const fallbackAmount = Number(amountSats); + if (Number.isFinite(fallbackAmount) && fallbackAmount > 0) { + return Math.round(fallbackAmount); + } + + const decodedInvoice = decode(invoice); + const invoiceAmount = Number(decodedInvoice?.satoshis); + if (!Number.isFinite(invoiceAmount) || invoiceAmount <= 0) { + throw new Error('Lightning invoice amount is required for USD payments'); + } + + return Math.round(invoiceAmount); +} + +export function normalizeOrchestraBackendError(result, fallbackMessage) { + const rawError = result?.error || result; + const message = + rawError?.message || + (typeof rawError === 'string' ? rawError : null) || + fallbackMessage; + + return { + message, + code: rawError?.code, + minimumSats: rawError?.minimumSats, + }; +} + +export function mapOrchestraQuoteToLightningQuote(result, invoiceAmountSats) { + if (!result || typeof result !== 'object') { + throw new Error('Invalid Orchestra quote response'); + } + + if (result.error) { + const backendError = normalizeOrchestraBackendError( + result, + 'Unable to create Lightning quote', + ); + const error = new Error(backendError.message); + error.code = backendError.code; + error.minimumSats = backendError.minimumSats; + throw error; + } + + const tokenAmountRequired = Number(result.amountIn); + const estimatedAmmFee = Number(result.quoteFees || 0); + const expiresAt = Number(result.expiresAt); + + if (!result.quoteId) throw new Error('Missing Orchestra quote ID'); + if (!result.depositAddress) { + throw new Error('Missing Orchestra deposit address'); + } + if (!Number.isFinite(tokenAmountRequired) || tokenAmountRequired <= 0) { + throw new Error('Invalid Orchestra quote amount'); + } + if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) { + throw new Error('Invalid Orchestra quote expiration'); + } + + return { + invoiceAmountSats, + estimatedLightningFee: 0, + btcAmountRequired: invoiceAmountSats, + tokenAmountRequired, + estimatedAmmFee, + executionPrice: null, + priceImpact: null, + poolId: result.quoteId, + fee: estimatedAmmFee, + quoteId: result.quoteId, + depositAddress: result.depositAddress, + expiresAt, + estimatedOut: result.estimatedOut, + orchestra: true, + }; +} + +export function isUsableOrchestraQuote(quote) { + return ( + !!quote?.orchestra && + !!quote?.quoteId && + !!quote?.depositAddress && + Number.isFinite(Number(quote?.tokenAmountRequired)) && + Number(quote.tokenAmountRequired) > 0 && + Number(quote?.expiresAt || 0) > Date.now() + ); +} + +export function isOrchestraSwapFailed(tx) { + let details; + try { + details = JSON.parse(tx.details); + } catch { + details = tx.details; + } + + return ( + tx?.paymentStatus === 'completed' && + details?.isFlashnetStablecoin === true && + details?.runcount > 20 + ); +} diff --git a/app/functions/spark/payments.js b/app/functions/spark/payments.js index f57b9035..9ee58508 100644 --- a/app/functions/spark/payments.js +++ b/app/functions/spark/payments.js @@ -11,7 +11,6 @@ import { getSparkAddress, sparkWallet, sendSparkTokens, - getSparkLightningSendRequest, getSingleTxDetails, getSparkPaymentStatus, } from '.'; @@ -22,7 +21,6 @@ import { import { DEFAULT_PAYMENT_EXPIRY_SEC, IS_SPARK_ID, - IS_SPARK_REQUEST_ID, USDB_TOKEN_ID, } from '../../constants'; import sha256Hash from '../hash'; @@ -153,6 +151,9 @@ export const sparkPaymenWrapper = async ({ const swapPaymentResponse = await payLightningWithToken(mnemonic, { invoice: address, tokenAddress: USD_ASSET_ADDRESS, + quote: swapPaymentQuote, + amountSats, + refundAddress: sparkInformation?.sparkAddress, }); if (!swapPaymentResponse.didWork) @@ -161,76 +162,45 @@ export const sparkPaymenWrapper = async ({ 'Error when sending lightning payment from USD balance', ); - // delete swap transfer and combine all info into one tx - setFlashnetTransfer(swapPaymentResponse.result.swapTransferId); - - const [lightningSendResponse, userSwaps] = await Promise.all([ - IS_SPARK_REQUEST_ID.test( - swapPaymentResponse.result.lightningPaymentId, - ) - ? getSparkLightningSendRequest( - swapPaymentResponse.result.lightningPaymentId, - mnemonic, - ) - : getSingleTxDetails( - mnemonic, - swapPaymentResponse.result.lightningPaymentId, - ), - getUserSwapHistory(mnemonic, 5), - ]); - - if (userSwaps.didWork) { - const swap = userSwaps.swaps.find( - savedSwap => - savedSwap.outboundTransferId === - swapPaymentResponse.result.swapTransferId, - ); - - // if swap is found delte from tx history - if (swap) { - setFlashnetTransfer(swap.inboundTransferId); - } - } - - const didUseLightning = IS_SPARK_REQUEST_ID.test( - swapPaymentResponse.result.lightningPaymentId, - ); - - const usdToSatFee = dollarsToSats( - swapPaymentResponse.result.ammFeePaid / 1000000, - poolInfoRef.currentPriceAInB, - ); - const lnFee = swapPaymentResponse.result.lightningFeePaid; - + const now = Date.now(); const tx = { - id: swapPaymentResponse.result.lightningPaymentId, - paymentStatus: didUseLightning ? 'pending' : 'completed', - paymentType: didUseLightning ? 'lightning' : 'spark', + id: swapPaymentResponse.result.swapTransferId, + paymentStatus: 'pending', + paymentType: 'spark', accountId: sparkInformation.identityPubKey, details: { sendingUUID, - fee: Math.round(usdToSatFee + lnFee), - totalFee: Math.round(usdToSatFee + lnFee), + fee: Math.round( + dollarsToSats( + swapPaymentResponse.result.ammFeePaid / 1000000, + poolInfoRef.currentPriceAInB, + ), + ), + totalFee: Math.round( + dollarsToSats( + swapPaymentResponse.result.ammFeePaid / 1000000, + poolInfoRef.currentPriceAInB, + ), + ), supportFee: 0, amount: swapPaymentResponse.result.tokenAmountSpent - swapPaymentResponse.result.ammFeePaid, description: memo || '', - address: address, - time: new Date( - lightningSendResponse[ - didUseLightning ? 'updatedAt' : 'updatedTime' - ], - ).getTime(), - createdAt: new Date( - lightningSendResponse[ - didUseLightning ? 'createdAt' : 'createdTime' - ], - ).getTime(), + address: swapPaymentResponse.result.depositAddress, + sourceSparkAddress: swapPaymentResponse.result.sourceSparkAddress, + time: now, + createdAt: now, direction: 'OUTGOING', preimage: '', isLRC20Payment: true, LRC20Token: USDB_TOKEN_ID, + isFlashnetStablecoin: true, + quoteId: swapPaymentResponse.result.quoteId, + destinationAddress: address, + destinationChain: 'lightning', + destinationAsset: 'BTC', + sourceMethod: 'USD', ...(paymentInfo?.data?.successAction ? { successAction: paymentInfo.data.successAction } : {}), diff --git a/app/functions/spark/restore.js b/app/functions/spark/restore.js index 5bccf425..ff6cdffb 100644 --- a/app/functions/spark/restore.js +++ b/app/functions/spark/restore.js @@ -499,6 +499,21 @@ export async function fullRestoreSparkState({ } } +function shouldRunOnThisTick(runcount, lastRunTimestamp) { + if (runcount < 10) return true; // first 10 calls: let the 10s interval handle it naturally + if (runcount > 22) return false; // after 21 calls, stop backoff and check every tick to avoid infinite backoff + + if (!lastRunTimestamp) return true; + + const backoffRun = runcount - 10; // 0-indexed backoff phase + const backoffMs = Math.min( + 10_000 * Math.pow(2, backoffRun), // 10s, 20s, 40s, 80s... + 300_000, // cap at 5 minutes + ); + + return Date.now() - lastRunTimestamp >= backoffMs; +} + export async function checkFlashnetStablecoinStatusLogic( tx, contactsPrivateKey, @@ -509,19 +524,39 @@ export async function checkFlashnetStablecoinStatusLogic( typeof tx.details === 'string' ? JSON.parse(tx.details) : tx.details; if (!details?.isFlashnetStablecoin || !details?.quoteId) return null; + const runcount = details.runcount || 0; + + // Skip this tick if we haven't waited long enough + if (!shouldRunOnThisTick(runcount, details.lastRunTimestamp)) return null; + const statusResult = await fetchBackend( 'checkFlashnetStablecoinStatus', - { quoteId: details.quoteId }, + { + quoteId: details.quoteId, + sourceSparkAddress: details.sourceSparkAddress, + sparkTxHash: tx.sparkID, + }, contactsPrivateKey, publicKey, ); - if (!statusResult || statusResult.error) return null; + if (!statusResult || statusResult.error) + return { + id: tx.sparkID, + paymentStatus: details.runcount === 21 ? 'completed' : 'pending', + paymentType: tx.paymentType, + accountId: tx.accountId, + details: { + ...details, + runcount: runcount + 1, + lastRunTimestamp: Date.now(), // <-- persist when we last fetched + }, + }; const newStatus = - statusResult.status === 'completed' + statusResult.status === 'completed' || details.runcount === 21 ? 'completed' - : statusResult.status === 'failed' + : ['refunded', 'failed'].includes(statusResult.status) ? 'failed' : null; @@ -532,7 +567,11 @@ export async function checkFlashnetStablecoinStatusLogic( paymentStatus: newStatus, paymentType: tx.paymentType, accountId: tx.accountId, - details, + details: { + ...details, + runcount: runcount + 1, + lastRunTimestamp: Date.now(), // <-- persist when we last fetched + }, }; } catch { return null; diff --git a/app/screens/inAccount/confirmTxPage.js b/app/screens/inAccount/confirmTxPage.js index fdfd8f3b..a7214e99 100644 --- a/app/screens/inAccount/confirmTxPage.js +++ b/app/screens/inAccount/confirmTxPage.js @@ -171,7 +171,12 @@ export default function ConfirmTxPage(props) { /> diff --git a/app/screens/inAccount/expandedTxPage.js b/app/screens/inAccount/expandedTxPage.js index c6ff963f..bd8cdf30 100644 --- a/app/screens/inAccount/expandedTxPage.js +++ b/app/screens/inAccount/expandedTxPage.js @@ -25,6 +25,7 @@ import formatTokensNumber from '../../functions/lrc20/formatTokensBalance'; import { useTranslation } from 'react-i18next'; import { useGlobalInsets } from '../../../context-store/insetsProvider'; import { useAppStatus } from '../../../context-store/appStatus'; +import { useToast } from '../../../context-store/toastManager'; import { formatLocalTimeShort } from '../../functions/timeFormatter'; import { useEffect, useMemo, useRef, useState } from 'react'; import CustomSearchInput from '../../functions/CustomElements/searchInput'; @@ -39,7 +40,7 @@ import { useImageCache } from '../../../context-store/imageCache'; import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'; import CustomSettingsTopBar from '../../functions/CustomElements/settingsTopBar'; import { currentPriceAinBToPriceDollars } from '../../functions/spark/flashnet'; -import { formatBalanceAmount } from '../../functions'; +import { formatBalanceAmount, copyToClipboard } from '../../functions'; import ThemeIcon from '../../functions/CustomElements/themeIcon'; import { claimSparkHodlLightningPayment, @@ -52,6 +53,8 @@ import useAdaptiveButtonLayout from '../../hooks/useAdaptiveButtonLayout'; import { useNavigateToContact } from '../../components/admin/homeComponents/contacts/utils/navigateToExpandedContact'; import { INSET_WINDOW_WIDTH, WINDOWWIDTH } from '../../constants/theme'; import ProfileImageRow from '../../components/admin/homeComponents/contacts/internalComponents/profileImageRow'; +import { isOrchestraSwapFailed } from '../../functions/spark/orchestraLightning'; +import { openComposer } from 'react-native-email-link'; export default function ExpandedTx(props) { const { decodedAddedContacts } = useGlobalContactsInfo(); @@ -72,9 +75,13 @@ export default function ExpandedTx(props) { const techicalDetailsLabel = t('screens.inAccount.expandedTxPage.detailsBTN'); const claimHTLCLabel = t('screens.inAccount.expandedTxPage.claimPayment'); + const contactSupportLabel = t( + 'screens.inAccount.expandedTxPage.contactSupport', + ); + const { showToast } = useToast(); const { shouldStack, containerProps, getLabelProps } = - useAdaptiveButtonLayout([techicalDetailsLabel, claimHTLCLabel]); + useAdaptiveButtonLayout([techicalDetailsLabel, contactSupportLabel]); const [transaction, setTransaction] = useState( props.route.params.transaction, @@ -83,6 +90,8 @@ export default function ExpandedTx(props) { const isBulkPayment = !!transaction.details?.isBulkPayment; const bulkPaymentGroup = transaction.details?.bulkPaymentGroup ?? []; + const showLNOrchestraAsFailed = isOrchestraSwapFailed(transaction); + // Contacts for ProfileImageRow — successful recipients only const bulkContacts = bulkPaymentGroup .filter(e => e.status !== 'failed') @@ -145,7 +154,8 @@ export default function ExpandedTx(props) { ? t('screens.inAccount.expandedTxPage.gift') : transaction.paymentType; - const isFailedPayment = transaction.paymentStatus === 'failed'; + const isFailedPayment = + transaction.paymentStatus === 'failed' || showLNOrchestraAsFailed; const isPending = transaction.paymentStatus === 'pending' || transaction.isBalancePending; const isSuccessful = !isFailedPayment && !isPending; @@ -257,6 +267,33 @@ export default function ExpandedTx(props) { } }; + const handleContactSupport = async () => { + const fields = [ + ['Payment ID', transaction.sparkID], + ['Quote ID', transaction.details?.quoteId], + ['Destination Address', transaction.details?.destinationAddress], + ['Destination Asset', transaction.details?.destinationAsset], + ['Destination Chain', transaction.details?.destinationChain], + ['Preimage', transaction.details?.preimage], + ['Address', transaction.details?.address], + ['Bitcoin TX ID', transaction.details?.onChainTxid], + ]; + const body = fields + .filter(([, v]) => v) + .map(([k, v]) => `${k}: ${v}`) + .join('\n'); + + try { + await openComposer({ + to: 'blake@blitzwalletapp.com', + subject: 'Failed Payment Support', + body, + }); + } catch { + copyToClipboard('blake@blitzwalletapp.com', showToast, null); + } + }; + const isFlashnetStablecoin = !!transaction.details?.isFlashnetStablecoin; const isLRC20Payment = transaction.details.isLRC20Payment; @@ -578,7 +615,12 @@ export default function ExpandedTx(props) { {renderInfoRow( t('constants.type'), isFlashnetStablecoin - ? t('screens.inAccount.expandedTxPage.chainSwap') + ? t('screens.inAccount.expandedTxPage.chainSwap', { + context: + transaction.details.destinationChain === 'lightning' + ? 'lightning' + : 'other', + }) : transactionPaymentType, true, { @@ -675,42 +717,29 @@ export default function ExpandedTx(props) { content={techicalDetailsLabel} /> - {transaction.details.isHoldInvoice && - isPending && - !transaction.details.didClaimHTLC && ( - - {isClaimingHtlc ? ( - - ) : ( - - )} - - )} + {isFailedPayment && ( + + + + )} {/* Receipt Dots */} diff --git a/locales/de-DE/translation.json b/locales/de-DE/translation.json index 7859a262..9f828b2f 100644 --- a/locales/de-DE/translation.json +++ b/locales/de-DE/translation.json @@ -1294,7 +1294,7 @@ "payWithUSDb": "Mit USDB bezahlen", "chainNotSupportedForAsset": "Nicht verfügbar in diesem Netzwerk", "swapInProgress": "Tausch läuft", - "swapInProgressDescription": "Ihre Mittel wurden gesendet. Der Stablecoin-Tausch wird verarbeitet und kann einige Minuten dauern.", + "swapInProgressDescription": "Ihre Mittel wurden gesendet. Die Zahlung wird verarbeitet und kann einige Minuten dauern.", "noAmount": "Bitte gib einen Betrag zum Senden ein.", "quoteStillLoading": "Angebot wird abgerufen, bitte einen Moment warten.", "noQuote": "Angebot konnte nicht abgerufen werden. Bitte versuche es erneut.", @@ -2065,7 +2065,9 @@ "gift": "Geschenk", "claimPayment": "Zahlung einfordern", "splitPayment": "Aufteilen", - "chainSwap": "Chain-Tausch" + "chainSwap_other": "Stablecoin", + "chainSwap_lightning": "Lightning", + "contactSupport": "Support" }, "explorePage": { "timeLeft": "({{time}} verbleibend)", diff --git a/locales/en/translation.json b/locales/en/translation.json index 0a8dbfe0..bbb525b4 100644 --- a/locales/en/translation.json +++ b/locales/en/translation.json @@ -1294,7 +1294,7 @@ "payWithUSDb": "Pay with USDb", "chainNotSupportedForAsset": "Not available on this network", "swapInProgress": "Payment in Progress", - "swapInProgressDescription": "Your funds have been sent. The stablecoin payment is processing and may take a few minutes.", + "swapInProgressDescription": "Your funds have been sent. The payment is processing and may take a few minutes.", "noAmount": "Please enter an amount to send.", "quoteStillLoading": "Getting a quote, please wait a moment.", "noQuote": "Could not get a quote. Please try again.", @@ -2065,7 +2065,9 @@ "gift": "Gift", "claimPayment": "Claim payment", "splitPayment": "Split", - "chainSwap": "Stablecoin" + "chainSwap_other": "Stablecoin", + "chainSwap_lightning": "Lightning", + "contactSupport": "Support" }, "explorePage": { "timeLeft": "({{time}} left)", diff --git a/locales/es/translation.json b/locales/es/translation.json index 358976b0..ef1dbaeb 100644 --- a/locales/es/translation.json +++ b/locales/es/translation.json @@ -1294,7 +1294,7 @@ "payWithUSDb": "Pagar con USDb", "chainNotSupportedForAsset": "No disponible en esta red", "swapInProgress": "Intercambio en Proceso", - "swapInProgressDescription": "Tus fondos han sido enviados. El intercambio de stablecoin está procesándose y puede tardar unos minutos.", + "swapInProgressDescription": "Tus fondos han sido enviados. El pago se está procesando y puede tardar unos minutos.", "noAmount": "Por favor, ingresa un monto para enviar.", "quoteStillLoading": "Obteniendo cotización, por favor espera un momento.", "noQuote": "No se pudo obtener una cotización. Por favor, inténtalo de nuevo.", @@ -2065,7 +2065,9 @@ "gift": "Regalo", "claimPayment": "Reclamar pago", "splitPayment": "Dividir", - "chainSwap": "Intercambio en Cadena" + "chainSwap_other": "Stablecoin", + "chainSwap_lightning": "Lightning", + "contactSupport": "Soporte" }, "explorePage": { "timeLeft": "({{time}} restantes)", diff --git a/locales/fr/translation.json b/locales/fr/translation.json index 60602fe4..f1034ff5 100644 --- a/locales/fr/translation.json +++ b/locales/fr/translation.json @@ -1294,7 +1294,7 @@ "payWithUSDb": "Payer avec USDb", "chainNotSupportedForAsset": "Non disponible sur ce réseau", "swapInProgress": "Échange en Cours", - "swapInProgressDescription": "Vos fonds ont été envoyés. L'échange de stablecoin est en cours de traitement et peut prendre quelques minutes.", + "swapInProgressDescription": "Vos fonds ont été envoyés. Le paiement est en cours de traitement et peut prendre quelques minutes.", "noAmount": "Veuillez saisir un montant à envoyer.", "quoteStillLoading": "Obtention d'un devis, veuillez patienter un instant.", "noQuote": "Impossible d'obtenir un devis. Veuillez réessayer.", @@ -2065,7 +2065,9 @@ "gift": "Cadeau", "claimPayment": "Réclamer le paiement", "splitPayment": "Partager", - "chainSwap": "Échange de Chaîne" + "chainSwap_other": "Stablecoin", + "chainSwap_lightning": "Lightning", + "contactSupport": "Support" }, "explorePage": { "timeLeft": "({{time}} restantes)", diff --git a/locales/it/translation.json b/locales/it/translation.json index 3bd59edb..df5940ec 100644 --- a/locales/it/translation.json +++ b/locales/it/translation.json @@ -1294,7 +1294,7 @@ "payWithUSDb": "Paga con USDb", "chainNotSupportedForAsset": "Non disponibile su questa rete", "swapInProgress": "Scambio in Corso", - "swapInProgressDescription": "I tuoi fondi sono stati inviati. Lo scambio di stablecoin è in elaborazione e potrebbe richiedere alcuni minuti.", + "swapInProgressDescription": "I tuoi fondi sono stati inviati. Il pagamento è in elaborazione e potrebbe richiedere alcuni minuti.", "noAmount": "Inserisci un importo da inviare.", "quoteStillLoading": "Ottenendo una quotazione, attendi un momento.", "noQuote": "Impossibile ottenere una quotazione. Riprova.", @@ -2065,7 +2065,9 @@ "gift": "Regalo", "claimPayment": "Richiedi pagamento", "splitPayment": "Dividi", - "chainSwap": "Chain Swap" + "chainSwap_other": "Stablecoin", + "chainSwap_lightning": "Lightning", + "contactSupport": "Supporto" }, "explorePage": { "timeLeft": "({{time}} rimanenti)", diff --git a/locales/pt-BR/translation.json b/locales/pt-BR/translation.json index 347c4716..4056ff00 100644 --- a/locales/pt-BR/translation.json +++ b/locales/pt-BR/translation.json @@ -1294,7 +1294,7 @@ "payWithUSDb": "Pagar com USDB", "chainNotSupportedForAsset": "Não disponível nesta rede", "swapInProgress": "Transação em andamento", - "swapInProgressDescription": "Seus fundos foram enviados. O pagamento de dólar digital está sendo processado e pode levar alguns minutos.", + "swapInProgressDescription": "Seus fundos foram enviados. O pagamento está sendo processado e pode levar alguns minutos.", "noAmount": "Por favor, insira uma quantia para enviar.", "quoteStillLoading": "Obtendo cotação, aguarde um momento.", "noQuote": "Não foi possível obter uma cotação. Por favor, tente novamente.", @@ -2065,7 +2065,9 @@ "gift": "Vale-Blitz", "claimPayment": "Reivindicar pagamento", "splitPayment": "Dividir", - "chainSwap": "Dólar digital" + "chainSwap_other": "Dólar digital", + "chainSwap_lightning": "Lightning", + "contactSupport": "Suporte" }, "explorePage": { "timeLeft": "({{time}} restantes)", diff --git a/locales/ru/translation.json b/locales/ru/translation.json index 8dca7908..7f2b77b5 100644 --- a/locales/ru/translation.json +++ b/locales/ru/translation.json @@ -1295,7 +1295,7 @@ "payWithUSDb": "Оплатить через USDb", "chainNotSupportedForAsset": "Недоступно в этой сети", "swapInProgress": "Обмен выполняется", - "swapInProgressDescription": "Ваши средства отправлены. Обмен стейблкоина обрабатывается и может занять несколько минут.", + "swapInProgressDescription": "Ваши средства отправлены. Платёж обрабатывается и может занять несколько минут.", "noAmount": "Пожалуйста, введите сумму для отправки.", "quoteStillLoading": "Получение котировки, подождите немного.", "noQuote": "Не удалось получить котировку. Попробуйте снова.", @@ -2066,7 +2066,9 @@ "gift": "Подарок", "claimPayment": "Получить платёж", "splitPayment": "Разделить", - "chainSwap": "Обмен в сети" + "chainSwap_other": "Stablecoin", + "chainSwap_lightning": "Lightning", + "contactSupport": "Поддержка" }, "explorePage": { "timeLeft": "(осталось {{time}})", diff --git a/locales/sv/translation.json b/locales/sv/translation.json index e4879de3..4b7cc479 100644 --- a/locales/sv/translation.json +++ b/locales/sv/translation.json @@ -1294,7 +1294,7 @@ "payWithUSDb": "Betala med USDb", "chainNotSupportedForAsset": "Inte tillgängligt i detta nätverk", "swapInProgress": "Byte pågår", - "swapInProgressDescription": "Dina pengar har skickats. Stablecoin-bytet bearbetas och kan ta några minuter.", + "swapInProgressDescription": "Dina pengar har skickats. Betalningen behandlas och kan ta några minuter.", "noAmount": "Ange ett belopp att skicka.", "quoteStillLoading": "Hämtar offert, vänta ett ögonblick.", "noQuote": "Det gick inte att hämta en offert. Försök igen.", @@ -2065,7 +2065,9 @@ "gift": "Gåva", "claimPayment": "Ta emot betalning", "splitPayment": "Dela", - "chainSwap": "Kedjeswap" + "chainSwap_other": "Stablecoin", + "chainSwap_lightning": "Lightning", + "contactSupport": "Support" }, "explorePage": { "timeLeft": "({{time}} kvar)",