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
This commit is contained in:
Blake Kaufman
2026-05-14 19:56:44 -04:00
committed by GitHub
parent 2eea1c3779
commit 70b14f07fd
22 changed files with 644 additions and 323 deletions
@@ -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',
});
});
});
@@ -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');
@@ -440,7 +440,7 @@ function withTimeout(promise, t) {
reject(
new Error(t('wallet.sendPages.handlingAddressErrors.timeoutError')),
),
20000,
60000,
),
),
]);
@@ -98,12 +98,28 @@ export default async function processBolt11Invoice(input, context) {
if (needUsdFee && !hasUsdQuote) {
usdPromiseIndex = promises.length;
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,
]),
);
}
@@ -186,12 +186,28 @@ export default async function processLNUrlPay(input, context) {
if (needUsdFee && !hasUsdQuote) {
usdPromiseIndex = promises.length;
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,
]),
);
}
@@ -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');
@@ -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',
+14 -31
View File
@@ -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,13 +688,10 @@ export const UserTransaction = memo(function UserTransaction({
)}
/>
</View>
{!isFailedPayment && (
<View>
{showSwapConversion ? (
<FormattedSatText
neverHideBalance={
frompage === TRANSACTION_CONSTANTS.VIEW_ALL_PAGE
}
neverHideBalance={frompage === TRANSACTION_CONSTANTS.VIEW_ALL_PAGE}
globalBalanceDenomination={userBalanceDenomination}
containerStyles={styles.amountContainer}
styles={{ color: amountColor }}
@@ -724,9 +709,7 @@ export const UserTransaction = memo(function UserTransaction({
/>
) : (
<FormattedSatText
neverHideBalance={
frompage === TRANSACTION_CONSTANTS.VIEW_ALL_PAGE
}
neverHideBalance={frompage === TRANSACTION_CONSTANTS.VIEW_ALL_PAGE}
globalBalanceDenomination={userBalanceDenomination}
containerStyles={styles.amountContainer}
styles={{ color: amountColor }}
@@ -752,7 +735,7 @@ export const UserTransaction = memo(function UserTransaction({
/>
)}
</View>
)}
{!isLastItem && (
<View style={[styles.txDivider, { backgroundColor: textColor }]} />
)}
+159 -105
View File
@@ -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,
{
if (tokenAddress !== USD_ASSET_ADDRESS) {
throw new Error('Only USDB Lightning payments are supported');
}
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,
rollbackOnFailure,
useExistingBtcBalance,
integratorFeeRateBps,
maxSlippageBps,
{
amountSats,
contactsPrivateKey: privateKey,
publicKey: resolvedPublicKey,
refundAddress: sparkAddressResponse.response,
},
);
return validateWebViewResponse(
response,
'Not able to payLightningWithToken',
);
} else {
const client = getFlashnetClient(mnemonic);
const result = await client.payLightningWithToken({
invoice,
tokenAddress,
maxSlippageBps,
maxLightningFeeSats: maxLightningFeeSats || undefined,
rollbackOnFailure,
useExistingBtcBalance,
integratorFeeRateBps,
integratorPublicKey: process.env.BLITZ_SPARK_PUBLICKEY,
if (!quoteResponse.didWork) {
throw new Error(quoteResponse.error || 'Unable to create quote');
}
paymentQuote = quoteResponse.quote;
}
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,
});
console.log('token lightning payment response:', result);
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,
);
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,
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,
},
};
} else {
return {
didWork: false,
error: result.error,
result: {
success: false,
error: result.error,
poolId: result.poolId,
tokenAmountSpent: result.tokenAmountSpent,
btcAmountReceived: result.btcAmountReceived,
},
};
}
}
} catch (error) {
console.warn(
'Pay Lightning with token error:',
formatError(error, 'payLightningWithToken'),
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,
},
};
}
};
+105
View File
@@ -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
);
}
+29 -59
View File
@@ -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 }
: {}),
+44 -5
View File
@@ -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;
+6 -1
View File
@@ -171,7 +171,12 @@ export default function ConfirmTxPage(props) {
/>
<ThemeText
styles={{ textTransform: 'capitalize' }}
content={t('wallet.stablecoinSend.chainSwap')}
content={t('screens.inAccount.expandedTxPage.chainSwap', {
context:
paymentInformation.destinationChain === 'lightning'
? 'lightning'
: 'other',
})}
/>
</View>
</View>
+50 -21
View File
@@ -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,11 +717,9 @@ export default function ExpandedTx(props) {
content={techicalDetailsLabel}
/>
</TouchableOpacity>
{transaction.details.isHoldInvoice &&
isPending &&
!transaction.details.didClaimHTLC && (
{isFailedPayment && (
<TouchableOpacity
onPress={claimHTLC}
onPress={handleContactSupport}
style={[
styles.button,
{
@@ -689,26 +729,15 @@ export default function ExpandedTx(props) {
},
shouldStack ? styles.buttonStacked : styles.buttonColumn,
]}
disabled={isClaimingHtlc}
>
{isClaimingHtlc ? (
<ActivityIndicator
color={
theme ? COLORS.lightModeText : COLORS.darkModeText
}
/>
) : (
<ThemeText
styles={{
includeFontPadding: false,
color: theme
? COLORS.lightModeText
: COLORS.darkModeText,
color: theme ? COLORS.lightModeText : COLORS.darkModeText,
}}
{...getLabelProps(1)}
content={claimHTLCLabel}
content={contactSupportLabel}
/>
)}
</TouchableOpacity>
)}
</View>
+4 -2
View File
@@ -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)",
+4 -2
View File
@@ -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)",
+4 -2
View File
@@ -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)",
+4 -2
View File
@@ -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)",
+4 -2
View File
@@ -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)",
+4 -2
View File
@@ -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)",
+4 -2
View File
@@ -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}})",
+4 -2
View File
@@ -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)",