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:
@@ -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(
|
||||
|
||||
@@ -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]}/${
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user