Files
com.blitzwallet/app/functions/spark/flashnet.js
T
Blake KaufmanandGitHub a5df591ac7 Audit repo (#1011)
* remove unused deps

* bump bitcoin address parser

* bump spark
2026-07-20 08:24:11 -04:00

1898 lines
51 KiB
JavaScript

// ============================================
// IMPROVED FLASHNET SWAP & LIGHTNING PAYMENT FUNCTIONS
// Based on Official Flashnet SDK Documentation v0.4.2+
// ============================================
import {
FlashnetError,
getErrorMetadata,
isFlashnetError,
isFlashnetErrorCode,
} 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,
sendWebViewRequestGlobal,
} from '../../../context-store/webViewContext';
import {
FLASHNET_ERROR_CODE_REGEX,
FLASHNET_REFUND_REGEX,
USDB_TOKEN_ID,
} from '../../constants';
import {
isFlashnetTransfer,
setFlashnetTransfer,
} from './handleFlashnetTransferIds';
import { getLocalStorageItem, setLocalStorageItem } from '../localStorage';
import {
bulkUpdateSparkTransactions,
getSingleSparkLightningRequest,
} from './transactions';
import { decode } from '../decodeBolt11';
import fetchBackend from '../../../db/handleBackend';
import { privateKeyFromSeedWords } from '../nostrCompatability';
import {
getLightningInvoiceAmountSats,
isUsableOrchestraQuote,
mapOrchestraQuoteToLightningQuote,
normalizeOrchestraBackendError,
} from './orchestraLightning';
// ============================================
// CONSTANTS & PURE UTILITIES
// ============================================
import {
BTC_ASSET_ADDRESS,
USD_ASSET_ADDRESS,
FLASHNET_POOL_IDENTITY_KEY,
DEFAULT_SLIPPAGE_BPS,
SEND_AMOUNT_INCREASE_BUFFER,
DEFAULT_MAX_SLIPPAGE_BPS,
INTEGRATOR_FEE,
INTEGRATOR_FEE_BPS,
satsToDollars,
dollarsToSats,
calculateFlashnetAmountIn,
convertToDecimals,
} from './swapAmountUtils';
export {
BTC_ASSET_ADDRESS,
USD_ASSET_ADDRESS,
FLASHNET_POOL_IDENTITY_KEY,
DEFAULT_SLIPPAGE_BPS,
SEND_AMOUNT_INCREASE_BUFFER,
DEFAULT_MAX_SLIPPAGE_BPS,
INTEGRATOR_FEE,
INTEGRATOR_FEE_BPS,
satsToDollars,
dollarsToSats,
calculateFlashnetAmountIn,
convertToDecimals,
};
// ============================================
// HELPER FUNCTIONS
// ============================================
/**
* Format error for logging
*/
const formatError = (error, operation) => {
if (isFlashnetError(error)) {
return {
operation,
errorCode: error.errorCode,
category: error.category,
message: error.userMessage,
userMessage: i18next.t(`flashnetUserMessages.${error.errorCode}`, {
defaultValue: error.userMessage,
}),
actionHint: error.actionHint,
requestId: error.requestId,
isRetryable: error.isRetryable,
recovery: error.recovery,
transferIds: error.transferIds,
clawbackAttempted: error.wasClawbackAttempted?.() || false,
fundsRecovered: error.wereAllTransfersRecovered?.() || false,
};
}
let parsedError;
if (typeof error === 'object') {
parsedError = error.message;
} else {
parsedError = error;
}
const match = parsedError.match(FLASHNET_ERROR_CODE_REGEX);
const errorCode = match?.[0] ?? null;
if (errorCode) {
const metadata = getErrorMetadata(errorCode);
return {
operation,
errorCode: errorCode,
category: metadata.category,
message: metadata.userMessage,
userMessage: i18next.t(`flashnetUserMessages.${errorCode}`, {
defaultValue: metadata.userMessage,
}),
actionHint: metadata.actionHint,
isRetryable: metadata.isRetryable,
recovery: metadata.recovery,
transferIds: metadata.transferIds,
clawbackAttempted: metadata.wasClawbackAttempted?.() || false,
fundsRecovered: metadata.wereAllTransfersRecovered?.() || false,
};
}
return {
operation,
message: error?.message || String(error),
};
};
/**
* Calculate minimum output with slippage tolerance
*/
export const calculateMinOutput = (expectedOutput, slippageBps) => {
const amount = BigInt(expectedOutput);
const slippageFactor = BigInt(10000 - slippageBps);
const minAmount = (amount * slippageFactor) / 10000n;
return minAmount.toString();
};
// ============================================
// POOL DISCOVERY & QUERYING
// ============================================
/**
* Find the best pool for a given token pair
* @param {string} mnemonic - Wallet mnemonic
* @param {string} tokenAAddress - First asset address
* @param {string} tokenBAddress - Second asset address
* @param {object} options - Optional filters
* @returns {Promise<object>} Pool details
*/
export const findBestPool = async (
mnemonic,
tokenAAddress,
tokenBAddress,
options = {},
) => {
try {
const runtime = await selectSparkRuntime(mnemonic);
if (runtime === 'webview') {
const response = await sendWebViewRequestGlobal(
OPERATION_TYPES.findBestPool,
{
mnemonic,
tokenAAddress,
tokenBAddress,
options,
},
);
return validateWebViewResponse(response, 'Not able to find best pool');
} else {
const client = getFlashnetClient(mnemonic);
console.log(client, tokenAAddress, tokenBAddress);
const pools = await client.listPools({
assetAAddress: tokenAAddress,
assetBAddress: tokenBAddress,
sort: 'TVL_DESC', // Prefer highest TVL (best liquidity)
minTvl: options.minTvl || 1000,
limit: options.limit || 10,
});
console.log(pools, 'test');
if (!pools.pools || pools.pools.length === 0) {
throw new Error(
i18next.t('screens.inAccount.swapsPage.noPoolsFoundError', {
tokenAAddress,
tokenBAddress,
}),
);
}
// Return the best pool (highest TVL)
return {
didWork: true,
pool: pools.pools[0],
totalAvailable: pools.totalCount,
};
}
} catch (error) {
const formatted = formatError(error, 'findBestPool');
console.warn('Find best pool error:', formatError(error, 'findBestPool'));
if (formatted.message === 'Flashnet client not initialized') {
initializeFlashnet(mnemonic);
}
return {
didWork: false,
error: error.message,
details: formatError(error, 'findBestPool'),
};
}
};
/**
* Get detailed information about a specific pool
* @param {string} mnemonic - Wallet mnemonic
* @param {string} poolId - Pool's lpPublicKey
* @returns {Promise<object>} Pool details
*/
export const getPoolDetails = async (mnemonic, poolId) => {
try {
const runtime = await selectSparkRuntime(mnemonic);
if (runtime === 'webview') {
const response = await sendWebViewRequestGlobal(
OPERATION_TYPES.getPoolDetails,
{
mnemonic,
poolId,
},
);
return validateWebViewResponse(response, 'Not able to get pool detials');
} else {
const client = getFlashnetClient(mnemonic);
const pool = await client.getPool(poolId);
return {
didWork: true,
pool,
marketData: {
tvl: pool.tvlAssetB,
volume24h: pool.volume24hAssetB,
priceChange24h: pool.priceChangePercent24h,
currentPrice: pool.currentPriceAInB,
reserves: {
assetA: pool.assetAReserve,
assetB: pool.assetBReserve,
},
},
};
}
} catch (error) {
console.warn(
'Get pool details error:',
formatError(error, 'getPoolDetails'),
);
return {
didWork: false,
error: error.message,
details: formatError(error, 'getPoolDetails'),
};
}
};
/**
* List all available pools with optional filters
* @param {string} mnemonic - Wallet mnemonic
* @param {object} filters - Optional filtering/sorting parameters
* @returns {Promise<object>} List of pools
*/
export const listAllPools = async (mnemonic, filters = {}) => {
try {
const runtime = await selectSparkRuntime(mnemonic);
if (runtime === 'webview') {
const response = await sendWebViewRequestGlobal(
OPERATION_TYPES.listAllPools,
{
mnemonic,
filters,
},
);
return validateWebViewResponse(response, 'Not able to list all pools');
} else {
const client = getFlashnetClient(mnemonic);
const response = await client.listPools({
minTvl: filters.minTvl || 0,
minVolume24h: filters.minVolume24h || 0,
sort: filters.sort || 'TVL_DESC',
limit: filters.limit || 50,
offset: filters.offset || 0,
hostNames: filters.hostNames,
curveTypes: filters.curveTypes,
});
return {
didWork: true,
pools: response.pools,
totalCount: response.totalCount,
};
}
} catch (error) {
console.warn('List pools error:', formatError(error, 'listAllPools'));
return {
didWork: false,
error: error.message,
details: formatError(error, 'listAllPools'),
};
}
};
/**
* Gets the minimum swap amounts for a given asset
* @param {string} mnemonic - Wallet mnemonic
* @param {object} filters - Asset hex
* @returns {Promise<object>} List of pools
*/
export const minFlashnetSwapAmounts = async (mnemonic, assetHex) => {
try {
const runtime = await selectSparkRuntime(mnemonic);
if (runtime === 'webview') {
const response = await sendWebViewRequestGlobal(
OPERATION_TYPES.minFlashnetSwapAmounts,
{ mnemonic, assetHex },
);
return validateWebViewResponse(
response,
'Not able to get min flashnet swap limits',
);
} else {
const client = getFlashnetClient(mnemonic);
const minMap = await client.getMinAmountsMap();
const assetData = minMap.get(assetHex.toLowerCase());
return {
didWork: true,
assetData: assetData,
};
}
} catch (error) {
console.warn(
'List pools error:',
formatError(error, 'minFlashnetSwapAmounts'),
);
return {
didWork: false,
error: error.message,
details: formatError(error, 'minFlashnetSwapAmounts'),
};
}
};
// ============================================
// SWAP SIMULATION & EXECUTION
// ============================================
/**
* Simulate a swap to get expected output and price impact
* @param {string} mnemonic - Wallet mnemonic
* @param {object} params - Swap parameters
* @returns {Promise<object>} Simulation results
*/
export const simulateSwap = async (
mnemonic,
{
poolId,
assetInAddress,
assetOutAddress,
amountIn,
integratorFeeRateBps = INTEGRATOR_FEE_BPS,
},
) => {
try {
const runtime = await selectSparkRuntime(mnemonic);
if (runtime === 'webview') {
const response = await sendWebViewRequestGlobal(
OPERATION_TYPES.simulateSwap,
{
mnemonic,
poolId,
assetInAddress,
assetOutAddress,
amountIn,
integratorFeeRateBps,
},
);
return validateWebViewResponse(
response,
'Not able to simulate flashnet swap',
);
} else {
const client = getFlashnetClient(mnemonic);
const simulation = await client.simulateSwap({
poolId,
assetInAddress,
assetOutAddress,
amountIn: amountIn.toString(),
integratorBps: integratorFeeRateBps,
});
return {
didWork: true,
simulation: {
expectedOutput: simulation.amountOut,
executionPrice: simulation.executionPrice,
priceImpact: simulation.priceImpactPct,
poolId: simulation.poolId,
feePaidAssetIn: simulation.feePaidAssetIn,
},
};
}
} catch (error) {
console.warn('Simulate swap error:', formatError(error, 'simulateSwap'));
return {
didWork: false,
error: error.message,
details: formatError(error, 'simulateSwap'),
};
}
};
/**
* Execute a token swap with slippage protection
* @param {string} mnemonic - Wallet mnemonic
* @param {object} params - Swap parameters
* @returns {Promise<object>} Swap result
*/
export const executeSwap = async (
mnemonic,
{
poolId,
assetInAddress,
assetOutAddress,
amountIn,
minAmountOut, // Optional - will be calculated if not provided
maxSlippageBps = DEFAULT_SLIPPAGE_BPS,
integratorFeeRateBps = INTEGRATOR_FEE_BPS,
},
) => {
try {
const runtime = await selectSparkRuntime(mnemonic);
if (runtime === 'webview') {
const response = await sendWebViewRequestGlobal(
OPERATION_TYPES.executeSwap,
{
mnemonic,
poolId,
assetInAddress,
assetOutAddress,
amountIn,
minAmountOut,
maxSlippageBps,
integratorFeeRateBps,
},
);
if (!response.didWork) {
if (assetInAddress === BTC_ASSET_ADDRESS) {
const id = getRefundTxidFromErrormessage(response.error);
if (id) {
setFlashnetTransfer(id);
}
}
const error = formatAndReturnWebviewError(response);
return error;
}
return validateWebViewResponse(response, 'Not able to executeSwap');
} else {
const client = getFlashnetClient(mnemonic);
// Simulate first if minAmountOut not provided
let calculatedMinOut = minAmountOut;
if (!calculatedMinOut) {
const simulation = await client.simulateSwap({
poolId,
assetInAddress,
assetOutAddress,
amountIn: amountIn.toString(),
integratorBps: integratorFeeRateBps,
});
calculatedMinOut = calculateMinOutput(
simulation.amountOut,
maxSlippageBps,
);
}
// Execute the swap
const swap = await client.executeSwap({
poolId,
assetInAddress,
assetOutAddress,
amountIn: amountIn.toString(),
minAmountOut: calculatedMinOut.toString(),
maxSlippageBps,
integratorFeeRateBps,
integratorPublicKey: process.env.BLITZ_SPARK_PUBLICKEY,
});
return {
didWork: true,
swap: {
amountOut: swap.amountOut,
executionPrice: swap.executionPrice,
feeAmount: swap.feeAmount,
flashnetRequestId: swap.flashnetRequestId,
outboundTransferId: swap.outboundTransferId,
poolId: swap.poolId,
},
};
}
} catch (error) {
const errorDetails = formatError(error, 'executeSwap');
console.warn('Execute swap error:', errorDetails);
const id = getRefundTxidFromErrormessage(error.message);
if (id) {
setFlashnetTransfer(id);
}
// Check for auto-clawback results
if (isFlashnetError(error) && error.wasClawbackAttempted()) {
errorDetails.clawbackSummary = {
attempted: true,
allRecovered: error.wereAllTransfersRecovered(),
partialRecovered: error.werePartialTransfersRecovered(),
recoveredCount: error.getRecoveredTransferCount?.() || 0,
recoveredIds: error.getRecoveredTransferIds?.() || [],
unrecoveredIds: error.getUnrecoveredTransferIds?.() || [],
};
}
return {
didWork: false,
error: error.message,
details: errorDetails,
};
}
};
// ============================================
// BITCOIN <-> TOKEN SWAPS (Convenience Functions)
// ============================================
/**
* Swap Bitcoin to Token
* @param {string} mnemonic - Wallet mnemonic
* @param {object} params - Swap parameters
* @returns {Promise<object>} Swap result
*/
export const swapBitcoinToToken = async (
mnemonic,
{
tokenAddress,
amountSats,
poolId = null,
maxSlippageBps = DEFAULT_SLIPPAGE_BPS,
},
) => {
try {
const runtime = await selectSparkRuntime(mnemonic);
if (runtime === 'webview') {
const response = await sendWebViewRequestGlobal(
OPERATION_TYPES.swapBitcoinToToken,
{
mnemonic,
tokenAddress,
amountSats,
poolId,
maxSlippageBps,
},
);
if (!response.didWork) {
const id = getRefundTxidFromErrormessage(response.error);
if (id) {
setFlashnetTransfer(id);
}
const error = formatAndReturnWebviewError(response);
return error;
}
return validateWebViewResponse(
response,
'Not able to swapBitcoinToToken',
);
} else {
// Find best pool if not provided
let targetPoolId = poolId;
if (!targetPoolId) {
const poolResult = await findBestPool(
mnemonic,
BTC_ASSET_ADDRESS,
tokenAddress,
);
if (!poolResult.didWork) {
throw new Error('No suitable pool found for BTC/' + tokenAddress);
}
targetPoolId = poolResult.pool.lpPublicKey;
}
return await executeSwap(mnemonic, {
poolId: targetPoolId,
assetInAddress: BTC_ASSET_ADDRESS,
assetOutAddress: tokenAddress,
amountIn: amountSats,
maxSlippageBps,
});
}
} catch (error) {
console.warn(
'Swap BTC to token error:',
formatError(error, 'swapBitcoinToToken'),
);
return {
didWork: false,
error: error.message,
details: formatError(error, 'swapBitcoinToToken'),
};
}
};
/**
* Swap Token to Bitcoin
* @param {string} mnemonic - Wallet mnemonic
* @param {object} params - Swap parameters
* @returns {Promise<object>} Swap result
*/
export const swapTokenToBitcoin = async (
mnemonic,
{
tokenAddress,
tokenAmount,
poolId = null,
maxSlippageBps = DEFAULT_SLIPPAGE_BPS,
},
) => {
try {
const runtime = await selectSparkRuntime(mnemonic);
if (runtime === 'webview') {
const response = await sendWebViewRequestGlobal(
OPERATION_TYPES.swapTokenToBitcoin,
{
mnemonic,
tokenAddress,
tokenAmount,
poolId,
maxSlippageBps,
},
);
if (!response.didWork) {
const error = formatAndReturnWebviewError(response);
return error;
}
return validateWebViewResponse(
response,
'Not able to swapTokenToBitcoin',
);
} else {
// Find best pool if not provided
let targetPoolId = poolId;
if (!targetPoolId) {
const poolResult = await findBestPool(
mnemonic,
tokenAddress,
BTC_ASSET_ADDRESS,
);
if (!poolResult.didWork) {
throw new Error(
'No suitable pool found for ' + tokenAddress + '/BTC',
);
}
targetPoolId = poolResult.pool.lpPublicKey;
}
return await executeSwap(mnemonic, {
poolId: targetPoolId,
assetInAddress: tokenAddress,
assetOutAddress: BTC_ASSET_ADDRESS,
amountIn: tokenAmount,
maxSlippageBps,
});
}
} catch (error) {
console.warn(
'Swap token to BTC error:',
formatError(error, 'swapTokenToBitcoin'),
);
return {
didWork: false,
error: error.message,
details: formatError(error, 'swapTokenToBitcoin'),
};
}
};
// ============================================
// LIGHTNING PAYMENTS WITH TOKEN
// ============================================
/**
* Get quote for paying a Lightning invoice with tokens
* @param {string} mnemonic - Wallet mnemonic
* @param {string} invoice - BOLT11 invoice
* @param {string} tokenAddress - Token to spend
* @returns {Promise<object>} Payment quote
*/
export const getLightningPaymentQuote = async (
mnemonic,
invoice,
tokenAddress,
_integratorFeeRateBps = INTEGRATOR_FEE_BPS,
_maxSlippageBps = DEFAULT_MAX_SLIPPAGE_BPS,
options = {},
) => {
try {
if (tokenAddress !== USD_ASSET_ADDRESS) {
throw new Error('Only USDB Lightning payments are supported');
}
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: backendError.message,
details: {
operation: 'getLightningPaymentQuote',
message: backendError.message,
code: backendError.code,
minimumSats: backendError.minimumSats,
},
};
}
};
/**
* Pay a Lightning invoice using tokens
* @param {string} mnemonic - Wallet mnemonic
* @param {object} params - Payment parameters
* @returns {Promise<object>} Payment result
*/
export const payLightningWithToken = async (
mnemonic,
{
invoice,
tokenAddress,
maxSlippageBps = DEFAULT_MAX_SLIPPAGE_BPS,
integratorFeeRateBps = INTEGRATOR_FEE_BPS,
quote = null,
amountSats,
contactsPrivateKey,
publicKey,
refundAddress,
},
) => {
try {
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,
integratorFeeRateBps,
maxSlippageBps,
{
amountSats,
contactsPrivateKey: privateKey,
publicKey: resolvedPublicKey,
refundAddress: sparkAddressResponse.response,
},
);
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,
});
if (!tokenPayment.didWork) {
throw new Error(tokenPayment.error || 'Unable to send USDB deposit');
}
const sparkTxHash = tokenPayment.response;
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: backendError.message,
details: {
operation: 'payLightningWithToken',
message: backendError.message,
code: backendError.code,
minimumSats: backendError.minimumSats,
},
};
}
};
// ============================================
// SWAP HISTORY
// ============================================
/**
* Get user's swap history
* @param {string} mnemonic - Wallet mnemonic
* @param {number} limit - Number of swaps to retrieve
* @returns {Promise<object>} Swap history
*/
export const getUserSwapHistory = async (mnemonic, limit = 50, offset) => {
try {
const runtime = await selectSparkRuntime(mnemonic);
if (runtime === 'webview') {
const response = await sendWebViewRequestGlobal(
OPERATION_TYPES.getUserSwapHistory,
{
mnemonic,
limit,
offset,
},
);
return validateWebViewResponse(
response,
'Not able to getUserSwapHistory',
);
} else {
const client = getFlashnetClient(mnemonic);
let result;
if (offset) {
result = await client.getUserSwaps(undefined, { limit, offset });
} else {
result = await client.getUserSwaps(undefined, { limit });
}
console.log('User swap history response:', result);
return {
didWork: true,
swaps: result.swaps || [],
totalCount: result.totalCount || 0,
};
}
} catch (error) {
console.warn(
'Get swap history error:',
formatError(error, 'getUserSwapHistory'),
);
return {
didWork: false,
error: error.message,
swaps: [],
details: formatError(error, 'getUserSwapHistory'),
};
}
};
// ============================================
// Track current LN -> USD swaps for the session
// ============================================
let activeSwapTransferIds = new Set();
/**
* Checks to see if we have any pending ln -> btc swaps
* @returns {Boolean} True if we have pending swaps
*/
export const isSwapActive = () => {
return activeSwapTransferIds.size > 0;
};
/**
* Gets pending swaps ids
* @returns {Set} Set of pending swap transfers
*/
export const getActiveSwapTransferIds = () => {
return new Set(activeSwapTransferIds);
};
/**
* Adds a transfer id to the pending swaps set
* @param {string} transferId - Outbound transfer id of an execution resposne
* @returns {undefined}
*/
export const addActiveSwap = transferId => {
activeSwapTransferIds.add(transferId);
console.log(
`[Swap Tracker] Added swap ${transferId}, total active: ${activeSwapTransferIds.size}`,
);
};
/**
* Removes a transfer id to the pending swaps set
* @param {string} transferId - Outbound transfer id of an execution resposne
* @returns {undefined}
*/
export const removeActiveSwap = transferId => {
activeSwapTransferIds.delete(transferId);
console.log(
`[Swap Tracker] Removed swap ${transferId}, total active: ${activeSwapTransferIds.size}`,
);
};
// ============================================
// AUTO SWAP COMPLETION TRACKING
// ============================================
// Storage key constant
const PENDING_SWAP_CONFIRMATIONS_KEY = 'pendingSwapConfirmations';
/**
* Saves an active swap to be checked later if not completed
* @param {string} sparkRequestID - Lightning spark request ID
* @param {string} outboundTransferId - Outbound transfer id of an execution resposne
* @returns {Promise<undefined>}
*/
export const savePendingSwapConfirmation = async (
sparkRequestID,
outboundTransferId,
) => {
try {
const existing = await getLocalStorageItem(PENDING_SWAP_CONFIRMATIONS_KEY);
const pending = existing ? JSON.parse(existing) : {};
pending[sparkRequestID] = {
outboundTransferId,
timestamp: Date.now(),
lastChecked: null,
};
await setLocalStorageItem(
PENDING_SWAP_CONFIRMATIONS_KEY,
JSON.stringify(pending),
);
console.log('[Swap Retry] Saved pending confirmation:', sparkRequestID);
} catch (err) {
console.error('[Swap Retry] Failed to save pending confirmation:', err);
}
};
/**
* Removes an active swap to be checked later if not completed
* @param {string} sparkRequestID - Lightning spark request ID
* @returns {Promise<undefined>}
*/
export const removePendingSwapConfirmation = async sparkRequestID => {
try {
const existing = await getLocalStorageItem(PENDING_SWAP_CONFIRMATIONS_KEY);
if (!existing) return;
const pending = JSON.parse(existing);
delete pending[sparkRequestID];
await setLocalStorageItem(
PENDING_SWAP_CONFIRMATIONS_KEY,
JSON.stringify(pending),
);
console.log('[Swap Retry] Removed pending confirmation:', sparkRequestID);
} catch (err) {
console.error('[Swap Retry] Failed to remove pending confirmation:', err);
}
};
/**
* Gets all active swaps to be checked if not completed
* @param {string} sparkRequestID - Lightning spark request ID
* @returns {Promise<object>} - object of active swaps
*/
export const getPendingSwapConfirmations = async () => {
try {
const existing = await getLocalStorageItem(PENDING_SWAP_CONFIRMATIONS_KEY);
return existing ? JSON.parse(existing) : {};
} catch (err) {
console.error('[Swap Retry] Failed to get pending confirmations:', err);
return {};
}
};
/**
* Completes the auto swap process
* @param {string} sparkRequestID - Lightning spark request ID
* @param {string} outboundTransferId - Outbound swap transfer ID
* @param {Object} lightningRequest - Lightning request response by spark
* @param {Object} txDetails - Saved ln swap payment details
* @param {Object} result - Execution result
* @param {string} invoice - Lightning invoice
* @returns {Promise<boolean>} - true or false based on sucess
*/
export const completeSwapConfirmation = async (
sparkRequestID,
outboundTransferId,
lightningRequest,
txDetails,
result,
invoice,
mnemoinc,
sparkInfoRef,
) => {
try {
console.log(
'[Swap Confirmation] Starting confirmation for:',
sparkRequestID,
);
const realFeeAmount = Math.round(
dollarsToSats(
result.swap.feeAmount / Math.pow(10, 6),
result.swap.executionPrice,
),
);
const userSwaps = await getUserSwapHistory(mnemoinc, 5);
const swap = userSwaps.swaps.find(
savedSwap => savedSwap.outboundTransferId === outboundTransferId,
);
if (!swap) {
console.error('[Swap Confirmation] Swap not found in history');
return false;
}
const description = invoice
? decode(invoice).tags.find(tag => tag.tagName === 'description')?.data ||
lightningRequest?.description ||
''
: lightningRequest?.description || '';
// Clear funding and ln payment from tx list
setFlashnetTransfer(swap.inboundTransferId);
setFlashnetTransfer(txDetails.id);
const tx = {
id: swap.outboundTransferId,
paymentStatus: 'completed',
paymentType: 'spark',
accountId: sparkInfoRef.current.identityPubKey,
details: {
fee: realFeeAmount,
totalFee: realFeeAmount,
supportFee: 0,
amount: parseFloat(result.swap.amountOut),
description: description,
address: sparkInfoRef.current.sparkAddress,
time: Date.now(),
createdAt: Date.now(),
direction: 'INCOMING',
isLRC20Payment: true,
LRC20Token: USDB_TOKEN_ID,
showSwapLabel: true,
currentPriceAInB: swap.price,
ln_funding_id: txDetails.id,
},
};
await bulkUpdateSparkTransactions([tx], 'fullUpdate-tokens');
removeActiveSwap(outboundTransferId);
await removePendingSwapConfirmation(sparkRequestID);
console.log('[Swap Confirmation] Completed successfully');
return true;
} catch (err) {
console.error('[Swap Confirmation] Error:', err);
return false;
}
};
export const retryPendingSwapConfirmations = async (mnemoinc, sparkInfoRef) => {
try {
console.log('[Swap Retry] Checking for pending confirmations...');
const pending = await getPendingSwapConfirmations();
const sparkRequestIDs = Object.keys(pending);
if (sparkRequestIDs.length === 0) {
console.log('[Swap Retry] No pending confirmations found');
return;
}
console.log(
`[Swap Retry] Found ${sparkRequestIDs.length} pending confirmation(s)`,
);
for (const sparkRequestID of sparkRequestIDs) {
const { outboundTransferId, timestamp, lastChecked } =
pending[sparkRequestID];
// Only remove if too old AND has been checked at least once
const MAX_AGE = 24 * 60 * 60 * 1000;
if (lastChecked && Date.now() - timestamp > MAX_AGE) {
console.warn(
`[Swap Retry] Confirmation too old (checked at least once), removing:`,
sparkRequestID,
);
await removePendingSwapConfirmation(sparkRequestID);
continue;
}
try {
console.log(`[Swap Retry] Retrying confirmation for:`, sparkRequestID);
// Update lastChecked timestamp
const updatedPending = await getPendingSwapConfirmations();
if (updatedPending[sparkRequestID]) {
updatedPending[sparkRequestID].lastChecked = Date.now();
await setLocalStorageItem(
PENDING_SWAP_CONFIRMATIONS_KEY,
JSON.stringify(updatedPending),
);
}
let swap = null;
let offset = 0;
const batchSize = 10;
const maxSwaps = 50;
while (!swap && offset < maxSwaps) {
const userSwaps = await getUserSwapHistory(
mnemoinc,
batchSize,
offset,
);
if (!userSwaps?.swaps || userSwaps.swaps.length === 0) {
break;
}
swap = userSwaps.swaps.find(
s => s.outboundTransferId === outboundTransferId,
);
if (!swap) {
offset += batchSize;
}
}
if (!swap) {
console.error(
'[Swap Retry] Swap not found in history after checking up to 50 swaps',
);
continue;
}
// Get the lightning request
const lightningRequest = await getSparkLightningPaymentStatus({
lightningInvoiceId: sparkRequestID,
mnemonic: mnemoinc,
});
if (!lightningRequest?.transfer?.sparkId) {
console.error('[Swap Retry] Lightning request not found');
await removePendingSwapConfirmation(sparkRequestID);
continue;
}
// Get transaction details
const txDetails = await getSingleTxDetails(
mnemoinc,
lightningRequest?.transfer?.sparkId,
);
if (!txDetails) {
console.error('[Swap Retry] Transaction details not found');
continue;
}
// Check if already completed
if (isFlashnetTransfer(lightningRequest.transfer.sparkId)) {
console.log('[Swap Retry] Already completed, removing');
await removePendingSwapConfirmation(sparkRequestID);
continue;
}
const result = {
swap: {
...swap,
feeAmount: swap.feePaid || 0,
executionPrice: swap.price,
amountOut: swap.amountOut,
},
};
const invoice = lightningRequest?.invoice?.encodedInvoice || '';
lightningRequest.description = lightningRequest?.invoice?.memo || '';
const success = await completeSwapConfirmation(
sparkRequestID,
outboundTransferId,
lightningRequest,
txDetails,
result,
invoice,
mnemoinc,
sparkInfoRef,
);
if (success) {
console.log(
'[Swap Retry] Successfully completed pending confirmation',
);
} else {
console.warn('[Swap Retry] Failed to complete confirmation');
}
// Add delay between retries
await new Promise(res => setTimeout(res, 1000));
} catch (err) {
console.error(`[Swap Retry] Error processing ${sparkRequestID}:`, err);
}
}
console.log('[Swap Retry] Finished processing pending confirmations');
} catch (err) {
console.error('[Swap Retry] Error in retry process:', err);
}
};
// ============================================
// UTILITY FUNCTIONS
// ============================================
/**
* Calculate expected output for a given input amount
* @param {string} mnemonic - Wallet mnemonic
* @param {object} params - Calculation parameters
* @returns {Promise<object>} Expected output
*/
export const calculateSwapOutput = async (
mnemonic,
{ poolId, assetInAddress, assetOutAddress, amountIn },
) => {
return await simulateSwap(mnemonic, {
poolId,
assetInAddress,
assetOutAddress,
amountIn,
});
};
/**
* Check if a swap is viable (sufficient liquidity, reasonable price impact)
* @param {string} mnemonic - Wallet mnemonic
* @param {object} params - Swap parameters
* @param {number} maxPriceImpactPct - Maximum acceptable price impact %
* @returns {Promise<object>} Viability check result
*/
export const checkSwapViability = async (
mnemonic,
params,
maxPriceImpactPct = 5,
) => {
try {
const simulation = await simulateSwap(mnemonic, params);
if (!simulation.didWork) {
return {
viable: false,
reason: 'Simulation failed',
error: simulation.error,
};
}
const priceImpact = parseFloat(simulation.simulation.priceImpact);
if (priceImpact > maxPriceImpactPct) {
return {
viable: false,
reason: `Price impact too high: ${priceImpact.toFixed(
2,
)}% (max: ${maxPriceImpactPct}%)`,
priceImpact,
simulation: simulation.simulation,
};
}
return {
viable: true,
priceImpact,
simulation: simulation.simulation,
};
} catch (error) {
return {
viable: false,
reason: error.message,
error: formatError(error, 'checkSwapViability'),
};
}
};
/**
* Get current price for a token pair
* @param {string} mnemonic - Wallet mnemonic
* @param {string} poolId - Pool ID
* @returns {Promise<object>} Current price
*/
export const getCurrentPrice = async (mnemonic, poolId) => {
try {
const poolResult = await getPoolDetails(mnemonic, poolId);
if (!poolResult.didWork) {
throw new Error('Failed to get pool details');
}
return {
didWork: true,
price: poolResult.marketData.currentPrice,
priceChange24h: poolResult.marketData.priceChange24h,
volume24h: poolResult.marketData.volume24h,
tvl: poolResult.marketData.tvl,
};
} catch (error) {
console.warn(
'Get current price error:',
formatError(error, 'getCurrentPrice'),
);
return {
didWork: false,
error: error.message,
details: formatError(error, 'getCurrentPrice'),
};
}
};
/**
* Convert exchangeRate to fiat price
* @param {string|number|bigint} currentPriceAinB - Price of Bitcoin in dollars
* @returns {number} Amount in dollars
*/
export function currentPriceAinBToPriceDollars(currentPriceAInB) {
try {
const numPrice =
typeof currentPriceAInB === 'bigint'
? Number(currentPriceAInB)
: Number(currentPriceAInB || 0);
if (isNaN(numPrice)) {
return 0;
}
return (numPrice * 100000000) / 1000000;
} catch (error) {
console.error('Error in currentPriceAinBToPriceDollars:', error, {
currentPriceAInB,
});
return 0;
}
}
// ============================================
// ERROR HANDLING UTILITIES
// ============================================
/**
* Check if error is a Flashnet error and handle accordingly
* @param {Error} error - The error to check
* @returns {object} Error information
*/
export const handleFlashnetError = error => {
if (!isFlashnetErrorCode(error.errorCode)) {
return {
isFlashnetError: false,
message: error.message,
};
}
const flashnetError = new FlashnetError(error.error, {
response: {
...error,
},
});
const errorInfo = {
isFlashnetError: true,
errorCode: flashnetError.errorCode,
category: flashnetError.category,
message: flashnetError.userMessage,
userMessage: i18next.t(`flashnetUserMessages.${flashnetError.errorCode}`, {
defaultValue: flashnetError.userMessage,
}),
actionHint: flashnetError.actionHint,
isRetryable: flashnetError.isRetryable,
recovery: flashnetError.recovery,
};
// Check for specific error types
if (flashnetError.isSlippageError()) {
errorInfo.type = 'slippage';
errorInfo.userMessage = 'screens.inAccount.swapsPage.slippageError';
} else if (flashnetError.isInsufficientLiquidityError()) {
errorInfo.type = 'insufficient_liquidity';
errorInfo.userMessage = 'screens.inAccount.swapsPage.noLiquidity';
} else if (flashnetError.isAuthError()) {
errorInfo.type = 'authentication';
errorInfo.userMessage = 'screens.inAccount.swapsPage.authenticationError';
} else if (flashnetError.isPoolNotFoundError()) {
errorInfo.type = 'pool_not_found';
errorInfo.userMessage = 'screens.inAccount.swapsPage.noPoolError';
}
errorInfo.userMessage = i18next.t(errorInfo.userMessage);
// Check fund recovery status
// if (flashnetError.wasClawbackAttempted?.()) {
// errorInfo.clawback = {
// attempted: true,
// allRecovered: flashnetError.wereAllTransfersRecovered?.() || false,
// partialRecovered:
// flashnetError.werePartialTransfersRecovered?.() || false,
// recoveredCount: flashnetError.getRecoveredTransferCount?.() || 0,
// };
// if (errorInfo.clawback.allRecovered) {
// errorInfo.userMessage =
// i18next.t(errorInfo.userMessage) +
// ' ' +
// i18next.t('screens.inAccount.swapsPage.automaticRecovery');
// } else if (errorInfo.clawback.partialRecovered) {
// errorInfo.userMessage =
// i18next.t(errorInfo.userMessage) +
// ' ' +
// i18next.t('screens.inAccount.swapsPage.semiRecovered');
// }
// } else if (flashnetError.willAutoRefund?.()) {
// errorInfo.autoRefund = true;
// errorInfo.userMessage =
// i18next.t(errorInfo.userMessage) +
// ' ' +
// i18next.t('screens.inAccount.swapsPage.willRecover');
// }
return errorInfo;
};
/**
* Get refund txid from error message
* @param {string} error - The error to check
* @returns {txid|undefined} transaction id
*/
const getRefundTxidFromErrormessage = message => {
try {
const match = message.match(FLASHNET_REFUND_REGEX);
if (match) {
const transferID = match[1]; // first capture group
return transferID;
} else {
console.log('No transfer ID found');
}
} catch (err) {
console.log('error getting txid from error message', err);
}
};
/**
* Format and return errormessage from webview
* @param {Object} error - The error to check
* @returns {Object} error response
*/
const formatAndReturnWebviewError = receivedError => {
try {
if (receivedError.formatted) {
console.log(receivedError.formatted.errorCode);
const error = handleFlashnetError(receivedError.formatted);
return {
didWork: false,
error: receivedError.error,
details: error,
};
}
} catch (err) {
console.log('error getting txid from error message', err);
}
};
// ============================================
// MANUAL CLAWBACK & RECOVERY
// ============================================
/**
* Manually request clawback for a failed swap
* @param {string} mnemonic - Wallet mnemonic
* @param {string} sparkTransferId - Transfer ID to recover
* @param {string} poolId - Pool ID where funds are stuck
* @returns {Promise<object>} Clawback result
*/
export const requestManualClawback = async (
mnemonic,
sparkTransferId,
poolId,
) => {
try {
const runtime = await selectSparkRuntime(mnemonic);
if (runtime === 'webview') {
const response = await sendWebViewRequestGlobal(
OPERATION_TYPES.requestClawback,
{ mnemonic, sparkTransferId, poolId },
);
return validateWebViewResponse(response, 'Not able to request clawback');
} else {
const client = getFlashnetClient(mnemonic);
console.log({
sparkTransferId,
poolId,
});
const result = await client.clawback({
sparkTransferId,
lpIdentityPublicKey: poolId,
});
if (!result || result.error) {
return {
didWork: false,
error: result?.error || 'Clawback request failed',
};
}
if (result.accepted) {
return {
didWork: true,
accepted: true,
message: 'Clawback request accepted',
internalRequestId: result.internalRequestId,
};
} else {
return {
didWork: false,
accepted: false,
error: result.error || 'Clawback request rejected by pool',
};
}
}
} catch (error) {
console.warn(
'Manual clawback error:',
formatError(error, 'requestManualClawback'),
);
return {
didWork: false,
error: error.message,
details: formatError(error, 'requestManualClawback'),
};
}
};
/**
* Manually request clawback Eligibility
* @param {string} mnemonic - Wallet mnemonic
* @param {string} sparkTransferId - Transfer ID to recover
* @returns {Promise<object>} Clawback result
*/
export const checkClawbackEligibility = async (mnemonic, sparkTransferId) => {
try {
const runtime = await selectSparkRuntime(mnemonic);
if (runtime === 'webview') {
const response = await sendWebViewRequestGlobal(
OPERATION_TYPES.checkClawbackEligibility,
{ mnemonic, sparkTransferId },
);
return validateWebViewResponse(
response,
'Not able to checkClawbackEligibility',
);
} else {
const client = getFlashnetClient(mnemonic);
const eligibility = await client.checkClawbackEligibility({
sparkTransferId,
});
if (eligibility.accepted) {
console.log('Transfer is eligible for clawback');
return {
didWork: true,
error: null,
response: true,
};
} else {
console.log('Cannot clawback:', eligibility.error);
return {
didWork: true,
error: eligibility.error,
response: false,
};
}
}
} catch (error) {
console.warn(
'Manual clawback error:',
formatError(error, 'requestManualClawback'),
);
return {
didWork: false,
error: error.message,
response: false,
};
}
};
/**
* Check status of a manual clawback request
* @param {string} mnemonic - Wallet mnemonic
* @param {string} internalRequestId - Request ID from clawback request
* @returns {Promise<object>} Clawback status
*/
export const checkClawbackStatus = async (mnemonic, internalRequestId) => {
try {
const runtime = await selectSparkRuntime(mnemonic);
if (runtime === 'webview') {
const response = await sendWebViewRequestGlobal(
OPERATION_TYPES.checkClawbackStatus,
{ mnemonic, internalRequestId },
);
return validateWebViewResponse(
response,
'Not able to check clawback status',
);
} else {
const client = getFlashnetClient(mnemonic);
const status = await client.checkClawbackStatus({ internalRequestId });
return {
didWork: true,
status: status.status,
transferId: status.transferId,
isComplete: status.status === 'completed',
isFailed: status.status === 'failed',
};
}
} catch (error) {
console.warn(
'Check clawback status error:',
formatError(error, 'checkClawbackStatus'),
);
return {
didWork: false,
error: error.message,
details: formatError(error, 'checkClawbackStatus'),
};
}
};
/**
* Batch request clawback for multiple transfers
* @param {string} mnemonic - Wallet mnemonic
* @param {Array<{transferId: string, poolId: string}>} transfers - Transfers to recover
* @returns {Promise<object>} Batch clawback results
*/
export const requestBatchClawback = async (mnemonic, transferIds, poolId) => {
try {
const runtime = await selectSparkRuntime(mnemonic);
if (runtime === 'webview') {
const response = await sendWebViewRequestGlobal(
OPERATION_TYPES.requestBatchClawback,
{ mnemonic, transferIds, poolId },
);
return validateWebViewResponse(
response,
'Not able to check clawback status',
);
} else {
const client = getFlashnetClient(mnemonic);
const result = await client.clawbackMultiple(transferIds, poolId);
return {
didWork: true,
result,
};
}
} catch (error) {
console.warn(
'Batch clawback error:',
formatError(error, 'requestBatchClawback'),
);
return {
didWork: false,
error: error.message,
};
}
};
/**
* Batch request clawback for multiple transfers
* @param {string} mnemonic - Wallet mnemonic
* @param {Array<{transferId: string, poolId: string}>} transfers - Transfers to recover
* @returns {Promise<object>} Batch clawback results
*/
export const listClawbackableTransfers = async (
mnemonic,
limit = 100,
offset,
) => {
try {
const runtime = await selectSparkRuntime(mnemonic);
if (runtime === 'webview') {
const response = await sendWebViewRequestGlobal(
OPERATION_TYPES.listClawbackableTransfers,
{ mnemonic, limit },
);
return validateWebViewResponse(
response,
'Not able to listClawbackableTransfers',
);
} else {
const client = getFlashnetClient(mnemonic);
const resposne = await client.listClawbackableTransfers({ limit });
return {
didWork: true,
resposne: resposne,
};
}
} catch (error) {
console.warn(
'Check clawback status error:',
formatError(error, 'checkClawbackStatus'),
);
return {
didWork: false,
error: error.message,
details: formatError(error, 'checkClawbackStatus'),
};
}
};
// Export all functions
export default {
// Pool functions
findBestPool,
getPoolDetails,
listAllPools,
// Swap functions
simulateSwap,
executeSwap,
swapBitcoinToToken,
swapTokenToBitcoin,
// Lightning functions
getLightningPaymentQuote,
payLightningWithToken,
// History functions
getUserSwapHistory,
// Utility functions
// calculateSwapOutput,
// checkSwapViability,
// getCurrentPrice,
handleFlashnetError,
satsToDollars,
dollarsToSats,
// Manual recovery functions
requestManualClawback,
checkClawbackStatus,
requestBatchClawback,
};