From 4e0346f9c041c749fccb3634b44aba1e83e35471 Mon Sep 17 00:00:00 2001 From: Khushal Date: Thu, 18 Sep 2025 18:09:47 +0530 Subject: [PATCH] feat(CLNRest): fallback to mempool.space for raw tx broadcast as it lacks publishTransaction --- App.tsx | 6 +- locales/en.json | 22 +++--- stores/Stores.ts | 2 +- stores/SweepStore.ts | 54 ++++++-------- stores/TransactionsStore.ts | 44 ++++++++++- views/{ => Tools}/WIFSweeper.tsx | 123 +++++++++++++++++++++---------- views/TxHex.tsx | 92 +++++++++++++++++------ ~ | 33 --------- 8 files changed, 236 insertions(+), 140 deletions(-) rename views/{ => Tools}/WIFSweeper.tsx (76%) delete mode 100644 ~ diff --git a/App.tsx b/App.tsx index ed5fe4446..65f22e740 100644 --- a/App.tsx +++ b/App.tsx @@ -35,11 +35,11 @@ import { posStore, settingsStore, swapStore, - sweepStore, syncStore, transactionsStore, unitsStore, - utxosStore + utxosStore, + sweepStore } from './stores/Stores'; import NavigationService from './NavigationService'; import PushNotificationManager from './PushNotificationManager'; @@ -259,6 +259,7 @@ import Watchtowers from './views/Tools/Watchtowers/WatchtowerList'; import AddWatchtower from './views/Tools/Watchtowers/AddWatchtower'; import WatchtowerDetails from './views/Tools/Watchtowers/WatchtowerDetails'; import ShareIntentProcessing from './views/ShareIntentProcessing'; +import WIFSweeper from './views/Tools/WIFSweeper'; import { isLightTheme, themeColor } from './utils/ThemeUtils'; import LinkingUtils from './utils/LinkingUtils'; @@ -266,7 +267,6 @@ import CreateWithdrawalRequest from './views/Tools/CreateWithdrawalRequest'; import WithdrawalRequestView from './views/WithdrawalRequest'; import WithdrawalRequestInfo from './views/WithdrawalRequestInfo'; import RedeemWithdrawalRequest from './views/RedeemWithdrawalRequest'; -import WIFSweeper from './views/WIFSweeper'; export default class App extends React.PureComponent { private backPressListenerSubscription: NativeEventSubscription; diff --git a/locales/en.json b/locales/en.json index 94a9a46c5..ddf5ae12e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -843,15 +843,16 @@ "views.Wif.createTransaction": "Create Transaction", "views.Wif.invalidBase58Chars": "Invalid base58 characters", "views.Wif.noUtxos": "The unspent outputs from this private key contain insufficient funds to spend(0 sats).", - "views.Sweep.failedToFetchTxHex": "Failed to fetch transaction hex for {{txid}}", - "views.Sweep.failedToFetchTxDetails": "Failed to fetch transaction details for {{txid}}", - "views.Sweep.outputNotFound": "Output {{vout}} not found in transaction {{txid}}", - "views.Sweep.outputIndexNotFound": "Output index {{vout}} not found in transaction {{txid}}", - "views.Sweep.failedToDerivePubkey": "Failed to derive public key", - "views.Sweep.noUtxosFound": "No UTXOs found for the given private key", - "views.Sweep.addressTypeNotSupported": "This address type is not yet supported for sweeping", - "views.Sweep.insufficientFundsAfterFees": "Insufficient funds after fees", - "views.Sweep.noTaprootSupport": "Taproot (P2TR) sweep is not supported yet", + "views.Wif.failedToFetchTxHex": "Failed to fetch transaction hex for {{txid}}", + "views.Wif.failedToFetchTxDetails": "Failed to fetch transaction details for {{txid}}", + "views.Wif.outputNotFound": "Output {{vout}} not found in transaction {{txid}}", + "views.Wif.outputIndexNotFound": "Output index {{vout}} not found in transaction {{txid}}", + "views.Wif.failedToDerivePubkey": "Failed to derive public key", + "views.Wif.noUtxosFound": "No UTXOs found for the given private key", + "views.Wif.addressTypeNotSupported": "This address type is not yet supported for sweeping", + "views.Wif.insufficientFundsAfterFees": "Insufficient funds after fees", + "views.Wif.failedToCreateTransaction": "Failed to create transaction. Please check your inputs and try again.", + "views.Wif.errorFetchingUtxos": "Error fetching UTXOs", "views.Settings.title": "Settings", "views.Settings.enabled": "Enabled", "views.Settings.disabled": "Disabled", @@ -1539,6 +1540,9 @@ "views.TxHex.channelWarning": "No channel loaded in the channel funding flow engine. DO NOT PUBLISH if this transaction is for a channel open.", "views.TxHex.broadcast": "Broadcast TX", "views.TxHex.finalizeFlowAndBroadcast": "Finalize funding flow + Broadcast", + "views.TxHex.broadcastingViaMempool": "Broadcasting via Mempool.space", + "views.TxHex.thirdPartyBroadcastWarning": "This implementation requires broadcasting via a third-party service (Mempool.space).", + "views.TxHex.transactionBroadcastCancelled": "Transaction broadcast cancelled", "pos.customItem": "Custom item", "time.seconds": "Seconds", "time.minutes": "Minutes", diff --git a/stores/Stores.ts b/stores/Stores.ts index 57333f575..ff023543a 100644 --- a/stores/Stores.ts +++ b/stores/Stores.ts @@ -21,11 +21,11 @@ import PaymentsStore from './PaymentsStore'; import PosStore from './PosStore'; import SettingsStore from './SettingsStore'; import SwapStore from './SwapStore'; -import SweepStore from './SweepStore'; import SyncStore from './SyncStore'; import TransactionsStore from './TransactionsStore'; import UnitsStore from './UnitsStore'; import UTXOsStore from './UTXOsStore'; +import SweepStore from './SweepStore'; export const settingsStore = new SettingsStore(); export const modalStore = new ModalStore(); diff --git a/stores/SweepStore.ts b/stores/SweepStore.ts index 14272ea85..088ecc308 100644 --- a/stores/SweepStore.ts +++ b/stores/SweepStore.ts @@ -12,10 +12,8 @@ import { localeString } from '../utils/LocaleUtils'; import ecc from '../zeus_modules/noble_ecc'; export default class SweepStore { - @observable loading: boolean = false; @observable sweepErrorMsg: string | null = null; @observable sweepError: boolean = false; - @observable sweepTxHex: string | null = null; @observable onChainBalance: number; @observable destination: string; @observable txHex: string | null = null; @@ -37,11 +35,6 @@ export default class SweepStore { this.nodeInfoStore = nodeInfoStore; } - @action - setLoading(loading: boolean) { - this.loading = loading; - } - @action resetSweepError() { this.sweepError = false; @@ -53,7 +46,7 @@ export default class SweepStore { `${wifUtils.baseUrl(network)}/address/${address}/utxo` ); if (!res.ok) { - throw new Error(localeString(`Error fetching UTXOs`)); + throw new Error(localeString('views.Wif.errorFetchingUtxos')); } const utxos = await res.json(); @@ -104,7 +97,7 @@ export default class SweepStore { } } - throw new Error(localeString('views.Sweep.addressTypeNotSupported')); + throw new Error(localeString('views.Wif.noUtxosFound')); } @action @@ -141,7 +134,7 @@ export default class SweepStore { if (this.addressType === 'p2tr') { this.sweepError = true; this.sweepErrorMsg = localeString( - 'views.Sweep.addressTypeNotSupported' + 'views.Wif.addressTypeNotSupported' ); return; } @@ -153,11 +146,12 @@ export default class SweepStore { const res = await fetch( `${wifUtils.baseUrl(networkStr)}/tx/${txid}/hex` ); + if (!res.ok) throw new Error( - localeString( - `views.Sweep.failedToFetchTxHex:${txid}` - ) + localeString('views.Wif.failedToFetchTxHex', { + txid + }) ); const rawTxHex = await res.text(); @@ -177,9 +171,9 @@ export default class SweepStore { ); if (!res.ok) throw new Error( - localeString( - `views.Sweep.failedToFetchTxDetails:${txid}` - ) + localeString('views.Wif.failedToFetchTxDetails', { + txid + }) ); const tx = await res.json(); @@ -187,9 +181,10 @@ export default class SweepStore { if (!output) throw new Error( - localeString( - `views.Sweep.outputIndexNotFound:${vout}:${txid}` - ) + localeString('views.Sweep.outputIndexNotFound', { + vout, + txid + }) ); const value = Math.round(output.value); @@ -218,9 +213,9 @@ export default class SweepStore { ); if (!res.ok) throw new Error( - localeString( - `views.Sweep.failedToFetchTxDetails:${txid}` - ) + localeString('views.Wif.failedToFetchTxDetails', { + txid + }) ); const tx = await res.json(); @@ -228,9 +223,10 @@ export default class SweepStore { if (!output) throw new Error( - localeString( - `views.Sweep.outputNotFound:${vout}:${txid}` - ) + localeString('views.Wif.outputNotFound', { + vout, + txid + }) ); const scriptPubKeyHex = output.scriptpubkey; const script = Buffer.from(scriptPubKeyHex, 'hex'); @@ -240,7 +236,7 @@ export default class SweepStore { if (!fullPub) { throw new Error( - localeString('views.Sweep.failedToDerivePubkey') + localeString('views.Wif.failedToDerivePubkey') ); } @@ -276,9 +272,7 @@ export default class SweepStore { const fullPub = ecc.pointFromScalar(privateKey, true); if (!fullPub) - throw new Error( - localeString('views.Sweep.failedToDerivePubkey') - ); + throw new Error(localeString('views.Wif.failedToDerivePubkey')); const signer: bitcoin.Signer = { publicKey: Buffer.from(fullPub), @@ -310,7 +304,7 @@ export default class SweepStore { if (this.valueToSend <= 0) throw new Error( - localeString('views.Sweep.insufficientFundsAfterFees') + localeString('views.Wif.insufficientFundsAfterFees') ); this.psbt.addOutput({ diff --git a/stores/TransactionsStore.ts b/stores/TransactionsStore.ts index 63ec81f16..245ef68af 100644 --- a/stores/TransactionsStore.ts +++ b/stores/TransactionsStore.ts @@ -129,7 +129,7 @@ export default class TransactionsStore { }; @action - public broadcast = (raw_final_tx: string) => { + public broadcast = async (raw_final_tx: string) => { this.loading = true; const tx_hex = raw_final_tx.includes('=') @@ -144,6 +144,48 @@ export default class TransactionsStore { txid = tx.getId(); } catch (e) {} + // CLN REST does not support publishTransaction; broadcast via mempool.space + if (this.settingsStore.implementation === 'cln-rest') { + const headers = { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'text/plain' + } as any; + + const url = `https://mempool.space/${ + this.nodeInfoStore.nodeInfo.isTestNet ? 'testnet/' : '' + }api/tx`; + + return ReactNativeBlobUtil.fetch('POST', url, headers, tx_hex) + .then((response: any) => { + const status = response.info().status; + const data = response.data; + if (status == 200) { + runInAction(() => { + this.txid = data || txid; + this.publishSuccess = true; + this.loading = false; + this.channelsStore.resetOpenChannel(); + }); + return data; + } else { + runInAction(() => { + this.error_msg = errorToUserFriendly(data); + this.error = true; + this.loading = false; + }); + } + }) + .catch((err: any) => { + runInAction(() => { + this.error_msg = errorToUserFriendly( + err?.error || err?.message || err?.toString() + ); + this.error = true; + this.loading = false; + }); + }); + } + return BackendUtils.publishTransaction({ tx_hex }) diff --git a/views/WIFSweeper.tsx b/views/Tools/WIFSweeper.tsx similarity index 76% rename from views/WIFSweeper.tsx rename to views/Tools/WIFSweeper.tsx index 0c06700fc..42762462f 100644 --- a/views/WIFSweeper.tsx +++ b/views/Tools/WIFSweeper.tsx @@ -5,34 +5,34 @@ import { Route } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; import { TouchableOpacity } from 'react-native-gesture-handler'; -import InvoicesStore from '../stores/InvoicesStore'; -import SweepStore from '../stores/SweepStore'; +import InvoicesStore from '../../stores/InvoicesStore'; +import SweepStore from '../../stores/SweepStore'; -import Button from '../components/Button'; -import { ErrorMessage } from '../components/SuccessErrorMessage'; -import Header from '../components/Header'; -import Screen from '../components/Screen'; -import TextInput from '../components/TextInput'; -import LoadingIndicator from '../components/LoadingIndicator'; -import OnchainFeeInput from '../components/OnchainFeeInput'; -import ShowHideToggle from '../components/ShowHideToggle'; +import Button from '../../components/Button'; +import { ErrorMessage } from '../../components/SuccessErrorMessage'; +import Header from '../../components/Header'; +import Screen from '../../components/Screen'; +import TextInput from '../../components/TextInput'; +import LoadingIndicator from '../../components/LoadingIndicator'; +import OnchainFeeInput from '../../components/OnchainFeeInput'; +import ShowHideToggle from '../../components/ShowHideToggle'; -import { localeString } from '../utils/LocaleUtils'; -import { themeColor } from '../utils/ThemeUtils'; -import wifUtils from '../utils/WIFUtils'; -import AddressUtils from '../utils/AddressUtils'; +import { localeString } from '../../utils/LocaleUtils'; +import { themeColor } from '../../utils/ThemeUtils'; +import wifUtils from '../../utils/WIFUtils'; +import AddressUtils from '../../utils/AddressUtils'; -import Scan from '../assets/images/SVG/Scan.svg'; +import Scan from '../../assets/images/SVG/Scan.svg'; -interface SweepProps { +interface WIFSweepProps { exitSetup: any; navigation: StackNavigationProp; InvoicesStore: InvoicesStore; SweepStore: SweepStore; - route: Route<'Sweep', { wif: string }>; + route: Route<'WIFSweeper', { wif: string }>; } -interface SweepState { +interface WIFSweepState { privateKey: string; hidden: boolean; isValid: boolean; @@ -41,13 +41,14 @@ interface SweepState { onChainAddressloading: boolean; feeRate: string; feeLoadingError: boolean; + isWifValid: boolean; } @inject('SweepStore', 'InvoicesStore') @observer export default class WIFSweeper extends React.Component< - SweepProps, - SweepState + WIFSweepProps, + WIFSweepState > { state = { privateKey: '', @@ -57,7 +58,8 @@ export default class WIFSweeper extends React.Component< loading: false, onChainAddressloading: false, feeRate: '2', - feeLoadingError: false + feeLoadingError: false, + isWifValid: true }; componentDidMount() { @@ -74,7 +76,7 @@ export default class WIFSweeper extends React.Component< this.initFromProps(this.props); } - initFromProps(props: SweepProps) { + initFromProps(props: WIFSweepProps) { const { route } = props; const scannedKey = route.params?.wif; @@ -85,7 +87,7 @@ export default class WIFSweeper extends React.Component< } } - componentDidUpdate(prevProps: SweepProps) { + componentDidUpdate(prevProps: WIFSweepProps) { if (prevProps.route.params?.wif !== this.props.route.params?.wif) { this.initFromProps(this.props); } @@ -160,11 +162,25 @@ export default class WIFSweeper extends React.Component< 'views.Wif.enterPrivateKeyPlaceholder' )} value={privateKey} - onChangeText={(text: string) => - this.setState({ privateKey: text }) - } + onChangeText={(text: string) => { + const isWifValid = + text.length === 0 || + wifUtils.validateWIF(text).isValid; + this.setState({ + privateKey: text, + isWifValid + }); + }} secureTextEntry={hidden} - style={{ flex: 1, marginRight: 15 }} + style={{ + flex: 1, + marginRight: 15 + }} + textColor={ + !this.state.isWifValid + ? themeColor('delete') + : themeColor('text') + } locked={loading} /> {onChainAddressloading && ( @@ -228,7 +251,7 @@ export default class WIFSweeper extends React.Component< onChainAddressloading: true }); this.props.SweepStore.resetSweepError(); - const { isValid } = + const { isValid, error } = wifUtils.validateWIF(privateKey); if (isValid) { await this.props.SweepStore.prepareSweepInputs( @@ -236,16 +259,17 @@ export default class WIFSweeper extends React.Component< ); } else { throw new Error( - localeString( - 'views.Wif.invalidWif' - ) + error || + localeString( + 'views.Wif.invalidWif' + ) ); } if (SweepStore.sweepError) { throw new Error( SweepStore.sweepErrorMsg || localeString( - 'views.Sweep.unknownError' + 'views.Wif.invalidWif' ) ); } @@ -295,7 +319,9 @@ export default class WIFSweeper extends React.Component< )} secondary disabled={ - !privateKey || !!SweepStore.destination + !privateKey || + !this.state.isWifValid || + !!SweepStore.destination } /> @@ -332,12 +358,30 @@ export default class WIFSweeper extends React.Component< this.state.privateKey ); if (isValid) { - await this.props.SweepStore.finalizeSweepTransaction( - feeRate - ); - navigation.navigate('TxHex', { - txHex: SweepStore.txHex - }); + try { + await this.props.SweepStore.finalizeSweepTransaction( + feeRate + ); + if (SweepStore.txHex) { + navigation.navigate('TxHex', { + txHex: SweepStore.txHex + }); + } else { + throw new Error( + localeString( + 'views.Wif.failedToCreateTransaction' + ) + ); + } + } catch (e: any) { + this.props.SweepStore.sweepError = true; + this.props.SweepStore.sweepErrorMsg = + e.message || + localeString( + 'views.Wif.failedToCreateTransaction' + ); + this.setState({ error: e.message }); + } } else { this.props.SweepStore.sweepError = true; this.props.SweepStore.sweepErrorMsg = @@ -349,6 +393,7 @@ export default class WIFSweeper extends React.Component< !privateKey || loading || !SweepStore.destination || + !this.state.isValid || sweepError || feeLoadingError || (!!error && error.length > 0) diff --git a/views/TxHex.tsx b/views/TxHex.tsx index 659f8a7e6..6a646b589 100644 --- a/views/TxHex.tsx +++ b/views/TxHex.tsx @@ -4,7 +4,8 @@ import { StyleSheet, Text, TouchableOpacity, - View + View, + Alert } from 'react-native'; import { inject, observer } from 'mobx-react'; import { ButtonGroup } from 'react-native-elements'; @@ -12,6 +13,7 @@ import { UR, UREncoder } from '@ngraveio/bc-ur'; import clone from 'lodash/clone'; import { Route } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; +import { runInAction } from 'mobx'; const bitcoin = require('bitcoinjs-lib'); @@ -34,6 +36,7 @@ import UrlUtils from '../utils/UrlUtils'; import ChannelsStore from '../stores/ChannelsStore'; import NodeInfoStore from '../stores/NodeInfoStore'; import TransactionsStore from '../stores/TransactionsStore'; +import SettingsStore from '../stores/SettingsStore'; import { getQRAnimationInterval, QRAnimationSpeed @@ -41,6 +44,7 @@ import { interface TxHexProps { navigation: StackNavigationProp; + SettingsStore: SettingsStore; ChannelsStore: ChannelsStore; NodeInfoStore: NodeInfoStore; TransactionsStore: TransactionsStore; @@ -59,7 +63,7 @@ interface TxHexState { qrAnimationSpeed: QRAnimationSpeed; } -@inject('ChannelsStore', 'NodeInfoStore', 'TransactionsStore') +@inject('ChannelsStore', 'NodeInfoStore', 'TransactionsStore', 'SettingsStore') @observer export default class TxHex extends React.Component { private qrAnimationInterval?: any; @@ -147,6 +151,67 @@ export default class TxHex extends React.Component { } } + handleBroadcast = async () => { + const { txHex } = this.state; + const { navigation, SettingsStore, TransactionsStore, ChannelsStore } = + this.props; + const { pending_chan_ids } = ChannelsStore; + + try { + if (pending_chan_ids.length > 0) { + await TransactionsStore.finalizeTxHexAndBroadcastChannel( + txHex, + pending_chan_ids + ); + navigation.navigate('SendingOnChain'); + return; + } + + if (SettingsStore.implementation === 'cln-rest') { + const userConfirmed = await new Promise((resolve) => { + Alert.alert( + localeString('views.TxHex.broadcastingViaMempool'), + localeString('views.TxHex.thirdPartyBroadcastWarning'), + [ + { + text: localeString('general.cancel'), + style: 'cancel', + onPress: () => resolve(false) + }, + { text: 'OK', onPress: () => resolve(true) } + ] + ); + }); + + if (!userConfirmed) { + runInAction(() => { + TransactionsStore.error = true; + TransactionsStore.error_msg = localeString( + 'views.TxHex.transactionBroadcastCancelled' + ); + TransactionsStore.loading = false; + }); + return; + } + } + + await TransactionsStore.broadcast(txHex); + + if (!TransactionsStore.error) { + navigation.navigate('SendingOnChain'); + } + } catch (error: any) { + console.error('Broadcast failed:', error); + + runInAction(() => { + TransactionsStore.error = true; + TransactionsStore.error_msg = + error?.message || 'Broadcast failed'; + TransactionsStore.loading = false; + }); + } + }; + render() { const { ChannelsStore, NodeInfoStore, TransactionsStore, navigation } = this.props; @@ -415,28 +480,7 @@ export default class TxHex extends React.Component { ? 'views.TxHex.finalizeFlowAndBroadcast' : 'views.TxHex.broadcast' )} - onPress={() => { - if ( - pending_chan_ids.length > 0 - ) { - TransactionsStore.finalizeTxHexAndBroadcastChannel( - txHex, - pending_chan_ids - ).then(() => { - navigation.navigate( - 'SendingOnChain' - ); - }); - } else { - TransactionsStore.broadcast( - txHex - ).then(() => { - navigation.navigate( - 'SendingOnChain' - ); - }); - } - }} + onPress={this.handleBroadcast} containerStyle={{ width: '100%' }} buttonStyle={{ height: 40 }} tertiary diff --git a/~ b/~ deleted file mode 100644 index 0e11b32b2..000000000 --- a/~ +++ /dev/null @@ -1,33 +0,0 @@ -pick 8f75f07d # fix: disable create transaction button on fee loading error -fixup b5f94d6d # fix: errors in the sweep view - -# Rebase 5b024b2a..b5f94d6d onto 5b024b2a (2 commands) -# -# Commands: -# p, pick = use commit -# r, reword = use commit, but edit the commit message -# e, edit = use commit, but stop for amending -# s, squash = use commit, but meld into previous commit -# f, fixup [-C | -c] = like "squash" but keep only the previous -# commit's log message, unless -C is used, in which case -# keep only this commit's message; -c is same as -C but -# opens the editor -# x, exec = run command (the rest of the line) using shell -# b, break = stop here (continue rebase later with 'git rebase --continue') -# d, drop = remove commit -# l, label