feat: Developer Tools: add updateChannelPolicy with create_missing_edge

Adds an updateChannelPolicy command to the LND Developer Tools,
mirroring lncli updatechanpolicy: base_fee_msat, fee_rate_ppm,
time_lock_delta, and min/max_htlc_msat inputs, a create_missing_edge
toggle, and a global or per-channel target picker.

Backed by a raw PolicyUpdateRequest passthrough on lnd (REST),
embedded-lnd (new lndmobile binding + iOS dispatch table entry), and
lightning-node-connect, kept separate from the production setFees
flow so units and inbound-fee handling there are untouched.
This commit is contained in:
Evan Kaloudis
2026-07-13 11:39:14 -04:00
parent 3783213a8d
commit 89447a25c2
9 changed files with 258 additions and 9 deletions
+3 -1
View File
@@ -40,7 +40,8 @@ const {
abandonChannel,
openChannel,
openChannelSync,
decodeOpenStatusUpdate
decodeOpenStatusUpdate,
updateChannelPolicy
} = lndMobile.channel;
const {
signMessageNodePubkey,
@@ -316,6 +317,7 @@ export default class EmbeddedLND extends LND {
// getFees = () => N/A;
// setFees = () => N/A;
updateChannelPolicy = async (data: any) => await updateChannelPolicy(data);
signMessageWithAddr = async (message: string, address: string) => {
return await signMsgWithAddr(
+3
View File
@@ -643,6 +643,9 @@ export default class LND {
min_htlc_msat_specified: min_htlc ? true : false
});
};
// takes a raw PolicyUpdateRequest, used by Developer Tools
updateChannelPolicy = (data: any) =>
this.postRequest('/v1/chanpolicy', data);
getRoutes = (urlParams?: Array<string>) =>
this.getRequest(
`/v1/graph/routes/${urlParams && urlParams[0]}/${
+5
View File
@@ -472,6 +472,11 @@ export default class LightningNodeConnect {
.updateChannelPolicy(params)
.then((data: lnrpc.PolicyUpdateResponse) => snakeize(data));
};
// takes a raw PolicyUpdateRequest, used by Developer Tools
updateChannelPolicy = async (data: any) =>
await this.lnc.lnd.lightning
.updateChannelPolicy(data)
.then((res: lnrpc.PolicyUpdateResponse) => snakeize(res));
getRoutes = async (urlParams?: Array<string>) =>
await this.lnc.lnd.lightning
.queryRoutes({
+1
View File
@@ -104,6 +104,7 @@ open class Lnd {
"VerifyChanBackup": { bytes, cb in LndmobileVerifyChanBackup(bytes, cb) },
"GetChanInfo": { bytes, cb in LndmobileGetChanInfo(bytes, cb) },
"AbandonChannel": { bytes, cb in LndmobileAbandonChannel(bytes, cb) },
"UpdateChannelPolicy": { bytes, cb in LndmobileUpdateChannelPolicy(bytes, cb) },
"GetNetworkInfo": { bytes, cb in LndmobileGetNetworkInfo(bytes, cb) },
// onchain
+15 -2
View File
@@ -60,7 +60,8 @@ import {
restoreChannelBackups,
abandonChannel,
getChanInfo,
closedChannels
closedChannels,
updateChannelPolicy
} from './channel';
import {
getTransactions,
@@ -356,6 +357,17 @@ export interface ILndMobileInjections {
pendingFundingShimOnly?: boolean,
iKnowWhatIAmDoing?: boolean
) => Promise<lnrpc.AbandonChannelResponse>;
updateChannelPolicy: (data: {
base_fee_msat?: string;
fee_rate_ppm?: number;
time_lock_delta?: number;
min_htlc_msat?: string;
min_htlc_msat_specified?: boolean;
max_htlc_msat?: string;
chan_point?: { funding_txid_str: string; output_index: number };
global?: boolean;
create_missing_edge?: boolean;
}) => Promise<lnrpc.PolicyUpdateResponse>;
};
onchain: {
getTransactions: (data?: any) => Promise<lnrpc.TransactionDetails>;
@@ -695,7 +707,8 @@ export default {
decodeChannelAcceptRequest,
channelAcceptorResponse,
getChanInfo,
closedChannels
closedChannels,
updateChannelPolicy
},
onchain: {
getTransactions,
+52
View File
@@ -245,6 +245,58 @@ export const abandonChannel = async (
});
return response;
};
/**
* @throws
*/
export const updateChannelPolicy = async (data: {
base_fee_msat?: string;
fee_rate_ppm?: number;
time_lock_delta?: number;
min_htlc_msat?: string;
min_htlc_msat_specified?: boolean;
max_htlc_msat?: string;
chan_point?: { funding_txid_str: string; output_index: number };
global?: boolean;
create_missing_edge?: boolean;
}): Promise<lnrpc.PolicyUpdateResponse> => {
const options: lnrpc.IPolicyUpdateRequest = {
base_fee_msat: data.base_fee_msat
? Long.fromValue(data.base_fee_msat)
: undefined,
fee_rate_ppm: data.fee_rate_ppm,
time_lock_delta: data.time_lock_delta,
min_htlc_msat: data.min_htlc_msat
? Long.fromValue(data.min_htlc_msat)
: undefined,
min_htlc_msat_specified: data.min_htlc_msat_specified,
max_htlc_msat: data.max_htlc_msat
? Long.fromValue(data.max_htlc_msat)
: undefined,
create_missing_edge: data.create_missing_edge
};
if (data.global) {
options.global = true;
} else if (data.chan_point) {
options.chan_point = {
funding_txid_str: data.chan_point.funding_txid_str,
output_index: data.chan_point.output_index
};
}
const response = await sendCommand<
lnrpc.IPolicyUpdateRequest,
lnrpc.PolicyUpdateRequest,
lnrpc.PolicyUpdateResponse
>({
request: lnrpc.PolicyUpdateRequest,
response: lnrpc.PolicyUpdateResponse,
method: 'UpdateChannelPolicy',
options
});
return response;
};
/**
* @throws
*/
+1
View File
@@ -997,6 +997,7 @@
"views.Tools.developerTools.abandonChannel.title": "Abandon Channel",
"views.Tools.developerTools.abandonChannel.message": "Are you sure you want to abandon this channel? The channel will be removed from the database and you may lose funds if you haven't already recovered them.",
"views.Tools.developerTools.abandonChannel.confirm": "Abandon",
"views.Tools.developerTools.updateChannelPolicy.global": "Global (all channels)",
"views.Tools.nodeConfigExportImport.title": "Export/Import Wallet Configurations",
"views.Tools.nodeConfigExportImport.explainerAndroid": "Exported wallet configurations can be found in Files > Downloads.",
"views.Tools.nodeConfigExportImport.explaineriOS": "Exported wallet configurations can be found in the Files app under the ZEUS folder",
+2
View File
@@ -115,6 +115,8 @@ class BackendUtils {
getNodeInfo = (...args: any[]) => this.call('getNodeInfo', args);
getFees = (...args: any[]) => this.call('getFees', args);
setFees = (...args: any[]) => this.call('setFees', args);
updateChannelPolicy = (...args: any[]) =>
this.call('updateChannelPolicy', args);
getRoutes = (...args: any[]) => this.call('getRoutes', args);
getForwardingHistory = (...args: any[]) =>
this.call('getForwardingHistory', args);
+176 -6
View File
@@ -16,6 +16,7 @@ import Header from '../../components/Header';
import Screen from '../../components/Screen';
import CopyButton from '../../components/CopyButton';
import Switch from '../../components/Switch';
import TextInput from '../../components/TextInput';
import { localeString } from '../../utils/LocaleUtils';
import { themeColor } from '../../utils/ThemeUtils';
@@ -48,7 +49,7 @@ interface CategoryProps {
selectedCommand: string | null;
onCommand: (
command: string,
param?: string | Array<string | boolean | undefined>
param?: string | Array<string | boolean | object | undefined>
) => Promise<void>;
implementation: Implementations;
open: boolean;
@@ -59,7 +60,7 @@ interface CommandProps {
command: string;
onTap: (
command: string,
param?: string | Array<string | boolean | undefined>
param?: string | Array<string | boolean | object | undefined>
) => Promise<void>;
selected: boolean;
}
@@ -79,6 +80,12 @@ interface CommandState {
expanded: boolean;
pendingFundingShimOnly?: boolean;
iKnowWhatIAmDoing?: boolean;
baseFeeMsat: string;
feeRatePpm: string;
timeLockDelta: string;
minHtlcMsat: string;
maxHtlcMsat: string;
createMissingEdge?: boolean;
}
interface ResponseContainerProps {
@@ -258,6 +265,14 @@ const categories: Array<{
'embedded-lnd',
'lightning-node-connect'
]
},
{
name: 'updateChannelPolicy',
compatibleImplementations: [
'lnd',
'embedded-lnd',
'lightning-node-connect'
]
}
]
}
@@ -297,13 +312,24 @@ const ResponseContainer = ({
);
class Command extends React.Component<CommandProps, CommandState> {
private commandsWithSubItems = ['getChannelInfo', 'abandonChannel'];
private commandsWithSubItems = [
'getChannelInfo',
'abandonChannel',
'updateChannelPolicy'
];
state: CommandState = {
loading: false,
expanded: false,
pendingFundingShimOnly: false,
iKnowWhatIAmDoing: false
iKnowWhatIAmDoing: false,
// lnd defaults for lncli updatechanpolicy
baseFeeMsat: '1000',
feeRatePpm: '1',
timeLockDelta: '80',
minHtlcMsat: '',
maxHtlcMsat: '',
createMissingEdge: false
};
private loadSubItems = async () => {
@@ -315,7 +341,12 @@ class Command extends React.Component<CommandProps, CommandState> {
const subItems = channels.map((channel: any) => {
const label = `Channel ${channel.chan_id} (${channel.remote_pubkey})`;
if (this.props.command === 'abandonChannel') {
if (this.props.command === 'updateChannelPolicy') {
return {
label,
commandParameters: [channel.channel_point || '']
};
} else if (this.props.command === 'abandonChannel') {
// Parse channel_point (format: "txid:index") for abandonChannel
const channelPoint = channel.channel_point || '';
const [fundingTxId, outputIndex] =
@@ -340,6 +371,14 @@ class Command extends React.Component<CommandProps, CommandState> {
};
}
});
if (this.props.command === 'updateChannelPolicy') {
subItems.unshift({
label: localeString(
'views.Tools.developerTools.updateChannelPolicy.global'
),
commandParameters: ['global']
});
}
this.setState({ subItems, loading: false });
} catch (error) {
console.error('Error loading channels:', error);
@@ -366,6 +405,42 @@ class Command extends React.Component<CommandProps, CommandState> {
channelInfo?: { chanId: string; remotePubkey: string; outpoint: string }
): void {
this.setState({ selectedSubItemIndex });
if (command === 'updateChannelPolicy') {
const {
baseFeeMsat,
feeRatePpm,
timeLockDelta,
minHtlcMsat,
maxHtlcMsat,
createMissingEdge
} = this.state;
const data: any = {
base_fee_msat: baseFeeMsat || '0',
fee_rate_ppm: Number(feeRatePpm) || 0,
time_lock_delta: Number(timeLockDelta) || 0
};
if (minHtlcMsat) {
data.min_htlc_msat = minHtlcMsat;
data.min_htlc_msat_specified = true;
}
if (maxHtlcMsat) data.max_htlc_msat = maxHtlcMsat;
if (createMissingEdge) data.create_missing_edge = true;
if (commandParameters[0] === 'global') {
data.global = true;
} else {
const [fundingTxId, outputIndex] =
commandParameters[0].split(':');
data.chan_point = {
funding_txid_str: fundingTxId,
output_index: Number(outputIndex) || 0
};
}
this.props.onTap(command, [data]);
return;
}
// For abandonChannel, include boolean parameters only if explicitly set to true
if (command === 'abandonChannel' && channelInfo) {
const params: Array<string | boolean | undefined> = [
@@ -503,6 +578,91 @@ class Command extends React.Component<CommandProps, CommandState> {
</View>
</View>
)}
{command === 'updateChannelPolicy' && (
<View
style={[
styles.booleanParamsContainer,
{
backgroundColor:
themeColor('background')
}
]}
>
{(
[
{
key: 'baseFeeMsat',
label: 'base_fee_msat'
},
{
key: 'feeRatePpm',
label: 'fee_rate_ppm'
},
{
key: 'timeLockDelta',
label: 'time_lock_delta'
},
{
key: 'minHtlcMsat',
label: 'min_htlc_msat'
},
{
key: 'maxHtlcMsat',
label: 'max_htlc_msat'
}
] as const
).map((field) => (
<View
style={styles.textParamRow}
key={field.key}
>
<Text
style={[
styles.booleanParamLabel,
{
color: themeColor('text')
}
]}
>
{field.label}
</Text>
<TextInput
value={this.state[field.key]}
onChangeText={(value: string) =>
this.setState({
[field.key]: value
} as any)
}
keyboardType="numeric"
style={styles.textParamInput}
/>
</View>
))}
<View style={styles.booleanParamRow}>
<Text
style={[
styles.booleanParamLabel,
{
color: themeColor('text')
}
]}
>
create_missing_edge
</Text>
<Switch
value={
this.state.createMissingEdge ||
false
}
onValueChange={(value: boolean) =>
this.setState({
createMissingEdge: value
})
}
/>
</View>
</View>
)}
{loading ? (
<ActivityIndicator color={themeColor('text')} />
) : subItems!.length === 0 ? (
@@ -660,7 +820,7 @@ export default class DeveloperTools extends React.Component<
handleCommand = async (
command: string,
param?: string | Array<string | boolean | undefined>
param?: string | Array<string | boolean | object | undefined>
) => {
this.setState({
selectedCommand: command,
@@ -908,6 +1068,16 @@ const styles = StyleSheet.create({
fontFamily: 'PPNeueMontreal-Book',
flex: 1
},
textParamRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 4
},
textParamInput: {
height: 40,
width: 150
},
responseContainer: {
marginTop: 16,
padding: 16,