diff --git a/e2e/lightning.e2e.js b/e2e/lightning.e2e.js index 6d18bbbd0..8e6fdf854 100644 --- a/e2e/lightning.e2e.js +++ b/e2e/lightning.e2e.js @@ -92,9 +92,11 @@ d('Lightning', () => { ).getAttributes(); await element(by.id('NavigationBack')).atIndex(0).tap(); await sleep(100); + await element(by.id('NavigationBack')).atIndex(0).tap(); + await element(by.id('DevSettings')).tap(); + await element(by.id('LDKDebug')).tap(); // connect to LND - await element(by.id('Channels')).tap(); await element(by.id('AddPeerInput')).replaceText( `${lndNodeID}@127.0.0.1:9735`, ); @@ -145,6 +147,8 @@ d('Lightning', () => { await sleep(500); await element(by.id('NavigationBack')).atIndex(0).tap(); await sleep(100); + await element(by.id('NavigationBack')).atIndex(0).tap(); + await element(by.id('AdvancedSettings')).atIndex(0).tap(); await element(by.id('Channels')).tap(); await element(by.id('Channel')).atIndex(0).tap(); await expect( diff --git a/e2e/settings.e2e.js b/e2e/settings.e2e.js index f1b42e6f8..0a0536e56 100644 --- a/e2e/settings.e2e.js +++ b/e2e/settings.e2e.js @@ -407,8 +407,8 @@ d('Settings', () => { if (!__DEV__) { await element(by.id('DevOptions')).multiTap(5); // enable dev mode } - await element(by.id('AdvancedSettings')).tap(); - await element(by.id('Channels')).tap(); + await element(by.id('DevSettings')).tap(); + await element(by.id('LDKDebug')).tap(); await element(by.id('CopyNodeId')).tap(); await element(by.id('RefreshLDK')).tap(); await element(by.id('RestartLDK')).tap(); @@ -417,7 +417,8 @@ d('Settings', () => { .toBeVisible() .withTimeout(5000); await element(by.id('NavigationBack')).atIndex(0).tap(); - + await element(by.id('NavigationBack')).atIndex(0).tap(); + await element(by.id('AdvancedSettings')).tap(); await element(by.id('LightningNodeInfo')).tap(); // TODO: this fails too often on CI // await waitFor(element(by.id('LDKNodeID'))) diff --git a/src/navigation/settings/SettingsNavigator.tsx b/src/navigation/settings/SettingsNavigator.tsx index b42e01b74..0656f0810 100644 --- a/src/navigation/settings/SettingsNavigator.tsx +++ b/src/navigation/settings/SettingsNavigator.tsx @@ -15,6 +15,7 @@ import CoinSelectPreference from '../../screens/Settings/CoinSelectPreference'; import PaymentPreference from '../../screens/Settings/PaymentPreference'; import AddressTypePreference from '../../screens/Settings/AddressTypePreference'; import DevSettings from '../../screens/Settings/DevSettings'; +import LdkDebug from '../../screens/Settings/DevSettings/LdkDebug'; import AddressViewer from '../../screens/Settings/AddressViewer'; import LightningNodeInfo from '../../screens/Settings/Lightning/LightningNodeInfo'; import UnitSettings from '../../screens/Settings/Unit'; @@ -91,6 +92,7 @@ export type SettingsStackParamList = { PaymentPreference: undefined; AddressTypePreference: undefined; DevSettings: undefined; + LdkDebug: undefined; ExportToPhone: undefined; ResetAndRestore: undefined; BitcoinNetworkSelection: undefined; @@ -160,6 +162,7 @@ const SettingsNavigator = (): ReactElement => { component={AddressTypePreference} /> + diff --git a/src/screens/Settings/DevSettings/LdkDebug.tsx b/src/screens/Settings/DevSettings/LdkDebug.tsx new file mode 100644 index 000000000..dcbf2652e --- /dev/null +++ b/src/screens/Settings/DevSettings/LdkDebug.tsx @@ -0,0 +1,433 @@ +import React, { ReactElement, memo, useState } from 'react'; +import { StyleSheet, ScrollView } from 'react-native'; +import Share from 'react-native-share'; +import { useTranslation } from 'react-i18next'; +import Clipboard from '@react-native-clipboard/clipboard'; +import lm from '@synonymdev/react-native-ldk'; + +import { Caption13Up } from '../../../styles/text'; +import { View as ThemedView, TextInput } from '../../../styles/components'; +import SafeAreaInset from '../../../components/SafeAreaInset'; +import Button from '../../../components/buttons/Button'; +import NavigationHeader from '../../../components/NavigationHeader'; +import { useLightningBalance } from '../../../hooks/lightning'; +import { useAppDispatch, useAppSelector } from '../../../hooks/redux'; +import { zipLogs } from '../../../utils/lightning/logs'; +import { showToast } from '../../../utils/notifications'; +import { + addPeer, + getNodeId, + payLightningInvoice, + rebroadcastAllKnownTransactions, + recoverOutputs, + recoverOutputsFromForceClose, + refreshLdk, + setupLdk, + removeUnusedPeers, +} from '../../../utils/lightning'; +import { openChannelsSelector } from '../../../store/reselect/lightning'; +import { showBottomSheet } from '../../../store/utils/ui'; +import { removeLightningPeer } from '../../../store/slices/lightning'; +import { + createLightningInvoice, + savePeer, +} from '../../../store/utils/lightning'; +import { + selectedNetworkSelector, + selectedWalletSelector, +} from '../../../store/reselect/wallet'; + +const LdkDebug = (): ReactElement => { + const { t } = useTranslation('lightning'); + const dispatch = useAppDispatch(); + const [peer, setPeer] = useState(''); + const [payingInvoice, setPayingInvoice] = useState(false); + const [refreshingLdk, setRefreshingLdk] = useState(false); + const [restartingLdk, setRestartingLdk] = useState(false); + const [rebroadcastingLdk, setRebroadcastingLdk] = useState(false); + const [spendingStuckOutputs, setSpendingStuckOutputs] = useState(false); + + const { localBalance, remoteBalance } = useLightningBalance(); + const selectedWallet = useAppSelector(selectedWalletSelector); + const selectedNetwork = useAppSelector(selectedNetworkSelector); + const openChannels = useAppSelector(openChannelsSelector); + + const onNodeId = async (): Promise => { + const nodeId = await getNodeId(); + if (nodeId.isErr()) { + console.log(nodeId.error.message); + return; + } + console.log(`Node ID: ${nodeId.value}`); + Clipboard.setString(nodeId.value); + showToast({ + type: 'success', + title: 'Copied Node ID to Clipboard', + description: nodeId.value, + }); + }; + + const onRefreshLdk = async (): Promise => { + setRefreshingLdk(true); + await refreshLdk({ selectedWallet, selectedNetwork }); + setRefreshingLdk(false); + }; + + const onRestartLdk = async (): Promise => { + setRestartingLdk(true); + await setupLdk({ selectedWallet, selectedNetwork }); + setRestartingLdk(false); + }; + + const onAddPeer = async (): Promise => { + if (!peer) { + // Attempt to grab and set peer string from clipboard. + const clipboardStr = await Clipboard.getString(); + setPeer(clipboardStr); + return; + } + const addPeerRes = await addPeer({ peer, timeout: 5000 }); + if (addPeerRes.isErr()) { + showToast({ + type: 'warning', + title: t('error_add_title'), + description: addPeerRes.error.message, + }); + return; + } + const savePeerRes = savePeer({ selectedWallet, selectedNetwork, peer }); + if (savePeerRes.isErr()) { + showToast({ + type: 'warning', + title: t('error_save_title'), + description: savePeerRes.error.message, + }); + return; + } + showToast({ + type: 'success', + title: savePeerRes.value, + description: t('peer_saved'), + }); + }; + + const onListPeers = async (): Promise => { + const peers = await lm.getPeers(); + console.log({ peers }); + }; + + const onDisconnectPeers = async (): Promise => { + const peers = await lm.getPeers(); + + const promises = peers.map(({ pubKey, address, port }) => { + const peerStr = `${pubKey}@${address}:${port}`; + // Remove peer from local storage + dispatch( + removeLightningPeer({ + peer: peerStr, + selectedWallet, + selectedNetwork, + }), + ); + // Instruct LDK to disconnect from peer + return lm.removePeer({ pubKey, address, port, timeout: 5000 }); + }); + + const results = await Promise.all(promises); + for (const result of results) { + if (result.isOk()) { + console.log('Disconnected from peer.'); + } else { + console.error(`Failed to disconnect: ${result.error.message}`); + } + } + }; + + const onRemoveUnusedPeers = async (): Promise => { + const res = await removeUnusedPeers({ selectedWallet, selectedNetwork }); + if (res.isErr()) { + showToast({ + type: 'warning', + title: 'No unused peers removed', + description: res.error.message, + }); + } else { + showToast({ + type: 'info', + title: 'Removed unused peers', + description: res.value, + }); + } + }; + + const onExportLogs = async (): Promise => { + const result = await zipLogs(); + if (result.isErr()) { + showToast({ + type: 'warning', + title: t('error_logs'), + description: t('error_logs_description'), + }); + return; + } + + // Share the zip file + await Share.open({ + type: 'application/zip', + url: `file://${result.value}`, + title: t('export_logs'), + }); + }; + + const onCreateInvoice = async (amountSats = 100): Promise => { + const createPaymentRequest = await createLightningInvoice({ + amountSats, + description: '', + expiryDeltaSeconds: 99999, + selectedNetwork, + selectedWallet, + }); + if (createPaymentRequest.isErr()) { + showToast({ + type: 'warning', + title: t('error_invoice'), + description: createPaymentRequest.error.message, + }); + return; + } + const { to_str } = createPaymentRequest.value; + console.log(to_str); + Clipboard.setString(to_str); + showToast({ + type: 'success', + title: t('invoice_copied'), + description: to_str, + }); + }; + + const onRebroadcastLdkTxs = async (): Promise => { + setRebroadcastingLdk(true); + await rebroadcastAllKnownTransactions(); + setRebroadcastingLdk(false); + }; + + const onSpendStuckOutputs = async (): Promise => { + setSpendingStuckOutputs(true); + const res = await recoverOutputs(); + if (res.isOk()) { + showToast({ + type: 'info', + title: 'Stuck outputs recovered', + description: res.value, + }); + } else { + showToast({ + type: 'warning', + title: 'No stuck outputs recovered', + description: res.error.message, + }); + } + setSpendingStuckOutputs(false); + }; + + const onForceCloseChannels = (): void => { + showBottomSheet('forceTransfer'); + }; + + const onSpendOutputsFromForceClose = async (): Promise => { + setSpendingStuckOutputs(true); + const res = await recoverOutputsFromForceClose(); + if (res.isOk()) { + showToast({ + type: 'info', + title: 'Completed', + description: res.value, + }); + } else { + showToast({ + type: 'warning', + title: 'No stuck outputs recovered', + description: res.error.message, + }); + } + setSpendingStuckOutputs(false); + }; + + const onPayInvoiceFromClipboard = async (): Promise => { + setPayingInvoice(true); + const invoice = await Clipboard.getString(); + if (!invoice) { + showToast({ + type: 'warning', + title: 'No Invoice Detected', + description: 'Unable to retrieve anything from the clipboard.', + }); + setPayingInvoice(false); + return; + } + const response = await payLightningInvoice({ invoice }); + if (response.isErr()) { + showToast({ + type: 'warning', + title: 'Invoice Payment Failed', + description: response.error.message, + }); + setPayingInvoice(false); + return; + } + await refreshLdk({ selectedWallet, selectedNetwork }); + setPayingInvoice(false); + }; + + return ( + + + + + Add Peer + +