feat(CLNRest): fallback to mempool.space for raw tx broadcast as it lacks publishTransaction

This commit is contained in:
Khushal
2025-10-23 12:45:02 +05:30
parent cc77c2ee8a
commit 4e0346f9c0
8 changed files with 236 additions and 140 deletions
+3 -3
View File
@@ -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;
+13 -9
View File
@@ -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",
+1 -1
View File
@@ -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();
+24 -30
View File
@@ -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({
+43 -1
View File
@@ -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
})
@@ -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<any, any>;
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}
/>
<ShowHideToggle
@@ -207,6 +223,13 @@ export default class WIFSweeper extends React.Component<
marginHorizontal: 20
}}
value={SweepStore.destination}
textColor={
SweepStore.destination &&
!this.state.isValid
? themeColor('delete')
: themeColor('text')
}
locked={loading}
/>
{onChainAddressloading && (
<View style={styles.loadingOverlay}>
@@ -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,6 +259,7 @@ export default class WIFSweeper extends React.Component<
);
} else {
throw new Error(
error ||
localeString(
'views.Wif.invalidWif'
)
@@ -245,7 +269,7 @@ export default class WIFSweeper extends React.Component<
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
}
/>
</View>
@@ -332,12 +358,30 @@ export default class WIFSweeper extends React.Component<
this.state.privateKey
);
if (isValid) {
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)
+68 -24
View File
@@ -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<any, any>;
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<TxHexProps, TxHexState> {
private qrAnimationInterval?: any;
@@ -147,6 +151,67 @@ export default class TxHex extends React.Component<TxHexProps, TxHexState> {
}
}
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<boolean>((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<TxHexProps, TxHexState> {
? '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
-33
View File
@@ -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 <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup [-C | -c] <commit> = 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 <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <label> = reset HEAD to a label
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
# create a merge commit using the original merge commit's
# message (or the oneline, if no original merge commit was
# specified); use -c <commit> to reword the commit message
# u, update-ref <ref> = track a placeholder for the <ref> to be updated
# to this position in the new commits. The <ref> is
# updated at the end of the rebase
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.
#