Improve liquid (#890)
* remove unused imports * remove legacy liquid navigation listener * add extra details prop to spark receive payment wrapper * improve robusness of liquid swaps * bump breez sdk + change swap message * making sure to only mark failed if payment truly failed * improve fee
This commit is contained in:
@@ -53,10 +53,7 @@ import {
|
||||
LOGIN_SECURITY_MODE_TYPE_KEY,
|
||||
} from './app/constants';
|
||||
import { LiquidEventProvider } from './context-store/liquidEventContext';
|
||||
import {
|
||||
LiquidNavigationListener,
|
||||
RootstockNavigationListener,
|
||||
} from './context-store/SDKNavigation';
|
||||
import { RootstockNavigationListener } from './context-store/SDKNavigation';
|
||||
import {
|
||||
GlobalThemeProvider,
|
||||
useGlobalThemeContext,
|
||||
@@ -598,7 +595,6 @@ function ResetStack(): JSX.Element | null {
|
||||
{/* <StatusBar style={theme ? 'light' : 'dark'} translucent={true} /> */}
|
||||
<HandleLNURLPayments />
|
||||
<RootstockNavigationListener />
|
||||
<LiquidNavigationListener />
|
||||
<ToastContainer />
|
||||
<SparkConnectionManager />
|
||||
{/* <EcashNavigationListener /> */}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
// liquidToSparkSwap orchestrates the Liquid -> Spark auto-swap: reuse-or-mint a
|
||||
// fixed-amount spark lightning invoice, insert a pending placeholder keyed by
|
||||
// the invoice id, then pay it from Liquid. Dependencies are mocked so we can
|
||||
// steer each branch.
|
||||
jest.mock('../../../app/functions/breezLiquid', () => ({
|
||||
breezLiquidPaymentWrapper: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../app/functions/spark/payments', () => ({
|
||||
sparkReceivePaymentWrapper: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../app/functions/spark/transactions', () => ({
|
||||
deleteUnpaidSparkLightningTransaction: jest.fn(),
|
||||
getActiveLiquidSwapInvoice: jest.fn(),
|
||||
getSparkTransactionBySparkId: jest.fn(),
|
||||
insertSparkTransactionPlaceholders: jest.fn(),
|
||||
updateSparkTransactionDetails: jest.fn(),
|
||||
bulkUpdateSparkTransactions: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('i18next', () => ({
|
||||
__esModule: true,
|
||||
default: { t: key => key },
|
||||
}));
|
||||
|
||||
const {
|
||||
breezLiquidPaymentWrapper,
|
||||
} = require('../../../app/functions/breezLiquid');
|
||||
const {
|
||||
sparkReceivePaymentWrapper,
|
||||
} = require('../../../app/functions/spark/payments');
|
||||
const {
|
||||
deleteUnpaidSparkLightningTransaction,
|
||||
getActiveLiquidSwapInvoice,
|
||||
getSparkTransactionBySparkId,
|
||||
insertSparkTransactionPlaceholders,
|
||||
updateSparkTransactionDetails,
|
||||
bulkUpdateSparkTransactions,
|
||||
} = require('../../../app/functions/spark/transactions');
|
||||
|
||||
const liquidToSparkSwap =
|
||||
require('../../../app/functions/spark/liquidToSparkSwap').default;
|
||||
|
||||
const sparkInformation = { identityPubKey: 'acct-1' };
|
||||
const baseArgs = {
|
||||
mnemonic: 'seed words',
|
||||
sparkInformation,
|
||||
spendableSat: 50000,
|
||||
sendWebViewRequest: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getActiveLiquidSwapInvoice.mockResolvedValue(null);
|
||||
sparkReceivePaymentWrapper.mockImplementation(({ amountSats }) => ({
|
||||
didWork: true,
|
||||
data: { id: `invoice-id-${amountSats}` },
|
||||
invoice: `lnbc-encoded-${amountSats}`,
|
||||
}));
|
||||
deleteUnpaidSparkLightningTransaction.mockResolvedValue(true);
|
||||
breezLiquidPaymentWrapper.mockImplementation(({ getFee }) => {
|
||||
if (getFee) return Promise.resolve({ didWork: true, fee: 103 });
|
||||
return Promise.resolve({ didWork: true });
|
||||
});
|
||||
insertSparkTransactionPlaceholders.mockResolvedValue(true);
|
||||
updateSparkTransactionDetails.mockResolvedValue(true);
|
||||
getSparkTransactionBySparkId.mockResolvedValue({ paymentStatus: 'pending' });
|
||||
bulkUpdateSparkTransactions.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
test('mints a non-zero invoice below spendable minus fees, inserts a pending placeholder keyed by the invoice id, then pays it', async () => {
|
||||
const result = await liquidToSparkSwap(baseArgs);
|
||||
|
||||
expect(result).toEqual({ didWork: true });
|
||||
|
||||
// First invoice is exploratory, second is the final amount after fee quote.
|
||||
expect(sparkReceivePaymentWrapper).toHaveBeenCalledTimes(2);
|
||||
const receiveArgs = sparkReceivePaymentWrapper.mock.calls[1][0];
|
||||
expect(receiveArgs.paymentType).toBe('lightning');
|
||||
expect(receiveArgs.amountSats).toBe(49894);
|
||||
expect(
|
||||
sparkReceivePaymentWrapper.mock.calls[0][0].amountSats +
|
||||
34 +
|
||||
19 +
|
||||
Math.ceil(
|
||||
sparkReceivePaymentWrapper.mock.calls[0][0].amountSats * 0.001,
|
||||
) +
|
||||
Math.ceil(sparkReceivePaymentWrapper.mock.calls[0][0].amountSats * 0.01) +
|
||||
2,
|
||||
).toBeLessThan(baseArgs.spendableSat);
|
||||
expect(receiveArgs.extraDetails.isLiquidSwapCandidate).toBe(true);
|
||||
expect(typeof receiveArgs.extraDetails.swapExpiresAt).toBe('number');
|
||||
expect(deleteUnpaidSparkLightningTransaction).toHaveBeenCalledWith(
|
||||
'invoice-id-49400',
|
||||
);
|
||||
expect(updateSparkTransactionDetails).toHaveBeenCalledWith(
|
||||
'invoice-id-49894',
|
||||
expect.objectContaining({
|
||||
isLiquidSwap: true,
|
||||
isLiquidSwapCandidate: false,
|
||||
swapInvoice: 'lnbc-encoded-49894',
|
||||
swapAmountSat: 49894,
|
||||
swapFeeSat: 103,
|
||||
fee: 103,
|
||||
}),
|
||||
);
|
||||
|
||||
// Placeholder inserted, pending, keyed by the invoice id.
|
||||
expect(insertSparkTransactionPlaceholders).toHaveBeenCalledTimes(1);
|
||||
const [[placeholder]] = insertSparkTransactionPlaceholders.mock.calls[0];
|
||||
expect(placeholder.id).toBe('invoice-id-49894');
|
||||
expect(placeholder.accountId).toBe('acct-1');
|
||||
expect(placeholder.paymentStatus).toBe('pending');
|
||||
expect(placeholder.paymentType).toBe('lightning');
|
||||
expect(placeholder.details.isLiquidSwap).toBe(true);
|
||||
expect(placeholder.details.amount).toBe(49894);
|
||||
expect(placeholder.details.fee).toBe(103);
|
||||
|
||||
// Quoted the invoice, then paid the final fixed-amount invoice without drain.
|
||||
expect(breezLiquidPaymentWrapper).toHaveBeenLastCalledWith({
|
||||
paymentType: 'lightning',
|
||||
invoice: 'lnbc-encoded-49894',
|
||||
});
|
||||
});
|
||||
|
||||
test('reuses an active swap invoice instead of minting a new one', async () => {
|
||||
getActiveLiquidSwapInvoice.mockResolvedValue({
|
||||
sparkID: 'reused-invoice-id',
|
||||
amount: 49400,
|
||||
details: {
|
||||
isLiquidSwap: true,
|
||||
swapInvoice: 'lnbc-reused',
|
||||
swapAmountSat: 49400,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await liquidToSparkSwap(baseArgs);
|
||||
|
||||
expect(result).toEqual({ didWork: true });
|
||||
expect(sparkReceivePaymentWrapper).not.toHaveBeenCalled();
|
||||
expect(breezLiquidPaymentWrapper).toHaveBeenLastCalledWith({
|
||||
paymentType: 'lightning',
|
||||
invoice: 'lnbc-reused',
|
||||
});
|
||||
const [[placeholder]] = insertSparkTransactionPlaceholders.mock.calls[0];
|
||||
expect(placeholder.id).toBe('reused-invoice-id');
|
||||
expect(placeholder.details.amount).toBe(49400);
|
||||
expect(placeholder.details.fee).toBe(103);
|
||||
});
|
||||
|
||||
test('a concurrent call is rejected while a swap is in progress', async () => {
|
||||
// Park the first call on its very first await (after the lock is taken).
|
||||
let releaseLookup;
|
||||
getActiveLiquidSwapInvoice.mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
releaseLookup = () => resolve(null);
|
||||
}),
|
||||
);
|
||||
|
||||
const first = liquidToSparkSwap(baseArgs);
|
||||
await Promise.resolve();
|
||||
|
||||
const second = await liquidToSparkSwap(baseArgs);
|
||||
expect(second.didWork).toBe(false);
|
||||
expect(second.error).toMatch(/in progress/i);
|
||||
|
||||
releaseLookup();
|
||||
await first;
|
||||
|
||||
// Only the first call did real work; it still performs the quote-adjust cycle.
|
||||
expect(sparkReceivePaymentWrapper).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('marks the placeholder failed when the Liquid payment fails', async () => {
|
||||
breezLiquidPaymentWrapper.mockImplementation(({ getFee }) => {
|
||||
if (getFee) return Promise.resolve({ didWork: true, fee: 103 });
|
||||
return Promise.resolve({
|
||||
didWork: false,
|
||||
error: { message: 'boltz rejected' },
|
||||
});
|
||||
});
|
||||
|
||||
const result = await liquidToSparkSwap(baseArgs);
|
||||
|
||||
expect(result.didWork).toBe(false);
|
||||
expect(bulkUpdateSparkTransactions).toHaveBeenCalledTimes(1);
|
||||
const [[failedTx]] = bulkUpdateSparkTransactions.mock.calls[0];
|
||||
expect(failedTx.id).toBe('invoice-id-49894');
|
||||
expect(failedTx.accountId).toBe('acct-1');
|
||||
expect(failedTx.paymentStatus).toBe('failed');
|
||||
});
|
||||
|
||||
test('uses a fee-aware retry amount after a quote failure instead of dropping by 10%', async () => {
|
||||
let quoteCalls = 0;
|
||||
breezLiquidPaymentWrapper.mockImplementation(({ getFee }) => {
|
||||
if (getFee) {
|
||||
quoteCalls += 1;
|
||||
if (quoteCalls === 1) {
|
||||
return Promise.resolve({
|
||||
didWork: false,
|
||||
error: { message: 'amount too high' },
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ didWork: true, fee: 103 });
|
||||
}
|
||||
return Promise.resolve({ didWork: true });
|
||||
});
|
||||
|
||||
const result = await liquidToSparkSwap({
|
||||
...baseArgs,
|
||||
spendableSat: 1_000_000,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ didWork: true });
|
||||
expect(sparkReceivePaymentWrapper).toHaveBeenCalledTimes(3);
|
||||
expect(sparkReceivePaymentWrapper.mock.calls[0][0].amountSats).toBe(989063);
|
||||
expect(sparkReceivePaymentWrapper.mock.calls[1][0].amountSats).toBe(967195);
|
||||
expect(sparkReceivePaymentWrapper.mock.calls[2][0].amountSats).toBe(989062);
|
||||
expect(
|
||||
sparkReceivePaymentWrapper.mock.calls[1][0].amountSats,
|
||||
).toBeGreaterThan(900000);
|
||||
expect(deleteUnpaidSparkLightningTransaction).toHaveBeenCalledWith(
|
||||
'invoice-id-989063',
|
||||
);
|
||||
});
|
||||
|
||||
test('does nothing destructive when the spark wallet is not connected', async () => {
|
||||
const result = await liquidToSparkSwap({
|
||||
...baseArgs,
|
||||
sparkInformation: {},
|
||||
});
|
||||
|
||||
expect(result.didWork).toBe(false);
|
||||
expect(sparkReceivePaymentWrapper).not.toHaveBeenCalled();
|
||||
expect(insertSparkTransactionPlaceholders).not.toHaveBeenCalled();
|
||||
expect(breezLiquidPaymentWrapper).not.toHaveBeenCalled();
|
||||
// No placeholder id yet, so no failed-cleanup write either.
|
||||
expect(bulkUpdateSparkTransactions).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -196,6 +196,57 @@ describe('Spark transaction bulk update guards', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('only overwrites an existing fee when the incoming fee is larger', async () => {
|
||||
const mockDb = createMockDb();
|
||||
mockDb.getAllAsync.mockResolvedValue([
|
||||
{
|
||||
sparkID: 'liquid-swap',
|
||||
paymentStatus: 'pending',
|
||||
paymentType: 'lightning',
|
||||
accountId: 'identity-pubkey',
|
||||
details: JSON.stringify({
|
||||
amount: 49254,
|
||||
fee: 250,
|
||||
direction: 'INCOMING',
|
||||
}),
|
||||
},
|
||||
]);
|
||||
const { bulkUpdateSparkTransactions } = loadTransactionsModule(mockDb);
|
||||
|
||||
await bulkUpdateSparkTransactions([
|
||||
{
|
||||
id: 'liquid-swap',
|
||||
paymentStatus: 'completed',
|
||||
paymentType: 'lightning',
|
||||
accountId: 'identity-pubkey',
|
||||
details: {
|
||||
amount: 49254,
|
||||
fee: 0,
|
||||
direction: 'INCOMING',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
let updateCall = findUpdateCall(mockDb);
|
||||
expect(JSON.parse(updateCall[1][3]).fee).toBe(250);
|
||||
|
||||
mockDb.runAsync.mockClear();
|
||||
await bulkUpdateSparkTransactions([
|
||||
{
|
||||
id: 'liquid-swap',
|
||||
paymentStatus: 'completed',
|
||||
paymentType: 'lightning',
|
||||
accountId: 'identity-pubkey',
|
||||
details: {
|
||||
fee: 300,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
updateCall = findUpdateCall(mockDb);
|
||||
expect(JSON.parse(updateCall[1][3]).fee).toBe(300);
|
||||
});
|
||||
|
||||
it('runs SAR correlation before BEGIN and preserves its description on update', async () => {
|
||||
const callOrder = [];
|
||||
const mockDb = createMockDb();
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// When a settled lightning payment matches a Liquid-swap request, the payment
|
||||
// object must carry useTempId/tempId so bulkUpdateSparkTransactions remaps the
|
||||
// pre-inserted pending placeholder (keyed by the invoice id) onto the final
|
||||
// spark id instead of creating a duplicate row.
|
||||
jest.mock('bolt11', () => ({ decode: jest.fn() }));
|
||||
|
||||
jest.mock('../../../app/functions/spark/index', () => ({
|
||||
getSparkPaymentStatus: jest.fn(() => 'completed'),
|
||||
sparkPaymentType: jest.fn(() => 'lightning'),
|
||||
}));
|
||||
|
||||
jest.mock('../../../app/functions/spark/calculateSupportFee', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(async () => 0),
|
||||
}));
|
||||
|
||||
jest.mock('../../../app/functions/spark/transactions', () => ({
|
||||
deleteUnpaidSparkLightningTransaction: jest.fn(),
|
||||
getActiveAutoSwapByAmount: jest.fn(),
|
||||
updateSparkTransactionDetails: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../app/functions/spark/flashnet', () => ({
|
||||
FLASHNET_POOL_IDENTITY_KEY: 'pool-key',
|
||||
getActiveSwapTransferIds: jest.fn(async () => []),
|
||||
getUserSwapHistory: jest.fn(async () => []),
|
||||
}));
|
||||
|
||||
jest.mock('../../../app/functions/spark/handleFlashnetTransferIds', () => ({
|
||||
setFlashnetTransfer: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('i18next', () => ({
|
||||
__esModule: true,
|
||||
default: { t: key => key },
|
||||
}));
|
||||
|
||||
const {
|
||||
deleteUnpaidSparkLightningTransaction,
|
||||
} = require('../../../app/functions/spark/transactions');
|
||||
const {
|
||||
transformTxToPaymentObject,
|
||||
} = require('../../../app/functions/spark/transformTxToPayment');
|
||||
|
||||
test('liquid-swap match remaps the placeholder via useTempId/tempId', async () => {
|
||||
const tx = {
|
||||
totalValue: 49500,
|
||||
status: 'TRANSFER_STATUS_COMPLETED',
|
||||
transferDirection: 'INCOMING',
|
||||
receiverIdentityPublicKey: 'acct-1',
|
||||
senderIdentityPublicKey: 'someone',
|
||||
createdTime: '2026-06-03T00:00:00.000Z',
|
||||
transfer: { sparkId: 'final-spark-id' },
|
||||
userRequest: {
|
||||
id: 'invoice-id-1',
|
||||
typename: 'LightningReceiveRequest',
|
||||
invoice: { encodedInvoice: '' },
|
||||
},
|
||||
};
|
||||
|
||||
const unpaidLNInvoices = [
|
||||
{
|
||||
sparkID: 'invoice-id-1',
|
||||
description: 'Liquid swap',
|
||||
shouldNavigate: 0,
|
||||
details: JSON.stringify({
|
||||
isLiquidSwap: true,
|
||||
createdTime: 111,
|
||||
fee: 250,
|
||||
swapFeeSat: 250,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const result = await transformTxToPaymentObject(
|
||||
tx,
|
||||
'spark-addr',
|
||||
'lightning',
|
||||
false,
|
||||
unpaidLNInvoices,
|
||||
'acct-1',
|
||||
1,
|
||||
false,
|
||||
[],
|
||||
'seed words',
|
||||
);
|
||||
|
||||
expect(result.id).toBe('final-spark-id');
|
||||
expect(result.useTempId).toBe(true);
|
||||
expect(result.tempId).toBe('invoice-id-1');
|
||||
expect(result.paymentStatus).toBe('completed');
|
||||
expect(result.paymentType).toBe('lightning');
|
||||
expect(result.details.isLiquidSwap).toBe(true);
|
||||
expect(result.details.fee).toBe(250);
|
||||
expect(result.details.totalFee).toBe(250);
|
||||
expect(result.details.amount).toBe(49500);
|
||||
|
||||
// It is not a USD swap, so the request row is cleaned up once claimed.
|
||||
expect(deleteUnpaidSparkLightningTransaction).toHaveBeenCalledWith(
|
||||
'invoice-id-1',
|
||||
);
|
||||
});
|
||||
|
||||
test('a normal (non-liquid) lightning match does not set tempId', async () => {
|
||||
const tx = {
|
||||
totalValue: 1000,
|
||||
status: 'TRANSFER_STATUS_COMPLETED',
|
||||
transferDirection: 'INCOMING',
|
||||
receiverIdentityPublicKey: 'acct-1',
|
||||
senderIdentityPublicKey: 'someone',
|
||||
createdTime: '2026-06-03T00:00:00.000Z',
|
||||
transfer: { sparkId: 'final-spark-id-2' },
|
||||
userRequest: {
|
||||
id: 'invoice-id-2',
|
||||
typename: 'LightningReceiveRequest',
|
||||
invoice: { encodedInvoice: '' },
|
||||
},
|
||||
};
|
||||
|
||||
const unpaidLNInvoices = [
|
||||
{
|
||||
sparkID: 'invoice-id-2',
|
||||
description: 'normal',
|
||||
shouldNavigate: 0,
|
||||
details: JSON.stringify({ createdTime: 222 }),
|
||||
},
|
||||
];
|
||||
|
||||
const result = await transformTxToPaymentObject(
|
||||
tx,
|
||||
'spark-addr',
|
||||
'lightning',
|
||||
false,
|
||||
unpaidLNInvoices,
|
||||
'acct-1',
|
||||
1,
|
||||
false,
|
||||
[],
|
||||
'seed words',
|
||||
);
|
||||
|
||||
expect(result.id).toBe('final-spark-id-2');
|
||||
expect(result.useTempId).toBeUndefined();
|
||||
expect(result.tempId).toBeUndefined();
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
// import {InputTypeVariant} from '@breeztech/react-native-breez-sdk-liquid';
|
||||
import {
|
||||
MIN_USD_BTC_LIGHTNING_SWAP,
|
||||
SATSPERBITCOIN,
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
// import {InputTypeVariant} from '@breeztech/react-native-breez-sdk-liquid';
|
||||
// import {SATSPERBITCOIN} from '../../../../../constants';
|
||||
// import {
|
||||
// crashlyticsLogReport,
|
||||
// crashlyticsRecordErrorReport,
|
||||
// } from '../../../../../functions/crashlyticsLogs';
|
||||
|
||||
// export default function processBolt12Offer(input, context) {
|
||||
// const {
|
||||
// nodeInformation,
|
||||
// masterInfoObject,
|
||||
// navigate,
|
||||
// goBackFunction,
|
||||
// comingFromAccept,
|
||||
// enteredPaymentInfo,
|
||||
// } = context;
|
||||
// try {
|
||||
// crashlyticsLogReport('Handling decode bolt12 offers');
|
||||
|
||||
// const amountMsat = comingFromAccept ? enteredPaymentInfo.amount * 1000 : 0;
|
||||
// const fiatValue =
|
||||
// !!amountMsat &&
|
||||
// Number(amountMsat / 1000) /
|
||||
// (SATSPERBITCOIN / (nodeInformation.fiatStats?.value || 65000));
|
||||
|
||||
// return {
|
||||
// data: input,
|
||||
// type: InputTypeVariant.BOLT12_OFFER,
|
||||
// paymentNetwork: 'lightning',
|
||||
// sendAmount: !amountMsat
|
||||
// ? ''
|
||||
// : `${
|
||||
// masterInfoObject.userBalanceDenomination != 'fiat'
|
||||
// ? `${Math.round(amountMsat / 1000)}`
|
||||
// : fiatValue < 0.01
|
||||
// ? ''
|
||||
// : `${fiatValue.toFixed(2)}`
|
||||
// }`,
|
||||
// canEditPayment: comingFromAccept ? false : !amountMsat,
|
||||
// };
|
||||
// } catch (err) {
|
||||
// console.log('process bolt12 invoice error', err);
|
||||
// crashlyticsRecordErrorReport(err.message);
|
||||
// }
|
||||
// }
|
||||
@@ -1,4 +1,3 @@
|
||||
// import {InputTypeVariant} from '@breeztech/react-native-breez-sdk-liquid';
|
||||
import {
|
||||
MIN_USD_BTC_LIGHTNING_SWAP,
|
||||
SATSPERBITCOIN,
|
||||
|
||||
+12
-5
@@ -14,10 +14,12 @@ import CustomButton from '../../../../../functions/CustomElements/button';
|
||||
import FormattedSatText from '../../../../../functions/CustomElements/satTextDisplay';
|
||||
import SkeletonTextPlaceholder from '../../../../../functions/CustomElements/skeletonTextView';
|
||||
import { useAppStatus } from '../../../../../../context-store/appStatus';
|
||||
import { useGlobalContactsInfo } from '../../../../../../context-store/globalContacts';
|
||||
import displayCorrectDenomination from '../../../../../functions/displayCorrectDenomination';
|
||||
import { useGlobalContextProvider } from '../../../../../../context-store/context';
|
||||
import { useNodeContext } from '../../../../../../context-store/nodeContext';
|
||||
import { useSparkWallet } from '../../../../../../context-store/sparkContext';
|
||||
import { useKeysContext } from '../../../../../../context-store/keys';
|
||||
import { useWebView } from '../../../../../../context-store/webViewContext';
|
||||
import liquidToSparkSwap from '../../../../../functions/spark/liquidToSparkSwap';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import GetThemeColors from '../../../../../hooks/themeColors';
|
||||
@@ -29,7 +31,9 @@ import {
|
||||
|
||||
export default function LiquidSwapsPage() {
|
||||
const { minMaxLiquidSwapAmounts } = useAppStatus();
|
||||
const { globalContactsInformation } = useGlobalContactsInfo();
|
||||
const { sparkInformation } = useSparkWallet();
|
||||
const { accountMnemoinc } = useKeysContext();
|
||||
const { sendWebViewRequest } = useWebView();
|
||||
const { masterInfoObject, toggleMasterInfoObject } =
|
||||
useGlobalContextProvider();
|
||||
const { fiatStats } = useNodeContext();
|
||||
@@ -101,9 +105,12 @@ export default function LiquidSwapsPage() {
|
||||
|
||||
try {
|
||||
setIsSwapping(true);
|
||||
const response = await liquidToSparkSwap(
|
||||
globalContactsInformation.myProfile.uniqueName,
|
||||
);
|
||||
const response = await liquidToSparkSwap({
|
||||
mnemonic: accountMnemoinc,
|
||||
sparkInformation,
|
||||
spendableSat,
|
||||
sendWebViewRequest,
|
||||
});
|
||||
if (!response.didWork) throw new Error(t(response.error));
|
||||
|
||||
navigate.navigate('ErrorScreen', {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
receivePayment,
|
||||
sendPayment,
|
||||
} from '@breeztech/react-native-breez-sdk-liquid';
|
||||
import {BLITZ_DEFAULT_PAYMENT_DESCRIPTION} from '../../constants';
|
||||
import { BLITZ_DEFAULT_PAYMENT_DESCRIPTION } from '../../constants';
|
||||
import {
|
||||
crashlyticsLogReport,
|
||||
crashlyticsRecordErrorReport,
|
||||
@@ -56,7 +56,7 @@ export async function breezLiquidReceivePaymentWrapper({
|
||||
});
|
||||
|
||||
const destination = res.destination;
|
||||
return {destination, receiveFeesSat};
|
||||
return { destination, receiveFeesSat };
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
crashlyticsRecordErrorReport(err.message);
|
||||
@@ -68,17 +68,21 @@ export async function breezLiquidPaymentWrapper({
|
||||
sendAmount,
|
||||
invoice,
|
||||
shouldDrain,
|
||||
getFee = false,
|
||||
}) {
|
||||
try {
|
||||
crashlyticsLogReport('Starting liquid payment process');
|
||||
let optionalAmount;
|
||||
|
||||
if (paymentType === 'bolt12') {
|
||||
if (paymentType === 'bolt12' || sendAmount) {
|
||||
optionalAmount = {
|
||||
type: AmountVariant.BITCOIN,
|
||||
type: PayAmountVariant.BITCOIN,
|
||||
receiverAmountSat: sendAmount,
|
||||
};
|
||||
} else if (paymentType === 'bip21Liquid' && shouldDrain) {
|
||||
} else if (
|
||||
(paymentType === 'bip21Liquid' || paymentType === 'lightning') &&
|
||||
shouldDrain
|
||||
) {
|
||||
optionalAmount = {
|
||||
type: PayAmountVariant.DRAIN,
|
||||
};
|
||||
@@ -93,17 +97,21 @@ export async function breezLiquidPaymentWrapper({
|
||||
// If the fees are acceptable, continue to create the Send Payment
|
||||
const sendFeesSat = prepareResponse.feesSat;
|
||||
console.log(`Fees: ${sendFeesSat} sats`);
|
||||
if (getFee) {
|
||||
return { fee: sendFeesSat, didWork: true, prepareResponse };
|
||||
}
|
||||
|
||||
console.log('Sending payment');
|
||||
const sendResponse = await sendPayment({
|
||||
prepareResponse,
|
||||
});
|
||||
|
||||
const payment = sendResponse.payment;
|
||||
return {payment, fee: sendFeesSat, didWork: true};
|
||||
return { payment, fee: sendFeesSat, didWork: true };
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
crashlyticsRecordErrorReport(err.message);
|
||||
return {error: err, didWork: false};
|
||||
return { error: err, didWork: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,10 +152,10 @@ export async function breezLiquidLNAddressPaymentWrapper({
|
||||
});
|
||||
result.data.payment;
|
||||
const payment = result.data.payment;
|
||||
return {payment, fee: feesSat, didWork: true};
|
||||
return { payment, fee: feesSat, didWork: true };
|
||||
} catch (err) {
|
||||
console.log(err, 'BREEZ LIQUID TO LN ADDRESS PAYMENT WRAPPER');
|
||||
crashlyticsRecordErrorReport(err.message);
|
||||
return {error: err, didWork: false};
|
||||
return { error: err, didWork: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { InputTypes } from 'bitcoin-address-parser';
|
||||
import { getLNAddressForLiquidPayment } from '../../components/admin/homeComponents/sendBitcoin/functions/payments';
|
||||
import getLNURLDetails from '../lnurl/getLNURLDetails';
|
||||
import { sparkPaymenWrapper } from '../spark/payments';
|
||||
// import {InputTypeVariant} from '@breeztech/react-native-breez-sdk-liquid';
|
||||
|
||||
/**
|
||||
* Pay to a Lightning address using the most efficient available payment method
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// import {InputTypeVariant} from '@breeztech/react-native-breez-sdk-liquid';
|
||||
import { InputTypes } from 'bitcoin-address-parser';
|
||||
|
||||
const merchants = [
|
||||
|
||||
@@ -1,51 +1,404 @@
|
||||
// import {InputTypeVariant} from '@breeztech/react-native-breez-sdk-liquid';
|
||||
import {breezLiquidLNAddressPaymentWrapper} from '../breezLiquid';
|
||||
import i18next from 'i18next';
|
||||
import getLNURLDetails from '../lnurl/getLNURLDetails';
|
||||
import {InputTypes} from 'bitcoin-address-parser';
|
||||
import { breezLiquidPaymentWrapper } from '../breezLiquid';
|
||||
import { sparkReceivePaymentWrapper } from './payments';
|
||||
import {
|
||||
bulkUpdateSparkTransactions,
|
||||
deleteUnpaidSparkLightningTransaction,
|
||||
getActiveLiquidSwapInvoice,
|
||||
getSparkTransactionBySparkId,
|
||||
hasPaidSparkLightningInvoice,
|
||||
insertSparkTransactionPlaceholders,
|
||||
updateSparkTransactionDetails,
|
||||
} from './transactions';
|
||||
|
||||
export default async function liquidToSparkSwap(contactUsername) {
|
||||
try {
|
||||
let maxRunCount = 5;
|
||||
let runCount = 0;
|
||||
let parsedData = null;
|
||||
while (maxRunCount > runCount && !parsedData) {
|
||||
runCount += 1;
|
||||
try {
|
||||
const didGetData = await getLNURLDetails(
|
||||
`${contactUsername}@blitzwalletapp.com`,
|
||||
);
|
||||
if (!didGetData) throw new Error('Unable to get lnurl data');
|
||||
const parsed = {
|
||||
type: InputTypes.LNURL_PAY,
|
||||
data: {
|
||||
...didGetData,
|
||||
metadataStr: didGetData.metadata, //added for breeze
|
||||
domain: 'blitzwalletapp.com', //added for breeze
|
||||
},
|
||||
};
|
||||
parsedData = parsed;
|
||||
break;
|
||||
} catch (err) {
|
||||
console.log('Error parsing LNURL, assuming its a backend issue');
|
||||
await new Promise(res => setTimeout(res, 1000));
|
||||
}
|
||||
// How long a generated Liquid->Spark swap invoice stays reusable. The payment
|
||||
// happens almost immediately, so this only needs to cover an app restart that
|
||||
// happens mid-swap; we track it explicitly to avoid SDK timestamp-unit guessing.
|
||||
const LIQUID_SWAP_INVOICE_EXPIRY_SECONDS = 60 * 60;
|
||||
const LIQUID_SWAP_MAX_QUOTE_ATTEMPTS = 8;
|
||||
const LIQUID_SWAP_LOCKUP_TX_FEE_SATS = 34;
|
||||
const LIQUID_SWAP_CLAIM_TX_FEE_SATS = 19;
|
||||
const LIQUID_SWAP_SERVICE_FEE_RATE = 0.001;
|
||||
const LIQUID_SWAP_PARTNER_FEE_RATE = 0.01;
|
||||
const LIQUID_SWAP_ROUNDING_BUFFER_SATS = 2;
|
||||
const LIQUID_SWAP_RETRY_PERCENT_STEP = 0.1;
|
||||
const LIQUID_SWAP_RETRY_FEE_MULTIPLIER = 2;
|
||||
const LIQUID_SWAP_UNKNOWN_UPPER_BOUND_SATS = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
// In-memory reentrancy guard. Liquid balance updates can fire several times in
|
||||
// quick succession (sync + payment events); without this they would each kick
|
||||
// off a concurrent swap and race over the same funds.
|
||||
let isRunningLiquidSwap = false;
|
||||
|
||||
function normalizeSats(value) {
|
||||
return Math.max(Math.ceil(Number(value) || 0), 0);
|
||||
}
|
||||
|
||||
function getPercentageFeeSat(amountSat, feeRate) {
|
||||
return Math.ceil(Math.max(Number(amountSat) || 0, 0) * feeRate);
|
||||
}
|
||||
|
||||
function getModeledBaseSwapFeeSat(amountSat) {
|
||||
return (
|
||||
LIQUID_SWAP_LOCKUP_TX_FEE_SATS +
|
||||
LIQUID_SWAP_CLAIM_TX_FEE_SATS +
|
||||
getPercentageFeeSat(amountSat, LIQUID_SWAP_SERVICE_FEE_RATE)
|
||||
);
|
||||
}
|
||||
|
||||
function getPartnerFeeSat(amountSat) {
|
||||
return getPercentageFeeSat(amountSat, LIQUID_SWAP_PARTNER_FEE_RATE);
|
||||
}
|
||||
|
||||
function getEstimatedSwapFeeSat(amountSat) {
|
||||
return getModeledBaseSwapFeeSat(amountSat) + getPartnerFeeSat(amountSat);
|
||||
}
|
||||
|
||||
function getTrustedOrEstimatedFeeSat(amountSat, trustedFeeSat) {
|
||||
if (trustedFeeSat === undefined || trustedFeeSat === null) {
|
||||
return getEstimatedSwapFeeSat(amountSat);
|
||||
}
|
||||
|
||||
return normalizeSats(trustedFeeSat);
|
||||
}
|
||||
|
||||
function getTotalRequiredSat(amountSat, trustedFeeSat) {
|
||||
return (
|
||||
amountSat +
|
||||
getTrustedOrEstimatedFeeSat(amountSat, trustedFeeSat) +
|
||||
LIQUID_SWAP_ROUNDING_BUFFER_SATS
|
||||
);
|
||||
}
|
||||
|
||||
function invoiceFitsSpendable({ amountSat, feeSat, spendableSat }) {
|
||||
return amountSat > 0 && getTotalRequiredSat(amountSat, feeSat) < spendableSat;
|
||||
}
|
||||
|
||||
function getInvoiceAmountForFee(spendableSat, feeSat) {
|
||||
let low = 1;
|
||||
let high = Math.max(Math.floor(Number(spendableSat) || 0) - 1, 0);
|
||||
let bestAmountSat = 0;
|
||||
|
||||
while (low <= high) {
|
||||
const amountSat = Math.floor((low + high) / 2);
|
||||
|
||||
if (
|
||||
invoiceFitsSpendable({
|
||||
amountSat,
|
||||
feeSat,
|
||||
spendableSat,
|
||||
})
|
||||
) {
|
||||
bestAmountSat = amountSat;
|
||||
low = amountSat + 1;
|
||||
} else {
|
||||
high = amountSat - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsedData) throw new Error('errormessages.invoiceRetrivalError');
|
||||
return bestAmountSat;
|
||||
}
|
||||
|
||||
const paymentResponse = await breezLiquidLNAddressPaymentWrapper({
|
||||
description: i18next.t('swapMessages.liquid'),
|
||||
paymentInfo: parsedData.data,
|
||||
shouldDrain: true,
|
||||
});
|
||||
function getNextAmountAfterQuoteFailure({ amountSat, spendableSat }) {
|
||||
const feeAwareTargetSat = getInvoiceAmountForFee(spendableSat);
|
||||
if (feeAwareTargetSat > 0 && feeAwareTargetSat < amountSat) {
|
||||
return feeAwareTargetSat;
|
||||
}
|
||||
|
||||
if (!paymentResponse.didWork) throw new Error(paymentResponse.error);
|
||||
const percentStepSat = Math.ceil(amountSat * LIQUID_SWAP_RETRY_PERCENT_STEP);
|
||||
const feeStepSat =
|
||||
getEstimatedSwapFeeSat(amountSat) * LIQUID_SWAP_RETRY_FEE_MULTIPLIER;
|
||||
const reductionSat = Math.max(1, Math.min(percentStepSat, feeStepSat));
|
||||
|
||||
return {didWork: true};
|
||||
return Math.floor(amountSat - reductionSat);
|
||||
}
|
||||
|
||||
function getSwapError(error, fallback) {
|
||||
return error?.message || error || fallback;
|
||||
}
|
||||
|
||||
async function cleanupUnusedSwapInvoice(invoiceId) {
|
||||
if (!invoiceId) return;
|
||||
try {
|
||||
await deleteUnpaidSparkLightningTransaction(invoiceId);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
|
||||
return {didWork: false, error: err.message};
|
||||
console.log('liquidToSparkSwap invoice cleanup error', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function createSparkSwapInvoice({
|
||||
amountSat,
|
||||
mnemonic,
|
||||
sendWebViewRequest,
|
||||
}) {
|
||||
const invoiceResponse = await sparkReceivePaymentWrapper({
|
||||
paymentType: 'lightning',
|
||||
amountSats: amountSat,
|
||||
memo: i18next.t('swapMessages.liquid'),
|
||||
mnemoinc: mnemonic,
|
||||
sendWebViewRequest,
|
||||
shouldNavigate: false,
|
||||
includeSparkAddress: false,
|
||||
expirySeconds: LIQUID_SWAP_INVOICE_EXPIRY_SECONDS,
|
||||
extraDetails: {
|
||||
isLiquidSwapCandidate: true,
|
||||
swapAmountSat: amountSat,
|
||||
swapExpiresAt: Date.now() + LIQUID_SWAP_INVOICE_EXPIRY_SECONDS * 1000,
|
||||
},
|
||||
});
|
||||
if (!invoiceResponse.didWork) throw new Error(invoiceResponse.error);
|
||||
return {
|
||||
placeholderId: invoiceResponse.data.id,
|
||||
bolt11: invoiceResponse.invoice,
|
||||
amountSat,
|
||||
};
|
||||
}
|
||||
|
||||
async function quoteLiquidLightningFee(bolt11) {
|
||||
const feeResponse = await breezLiquidPaymentWrapper({
|
||||
paymentType: 'lightning',
|
||||
invoice: bolt11,
|
||||
getFee: true,
|
||||
});
|
||||
if (!feeResponse.didWork) {
|
||||
throw new Error(
|
||||
getSwapError(feeResponse.error, 'Unable to estimate Liquid swap fee'),
|
||||
);
|
||||
}
|
||||
return Math.ceil(Number(feeResponse.fee) || 0);
|
||||
}
|
||||
|
||||
async function getReusableSwapInvoice(spendableSat) {
|
||||
const existingInvoice = await getActiveLiquidSwapInvoice();
|
||||
const bolt11 = existingInvoice?.details?.swapInvoice;
|
||||
const amountSat = Math.floor(
|
||||
Number(existingInvoice?.details?.swapAmountSat || existingInvoice?.amount),
|
||||
);
|
||||
|
||||
if (!existingInvoice) return null;
|
||||
if (!bolt11 || !amountSat) {
|
||||
await cleanupUnusedSwapInvoice(existingInvoice.sparkID);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const feeSat = await quoteLiquidLightningFee(bolt11);
|
||||
if (
|
||||
invoiceFitsSpendable({
|
||||
amountSat,
|
||||
feeSat,
|
||||
spendableSat,
|
||||
})
|
||||
) {
|
||||
return {
|
||||
placeholderId: existingInvoice.sparkID,
|
||||
bolt11,
|
||||
amountSat,
|
||||
feeSat,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('liquidToSparkSwap reusable invoice fee quote error', err);
|
||||
}
|
||||
|
||||
await cleanupUnusedSwapInvoice(existingInvoice.sparkID);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function createQuotedSwapInvoice({
|
||||
spendableSat,
|
||||
mnemonic,
|
||||
sendWebViewRequest,
|
||||
}) {
|
||||
const triedAmounts = new Set();
|
||||
let nextAmountSat = getInvoiceAmountForFee(spendableSat);
|
||||
let upperBoundSat = LIQUID_SWAP_UNKNOWN_UPPER_BOUND_SATS;
|
||||
let lastError;
|
||||
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < LIQUID_SWAP_MAX_QUOTE_ATTEMPTS;
|
||||
attempt += 1
|
||||
) {
|
||||
if (nextAmountSat <= 0) break;
|
||||
if (triedAmounts.has(nextAmountSat)) nextAmountSat -= 1;
|
||||
triedAmounts.add(nextAmountSat);
|
||||
|
||||
const invoice = await createSparkSwapInvoice({
|
||||
amountSat: nextAmountSat,
|
||||
mnemonic,
|
||||
sendWebViewRequest,
|
||||
});
|
||||
|
||||
let feeSat;
|
||||
try {
|
||||
feeSat = await quoteLiquidLightningFee(invoice.bolt11);
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
await cleanupUnusedSwapInvoice(invoice.placeholderId);
|
||||
upperBoundSat = Math.min(upperBoundSat, nextAmountSat - 1);
|
||||
nextAmountSat = getNextAmountAfterQuoteFailure({
|
||||
amountSat: nextAmountSat,
|
||||
spendableSat,
|
||||
});
|
||||
nextAmountSat = Math.min(nextAmountSat, upperBoundSat);
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetAmountSat = Math.min(
|
||||
getInvoiceAmountForFee(spendableSat, feeSat),
|
||||
upperBoundSat,
|
||||
);
|
||||
if (targetAmountSat <= 0) {
|
||||
await cleanupUnusedSwapInvoice(invoice.placeholderId);
|
||||
throw new Error('Insufficient Liquid balance to cover swap fees');
|
||||
}
|
||||
|
||||
if (
|
||||
nextAmountSat !== targetAmountSat &&
|
||||
!triedAmounts.has(targetAmountSat)
|
||||
) {
|
||||
await cleanupUnusedSwapInvoice(invoice.placeholderId);
|
||||
nextAmountSat = targetAmountSat;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
invoiceFitsSpendable({
|
||||
amountSat: nextAmountSat,
|
||||
feeSat,
|
||||
spendableSat,
|
||||
})
|
||||
) {
|
||||
return { ...invoice, feeSat };
|
||||
}
|
||||
|
||||
await cleanupUnusedSwapInvoice(invoice.placeholderId);
|
||||
nextAmountSat = Math.min(targetAmountSat, nextAmountSat - 1);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
getSwapError(lastError, 'Unable to create a payable Liquid swap invoice'),
|
||||
);
|
||||
}
|
||||
|
||||
async function persistSwapInvoiceDetails({
|
||||
placeholderId,
|
||||
bolt11,
|
||||
amountSat,
|
||||
feeSat,
|
||||
}) {
|
||||
await updateSparkTransactionDetails(placeholderId, {
|
||||
isLiquidSwap: true,
|
||||
isLiquidSwapCandidate: false,
|
||||
swapInvoice: bolt11,
|
||||
swapAmountSat: amountSat,
|
||||
swapFeeSat: feeSat,
|
||||
fee: feeSat,
|
||||
swapExpiresAt: Date.now() + LIQUID_SWAP_INVOICE_EXPIRY_SECONDS * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// Sweeps spendable Liquid funds into Spark by paying a locally
|
||||
// generated fixed-amount Spark lightning invoice. Because we mint the invoice
|
||||
// ourselves we know its id up front, so we insert a pending placeholder
|
||||
// transaction immediately (visible in history) and the existing Spark
|
||||
// reconciliation updates that same row when the payment settles.
|
||||
export default async function liquidToSparkSwap({
|
||||
mnemonic,
|
||||
sparkInformation,
|
||||
spendableSat,
|
||||
sendWebViewRequest,
|
||||
}) {
|
||||
if (isRunningLiquidSwap) {
|
||||
return { didWork: false, error: 'Liquid swap already in progress' };
|
||||
}
|
||||
isRunningLiquidSwap = true;
|
||||
|
||||
const accountId = sparkInformation?.identityPubKey;
|
||||
let placeholderId;
|
||||
let swapInvoice;
|
||||
try {
|
||||
if (!accountId) throw new Error('Spark wallet not connected');
|
||||
const normalizedSpendableSat = Math.floor(Number(spendableSat) || 0);
|
||||
if (normalizedSpendableSat <= 1) {
|
||||
throw new Error('Insufficient Liquid balance to cover swap fees');
|
||||
}
|
||||
|
||||
swapInvoice =
|
||||
(await getReusableSwapInvoice(normalizedSpendableSat)) ||
|
||||
(await createQuotedSwapInvoice({
|
||||
spendableSat: normalizedSpendableSat,
|
||||
mnemonic,
|
||||
sendWebViewRequest,
|
||||
}));
|
||||
|
||||
placeholderId = swapInvoice.placeholderId;
|
||||
await persistSwapInvoiceDetails(swapInvoice);
|
||||
|
||||
const pendingSwapTx = {
|
||||
id: placeholderId,
|
||||
accountId,
|
||||
paymentStatus: 'pending',
|
||||
paymentType: 'lightning',
|
||||
details: {
|
||||
direction: 'INCOMING',
|
||||
isLiquidSwap: true,
|
||||
amount: swapInvoice.amountSat,
|
||||
fee: swapInvoice.feeSat,
|
||||
time: Date.now(),
|
||||
createdTime: Date.now(),
|
||||
description: i18next.t('swapMessages.liquid'),
|
||||
},
|
||||
};
|
||||
|
||||
// Show the pending swap in history right away once the swap starts.
|
||||
await insertSparkTransactionPlaceholders([pendingSwapTx]);
|
||||
|
||||
const paymentResponse = await breezLiquidPaymentWrapper({
|
||||
paymentType: 'lightning',
|
||||
invoice: swapInvoice.bolt11,
|
||||
});
|
||||
|
||||
if (!paymentResponse.didWork) {
|
||||
throw new Error(
|
||||
getSwapError(paymentResponse.error, 'Liquid swap payment failed'),
|
||||
);
|
||||
}
|
||||
|
||||
return { didWork: true };
|
||||
} catch (err) {
|
||||
console.log('liquidToSparkSwap error', err);
|
||||
// Don't leave the placeholder pending forever. If the payment actually went
|
||||
// through despite a failed response, a later settled payment will remap
|
||||
// onto this id and flip it back to completed.
|
||||
if (placeholderId && accountId) {
|
||||
const savedTransaction = await getSparkTransactionBySparkId(
|
||||
placeholderId,
|
||||
accountId,
|
||||
);
|
||||
|
||||
if (savedTransaction.paymentStatus !== 'completed') {
|
||||
try {
|
||||
await bulkUpdateSparkTransactions([
|
||||
{
|
||||
id: placeholderId,
|
||||
accountId,
|
||||
paymentStatus: 'failed',
|
||||
paymentType: 'lightning',
|
||||
details: { isLiquidSwap: true },
|
||||
},
|
||||
]);
|
||||
} catch (cleanupErr) {
|
||||
console.log(
|
||||
'liquidToSparkSwap placeholder cleanup error',
|
||||
cleanupErr,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { didWork: false, error: err.message };
|
||||
} finally {
|
||||
isRunningLiquidSwap = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,6 +659,7 @@ export const sparkReceivePaymentWrapper = async ({
|
||||
paymentHash,
|
||||
holdExpirySeconds,
|
||||
encryptedPreimage,
|
||||
extraDetails = {},
|
||||
}) => {
|
||||
try {
|
||||
// if (!sparkWallet[sha256Hash(mnemoinc)])
|
||||
@@ -723,6 +724,7 @@ export const sparkReceivePaymentWrapper = async ({
|
||||
shouldNavigate: true,
|
||||
isBlitzContactPayment: false,
|
||||
performSwaptoUSD,
|
||||
...extraDetails,
|
||||
},
|
||||
};
|
||||
await addSingleUnpaidSparkLightningTransaction(tempTransaction);
|
||||
|
||||
@@ -80,6 +80,25 @@ const resolvePaymentStatusForUpdate = (
|
||||
return incomingPaymentStatus;
|
||||
};
|
||||
|
||||
const isMeaningfulDetailValue = (key, value, shouldUpdateDescription) =>
|
||||
(value !== '' && value !== null && value !== undefined && value !== 0) ||
|
||||
(key === 'description' && shouldUpdateDescription);
|
||||
|
||||
const shouldUseIncomingDetailValue = (
|
||||
key,
|
||||
incomingValue,
|
||||
existingValue,
|
||||
shouldUpdateDescription,
|
||||
) => {
|
||||
if (key === 'fee') {
|
||||
const incomingFee = Number(incomingValue);
|
||||
const existingFee = Number(existingValue) || 0;
|
||||
return Number.isFinite(incomingFee) && incomingFee > existingFee;
|
||||
}
|
||||
|
||||
return isMeaningfulDetailValue(key, incomingValue, shouldUpdateDescription);
|
||||
};
|
||||
|
||||
export const insertSparkTransactionPlaceholders = async (
|
||||
transactions,
|
||||
updateType = 'transactions',
|
||||
@@ -773,6 +792,40 @@ export const getAllUnpaidSparkLightningInvoices = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Returns a still-valid Liquid->Spark swap lightning request (created by the
|
||||
// auto-swap flow) so we can reuse it instead of minting a new invoice on every
|
||||
// balance update or after an app restart. Validity is tracked by the explicit
|
||||
// `swapExpiresAt` (ms) we store at creation time to avoid SDK timestamp-unit
|
||||
// ambiguity.
|
||||
export const getActiveLiquidSwapInvoice = async () => {
|
||||
try {
|
||||
await ensureSparkDatabaseReady();
|
||||
const rows = await sqlLiteDB.getAllAsync(
|
||||
`SELECT * FROM ${LIGHTNING_REQUEST_IDS_TABLE_NAME}
|
||||
WHERE json_extract(details, '$.isLiquidSwap') = 1`,
|
||||
);
|
||||
const now = Date.now();
|
||||
const active = rows.find(row => {
|
||||
try {
|
||||
const details = row.details ? JSON.parse(row.details) : {};
|
||||
return Number(details.swapExpiresAt) > now;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (!active) return null;
|
||||
try {
|
||||
active.details = active.details ? JSON.parse(active.details) : {};
|
||||
} catch {
|
||||
active.details = {};
|
||||
}
|
||||
return active;
|
||||
} catch (error) {
|
||||
console.error('Error fetching active liquid swap invoice:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getAllUnpaidHoldInvoicesFromTxs = async () => {
|
||||
try {
|
||||
await ensureSparkDatabaseReady();
|
||||
@@ -1037,10 +1090,12 @@ export const bulkUpdateSparkTransactions = async (transactions, ...data) => {
|
||||
for (const key in tx.details) {
|
||||
const value = tx.details[key];
|
||||
if (
|
||||
value !== '' &&
|
||||
value !== null &&
|
||||
value !== undefined &&
|
||||
value !== 0
|
||||
shouldUseIncomingDetailValue(
|
||||
key,
|
||||
value,
|
||||
mergedDetails[key],
|
||||
shouldUpdateDescription,
|
||||
)
|
||||
) {
|
||||
mergedDetails[key] = value;
|
||||
}
|
||||
@@ -1140,11 +1195,12 @@ export const bulkUpdateSparkTransactions = async (transactions, ...data) => {
|
||||
for (const key in newDetails) {
|
||||
const value = newDetails[key];
|
||||
if (
|
||||
(value !== '' &&
|
||||
value !== null &&
|
||||
value !== undefined &&
|
||||
value !== 0) ||
|
||||
(key === 'description' && shouldUpdateDescription)
|
||||
shouldUseIncomingDetailValue(
|
||||
key,
|
||||
value,
|
||||
merged[key],
|
||||
shouldUpdateDescription,
|
||||
)
|
||||
) {
|
||||
merged[key] = value;
|
||||
}
|
||||
|
||||
@@ -70,6 +70,13 @@ export async function transformTxToPaymentObject(
|
||||
: undefined;
|
||||
|
||||
const isSwapPayment = foundInvoice && foundInvoiceDetails.performSwaptoUSD;
|
||||
const isLiquidSwap = foundInvoice && foundInvoiceDetails.isLiquidSwap;
|
||||
const liquidSwapFee = Number(
|
||||
foundInvoiceDetails?.fee ?? foundInvoiceDetails?.swapFeeSat ?? 0,
|
||||
);
|
||||
const effectivePaymentFee = isLiquidSwap
|
||||
? Math.max(paymentFee, Number.isFinite(liquidSwapFee) ? liquidSwapFee : 0)
|
||||
: paymentFee;
|
||||
|
||||
if (isSwapPayment) {
|
||||
updateSparkTransactionDetails(foundInvoice.sparkID, {
|
||||
@@ -92,6 +99,10 @@ export async function transformTxToPaymentObject(
|
||||
|
||||
return {
|
||||
id: tx.transfer ? tx.transfer.sparkId : tx.id,
|
||||
// The Liquid->Spark auto-swap pre-inserts a pending placeholder keyed by
|
||||
// the lightning request id. Remap it onto the settled payment so the
|
||||
// placeholder is updated rather than duplicated.
|
||||
...(isLiquidSwap && { useTempId: true, tempId: userRequestId }),
|
||||
paymentStatus: isSwapPayment
|
||||
? 'pending'
|
||||
: status === 'completed' || preimage
|
||||
@@ -101,10 +112,12 @@ export async function transformTxToPaymentObject(
|
||||
accountId: accountId,
|
||||
details: {
|
||||
...foundInvoiceDetails,
|
||||
fee: paymentFee,
|
||||
totalFee: paymentFee + supportFee,
|
||||
fee: effectivePaymentFee,
|
||||
totalFee: effectivePaymentFee + supportFee,
|
||||
supportFee: supportFee,
|
||||
amount: paymentAmount - paymentFee,
|
||||
amount: isLiquidSwap
|
||||
? paymentAmount
|
||||
: paymentAmount - effectivePaymentFee,
|
||||
address: userRequest
|
||||
? isSendRequest
|
||||
? userRequest?.encodedInvoice
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useAppStatus } from './appStatus';
|
||||
import { crashlyticsLogReport } from '../app/functions/crashlyticsLogs';
|
||||
import { useRootstockProvider } from './rootstockSwapContext';
|
||||
import i18next from 'i18next';
|
||||
import { useNodeContext } from './nodeContext';
|
||||
|
||||
export function RootstockNavigationListener() {
|
||||
const navigation = useNavigation();
|
||||
@@ -37,35 +36,3 @@ export function RootstockNavigationListener() {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function LiquidNavigationListener() {
|
||||
const navigation = useNavigation();
|
||||
const { didGetToHomepage } = useAppStatus();
|
||||
const { pendingLiquidPayment, setPendingLiquidPayment } = useNodeContext();
|
||||
const isNavigating = useRef(false); // Use a ref for local state
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingLiquidPayment) return;
|
||||
if (!didGetToHomepage) {
|
||||
setPendingLiquidPayment(null);
|
||||
return;
|
||||
}
|
||||
if (isNavigating.current) return;
|
||||
crashlyticsLogReport(`Navigating to confirm tx page in liquid listener `);
|
||||
isNavigating.current = true;
|
||||
|
||||
setTimeout(() => {
|
||||
requestAnimationFrame(() => {
|
||||
navigation.navigate('ErrorScreen', {
|
||||
errorMessage: i18next.t('errormessages.receivedLiquid'),
|
||||
});
|
||||
isNavigating.current = false;
|
||||
console.log('cleaning up navigation for liquid');
|
||||
});
|
||||
}, 100);
|
||||
|
||||
setPendingLiquidPayment(null);
|
||||
}, [pendingLiquidPayment, didGetToHomepage]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import loadNewFiatData from '../app/functions/saveAndUpdateFiatData';
|
||||
import { useKeysContext } from './keys';
|
||||
import { useAppStatus } from './appStatus';
|
||||
import { useSparkWallet } from './sparkContext';
|
||||
import { useGlobalContactsInfo } from './globalContacts';
|
||||
import { useWebView } from './webViewContext';
|
||||
import liquidToSparkSwap from '../app/functions/spark/liquidToSparkSwap';
|
||||
import { useAuthContext } from './authContext';
|
||||
import { ensureLiquidConnection } from '../app/functions/breezLiquid/liquidNodeManager';
|
||||
@@ -22,12 +22,11 @@ import { SATSPERBITCOIN } from '../app/constants';
|
||||
const NodeContextManager = createContext(null);
|
||||
|
||||
const GLobalNodeContextProider = ({ children }) => {
|
||||
const { globalContactsInformation } = useGlobalContactsInfo();
|
||||
const { sparkInformation } = useSparkWallet();
|
||||
const { sendWebViewRequest } = useWebView();
|
||||
const { contactsPrivateKey, publicKey, accountMnemoinc } = useKeysContext();
|
||||
const { didGetToHomepage, minMaxLiquidSwapAmounts } = useAppStatus();
|
||||
const { masterInfoObject } = useGlobalContextProvider();
|
||||
const [pendingLiquidPayment, setPendingLiquidPayment] = useState(null);
|
||||
const [liquidNodeInformation, setLiquidNodeInformation] = useState({
|
||||
didConnectToNode: null,
|
||||
transactions: [],
|
||||
@@ -108,11 +107,18 @@ const GLobalNodeContextProider = ({ children }) => {
|
||||
useEffect(() => {
|
||||
async function swapLiquidToSpark() {
|
||||
try {
|
||||
if (liquidNodeInformation.userBalance > minMaxLiquidSwapAmounts.min) {
|
||||
setPendingLiquidPayment(true);
|
||||
await liquidToSparkSwap(
|
||||
globalContactsInformation.myProfile.uniqueName,
|
||||
);
|
||||
// Only sweep funds that aren't already locked in an in-flight send,
|
||||
// so overlapping balance updates can't trigger a double swap.
|
||||
const spendableSat =
|
||||
liquidNodeInformation.userBalance -
|
||||
(liquidNodeInformation.pendingSend || 0);
|
||||
if (spendableSat > minMaxLiquidSwapAmounts.min) {
|
||||
await liquidToSparkSwap({
|
||||
mnemonic: accountMnemoinc,
|
||||
sparkInformation,
|
||||
spendableSat,
|
||||
sendWebViewRequest,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('transfering liquid to spark error', err);
|
||||
@@ -126,10 +132,12 @@ const GLobalNodeContextProider = ({ children }) => {
|
||||
}, [
|
||||
didGetToHomepage,
|
||||
liquidNodeInformation.userBalance,
|
||||
minMaxLiquidSwapAmounts,
|
||||
liquidNodeInformation.pendingSend,
|
||||
minMaxLiquidSwapAmounts.min,
|
||||
sparkInformation.didConnect,
|
||||
sparkInformation.identityPubKey,
|
||||
globalContactsInformation?.myProfile?.uniqueName,
|
||||
accountMnemoinc,
|
||||
sendWebViewRequest,
|
||||
masterInfoObject.enabledLiquidAutoSwap,
|
||||
]);
|
||||
|
||||
@@ -147,8 +155,6 @@ const GLobalNodeContextProider = ({ children }) => {
|
||||
toggleLiquidNodeInformation,
|
||||
toggleFiatStats,
|
||||
fiatStats,
|
||||
pendingLiquidPayment,
|
||||
setPendingLiquidPayment,
|
||||
SATS_PER_DOLLAR,
|
||||
}),
|
||||
[
|
||||
@@ -156,8 +162,6 @@ const GLobalNodeContextProider = ({ children }) => {
|
||||
fiatStats,
|
||||
toggleFiatStats,
|
||||
toggleLiquidNodeInformation,
|
||||
pendingLiquidPayment,
|
||||
setPendingLiquidPayment,
|
||||
SATS_PER_DOLLAR,
|
||||
],
|
||||
);
|
||||
|
||||
+8
-8
@@ -1195,12 +1195,12 @@ PODS:
|
||||
- BoringSSL-GRPC/Implementation (0.0.37):
|
||||
- BoringSSL-GRPC/Interface (= 0.0.37)
|
||||
- BoringSSL-GRPC/Interface (0.0.37)
|
||||
- breez_sdk_liquid (0.11.9):
|
||||
- BreezSDKLiquid (= 0.11.9)
|
||||
- breez_sdk_liquid (0.11.13):
|
||||
- BreezSDKLiquid (= 0.11.13)
|
||||
- React-Core
|
||||
- breez_sdk_liquidFFI (0.11.9)
|
||||
- BreezSDKLiquid (0.11.9):
|
||||
- breez_sdk_liquidFFI (= 0.11.9)
|
||||
- breez_sdk_liquidFFI (0.11.13)
|
||||
- BreezSDKLiquid (0.11.13):
|
||||
- breez_sdk_liquidFFI (= 0.11.13)
|
||||
- DoubleConversion (1.1.6)
|
||||
- EXApplication (7.0.7):
|
||||
- ExpoModulesCore
|
||||
@@ -5553,9 +5553,9 @@ SPEC CHECKSUMS:
|
||||
abseil: a05cc83bf02079535e17169a73c5be5ba47f714b
|
||||
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
|
||||
BoringSSL-GRPC: dded2a44897e45f28f08ae87a55ee4bcd19bc508
|
||||
breez_sdk_liquid: 9c382c355ae97f1b801d04ca09e56cece7337183
|
||||
breez_sdk_liquidFFI: d7ffd1b509eb220a6a479ff188572382d0aa5c2a
|
||||
BreezSDKLiquid: 609bb38df7f8e92825a022c86d37bedbe7e8d795
|
||||
breez_sdk_liquid: e05cd39b19b9e029ecf50195e4e7df0aefb745cd
|
||||
breez_sdk_liquidFFI: f05fadc0611126ade76d1fe6761ed8b020aabefb
|
||||
BreezSDKLiquid: ee6bf5a57f1b2533dc3c14c24c9773496f17b756
|
||||
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
|
||||
EXApplication: 296622817d459f46b6c5fe8691f4aac44d2b79e7
|
||||
EXConstants: a95804601ee4a6aa7800645f9b070d753b1142b3
|
||||
|
||||
@@ -2665,7 +2665,7 @@
|
||||
"contact": "Hi, add me on Blitz!"
|
||||
},
|
||||
"swapMessages": {
|
||||
"liquid": "Liquid to Spark Transfer"
|
||||
"liquid": "Liquid payment"
|
||||
},
|
||||
"tabs": {
|
||||
"home": "Wallet",
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@
|
||||
"dependencies": {
|
||||
"@azure/core-asynciterator-polyfill": "^1.0.2",
|
||||
"@branta-ops/branta": "^3.1.2",
|
||||
"@breeztech/react-native-breez-sdk-liquid": "^0.11.9",
|
||||
"@breeztech/react-native-breez-sdk-liquid": "^0.11.13",
|
||||
"@buildonspark/spark-sdk": "^0.8.0",
|
||||
"@craftzdog/react-native-buffer": "^6.1.0",
|
||||
"@flashnet/sdk": "^0.5.7",
|
||||
|
||||
@@ -1783,13 +1783,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@breeztech/react-native-breez-sdk-liquid@npm:^0.11.9":
|
||||
version: 0.11.9
|
||||
resolution: "@breeztech/react-native-breez-sdk-liquid@npm:0.11.9"
|
||||
"@breeztech/react-native-breez-sdk-liquid@npm:^0.11.13":
|
||||
version: 0.11.13
|
||||
resolution: "@breeztech/react-native-breez-sdk-liquid@npm:0.11.13"
|
||||
peerDependencies:
|
||||
react: "*"
|
||||
react-native: "*"
|
||||
checksum: cd359313669852aa36f5046a7217f64734b88ee11c581273ac1a6314c8081eb6d60d2f31785321fbe89bf3a1d8add508576d7e216287b5d5430e7ad199a1a979
|
||||
checksum: 48ba269676182de36b3689f5940bfcc0a2849bac3ed641c68c6e2cf6e89df9c27b12606df3ed63b5ec2ab21ff5b7a9696384ab4d28111810dba0efb63f2c824c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -4924,7 +4924,7 @@ __metadata:
|
||||
"@babel/preset-env": ^7.28.3
|
||||
"@babel/runtime": ^7.25.0
|
||||
"@branta-ops/branta": ^3.1.2
|
||||
"@breeztech/react-native-breez-sdk-liquid": ^0.11.9
|
||||
"@breeztech/react-native-breez-sdk-liquid": ^0.11.13
|
||||
"@buildonspark/spark-sdk": ^0.8.0
|
||||
"@craftzdog/react-native-buffer": ^6.1.0
|
||||
"@flashnet/sdk": ^0.5.7
|
||||
|
||||
Reference in New Issue
Block a user