feat: LND: Developer Tools: abandonchan

This commit is contained in:
Evan Kaloudis
2026-02-03 20:49:40 -05:00
parent 2e23839293
commit add198825f
6 changed files with 334 additions and 23 deletions
+22
View File
@@ -37,6 +37,7 @@ const {
pendingChannels,
closedChannels,
closeChannel,
abandonChannel,
openChannel,
openChannelSync,
decodeOpenStatusUpdate
@@ -273,6 +274,27 @@ export default class EmbeddedLND extends LND {
delivery_address
);
};
abandonChannel = async (
urlParams?: Array<string | boolean | undefined>
) => {
const fundingTxId =
urlParams && typeof urlParams[0] === 'string' ? urlParams[0] : '';
const outputIndex =
urlParams && typeof urlParams[1] === 'string'
? Number(urlParams[1])
: 0;
const pendingFundingShimOnly =
urlParams && urlParams[2] ? Boolean(urlParams[2]) : undefined;
const iKnowWhatIAmDoing =
urlParams && urlParams[3] ? Boolean(urlParams[3]) : undefined;
return await abandonChannel(
fundingTxId,
outputIndex,
pendingFundingShimOnly,
iKnowWhatIAmDoing
);
};
getNodeInfo = async (urlParams?: Array<string>) =>
await getNodeInfo((urlParams && urlParams[0]) || '');
+28
View File
@@ -533,6 +533,34 @@ export default class LND {
return this.deleteRequest(requestString);
};
abandonChannel = (urlParams?: Array<string | boolean | undefined>) => {
const fundingTxIdStr =
urlParams && typeof urlParams[0] === 'string' ? urlParams[0] : '';
const outputIndex =
urlParams && typeof urlParams[1] === 'string'
? Number(urlParams[1])
: 0;
// Build URL path: /v1/channels/abandon/{funding_txid_str}/{output_index}
let requestString = `/v1/channels/abandon/${fundingTxIdStr}/${outputIndex}`;
// Add boolean query parameters only if explicitly set (not undefined)
const queryParams: string[] = [];
if (urlParams && urlParams[2]) {
queryParams.push(
`pending_funding_shim_only=${Boolean(urlParams[2])}`
);
}
if (urlParams && urlParams[3]) {
queryParams.push(`i_know_what_i_am_doing=${Boolean(urlParams[3])}`);
}
if (queryParams.length > 0) {
requestString += `?${queryParams.join('&')}`;
}
return this.deleteRequest(requestString);
};
getNodeInfo = (urlParams?: Array<string>) =>
this.getRequest(`/v1/graph/node/${urlParams && urlParams[0]}`);
getFees = () => this.getRequest('/v1/fees');
+31
View File
@@ -364,6 +364,37 @@ export default class LightningNodeConnect {
return this.lnc.lnd.lightning.closeChannel(params);
};
abandonChannel = async (
urlParams?: Array<string | boolean | undefined>
) => {
const fundingTxidStr =
urlParams && typeof urlParams[0] === 'string'
? urlParams[0]
: undefined;
const outputIndex =
urlParams && typeof urlParams[1] === 'string'
? Number(urlParams[1])
: 0;
const params: any = {
channelPoint: {
fundingTxidStr,
fundingTxidBytes: undefined,
outputIndex
}
};
// Only include boolean parameters if they are explicitly set (not undefined)
if (urlParams && urlParams[2]) {
params.pendingFundingShimOnly = Boolean(urlParams[2]);
}
if (urlParams && urlParams[3]) {
params.iKnowWhatIAmDoing = Boolean(urlParams[3]);
}
return this.lnc.lnd.lightning.abandonChannel(params);
};
getNodeInfo = async (urlParams?: Array<string>) =>
await this.lnc.lnd.lightning
.getNodeInfo({ pub_key: urlParams && urlParams[0] })
+3 -1
View File
@@ -358,7 +358,9 @@ export interface ILndMobileInjections {
) => Promise<lnrpc.RestoreBackupResponse>;
abandonChannel: (
fundingTxId: string,
outputIndex: number
outputIndex: number,
pendingFundingShimOnly?: boolean,
iKnowWhatIAmDoing?: boolean
) => Promise<lnrpc.AbandonChannelResponse>;
};
onchain: {
+20 -7
View File
@@ -213,8 +213,26 @@ export const closeChannel = async (
*/
export const abandonChannel = async (
funding_txid: string,
output_index: number
output_index: number,
pending_funding_shim_only?: boolean,
i_know_what_i_am_doing?: boolean
): Promise<lnrpc.AbandonChannelResponse> => {
const options: any = {
channel_point: {
funding_txid_str: funding_txid,
output_index
}
};
// Only include boolean parameters if they are explicitly set (not undefined)
if (pending_funding_shim_only !== undefined) {
options.pending_funding_shim_only = pending_funding_shim_only;
}
if (i_know_what_i_am_doing !== undefined) {
options.i_know_what_i_am_doing = i_know_what_i_am_doing;
}
const response = await sendCommand<
lnrpc.IAbandonChannelRequest,
lnrpc.AbandonChannelRequest,
@@ -223,12 +241,7 @@ export const abandonChannel = async (
request: lnrpc.AbandonChannelRequest,
response: lnrpc.AbandonChannelResponse,
method: 'AbandonChannel',
options: {
channel_point: {
funding_txid_str: funding_txid,
output_index
}
}
options
});
return response;
};
+230 -15
View File
@@ -14,6 +14,7 @@ import MaterialIcons from '@react-native-vector-icons/material-icons';
import Header from '../../components/Header';
import Screen from '../../components/Screen';
import CopyButton from '../../components/CopyButton';
import Switch from '../../components/Switch';
import { localeString } from '../../utils/LocaleUtils';
import { themeColor } from '../../utils/ThemeUtils';
@@ -33,13 +34,19 @@ interface DeveloperToolsState {
response: string | null;
error: string | null;
showScrollTop: boolean;
showScrollBottom: boolean;
contentHeight: number;
scrollViewHeight: number;
}
interface CategoryProps {
title: string;
commands: DeveloperCommand[];
selectedCommand: string | null;
onCommand: (command: string) => Promise<void>;
onCommand: (
command: string,
param?: string | Array<string | boolean | undefined>
) => Promise<void>;
expanded: boolean;
onToggle: () => void;
implementation: Implementations;
@@ -47,7 +54,10 @@ interface CategoryProps {
interface CommandProps {
command: string;
onTap: (command: string, param?: string) => Promise<void>;
onTap: (
command: string,
param?: string | Array<string | boolean | undefined>
) => Promise<void>;
selected: boolean;
onToggle?: () => void;
visible?: boolean;
@@ -58,6 +68,8 @@ interface CommandState {
selectedSubItemIndex?: number;
loading: boolean;
expanded: boolean;
pendingFundingShimOnly?: boolean;
iKnowWhatIAmDoing?: boolean;
}
interface ResponseContainerProps {
@@ -229,6 +241,14 @@ const categories: Array<{
{
name: 'getForwardingHistory',
compatibleImplementations: ['lnd', 'lightning-node-connect']
},
{
name: 'abandonChannel',
compatibleImplementations: [
'lnd',
'embedded-lnd',
'lightning-node-connect'
]
}
]
}
@@ -268,11 +288,13 @@ const ResponseContainer = ({
);
class Command extends React.Component<CommandProps, CommandState> {
private commandsWithSubItems = ['getChannelInfo'];
private commandsWithSubItems = ['getChannelInfo', 'abandonChannel'];
state: CommandState = {
loading: false,
expanded: false
expanded: false,
pendingFundingShimOnly: false,
iKnowWhatIAmDoing: false
};
private loadSubItems = async () => {
@@ -281,10 +303,26 @@ class Command extends React.Component<CommandProps, CommandState> {
try {
const response = await BackendUtils.call('getChannels');
const channels = response.channels || [];
const subItems = channels.map((channel: any) => ({
label: `Channel ${channel.chan_id} (${channel.remote_pubkey})`,
commandParameters: [channel.chan_id]
}));
const subItems = channels.map((channel: any) => {
const label = `Channel ${channel.chan_id} (${channel.remote_pubkey})`;
if (this.props.command === 'abandonChannel') {
// Parse channel_point (format: "txid:index") for abandonChannel
const channelPoint = channel.channel_point || '';
const [fundingTxId, outputIndex] =
channelPoint.split(':');
return {
label,
commandParameters: [fundingTxId, outputIndex || '0']
};
} else {
// Use chan_id for getChannelInfo
return {
label,
commandParameters: [channel.chan_id]
};
}
});
this.setState({ subItems, loading: false });
} catch (error) {
console.error('Error loading channels:', error);
@@ -310,7 +348,23 @@ class Command extends React.Component<CommandProps, CommandState> {
selectedSubItemIndex: number
): void {
this.setState({ selectedSubItemIndex });
this.props.onTap(command, commandParameters);
// For abandonChannel, include boolean parameters only if explicitly set to true
if (command === 'abandonChannel') {
const params: Array<string | boolean | undefined> = [
...commandParameters
];
// Only include boolean params if they are explicitly true
// Pass undefined if false so backends can check for !== undefined
params.push(
this.state.pendingFundingShimOnly === true ? true : undefined
);
params.push(
this.state.iKnowWhatIAmDoing === true ? true : undefined
);
this.props.onTap(command, params);
} else {
this.props.onTap(command, commandParameters);
}
}
render() {
@@ -341,6 +395,64 @@ class Command extends React.Component<CommandProps, CommandState> {
<View
style={[{ backgroundColor: themeColor('secondary') }]}
>
{command === 'abandonChannel' && (
<View
style={[
styles.booleanParamsContainer,
{
backgroundColor:
themeColor('background')
}
]}
>
<View style={styles.booleanParamRow}>
<Text
style={[
styles.booleanParamLabel,
{
color: themeColor('text')
}
]}
>
pending_funding_shim_only
</Text>
<Switch
value={
this.state.pendingFundingShimOnly ||
false
}
onValueChange={(value: boolean) =>
this.setState({
pendingFundingShimOnly: value
})
}
/>
</View>
<View style={styles.booleanParamRow}>
<Text
style={[
styles.booleanParamLabel,
{
color: themeColor('text')
}
]}
>
i_know_what_i_am_doing
</Text>
<Switch
value={
this.state.iKnowWhatIAmDoing ||
false
}
onValueChange={(value: boolean) =>
this.setState({
iKnowWhatIAmDoing: value
})
}
/>
</View>
</View>
)}
{loading ? (
<ActivityIndicator color={themeColor('text')} />
) : subItems!.length === 0 ? (
@@ -457,19 +569,42 @@ export default class DeveloperTools extends React.Component<
loading: false,
response: null,
error: null,
showScrollTop: false
showScrollTop: false,
showScrollBottom: false,
contentHeight: 0,
scrollViewHeight: 0
};
handleScroll = (event: any) => {
const currentScrollPosition = event.nativeEvent.contentOffset.y;
this.setState({ showScrollTop: currentScrollPosition > 100 });
const contentHeight = event.nativeEvent.contentSize.height;
const scrollViewHeight = event.nativeEvent.layoutMeasurement.height;
const scrollableDistance = contentHeight - scrollViewHeight;
const distanceFromBottom = scrollableDistance - currentScrollPosition;
this.setState({
showScrollTop: currentScrollPosition > 100,
showScrollBottom:
distanceFromBottom > 100 && contentHeight > scrollViewHeight,
contentHeight,
scrollViewHeight
});
};
scrollToTop = () => {
this.scrollViewRef.current?.scrollTo({ y: 0, animated: true });
};
handleCommand = async (command: string, param?: string) => {
scrollToBottom = () => {
const { contentHeight, scrollViewHeight } = this.state;
const scrollToY = Math.max(0, contentHeight - scrollViewHeight);
this.scrollViewRef.current?.scrollTo({ y: scrollToY, animated: true });
};
handleCommand = async (
command: string,
param?: string | Array<string | boolean | undefined>
) => {
this.setState({
selectedCommand: command,
loading: true,
@@ -477,7 +612,21 @@ export default class DeveloperTools extends React.Component<
error: null
});
try {
const response = await BackendUtils.call(command, param);
// BackendUtils.call uses .apply() which spreads arrays as arguments.
// For methods expecting urlParams?: Array<string>, we need to wrap the array
// in another array so it's passed as a single array argument.
// For methods expecting a single string (like getChannelInfo), pass the array as-is
// so the first element is extracted.
const commandsNeedingArrayParam = [
'abandonChannel',
'closeChannel'
];
const args =
Array.isArray(param) &&
commandsNeedingArrayParam.includes(command)
? [param]
: param;
const response = await BackendUtils.call(command, args);
this.setState({
loading: false,
response: JSON.stringify(response, null, 2)
@@ -499,8 +648,14 @@ export default class DeveloperTools extends React.Component<
render() {
const { navigation, SettingsStore } = this.props;
const { expandedCategory, loading, response, error, showScrollTop } =
this.state;
const {
expandedCategory,
loading,
response,
error,
showScrollTop,
showScrollBottom
} = this.state;
return (
<Screen>
@@ -519,6 +674,14 @@ export default class DeveloperTools extends React.Component<
ref={this.scrollViewRef}
contentContainerStyle={styles.container}
onScroll={this.handleScroll}
onContentSizeChange={(_contentWidth, contentHeight) => {
this.setState({ contentHeight });
}}
onLayout={(event) => {
this.setState({
scrollViewHeight: event.nativeEvent.layout.height
});
}}
scrollEventThrottle={16}
>
{categories
@@ -589,6 +752,24 @@ export default class DeveloperTools extends React.Component<
/>
</TouchableOpacity>
)}
{showScrollBottom && (
<TouchableOpacity
style={[
styles.scrollBottomButton,
{
backgroundColor: themeColor('secondary'),
borderColor: themeColor('background')
}
]}
onPress={this.scrollToBottom}
>
<MaterialIcons
name="arrow-downward"
size={24}
color={themeColor('text')}
/>
</TouchableOpacity>
)}
</Screen>
);
}
@@ -611,6 +792,22 @@ const styles = StyleSheet.create({
shadowRadius: 3.84,
borderWidth: 1
},
scrollBottomButton: {
position: 'absolute',
bottom: 10,
left: 10,
width: 45,
height: 45,
borderRadius: 22.5,
justifyContent: 'center',
alignItems: 'center',
elevation: 5,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 3.84,
borderWidth: 1
},
container: {
gap: 10,
marginTop: 15,
@@ -642,6 +839,24 @@ const styles = StyleSheet.create({
paddingTop: 8,
paddingLeft: 32
},
booleanParamsContainer: {
marginTop: 8,
marginHorizontal: 16,
marginBottom: 8,
padding: 12,
borderRadius: 8
},
booleanParamRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 8
},
booleanParamLabel: {
fontSize: 12,
fontFamily: 'PPNeueMontreal-Book',
flex: 1
},
responseContainer: {
marginTop: 16,
padding: 16,