Node API
The Node API provides Lightning Network channel management and payment operations.
JSON-RPC namespace: node
Endpoints
Peer Management
- Connect to Peer
- Disconnect from Peer
- Get Connected Peers
- Is Peer Connected
- Connect to Watchtower
- Get Connected Watchtowers
Zero-Conf Whitelist
Peer Blacklist
Node Configuration
- Get Node Policy
- Get Channel Terms
- Get Channel Policy (deprecated)
Channel Operations
- Estimate Open Channel Fee
- Open Channel
- Estimate Deposit Channel Fee
- Deposit Channel
- Estimate Withdraw Channel Fee
- Withdraw Channel
- Estimate Update Channel Fee
- Update Channel
- Estimate Close Channel Fee
- Close Channel
- Estimate Force Close Channel Fee
- Force Close Channel
- Estimate Redeem Closed Channel Fee
- Redeem Closed Channel
Batch Channel Operations
- Batch Channel Operations
- Estimate Batch Channel Operations Fee
- Estimate Simulated Channel Operation Fee
- Estimate Simulated Channel Operations Fee
Payment Operations
Invoice Management
- Create Invoice
- Decode Invoice
- Estimate Pay Invoice Fee
- Pay Invoice
- Estimate Pay Empty Invoice Fee
- Pay Empty Invoice
- Resolve Hashlock Payment
- Reject Payment
- Register Preimage — moved to
preimage.SettlePreimage
Funding Allowances
- Set Funding Allowance
- Get Funding Allowance
- Increase Funding Allowance
- Decrease Funding Allowance
- Revoke Funding Allowance
Connect to Peer
Connect to a Lightning Network peer.
Method: ConnectToPeer
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network to connect on |
peer_url | string | YES | Peer connection URL |
Peer URL Format:
node_id@host:port
Response: Empty (success confirmation)
Example Request:
TypeScript
import { NodeServiceClient } from './proto/NodeServiceClientPb'
import { ConnectToPeerRequest } from './proto/node_pb'
const client = new NodeServiceClient('http://localhost:5003')
const request = new ConnectToPeerRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setPeerUrl('02abc123...@lightning.example.com:9735')
await client.connectToPeer(request, {})
console.log('Connected to peer')
Go
import (
pb "github.com/hydra/hydra-go/proto"
)
client := pb.NewNodeServiceClient(conn)
req := &pb.ConnectToPeerRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
PeerUrl: "02abc123...@lightning.example.com:9735",
}
_, err := client.ConnectToPeer(context.Background(), req)
if err != nil {
log.Fatal(err)
}
log.Println("Connected to peer")
Rust
use hydra_app::node_service_client::NodeServiceClient;
use hydra_app::{ConnectToPeerRequest, Network};
let mut client = NodeServiceClient::new(channel);
let request = tonic::Request::new(ConnectToPeerRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
peer_url: "02abc123...@lightning.example.com:9735".to_string(),
});
client.connect_to_peer(request).await?;
println!("Connected to peer");
Get Connected Peers
Get list of currently connected peers.
Method: GetConnectedPeers
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network to query |
Response:
| Field | Type | Description |
|---|---|---|
node_ids | string[] | Array of connected node IDs |
Example Request:
TypeScript
const request = new GetConnectedPeersRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
const response = await client.getConnectedPeers(request, {})
const peers = response.getNodeIdsList()
console.log('Connected peers:', peers)
Go
req := &pb.GetConnectedPeersRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
}
resp, err := client.GetConnectedPeers(context.Background(), req)
if err != nil {
log.Fatal(err)
}
peers := resp.NodeIds
log.Println("Connected peers:", peers)
Rust
let request = tonic::Request::new(GetConnectedPeersRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
});
let response = client.get_connected_peers(request).await?;
let peers = response.into_inner().node_ids;
println!("Connected peers: {:?}", peers);
Disconnect from Peer
Disconnect from a connected peer.
Method: DisconnectFromPeer
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Target network |
node_id | string | YES | Peer public key (hex) |
Response: Empty.
Example Request:
import { DisconnectFromPeerRequest } from './proto/node_pb'
const request = new DisconnectFromPeerRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setNodeId('02abc123...')
await client.disconnectFromPeer(request, {})
Is Peer Connected
Check whether a specific peer is currently connected.
Method: IsPeerConnected
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Target network |
node_id | string | YES | Peer public key (hex) |
Response:
| Field | Type | Description |
|---|---|---|
is_connected | bool | True if currently connected |
Example Request:
import { IsPeerConnectedRequest } from './proto/node_pb'
const request = new IsPeerConnectedRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setNodeId('02abc123...')
const response = await client.isPeerConnected(request, {})
console.log('Connected:', response.getIsConnected())
Connect to Watchtower
Added 2026-08-02.
Attach this node to a watchtower on a specific network. A watchtower holds the states and revocations this node signs, so that a node which loses its local history can be handed back the coverage it gave away, and so that a channel stays defended while this node is offline.
Method: ConnectToWatchtower
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Target network |
watchtower_url | string | YES | The watchtower's address in <node_id>@<host>:<port> form — the same form ConnectToPeer takes, since a watchtower is reached over the same transport |
Response: Empty.
The watchtower session is independent of the ordinary peer session. The same remote node may be both a channel peer and a watchtower, and the two connections are tracked separately — connecting one does not connect or disconnect the other.
A watchtower cannot defend a channel whose counterparty is the watchtower. It acts on evidence against a counterparty, so there is nobody for it to act against. On mainnet the Hydranet node serves as both peer and watchtower on the same address — that coverage applies to your channels with other peers. See the Setup Guide.
You usually don't need this call. (2026-09-04) An EVM / Tron network block in
config.yamlcan declare awatchtowerslist; the node dials those itself once its initial chain sync completes and keeps them attached across restarts. UseConnectToWatchtowerto attach one at runtime, andGetConnectedWatchtowersto see what is actually attached. See the Setup Guide.
Example Request:
import { ConnectToWatchtowerRequest } from './proto/node_pb'
const request = new ConnectToWatchtowerRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setWatchtowerUrl('02abc123...@watchtower.example.com:9735')
await client.connectToWatchtower(request, {})
Get Connected Watchtowers
Added 2026-09-04.
List the watchtowers this node currently has a session with — the counterpart to ConnectToWatchtower, which until now you could call but never verify.
Method: GetConnectedWatchtowers
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Target network |
Response:
| Field | Type | Description |
|---|---|---|
node_ids | string[] | Node ids of the attached watchtowers |
Returns node ids, not the full <node_id>@<host>:<port> strings you connected with — match on the id.
Example Request:
import { GetConnectedWatchtowersRequest } from './proto/node_pb'
const request = new GetConnectedWatchtowersRequest()
request.setNetwork({ protocol: 2, id: '42161' })
const res = await client.getConnectedWatchtowers(request, {})
console.log(res.getNodeIdsList())
Empty is a meaningful answer. A node with
watchtowersconfigured attaches only after its initial chain sync completes, so an empty list early in a boot is normal — poll it, or check it once the node reports synced. An empty list on a long-running node means your channels are undefended while it is offline.
Get Zero-Conf Whitelist
Returns the list of peers permitted to use zero-confirmation channels with this node.
Method: GetZeroConfWhitelist
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Target network |
Response:
| Field | Type | Description |
|---|---|---|
node_ids | string[] | Hex-encoded public keys of whitelisted peers |
Example Request:
import { GetZeroConfWhitelistRequest } from './proto/node_pb'
const request = new GetZeroConfWhitelistRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
const response = await client.getZeroConfWhitelist(request, {})
console.log('Whitelisted:', response.getNodeIdsList())
Add Peer To Zero-Conf Whitelist
Adds a peer to the zero-confirmation whitelist. Whitelisted peers can use channels before the funding transaction confirms on-chain.
Method: AddPeerToZeroConfWhitelist
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Target network |
node_id | string | YES | Peer public key to whitelist |
Response: Empty.
Example Request:
import { AddPeerToZeroConfWhitelistRequest } from './proto/node_pb'
const request = new AddPeerToZeroConfWhitelistRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setNodeId('02abc123...')
await client.addPeerToZeroConfWhitelist(request, {})
Remove Peer From Zero-Conf Whitelist
Removes a peer from the zero-confirmation whitelist.
Method: RemovePeerFromZeroConfWhitelist
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Target network |
node_id | string | YES | Peer public key to remove |
Response: Empty.
Example Request:
import { RemovePeerFromZeroConfWhitelistRequest } from './proto/node_pb'
const request = new RemovePeerFromZeroConfWhitelistRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setNodeId('02abc123...')
await client.removePeerFromZeroConfWhitelist(request, {})
Get Blacklist
Added 2026-07-20.
Returns the peer connection blacklist for a network — peers this node refuses connections with. The blacklist is also file-configurable; these RPCs control it at runtime.
This is the inverse of the zero-conf whitelist and independent of it: the whitelist grants a privilege to a peer you connect with, the blacklist refuses the connection outright.
Method: GetBlacklist
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Target network |
Response:
| Field | Type | Description |
|---|---|---|
node_ids | string[] | Hex-encoded public keys of blacklisted peers |
Example Request:
import { GetBlacklistRequest } from './proto/node_pb'
const request = new GetBlacklistRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
const response = await client.getBlacklist(request, {})
console.log('Blacklisted peers:', response.getNodeIdsList())
Add Peer To Blacklist
Added 2026-07-20.
Adds a peer to the connection blacklist. Subsequent connection attempts involving that peer are refused.
Method: AddPeerToBlacklist
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Target network |
node_id | string | YES | The peer's public key, hex-encoded |
Response: Empty.
Example Request:
import { AddPeerToBlacklistRequest } from './proto/node_pb'
const request = new AddPeerToBlacklistRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setNodeId('02abc123...')
await client.addPeerToBlacklist(request, {})
Remove Peer From Blacklist
Added 2026-07-20.
Removes a peer from the connection blacklist, allowing connections with it again.
Method: RemovePeerFromBlacklist
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Target network |
node_id | string | YES | The peer's public key, hex-encoded |
Response: Empty.
Example Request:
import { RemovePeerFromBlacklistRequest } from './proto/node_pb'
const request = new RemovePeerFromBlacklistRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setNodeId('02abc123...')
await client.removePeerFromBlacklist(request, {})
Get Node Policy
Added 2026-05-21.
Read this node's policy configuration for a specific asset on a network. A "node policy" aggregates every parameter another party needs to know before negotiating a channel with this node — reserve rules, routing limits, channel timing rules, etc. Used by liquidity providers and any third party that wants to size on-chain deposits accurately, predict runtime reserves, or know the routing-fee / payment ceilings the channel will be subject to.
The response is composed of sub-messages, one per policy category. New categories are added in a backwards-compatible way, so check for the presence of each sub-message rather than treating the response as a fixed shape.
Method: GetNodePolicy
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Target network |
asset_id | string | YES | Asset whose policy you want to read — required because several policy values are denominated in the asset's smallest unit |
Response:
| Field | Type | Description |
|---|---|---|
reserve | ReservePolicy | Channel-reserve parameters. Drives the deposit sizing algebra deposit = (free + fixed) / (1 − rate). |
dispute_period | DisputePeriodPolicy? | (2026-09-25) The force-closure dispute period this node gives the channels it opens, and the longest it accepts. Absent from a node that predates the field |
DisputePeriodPolicy — the force-closure dispute period this node gives the channels it opens: how long the side that closes a channel unilaterally waits before it can redeem. On Lithium one figure for both sides, the longer of the two proposals; on Lightning the to_self_delay each side sets for the other. Independent of the asset.
| Field | Type | Description |
|---|---|---|
proposed_secs | uint64 | The period this node proposes: what a channel it opens carries when the peer proposes nothing longer. A peer whose payments need more asks for it through OpenChannel's terms |
max_secs | uint64 | The longest period this node accepts having to wait itself; a requirement above it cannot be met by this node |
On Lithium a dispute opened against a channel is settled once its period has passed, and the settlement hands every payment without a preimage back to its sender, so a payment whose deadline lies further ahead than the period could be settled back before its receiver can claim it — and the node refuses it, on either end of the channel. Each channel reports its own period per side as
dispute_periodand the bound this puts on a payment's deadline aspayment_deadline_bound_secs(see Get Channel Terms). A Lightning channel's period is itsto_self_delayand bounds no payment: an HTLC's own deadline is enforced on-chain whatever the channel does.
ReservePolicy — channel reserve policy for a single (network, asset) pair. All DecimalString amounts are in the asset's display denomination.
| Field | Type | Description |
|---|---|---|
proposed_proportional_millionths | uint64? | (2026-09-13) The reserve ratio this node proposes for a channel holding this asset. This is the rate to size against. Range: [0, 1_000_000). Absent on a node predating the field |
counterparty_proportional_millionths | uint64 | Lower bound on a channel reserve this node accepts — an acceptance bound on the negotiated rate, not a rate it imposes. The exception is a protocol that fixes each side's reserve from the other's config and negotiates nothing, where it is that rate. Range: [0, 1_000_000) |
max_self_proportional_millionths | uint64 | Upper bound: the largest reserve ratio this node accepts on its own side. Range: [0, 1_000_000) |
min_absolute | DecimalString | Absolute minimum reserve. The runtime reserve is at least this, whatever the proportional rule says |
fee_channel_reserve | DecimalString | Fixed-fee reserve added on top of the proportional reserve as a gas / dust buffer. Applies once per channel, regardless of how many assets it holds |
Predicting the rate a channel will actually take
(2026-09-13) A channel carries the larger of the two sides' proposals, bounded by what each side accepts. So with both nodes' policies in hand you know the rate before the channel exists:
rate = max(your proposed_proportional_millionths,
their proposed_proportional_millionths)
⚠️ Size againstproposed_, not against a bound
counterparty_proportional_millionthsandmax_self_proportional_millionthsare the bounds of what is acceptable, not the rate. Sizing a deposit or a lease off either one is what makes a caller pay rent on ten times the reserve a channel will actually take — or predict a tenth of it and hang a swap waiting for a balance that never arrives.Read
proposed_proportional_millionthsfrom both ends and take the larger.
⚠️ Absent and zero are differentThe field is optional, and
0is a rate a node can genuinely be configured with — it means the channel holds no proportional reserve at all. Do not read a zero as "unset".Fall back to
max_self_proportional_millionthsonly when the field is absent, i.e. against a node that predates it. That fallback over-sizes, which is the safe direction: undersizing is what hangs.
Example Request:
import { GetNodePolicyRequest } from './proto/node_pb'
const request = new GetNodePolicyRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setAssetId('0x0000000000000000000000000000000000000000000000000000000000000000')
const response = await client.getNodePolicy(request, {})
const reserve = response.getReserve()
if (reserve) {
// The rate to size against. Absent only on a node predating the field —
// and 0 is a real rate, so test for presence, not truthiness.
const proposed = reserve.hasProposedProportionalMillionths()
? reserve.getProposedProportionalMillionths()
: reserve.getMaxSelfProportionalMillionths() // conservative fallback
console.log('Proposed reserve millionths:', proposed)
console.log('Min absolute:', reserve.getMinAbsolute()?.getValue())
console.log('Fee channel reserve:', reserve.getFeeChannelReserve()?.getValue())
}
Example Response:
{
"reserve": {
"proposed_proportional_millionths": 10000,
"counterparty_proportional_millionths": 1000,
"max_self_proportional_millionths": 50000,
"min_absolute": "0.0001",
"fee_channel_reserve": "0.00005"
}
}
Get Channel Terms
Added 2026-09-25.
Read the terms one of this node's channels actually carries: what holds for the channel as a whole, and each of its asset channels' own.
Sibling to Get Node Policy, which answers what the node will negotiate before a channel exists. This answers what a channel did: each side's announcement, already combined by whatever rule the protocol applies, and oriented from this node's point of view.
Use it to price or size against terms the node enforces — a liquidity provider valuing the fees a channel earns, a client sizing a payment against the ceiling the peer will admit or the deadline the channel can hold.
Terms change by announcement — an auth handshake, a gossip policy update, a config reconciliation at boot — not by payment, and the channel-wide ones never change after the channel opens. Cache the response with a TTL; do not re-read it per payment. Balances change every payment and live on the channel listing, which this deliberately does not repeat.
Method: GetChannelTerms
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Target network |
channel_id | string | YES | The channel, as the channel listing names it (Channel.id) |
Answers for every channel the node knows, closed ones included — a closed channel's terms are what it was held to, not an offer to route over it. A channel the node does not know is NOT_FOUND.
Response:
| Field | Type | Description |
|---|---|---|
terms | ChannelTerms | The terms the channel carries |
ChannelTerms — dispute_period and payment_deadline_bound_secs hold for the channel as a whole and are fixed when it opens, on both protocols; asset_channels carries each asset channel's own terms.
| Field | Type | Description |
|---|---|---|
dispute_period | DisputePeriod | The force-closure dispute period on each side: how long the side that closes unilaterally waits before it can redeem what the close settles to it. One figure for both sides on Lithium; on Lightning each side's to_self_delay, as set by the other |
payment_deadline_bound_secs | uint64? | How far past the present a pending payment's deadline may lie on this channel, for its receiver to be sure of claiming it however the channel closes; a payment further out is refused on both ends. On Lithium the dispute period less the node's 30-minute latency allowance. Absent where the protocol enforces a payment's own deadline whatever the channel does, as Lightning does |
asset_channels | map<string, AssetChannelTerms> | The terms each asset channel is held to, keyed by asset id as Channel.asset_channels is |
DisputePeriod
| Field | Type | Description |
|---|---|---|
own_secs | uint64? | What this node waits after closing the channel unilaterally. Absent while not known yet |
peer_secs | uint64? | What the peer waits after closing the channel unilaterally. Absent while not known yet |
AssetChannelTerms
| Field | Type | Description |
|---|---|---|
inbound | DirectionalPolicy? | What this node admits from an arriving payment. Its own announcement, so it does not wait on the peer. What such a payment was charged is forwarding_fee_peer |
outbound | DirectionalPolicy? | What the peer admits from a payment this node sends, under the mirror of the same rule. Absent until the peer has announced — a real state: a channel can be funded and known before its peer has gossiped |
forwarding_fee_self | ForwardingFee? | What this node keeps forwarding over this channel, and equally what a payment it forwards out costs. Absent on a side that has not announced |
forwarding_fee_peer | ForwardingFee? | What the peer keeps forwarding over this channel, and what an arriving payment paid. Absent under the same rule |
reserve_self | ReserveTerms | How the unspendable reserve on this node's side is sized |
reserve_peer | ReserveTerms | How the reserve on the peer's side is sized |
max_pending_payments_total | uint64? | Payments admitted in flight across both directions against one budget. Absent where the protocol has no channel-wide bound |
ForwardingFee — what a forwarding node keeps on a payment.
| Field | Type | Description |
|---|---|---|
proportional_millionths | uint32 | Proportional component of what the forwarder keeps. Both protocols charge one, so 0 means zero. See the note below on which amount it multiplies |
base | DecimalString | Flat component on top of the proportional one. Zero where the protocol charges no flat component |
DirectionalPolicy — what the side receiving one direction will admit. It carries no fee: a forwarder charges on the hop a payment leaves it by, so a direction's cost is the other side's announcement and is reported once, as forwarding_fee_self / _peer.
| Field | Type | Description |
|---|---|---|
max_payment_amount | DecimalString? | The most a single payment may carry in this direction. Absent = this side announced no ceiling and the spendable balance alone binds |
min_payment_amount | DecimalString? | The least a single payment may carry. Absent = no floor |
max_pending_payments | uint64? | Payments this side admits in flight in this direction. Unilateral on both protocols — never merged. Absent where the node cannot read it |
cltv_expiry_delta | Deadline? | The delay the sending side keeps between a payment it accepts and the one it forwards for it, summed per hop by a sender sizing a deadline |
ReserveTerms — the proportional term, floored at the flat one.
| Field | Type | Description |
|---|---|---|
proportional_millionths | uint32 | Proportional term against the asset channel's total liquidity. Zero where the reserve does not scale with the channel |
flat_floor | DecimalString | Flat floor. The reserve is never below this |
⚠️ A direction says what is ADMITTED, not what it COSTSA forwarder charges on the hop a payment leaves it by, so the two are set by opposite ends and are reported in different places.
- What this channel earns you, and what a payment you forward out over it costs —
forwarding_fee_self.- What a payment arriving over it paid —
forwarding_fee_peer.- What either side will accept —
inbound/outbound.There is exactly one copy of each fee, so there is nothing to pick wrong and nothing to drift.
⚠️ A fee is a price, not a settlement figureThe two protocols apply the proportion to different amounts. One multiplies the amount arriving at the forwarder and passes on the remainder; the other multiplies the amount leaving, as BOLT 4 does. For a rate
pand an arriving amountAthat isp·Aagainstp·A/(1+p)— identical to first order, differing byp²·A/(1+p), which at the prevailing 1000 ppm is one millionth of the payment.Each backend's own router and forwarder use the same rule, so a payment it plans and a payment it charges never disagree. Use this rate to price or value a hop; do not re-derive an exact fee from it and reconcile against that.
⚠️ Absent is not zero
forwarding_fee_self/_peerare absent until that side has announced. Charging nothing yet is not charging nothing: reading an absent fee as zero values an un-gossiped channel at zero and under-pays a forward through it.A direction is absent under the same rule, but independently: your own
inboundbounds are reported whether or not the peer has ever spoken, because they are yours and you enforce them on every arriving payment.
Reading the two reserve terms
The reserve a side holds is max(proportional × total_liquidity, flat_floor). The two protocols weight the terms differently, and the difference is what answers "if I deposit more, how much of it is locked?":
- a protocol that recomputes the reserve from current liquidity has a live proportional term, so a deposit raises the reserve in proportion;
- a protocol that sizes the reserve once at channel open and never recomputes it — not even on a splice — reports
proportional_millionths: 0and the realized figure asflat_floor. A deposit there adds no reserve at all.
⚠️max_pending_payments_totalis not the sum of the two directionsA protocol that bounds one shared settlement resource admits fewer payments in total than the two directional caps suggest. Adding them would report a bound nobody enforces. Read the total when it is present, and the directional figures when it is not.
⚠️ An absent direction is not a free one
inbound/outboundabsent means that side has not announced yet, not that it charges nothing. Treating an absentoutboundas a zero fee will under-pay a forward and have it failed back.
Example Request:
import { GetChannelTermsRequest } from './proto/node_pb'
const request = new GetChannelTermsRequest()
request.setNetwork({ protocol: 2, id: '42161' })
request.setChannelId('0x7f3a...')
const terms = (await client.getChannelTerms(request, {})).getTerms()
if (terms.hasPaymentDeadlineBoundSecs()) {
console.log('a payment may carry a deadline up to', terms.getPaymentDeadlineBoundSecs(), 's out')
}
terms.getAssetChannelsMap().forEach((asset, assetId) => {
// What WE earn here, whichever protocol answered. Reading a direction's
// fee instead gives the peer's on one backend and ours on the other.
const earned = asset.getForwardingFeeSelf()
if (!earned) return // not announced yet — not "charges nothing"
console.log(assetId, 'we keep', earned.getProportionalMillionths(), 'ppm to forward')
})
Example Response:
A Lithium channel with a two-day dispute period on both sides, so a payment on
it may carry a deadline up to 47.5 hours out, and one asset channel whose peer
has not announced yet: our own inbound bounds and forwarding_fee_self are
reported, the peer's halves are null. The protocol here recomputes its
reserve from current liquidity and bounds the two directions against one shared
budget. A Lightning channel reports its to_self_delay per side and no
payment_deadline_bound_secs, proportional_millionths: 0 on its reserves, no
max_pending_payments_total, and a block_delta rather than a time_delta.
{
"terms": {
"dispute_period": { "own_secs": 172800, "peer_secs": 172800 },
"payment_deadline_bound_secs": 171000,
"asset_channels": {
"0x0000000000000000000000000000000000000000000000000000000000000000": {
"inbound": {
"max_payment_amount": "2.5",
"min_payment_amount": null,
"max_pending_payments": 50,
"cltv_expiry_delta": { "time_delta": { "seconds": 28800 } }
},
"outbound": null,
"forwarding_fee_self": { "proportional_millionths": 1000, "base": "0" },
"forwarding_fee_peer": null,
"reserve_self": { "proportional_millionths": 10000, "flat_floor": "0" },
"reserve_peer": { "proportional_millionths": 10000, "flat_floor": "0" },
"max_pending_payments_total": 100
}
}
}
}
Get Channel Policy
Added 2026-09-19. Deprecated 2026-09-25.
⚠️ Deprecated: use Get Channel TermsGet Channel Terms reports the same asset terms under the channel they belong to, together with the terms the channel carries as a whole. This method keeps answering unchanged until it is removed; do not build new code on it.
Reports the terms of every asset channel the filters select, one entry each, over the same channels the channel listing returns, closed ones included.
Method: GetChannelPolicy
Parameters: every filter is optional and they AND together. Omitting all of them reports every asset channel on the network.
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Target network |
counterparty | string? | no | Narrow to the channels with one peer, by its node public key in that protocol's own encoding — the same string the channel listing carries, which is hex on some chains and base58 on others |
channel_id | string? | no | Narrow to one channel |
asset_id | string? | no | Narrow to one asset |
Response:
| Field | Type | Description |
|---|---|---|
policies | AssetChannelPolicy[] | One entry per asset channel the filters selected |
AssetChannelPolicy — the keys that identify one asset channel, followed by the fields of AssetChannelTerms with the same numbers and meanings: inbound, outbound, forwarding_fee_self, forwarding_fee_peer, reserve_self, reserve_peer, max_pending_payments_total.
| Field | Type | Description |
|---|---|---|
counterparty | string | The counterparty node, in that protocol's own encoding |
channel_id | string | The channel these terms belong to |
asset_id | string | The asset within the channel |
Estimate Open Channel Fee
Estimate the onchain fee for opening a new channel.
Method: EstimateOpenChannelFee
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network to open channel on |
node_id | string | YES | Peer's node ID |
asset_amounts | map<string, DepositAmount> | YES | Map of asset_id → amount to allocate |
fee_option | FeeOption | YES | Fee priority, or a custom rate |
terms | ChannelOpenTerms? | no | (2026-09-25) What the channel must carry beyond the node's own configuration, as for Open Channel. No term changes the fee; terms no channel can carry are refused here, before anything is signed |
DepositAmount — a oneof, exactly one set:
| Variant | Payload | Meaning |
|---|---|---|
all | {} | Deposit the entire available balance |
exact | { amount: DecimalString } | Deposit this amount |
dual_funded | { local, remote, self_deposit } | Dual-funded open with an explicit split — local / remote are the channel balances assigned to each side, self_deposit is what actually leaves your wallet |
FeeOption — also a oneof, exactly one set:
| Variant | Payload | Meaning |
|---|---|---|
low / medium / high | {} | Use the network's current fee estimate at that priority |
custom | { fee_rate: FeeRate } | Your own rate — FeeRate { max_fee_per_unit, priority_fee_per_unit }, both U256String (base units: sat/vB, wei) |
⚠️DecimalStringamounts are human-readable;U256Stringfee rates are notEvery
amounthere is aDecimalString:"0.1"is 0.1 BTC. Putting"10000000"there does not mean 0.1 BTC — it means ten million BTC.FeeRateis the opposite: it isU256Stringand is in base units. The two look identical on the wire ({value: "…"}), so check which one a field is before filling it in.
Both types above are
oneofs, so the examples nest one level deeper than a flat field would. The examples spell the variant names the way the standard generators emit them — prost names the Rust enum after the oneof itself (deposit_amount::Amount::Exact), protoc-gen-go wraps it (pb.DepositAmount_Exact_). If your generator names them differently, the shape is what matters: an amount is always a variant wrapping{ amount: DecimalString }, never a bare value.node.protoandbalance.protoare authoritative.
⚠️ Asking for the whole balance asexactis a refusal, not a quoteAn on-chain fee is paid on top of the amount, so an
exactamount within a fee of your whole spendable balance cannot be funded — there is nothing left to pay the fee with. These estimates fail in that case rather than quoting a fee for a transaction that cannot be built, so a caller sweeping toward its balance sees a clean refusal instead of a number it cannot use.To fund everything, send
allinstead. It takes the fee out of the amount rather than adding it, and returns the real fee for the resulting transaction.
Response:
| Field | Type | Description |
|---|---|---|
fee | DecimalString | Estimated onchain fee |
Example Request:
TypeScript
const request = new EstimateOpenChannelFeeRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setNodeId('02abc123...')
request.setAssetAmountsMap({
'0x0000000000000000000000000000000000000000000000000000000000000000': { exact: { amount: { value: '0.1' } } }
})
request.setFeeOption({ medium: {} }) // low | medium | high | custom
const response = await client.estimateOpenChannelFee(request, {})
console.log('Estimated fee:', response.getFee(), 'BTC')
Go
req := &pb.EstimateOpenChannelFeeRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
NodeId: "02abc123...",
AssetAmounts: map[string]*pb.Amount{
"0x0000000000000000000000000000000000000000000000000000000000000000": &pb.DepositAmount{Amount: &pb.DepositAmount_Exact_{Exact: &pb.DepositAmount_Exact{Amount: &pb.DecimalString{Value: "0.1"}}}},
},
FeeOption: &pb.FeeOption{FeeOption: &pb.FeeOption_Medium_{Medium: &pb.FeeOption_Medium{}}},
}
resp, err := client.EstimateOpenChannelFee(context.Background(), req)
if err != nil {
log.Fatal(err)
}
log.Println("Estimated fee:", resp.Fee, "BTC")
Rust
let mut asset_amounts = HashMap::new();
asset_amounts.insert("0x0000000000000000000000000000000000000000000000000000000000000000".to_string(), DepositAmount {
amount: Some(deposit_amount::Amount::Exact(deposit_amount::Exact {
amount: Some(DecimalString { value: "0.1".to_string() }),
})),
});
let request = tonic::Request::new(EstimateOpenChannelFeeRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
node_id: "02abc123...".to_string(),
asset_amounts,
fee_option: Some(FeeOption {
fee_option: Some(fee_option::FeeOption::Medium(Default::default())),
}),
});
let response = client.estimate_open_channel_fee(request).await?;
println!("Estimated fee: {} BTC", response.into_inner().fee);
Open Channel
Open a new Lightning channel with a peer.
Method: OpenChannel
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network to open on |
node_id | string | YES | Peer's node ID |
asset_amounts | map<string, DepositAmount> | YES | Assets to allocate |
fee_option | FeeOption | YES | Fee priority, or a custom rate |
terms | ChannelOpenTerms? | no | (2026-09-25) What the channel must carry beyond the node's own configuration. Absent asks for nothing beyond it |
ChannelOpenTerms — the dispute period is a floor the node's own proposal is raised to; the other terms are what the node announces on the channel in place of its configured terms, recorded at open as the channel's own for its life. Each protocol honours the terms it has a term for and ignores the rest.
| Field | Type | Description |
|---|---|---|
min_dispute_period_secs | uint64 | The least force-closure dispute period the channel must carry — see dispute_period on Get Node Policy. The node raises what it proposes to the peer to cover it, and refuses to open when that exceeds what it accepts (max_secs). On Lithium this is the window every pending payment must resolve inside, so a swap's legs need one that reaches their deadlines; on Lightning it is the to_self_delay this node sets for the peer. Zero asks for nothing |
cltv_expiry_delta | Deadline? | (2026-09-25) The delay this node keeps between a payment it takes in over the channel and the one it forwards for it, as a time_delta, in place of the configured one. One per channel on both protocols; never below the protocol's floor (8 h on Lithium) |
assets | map<string, AssetChannelOpenTerms> | (2026-09-25) What this node announces on each asset channel named, keyed by asset id. An asset the channel is not opened with may still be named: the terms apply when it is added |
AssetChannelOpenTerms — every field is optional and an absent or zero one keeps the configured value. Payment bounds are deliberately not here: one protocol announces them as a share of the channel's liquidity and the other as an absolute minimum, so no one figure would mean the same thing to both.
| Field | Type | Description |
|---|---|---|
reserve | ReserveTerms? | How the reserve this node requires of the peer is sized; under a symmetric reserve model the larger of the two sides' terms binds both. The floor is in the asset's display denomination. Lightning has no floor term and keeps the proportional part only |
max_inbound_pending_payments | uint64 | The most payments this node admits in flight from the peer (Lightning's max_accepted_htlcs). Zero keeps the configured value |
max_pending_payments_total | uint64 | Payments admitted in flight across both directions against one budget. Ignored by a protocol with no channel-wide bound. Zero keeps the configured value |
forwarding_fee | ForwardingFee? | What this node keeps forwarding a payment out over the channel, the flat part in the asset's display denomination. On Lithium a flat part is refused, and the proportional part must be the configured one: a Lithium forwarder charges its configured fee on every channel, so a channel announcing another would be priced at one fee and charged at the other. A per-channel fee is a Lightning term |
On Lithium a payment is refused on a channel whose dispute period does not reach its deadline, on either end; the channel reports that bound as
payment_deadline_bound_secs. A swap's legs carry deadlines set by the venue's claim ladder, so a channel opened to settle swaps over is opened with the period those deadlines need; the simple-swap estimator does this on its own, and asks the liquidity service for the same on the channels it has funded (seemin_dispute_period_secson the liquidity operations).
Response:
| Field | Type | Description |
|---|---|---|
txid | string | Funding transaction ID |
channel_id | string | Channel identifier |
Example Request:
TypeScript
const request = new OpenChannelRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setNodeId('02abc123...')
request.setAssetAmountsMap({
'0x0000000000000000000000000000000000000000000000000000000000000000': { exact: { amount: { value: '0.1' } } }
})
request.setFeeOption({ medium: {} })
const response = await client.openChannel(request, {})
console.log('Channel opened!')
console.log(' TX:', response.getTxid())
console.log(' Channel ID:', response.getChannelId())
Go
req := &pb.OpenChannelRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
NodeId: "02abc123...",
AssetAmounts: map[string]*pb.Amount{
"0x0000000000000000000000000000000000000000000000000000000000000000": &pb.DepositAmount{Amount: &pb.DepositAmount_Exact_{Exact: &pb.DepositAmount_Exact{Amount: &pb.DecimalString{Value: "0.1"}}}},
},
FeeOption: &pb.FeeOption{FeeOption: &pb.FeeOption_Medium_{Medium: &pb.FeeOption_Medium{}}},
}
resp, err := client.OpenChannel(context.Background(), req)
if err != nil {
log.Fatal(err)
}
log.Println("Channel opened!")
log.Println(" TX:", resp.Txid)
log.Println(" Channel ID:", resp.ChannelId)
Rust
let mut asset_amounts = HashMap::new();
asset_amounts.insert("0x0000000000000000000000000000000000000000000000000000000000000000".to_string(), DepositAmount {
amount: Some(deposit_amount::Amount::Exact(deposit_amount::Exact {
amount: Some(DecimalString { value: "0.1".to_string() }),
})),
});
let request = tonic::Request::new(OpenChannelRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
node_id: "02abc123...".to_string(),
asset_amounts,
fee_option: Some(FeeOption {
fee_option: Some(fee_option::FeeOption::Medium(Default::default())),
}),
});
let response = client.open_channel(request).await?;
let inner = response.into_inner();
println!("Channel opened!");
println!(" TX: {}", inner.txid);
println!(" Channel ID: {}", inner.channel_id);
Estimate Deposit Channel Fee
Estimate fee for depositing assets into an existing channel.
Method: EstimateDepositChannelFee
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network |
channel_id | string | YES | Channel to deposit to |
asset_amounts | map<string, DepositAmount> | YES | Assets to deposit |
fee_option | FeeOption | YES | Fee priority, or a custom rate |
Response:
| Field | Type | Description |
|---|---|---|
fee | DecimalString | Estimated fee |
Example Request:
TypeScript
const request = new EstimateDepositChannelFeeRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setChannelId('ch_abc123')
request.setAssetAmountsMap({
'0x0000000000000000000000000000000000000000000000000000000000000000': { exact: { amount: { value: '0.05' } } }
})
request.setFeeOption({ medium: {} })
const response = await client.estimateDepositChannelFee(request, {})
console.log('Deposit fee:', response.getFee())
Go
req := &pb.EstimateDepositChannelFeeRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
ChannelId: "ch_abc123",
AssetAmounts: map[string]*pb.Amount{
"0x0000000000000000000000000000000000000000000000000000000000000000": &pb.DepositAmount{Amount: &pb.DepositAmount_Exact_{Exact: &pb.DepositAmount_Exact{Amount: &pb.DecimalString{Value: "0.05"}}}},
},
FeeOption: &pb.FeeOption{FeeOption: &pb.FeeOption_Medium_{Medium: &pb.FeeOption_Medium{}}},
}
resp, err := client.EstimateDepositChannelFee(context.Background(), req)
if err != nil {
log.Fatal(err)
}
log.Println("Deposit fee:", resp.Fee)
Rust
let mut asset_amounts = HashMap::new();
asset_amounts.insert("0x0000000000000000000000000000000000000000000000000000000000000000".to_string(), DepositAmount {
amount: Some(deposit_amount::Amount::Exact(deposit_amount::Exact {
amount: Some(DecimalString { value: "0.05".to_string() }),
})),
});
let request = tonic::Request::new(EstimateDepositChannelFeeRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
channel_id: "ch_abc123".to_string(),
asset_amounts,
fee_option: Some(FeeOption {
fee_option: Some(fee_option::FeeOption::Medium(Default::default())),
}),
});
let response = client.estimate_deposit_channel_fee(request).await?;
println!("Deposit fee: {}", response.into_inner().fee);
Deposit Channel
Deposit additional assets into an existing channel.
Method: DepositChannel
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network |
channel_id | string | YES | Channel to deposit to |
asset_amounts | map<string, DepositAmount> | YES | Assets to deposit |
fee_option | FeeOption | YES | Fee priority, or a custom rate |
Response:
| Field | Type | Description |
|---|---|---|
txid | string | Deposit transaction ID |
Example Request:
TypeScript
const request = new DepositChannelRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setChannelId('ch_abc123')
request.setAssetAmountsMap({
'0x0000000000000000000000000000000000000000000000000000000000000000': { exact: { amount: { value: '0.05' } } }
})
request.setFeeOption({ medium: {} })
const response = await client.depositChannel(request, {})
console.log('Deposit TX:', response.getTxid())
Go
req := &pb.DepositChannelRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
ChannelId: "ch_abc123",
AssetAmounts: map[string]*pb.Amount{
"0x0000000000000000000000000000000000000000000000000000000000000000": &pb.DepositAmount{Amount: &pb.DepositAmount_Exact_{Exact: &pb.DepositAmount_Exact{Amount: &pb.DecimalString{Value: "0.05"}}}},
},
FeeOption: &pb.FeeOption{FeeOption: &pb.FeeOption_Medium_{Medium: &pb.FeeOption_Medium{}}},
}
resp, err := client.DepositChannel(context.Background(), req)
if err != nil {
log.Fatal(err)
}
log.Println("Deposit TX:", resp.Txid)
Rust
let mut asset_amounts = HashMap::new();
asset_amounts.insert("0x0000000000000000000000000000000000000000000000000000000000000000".to_string(), DepositAmount {
amount: Some(deposit_amount::Amount::Exact(deposit_amount::Exact {
amount: Some(DecimalString { value: "0.05".to_string() }),
})),
});
let request = tonic::Request::new(DepositChannelRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
channel_id: "ch_abc123".to_string(),
asset_amounts,
fee_option: Some(FeeOption {
fee_option: Some(fee_option::FeeOption::Medium(Default::default())),
}),
});
let response = client.deposit_channel(request).await?;
println!("Deposit TX: {}", response.into_inner().txid);
Estimate Withdraw Channel Fee
Estimate fee for withdrawing assets from a channel.
Method: EstimateWithdrawChannelFee
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network |
channel_id | string | YES | Channel to withdraw from |
asset_amounts | map<string, WithdrawAmount> | YES | Amounts to withdraw |
fee_option | FeeOption | YES | Fee priority, or a custom rate |
WithdrawAmount Object:
| Field | Type | Description |
|---|---|---|
self_withdrawal | Amount | Amount you withdraw |
counterparty_withdrawal | Amount | Amount the counterparty withdraws |
Response:
| Field | Type | Description |
|---|---|---|
fee | DecimalString | Estimated fee |
Example Request:
TypeScript
const request = new EstimateWithdrawChannelFeeRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setChannelId('ch_abc123')
request.setAssetAmountsMap({
'0x0000000000000000000000000000000000000000000000000000000000000000': {
selfWithdrawal: { exact: { amount: { value: '0.03' } } },
counterpartyWithdrawal: { exact: { amount: { value: '0.02' } } }
}
})
request.setFeeOption({ medium: {} })
const response = await client.estimateWithdrawChannelFee(request, {})
console.log('Withdrawal fee:', response.getFee())
Go
req := &pb.EstimateWithdrawChannelFeeRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
ChannelId: "ch_abc123",
AssetAmounts: map[string]*pb.WithdrawAmount{
"0x0000000000000000000000000000000000000000000000000000000000000000": {
SelfWithdrawal: &pb.Amount{Amount: &pb.Amount_Exact_{Exact: &pb.Amount_Exact{Amount: &pb.DecimalString{Value: "0.03"}}}},
CounterpartyWithdrawal: &pb.Amount{Amount: &pb.Amount_Exact_{Exact: &pb.Amount_Exact{Amount: &pb.DecimalString{Value: "0.02"}}}},
},
},
FeeOption: &pb.FeeOption{FeeOption: &pb.FeeOption_Medium_{Medium: &pb.FeeOption_Medium{}}},
}
resp, err := client.EstimateWithdrawChannelFee(context.Background(), req)
if err != nil {
log.Fatal(err)
}
log.Println("Withdrawal fee:", resp.Fee)
Rust
let mut asset_amounts = HashMap::new();
asset_amounts.insert("0x0000000000000000000000000000000000000000000000000000000000000000".to_string(), WithdrawAmount {
self_withdrawal: Some(Amount { amount: Some(amount::Amount::Exact(amount::Exact { amount: Some(DecimalString { value: "0.03".to_string() }) })) }),
counterparty_withdrawal: Some(Amount { amount: Some(amount::Amount::Exact(amount::Exact { amount: Some(DecimalString { value: "0.02".to_string() }) })) }),
});
let request = tonic::Request::new(EstimateWithdrawChannelFeeRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
channel_id: "ch_abc123".to_string(),
asset_amounts,
fee_option: Some(FeeOption {
fee_option: Some(fee_option::FeeOption::Medium(Default::default())),
}),
});
let response = client.estimate_withdraw_channel_fee(request).await?;
println!("Withdrawal fee: {}", response.into_inner().fee);
Withdraw Channel
Withdraw assets from an existing channel.
Method: WithdrawChannel
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network |
channel_id | string | YES | Channel to withdraw from |
asset_amounts | map<string, WithdrawAmount> | YES | Amounts to withdraw |
fee_option | FeeOption | YES | Fee priority, or a custom rate |
Response:
| Field | Type | Description |
|---|---|---|
txid | string | Withdrawal transaction ID |
Example Request:
TypeScript
const request = new WithdrawChannelRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setChannelId('ch_abc123')
request.setAssetAmountsMap({
'0x0000000000000000000000000000000000000000000000000000000000000000': {
selfWithdrawal: { exact: { amount: { value: '0.03' } } },
counterpartyWithdrawal: { exact: { amount: { value: '0.02' } } }
}
})
request.setFeeOption({ medium: {} })
const response = await client.withdrawChannel(request, {})
console.log('Withdrawal TX:', response.getTxid())
Go
req := &pb.WithdrawChannelRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
ChannelId: "ch_abc123",
AssetAmounts: map[string]*pb.WithdrawAmount{
"0x0000000000000000000000000000000000000000000000000000000000000000": {
SelfWithdrawal: &pb.Amount{Amount: &pb.Amount_Exact_{Exact: &pb.Amount_Exact{Amount: &pb.DecimalString{Value: "0.03"}}}},
CounterpartyWithdrawal: &pb.Amount{Amount: &pb.Amount_Exact_{Exact: &pb.Amount_Exact{Amount: &pb.DecimalString{Value: "0.02"}}}},
},
},
FeeOption: &pb.FeeOption{FeeOption: &pb.FeeOption_Medium_{Medium: &pb.FeeOption_Medium{}}},
}
resp, err := client.WithdrawChannel(context.Background(), req)
if err != nil {
log.Fatal(err)
}
log.Println("Withdrawal TX:", resp.Txid)
Rust
let mut asset_amounts = HashMap::new();
asset_amounts.insert("0x0000000000000000000000000000000000000000000000000000000000000000".to_string(), WithdrawAmount {
self_withdrawal: Some(Amount { amount: Some(amount::Amount::Exact(amount::Exact { amount: Some(DecimalString { value: "0.03".to_string() }) })) }),
counterparty_withdrawal: Some(Amount { amount: Some(amount::Amount::Exact(amount::Exact { amount: Some(DecimalString { value: "0.02".to_string() }) })) }),
});
let request = tonic::Request::new(WithdrawChannelRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
channel_id: "ch_abc123".to_string(),
asset_amounts,
fee_option: Some(FeeOption {
fee_option: Some(fee_option::FeeOption::Medium(Default::default())),
}),
});
let response = client.withdraw_channel(request).await?;
println!("Withdrawal TX: {}", response.into_inner().txid);
Estimate Update Channel Fee
Estimates the on-chain fee for a generic channel update before executing it.
Method: EstimateUpdateChannelFee
Parameters: same as Update Channel — network, channel_id, asset_updates, fee_option.
Response:
| Field | Type | Description |
|---|---|---|
fee | DecimalString | Estimated fee in the network's native asset |
Update Channel
Added 2026-09-11.
The general form of a channel update. Each side's wallet may deposit or withdraw independently, and part of the resulting balance may be credited across in the state co-signed alongside the transaction.
DepositChannel and WithdrawChannel are unchanged — they are the two shapes of this that are common enough to have their own call. UpdateChannel is how you reach every other combination: a deposit on one side against a withdrawal on the other, a withdrawal that hands part of itself to the peer, a pure credit with no wallet movement at all.
Method: UpdateChannel
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network |
channel_id | string | YES | Channel to update |
asset_updates | map<string, ChannelUpdateAmount> | YES | asset_id → what that asset's update asks for |
fee_option | FeeOption | YES | Fee priority, or a custom rate |
Response:
| Field | Type | Description |
|---|---|---|
txid | string | The update transaction ID |
ChannelUpdateAmount
Three parts, each independently optional:
| Field | Type | Description |
|---|---|---|
self_contribution | ChannelContribution | What your wallet contributes |
peer_contribution | ChannelContribution | What the counterparty's wallet contributes |
credit | BalanceCredit | What crosses between the two sides once both have settled |
ChannelContribution — a oneof over the direction that side's wallet moves:
| Arm | Payload | Meaning |
|---|---|---|
deposit | Amount | Out of the wallet, into the channel |
withdraw | Amount | Out of the channel, into the wallet |
BalanceCredit — a oneof over which way balance crosses:
| Arm | Payload | Meaning |
|---|---|---|
to_peer | DecimalString | Credit this much of your balance to the counterparty |
to_self | DecimalString | Credit this much of theirs to you |
Unset is not zero — it is cheaperLeaving a
contributionor acreditunset means that side's wallet does not move and nothing crosses. That is not the same as naming"0": an explicit zero is an amount the transaction still has to carry, and it costs more gas. Name only the part you care about.
A credit touches no wallet and reaches no chain
creditis settled in the co-signed state that follows the on-chain update, not in the transaction itself. It is how value moves between the two sides without either wallet paying for it — and it is what lets an exit carve its own fee out of what it withdraws.Your side must have granted the peer room for it: a credit
to_peeris bounded byallowed_paymenton that peer's funding allowance.
The two contributions must not cancelThe chain refuses an update that leaves the channel holding exactly what it already held. If your deposit on one side equals the peer's withdrawal on the other, you have described a payment, not an update — send it as one.
All resolves differently per direction
Amount.all is relative to whatever pot that side is drawing from:
withdraw: { all: {} }— that side's channel balance.deposit: { all: {} }— the local wallet. This makes it invalid onpeer_contribution, where the peer's wallet is not visible to your node; it is rejected.withdraw: { all: {} }on a side that also pays acredit— that side's withdrawable balance minus the credit. The on-chain movement settles first and the credit comes out of what is left, so the two never over-draw each other.
Example Request:
import { UpdateChannelRequest } from './proto/node_pb'
const BTC = '0x0000000000000000000000000000000000000000000000000000000000000000'
// Withdraw everything, handing 0.0001 to the peer out of what comes back —
// the shape a service-broadcast exit uses to pay its own fee.
const request = new UpdateChannelRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setChannelId('ch_abc123')
request.setAssetUpdatesMap({
[BTC]: {
selfContribution: { withdraw: { all: {} } },
// peerContribution left unset — their wallet does not move
credit: { toPeer: { value: '0.0001' } },
},
})
request.setFeeOption({ medium: {} })
console.log('update TX:', (await client.updateChannel(request, {})).getTxid())
Example Response:
{ "txid": "abc123def456..." }
The same operation is available atomically alongside others —
BatchChannelOperation.updatecarries aBatchUpdateChannelwith the samechannel_id+asset_updatespair.
Prefer
DepositChannel/WithdrawChannelwhen they say what you mean. They are the same machinery with a narrower request, and a reader of your code can tell at a glance which way funds are going.
Estimate Close Channel Fee
Estimate fee for cooperatively closing a channel.
Method: EstimateCloseChannelFee
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network |
channel_id | string | YES | Channel to close |
asset_ids | string[] | YES | Assets to settle |
settlement_credits | map<string, BalanceCredit> | NO | Map of asset_id → balance moved between the two sides in the split the close pays out. A close pays each side its own balance, so this is what lets one side be paid a fee out of the channel being closed — including out of a reserve, which no off-chain payment may spend. An asset absent from the map pays each side exactly what it holds. An asset here that asset_ids does not settle is an error. |
fee_option | FeeOption | YES | Fee priority, or a custom rate |
Response:
| Field | Type | Description |
|---|---|---|
fee | DecimalString | Estimated fee |
Example Request:
TypeScript
const request = new EstimateCloseChannelFeeRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setChannelId('ch_abc123')
request.setAssetIdsList(['0x0000000000000000000000000000000000000000000000000000000000000000'])
request.setFeeOption({ medium: {} })
const response = await client.estimateCloseChannelFee(request, {})
console.log('Close fee:', response.getFee())
Go
req := &pb.EstimateCloseChannelFeeRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
ChannelId: "ch_abc123",
AssetIds: []string{"0x0000000000000000000000000000000000000000000000000000000000000000"},
FeeOption: &pb.FeeOption{FeeOption: &pb.FeeOption_Medium_{Medium: &pb.FeeOption_Medium{}}},
}
resp, err := client.EstimateCloseChannelFee(context.Background(), req)
if err != nil {
log.Fatal(err)
}
log.Println("Close fee:", resp.Fee)
Rust
let request = tonic::Request::new(EstimateCloseChannelFeeRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
channel_id: "ch_abc123".to_string(),
asset_ids: vec!["0x0000000000000000000000000000000000000000000000000000000000000000".to_string()],
fee_option: Some(FeeOption {
fee_option: Some(fee_option::FeeOption::Medium(Default::default())),
}),
});
let response = client.estimate_close_channel_fee(request).await?;
println!("Close fee: {}", response.into_inner().fee);
Close Channel
Cooperatively close a channel with your peer.
Method: CloseChannel
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network |
channel_id | string | YES | Channel to close |
asset_ids | string[] | YES | Assets to settle |
settlement_credits | map<string, BalanceCredit> | NO | Map of asset_id → balance moved between the two sides in the split the close pays out. A close pays each side its own balance, so this is what lets one side be paid a fee out of the channel being closed — including out of a reserve, which no off-chain payment may spend. An asset absent from the map pays each side exactly what it holds. An asset here that asset_ids does not settle is an error. |
fee_option | FeeOption | YES | Fee priority, or a custom rate |
Response:
| Field | Type | Description |
|---|---|---|
txid | string | Closing transaction ID |
Example Request:
TypeScript
const request = new CloseChannelRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setChannelId('ch_abc123')
request.setAssetIdsList(['0x0000000000000000000000000000000000000000000000000000000000000000'])
request.setFeeOption({ medium: {} })
const response = await client.closeChannel(request, {})
console.log('Channel closed, TX:', response.getTxid())
Go
req := &pb.CloseChannelRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
ChannelId: "ch_abc123",
AssetIds: []string{"0x0000000000000000000000000000000000000000000000000000000000000000"},
FeeOption: &pb.FeeOption{FeeOption: &pb.FeeOption_Medium_{Medium: &pb.FeeOption_Medium{}}},
}
resp, err := client.CloseChannel(context.Background(), req)
if err != nil {
log.Fatal(err)
}
log.Println("Channel closed, TX:", resp.Txid)
Rust
let request = tonic::Request::new(CloseChannelRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
channel_id: "ch_abc123".to_string(),
asset_ids: vec!["0x0000000000000000000000000000000000000000000000000000000000000000".to_string()],
fee_option: Some(FeeOption {
fee_option: Some(fee_option::FeeOption::Medium(Default::default())),
}),
});
let response = client.close_channel(request).await?;
println!("Channel closed, TX: {}", response.into_inner().txid);
Estimate Force Close Channel Fee
Estimate fee for force-closing an unresponsive channel.
Method: EstimateForceCloseChannelFee
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network |
channel_id | string | YES | Channel to force close |
asset_ids | string[] | YES | Assets to settle |
fee_option | FeeOption | YES | Fee priority, or a custom rate |
Response:
| Field | Type | Description |
|---|---|---|
fee | DecimalString | Estimated fee |
Example Request:
TypeScript
const request = new EstimateForceCloseChannelFeeRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setChannelId('ch_abc123')
request.setAssetIdsList(['0x0000000000000000000000000000000000000000000000000000000000000000'])
request.setFeeOption({ medium: {} })
const response = await client.estimateForceCloseChannelFee(request, {})
console.log('Force close fee:', response.getFee())
Go
req := &pb.EstimateForceCloseChannelFeeRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
ChannelId: "ch_abc123",
AssetIds: []string{"0x0000000000000000000000000000000000000000000000000000000000000000"},
FeeOption: &pb.FeeOption{FeeOption: &pb.FeeOption_Medium_{Medium: &pb.FeeOption_Medium{}}},
}
resp, err := client.EstimateForceCloseChannelFee(context.Background(), req)
if err != nil {
log.Fatal(err)
}
log.Println("Force close fee:", resp.Fee)
Rust
let request = tonic::Request::new(EstimateForceCloseChannelFeeRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
channel_id: "ch_abc123".to_string(),
asset_ids: vec!["0x0000000000000000000000000000000000000000000000000000000000000000".to_string()],
fee_option: Some(FeeOption {
fee_option: Some(fee_option::FeeOption::Medium(Default::default())),
}),
});
let response = client.estimate_force_close_channel_fee(request).await?;
println!("Force close fee: {}", response.into_inner().fee);
Force Close Channel
Force close a channel without peer cooperation.
(2026-09-25) Refused with
FAILED_PRECONDITIONwhile the channel holds the inbound of a swap this node is still running: on Lithium the peer may confirm a dispute the moment it stands, and the confirmation hands every payment without a preimage back to its sender, so the swap's inbound would be lost the instant its preimage arrived. Wait for the swap to settle or fail. A pending payment the swap engine does not know is not checked, and the same estimate refuses the same closes.
Method: ForceCloseChannel
Important Notes:
- Use only when peer is unresponsive
- Assets locked until dispute period expires
- May require additional RedeemClosedChannel transaction
- Higher fees than cooperative close
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network |
channel_id | string | YES | Channel to force close |
asset_ids | string[] | YES | Assets to settle |
fee_option | FeeOption | YES | Fee priority, or a custom rate |
Response:
| Field | Type | Description |
|---|---|---|
txid | string | Force close transaction ID |
Example Request:
TypeScript
const request = new ForceCloseChannelRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setChannelId('ch_abc123')
request.setAssetIdsList(['0x0000000000000000000000000000000000000000000000000000000000000000'])
request.setFeeOption({ medium: {} })
const response = await client.forceCloseChannel(request, {})
console.log('Force close initiated, TX:', response.getTxid())
console.log('Assets will be spendable after dispute period')
Go
req := &pb.ForceCloseChannelRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
ChannelId: "ch_abc123",
AssetIds: []string{"0x0000000000000000000000000000000000000000000000000000000000000000"},
FeeOption: &pb.FeeOption{FeeOption: &pb.FeeOption_Medium_{Medium: &pb.FeeOption_Medium{}}},
}
resp, err := client.ForceCloseChannel(context.Background(), req)
if err != nil {
log.Fatal(err)
}
log.Println("Force close initiated, TX:", resp.Txid)
log.Println("Assets will be spendable after dispute period")
Rust
let request = tonic::Request::new(ForceCloseChannelRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
channel_id: "ch_abc123".to_string(),
asset_ids: vec!["0x0000000000000000000000000000000000000000000000000000000000000000".to_string()],
fee_option: Some(FeeOption {
fee_option: Some(fee_option::FeeOption::Medium(Default::default())),
}),
});
let response = client.force_close_channel(request).await?;
println!("Force close initiated, TX: {}", response.into_inner().txid);
println!("Assets will be spendable after dispute period");
Estimate Redeem Closed Channel Fee
Estimate fee for redeeming a force-closed channel.
Method: EstimateRedeemClosedChannelFee
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network |
channel_id | string | YES | Closed channel |
asset_ids | string[] | YES | Assets to redeem |
fee_option | FeeOption | YES | Fee priority, or a custom rate |
Response:
| Field | Type | Description |
|---|---|---|
fee | DecimalString | Estimated fee |
Example Request:
TypeScript
const request = new EstimateRedeemClosedChannelFeeRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setChannelId('ch_abc123')
request.setAssetIdsList(['0x0000000000000000000000000000000000000000000000000000000000000000'])
request.setFeeOption({ medium: {} })
const response = await client.estimateRedeemClosedChannelFee(request, {})
console.log('Redemption fee:', response.getFee())
Go
req := &pb.EstimateRedeemClosedChannelFeeRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
ChannelId: "ch_abc123",
AssetIds: []string{"0x0000000000000000000000000000000000000000000000000000000000000000"},
FeeOption: &pb.FeeOption{FeeOption: &pb.FeeOption_Medium_{Medium: &pb.FeeOption_Medium{}}},
}
resp, err := client.EstimateRedeemClosedChannelFee(context.Background(), req)
if err != nil {
log.Fatal(err)
}
log.Println("Redemption fee:", resp.Fee)
Rust
let request = tonic::Request::new(EstimateRedeemClosedChannelFeeRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
channel_id: "ch_abc123".to_string(),
asset_ids: vec!["0x0000000000000000000000000000000000000000000000000000000000000000".to_string()],
fee_option: Some(FeeOption {
fee_option: Some(fee_option::FeeOption::Medium(Default::default())),
}),
});
let response = client.estimate_redeem_closed_channel_fee(request).await?;
println!("Redemption fee: {}", response.into_inner().fee);
Redeem Closed Channel
Redeem assets from a force-closed channel after dispute period.
(2026-09-25) On Lithium, refused while the peer's dispute holds a payment of this node's without a preimage on chain, naming the payment: the side that did not open a dispute may confirm its settlement at once, and the confirmation would hand that payment back to its sender. The channel becomes redeemable once the preimage lands on chain or the payment's deadline passes.
Method: RedeemClosedChannel
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network |
channel_id | string | YES | Closed channel |
asset_ids | string[] | YES | Assets to redeem |
fee_option | FeeOption | YES | Fee priority, or a custom rate |
Response:
| Field | Type | Description |
|---|---|---|
txid | string | Redemption transaction ID |
redeemed | map<string, DecimalString> | (2026-09-15) What this wallet recovers, per asset ID, in whole units |
redeemed is read as the redemption is built, not after. The redemption is what takes that balance off the channel, so once the transaction exists there is nothing left to report it from — and on-chain the payout arrives as a contract transfer that carries no transfer record of its own for a native asset. Assets with nothing to redeem are absent rather than zero; an empty map is a confirmation broadcast on a channel whose own side is already paid out.
Example Request:
TypeScript
const request = new RedeemClosedChannelRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setChannelId('ch_abc123')
request.setAssetIdsList(['0x0000000000000000000000000000000000000000000000000000000000000000'])
request.setFeeOption({ medium: {} })
const response = await client.redeemClosedChannel(request, {})
console.log('Assets redeemed, TX:', response.getTxid())
Go
req := &pb.RedeemClosedChannelRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
ChannelId: "ch_abc123",
AssetIds: []string{"0x0000000000000000000000000000000000000000000000000000000000000000"},
FeeOption: &pb.FeeOption{FeeOption: &pb.FeeOption_Medium_{Medium: &pb.FeeOption_Medium{}}},
}
resp, err := client.RedeemClosedChannel(context.Background(), req)
if err != nil {
log.Fatal(err)
}
log.Println("Assets redeemed, TX:", resp.Txid)
Rust
let request = tonic::Request::new(RedeemClosedChannelRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
channel_id: "ch_abc123".to_string(),
asset_ids: vec!["0x0000000000000000000000000000000000000000000000000000000000000000".to_string()],
fee_option: Some(FeeOption {
fee_option: Some(fee_option::FeeOption::Medium(Default::default())),
}),
});
let response = client.redeem_closed_channel(request).await?;
println!("Assets redeemed, TX: {}", response.into_inner().txid);
Batch Channel Operations
Executes a list of channel operations atomically in a single transaction — all of them land, or none do.
(2026-09-25) A batch is held to the refusals its single operations carry: a
closeover a slot with a payment still in flight, aforce_closeon a channel holding the inbound of a swap this node is still running (FAILED_PRECONDITION, naming the swap), and aredeemnaming an asset whose dispute this node may not settle yet, are refused before anything is built, and so is the batch's estimate.
Method: BatchChannelOperations
BatchChannelOperation is a oneof over seven arms:
| Arm | Payload | Equivalent single call |
|---|---|---|
open | BatchOpenChannel | OpenChannel |
deposit | BatchDepositChannel | DepositChannel |
withdraw | BatchWithdrawChannel | WithdrawChannel |
close | BatchCloseChannel | CloseChannel |
force_close | BatchForceCloseChannel | ForceCloseChannel |
redeem | BatchRedeemChannel | RedeemClosedChannel |
update | BatchUpdateChannel — { channel_id, asset_updates } | (2026-09-11) UpdateChannel |
Each Batch* payload is its single call's request minus network and fee_option, which the batch supplies once for all of them.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Target network |
operations | BatchChannelOperation[] | YES | Operations to execute atomically |
fee_option | FeeOption | YES | Fee priority, or a custom rate |
Response:
| Field | Type | Description |
|---|---|---|
txid | string | Single transaction ID for the entire batch |
opened_channels | BatchOpenedChannel[] | Channels created by Open operations (carries the input index of the originating operation) |
Example Request:
import { BatchChannelOperationsRequest } from './proto/node_pb'
const request = new BatchChannelOperationsRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setOperationsList([/* BatchChannelOperation entries */])
request.setFeeOption({ medium: {} })
const response = await client.batchChannelOperations(request, {})
console.log('Batch TX:', response.getTxid())
Estimate Batch Channel Operations Fee
Estimates fees for a batch operation before executing it.
Method: EstimateBatchChannelOperationsFee
Parameters: Same as Batch Channel Operations — network, operations, fee_option.
Response:
| Field | Type | Description |
|---|---|---|
fee | DecimalString | The estimated total on-chain fee for the whole batch, in the network's native asset |
The estimate returns only the fee — no
txid, noopened_channels. Those exist onBatchChannelOperations, which actually broadcasts. An earlier revision of this page repeated that response table here.
Example:
const response = await client.estimateBatchChannelOperationsFee(request, {})
console.log('Batch fee:', response.getFee()?.getValue())
Estimate Simulated Channel Operation Fee
Estimates the fee for a single hypothetical operation against a simulated channel snapshot — useful for UI fee previews where the channel doesn't yet exist.
Method: EstimateSimulatedChannelOperationFee
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Target network |
channel | SimulatedChannel | YES | Hypothetical channel snapshot |
operation | SimulatedChannelOperation enum | YES | Which operation to price |
fee_option | FeeOption | YES | Fee priority, or a custom rate |
Response:
| Field | Type | Description |
|---|---|---|
fee | DecimalString | Estimated fee, in the network's native currency |
SimulatedChannel
{ assets: SimulatedAssetChannel[] } — one entry per asset the hypothetical channel would hold:
| Field | Type | Description |
|---|---|---|
asset_id | string | The asset |
onchain_self_balance | DecimalString | Your currently committed on-chain balance |
onchain_counterparty_balance | DecimalString | The counterparty's currently committed on-chain balance |
latest_self_balance | DecimalString | Your latest off-chain balance |
latest_counterparty_balance | DecimalString | The counterparty's latest off-chain balance |
num_htlcs | uint64 | Pending HTLCs — they enlarge the commitment transaction, so they move the fee |
SimulatedChannelOperation
An enum, not a message:
| Value | Constant |
|---|---|
0 | SIMULATED_CHANNEL_OPERATION_UNSPECIFIED — invalid default |
1 | SIMULATED_CHANNEL_OPERATION_OPEN |
2 | SIMULATED_CHANNEL_OPERATION_DEPOSIT |
3 | SIMULATED_CHANNEL_OPERATION_WITHDRAW |
4 | SIMULATED_CHANNEL_OPERATION_COOPERATIVE_CLOSE |
5 | SIMULATED_CHANNEL_OPERATION_FORCE_CLOSE |
6 | SIMULATED_CHANNEL_OPERATION_CONFIRM_SETTLEMENT |
SimulatedChannelOpis a different type — the{ channel, operation }pair taken by the plural call, which prices several snapshots at once. The singular call takes the channel and the enum as separate fields.
Example:
import { EstimateSimulatedChannelOperationFeeRequest } from './proto/node_pb'
const request = new EstimateSimulatedChannelOperationFeeRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setChannel({ assets: [{
assetId: '0x0000000000000000000000000000000000000000000000000000000000000000',
onchainSelfBalance: { value: '0.1' },
onchainCounterpartyBalance: { value: '0' },
latestSelfBalance: { value: '0.1' },
latestCounterpartyBalance: { value: '0' },
numHtlcs: 0,
}] })
request.setOperation(2) // SIMULATED_CHANNEL_OPERATION_DEPOSIT
request.setFeeOption({ medium: {} })
const response = await client.estimateSimulatedChannelOperationFee(request, {})
console.log('Estimate:', response.getFee()?.getValue())
Estimate Simulated Channel Operations Fee
Estimates the total fee for a list of hypothetical operations across simulated channels — the multi-operation counterpart to the previous RPC.
Method: EstimateSimulatedChannelOperationsFee
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Target network |
operations | SimulatedChannelOp[] | YES | Simulated operations to estimate |
fee_option | FeeOption | YES | Fee priority, or a custom rate |
SimulatedChannelOp pairs a snapshot with the operation to price against it:
| Field | Type | Description |
|---|---|---|
channel | SimulatedChannel | The hypothetical channel |
operation | SimulatedChannelOperation enum | What to price |
Response:
| Field | Type | Description |
|---|---|---|
fee | DecimalString | Estimated total fee for all of them, in the network's native asset |
This is not the sum of the singular estimates. Operations batched into one transaction share its overhead, which is the reason to ask for them together.
Example:
import { EstimateSimulatedChannelOperationsFeeRequest } from './proto/node_pb'
const request = new EstimateSimulatedChannelOperationsFeeRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setOperationsList([/* SimulatedChannelOp entries */])
request.setFeeOption({ medium: {} })
console.log('Total:', (await client.estimateSimulatedChannelOperationsFee(request, {})).getFee()?.getValue())
Send Channel Payment
Send a direct payment through a specific channel.
(2026-09-25) A
hashlocknaming a hash this node does not hold the preimage for makes a held payment: the payee holds it as offered until someone resolves it with the preimage (Resolve Hashlock Payment) or rejects it. A payee that issued a hold invoice for the hash (Create Invoice withhashlock) holds a payment to that invoice's terms only when the payment pays the invoice. Without ahashlockthe node generates the preimage and the payment settles on its own.
Method: SendChannelPayment
Use Cases:
- Direct peer payments
- Testing channel functionality
- Hashlock payments (HTLC)
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network |
channel_id | string | YES | Channel to use |
asset_amounts | map<string, Amount> | YES | Assets to send |
hashlock | Hashlock | NO | Optional hashlock for HTLC |
cltv_buffer_secs | uint64 | NO | Renamed from expiry_timeout_secs (2026-05-10). Optional HTLC effective lifetime in seconds (BOLT-11 tag c semantic). KeySend has no separate invoice-validity knob — this buffer alone bounds the HTLC. |
Hashlock — a oneof, not a struct with a hash field. Which arm you set decides who can settle the payment:
| Arm | Payload | Meaning |
|---|---|---|
known | { preimage: string } | You hold the preimage, hex-encoded. The recipient can claim as soon as the HTLC arrives |
unknown | { hash: string } | You hold only the hash, hex-encoded. The payment parks as pending_preimage until someone supplies the preimage — see ResolveHashlockPayment |
There is no
payment_hashfield onHashlock. An earlier revision of this page showed one; the proto has always carried the two-arm oneof. Sending{ hash }where you meant{ preimage }produces a payment nobody can settle.
Response:
| Field | Type | Description |
|---|---|---|
payment_id | string | Payment identifier |
Example Request:
TypeScript
const request = new SendChannelPaymentRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setChannelId('ch_abc123')
request.setAssetAmountsMap({
'0x0000000000000000000000000000000000000000000000000000000000000000': { exact: { amount: { value: '0.001' } } }
})
// Optional: set hashlock for HTLC
// request.setHashlock({ paymentHash: 'abc123...' })
request.setCltvBufferSecs(3600) // HTLC lifetime, seconds
const response = await client.sendChannelPayment(request, {})
console.log('Payment sent:', response.getPaymentId())
Go
req := &pb.SendChannelPaymentRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
ChannelId: "ch_abc123",
AssetAmounts: map[string]*pb.Amount{
"0x0000000000000000000000000000000000000000000000000000000000000000": &pb.Amount{Amount: &pb.Amount_Exact_{Exact: &pb.Amount_Exact{Amount: &pb.DecimalString{Value: "0.001"}}}},
},
// Optional: set hashlock for HTLC
// Hashlock: &pb.Hashlock{PaymentHash: "abc123..."},
CltvBufferSecs: 3600, // HTLC lifetime, seconds
}
resp, err := client.SendChannelPayment(context.Background(), req)
if err != nil {
log.Fatal(err)
}
log.Println("Payment sent:", resp.PaymentId)
Rust
let mut asset_amounts = HashMap::new();
asset_amounts.insert("0x0000000000000000000000000000000000000000000000000000000000000000".to_string(), Amount {
amount: Some(amount::Amount::Exact(amount::Exact {
amount: Some(DecimalString { value: "0.001".to_string() }),
})),
});
let request = tonic::Request::new(SendChannelPaymentRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
channel_id: "ch_abc123".to_string(),
asset_amounts,
// Optional: set hashlock for HTLC
// hashlock: Some(Hashlock {
// payment_hash: "abc123...".to_string(),
// }),
cltv_buffer_secs: Some(3600), // HTLC lifetime, seconds
});
let response = client.send_channel_payment(request).await?;
println!("Payment sent: {}", response.into_inner().payment_id);
Estimate Send Payment Fee
Estimate fee for a routed Lightning payment.
Method: EstimateSendPaymentFee
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network |
recipient_node_id | string | YES | Recipient's node ID |
asset_amounts | map<string, Amount> | YES | Assets to send |
hashlock | Hashlock | NO | Optional hashlock |
cltv_buffer_secs | uint64 | NO | Renamed from expiry_timeout_secs (2026-05-10). HTLC effective lifetime, seconds (BOLT-11 c). |
max_total_cltv_secs | uint64 | NO | Added 2026-05-10. Optional clamp on the route's max-total-CLTV (seconds). For atomic-swap-correct timing pass the invoice's cltv_buffer_secs; for permissive multi-hop routing omit. |
Response:
| Field | Type | Description |
|---|---|---|
fees | map<string, DecimalString> | Fees per asset |
Example Request:
TypeScript
const request = new EstimateSendPaymentFeeRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setRecipientNodeId('03def456...')
request.setAssetAmountsMap({
'0x0000000000000000000000000000000000000000000000000000000000000000': { exact: { amount: { value: '0.005' } } }
})
const response = await client.estimateSendPaymentFee(request, {})
const fees = response.getFeesMap()
console.log('Routing fee:', fees.get('0x0000000000000000000000000000000000000000000000000000000000000000'), 'BTC')
Go
req := &pb.EstimateSendPaymentFeeRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
RecipientNodeId: "03def456...",
AssetAmounts: map[string]*pb.Amount{
"0x0000000000000000000000000000000000000000000000000000000000000000": &pb.Amount{Amount: &pb.Amount_Exact_{Exact: &pb.Amount_Exact{Amount: &pb.DecimalString{Value: "0.005"}}}},
},
}
resp, err := client.EstimateSendPaymentFee(context.Background(), req)
if err != nil {
log.Fatal(err)
}
fees := resp.Fees
log.Println("Routing fee:", fees["0x0000000000000000000000000000000000000000000000000000000000000000"], "BTC")
Rust
let mut asset_amounts = HashMap::new();
asset_amounts.insert("0x0000000000000000000000000000000000000000000000000000000000000000".to_string(), Amount {
amount: Some(amount::Amount::Exact(amount::Exact {
amount: Some(DecimalString { value: "0.005".to_string() }),
})),
});
let request = tonic::Request::new(EstimateSendPaymentFeeRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
recipient_node_id: "03def456...".to_string(),
asset_amounts,
hashlock: None,
cltv_buffer_secs: None,
});
let response = client.estimate_send_payment_fee(request).await?;
let fees = response.into_inner().fees;
println!("Routing fee: {} BTC", fees.get("0x0000000000000000000000000000000000000000000000000000000000000000").unwrap_or(&"0".to_string()));
Send Payment
Send a routed Lightning payment through the network.
(2026-09-25) As for Send Channel Payment, a
hashlocknaming a hash this node cannot open makes a held payment, which the payee holds as offered. The payee of an invoice receives exactly the deadline the invoice promised whatever the route: the first hop carries that deadline plus every forwarding hop's delay, and each forwarder takes its own delay off. The route search crosses only channels whose dispute window can hold the deadline, so a covering channel beside a short one to the same peer is the one taken; a payment is refused only when no route can carry its deadline, because every hop's channel falls short of it or it lies past the protocol's ceiling.
Method: SendPayment
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network |
recipient_node_id | string | YES | Recipient's node ID |
asset_amounts | map<string, Amount> | YES | Assets to send |
hashlock | Hashlock | NO | Optional hashlock |
cltv_buffer_secs | uint64 | NO | Renamed from expiry_timeout_secs (2026-05-10). HTLC effective lifetime, seconds (BOLT-11 c). |
max_total_cltv_secs | uint64 | NO | Added 2026-05-10. Optional clamp on the route's max-total-CLTV (seconds). For atomic-swap-correct timing pass the invoice's cltv_buffer_secs; for permissive multi-hop routing omit. |
Response:
| Field | Type | Description |
|---|---|---|
payment_id | string | Payment identifier |
Example Request:
TypeScript
const request = new SendPaymentRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setRecipientNodeId('03def456...')
request.setAssetAmountsMap({
'0x0000000000000000000000000000000000000000000000000000000000000000': { exact: { amount: { value: '0.005' } } }
})
request.setCltvBufferSecs(3600)
const response = await client.sendPayment(request, {})
console.log('Payment routed:', response.getPaymentId())
Go
req := &pb.SendPaymentRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
RecipientNodeId: "03def456...",
AssetAmounts: map[string]*pb.Amount{
"0x0000000000000000000000000000000000000000000000000000000000000000": &pb.Amount{Amount: &pb.Amount_Exact_{Exact: &pb.Amount_Exact{Amount: &pb.DecimalString{Value: "0.005"}}}},
},
CltvBufferSecs: 3600,
}
resp, err := client.SendPayment(context.Background(), req)
if err != nil {
log.Fatal(err)
}
log.Println("Payment routed:", resp.PaymentId)
Rust
let mut asset_amounts = HashMap::new();
asset_amounts.insert("0x0000000000000000000000000000000000000000000000000000000000000000".to_string(), Amount {
amount: Some(amount::Amount::Exact(amount::Exact {
amount: Some(DecimalString { value: "0.005".to_string() }),
})),
});
let request = tonic::Request::new(SendPaymentRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
recipient_node_id: "03def456...".to_string(),
asset_amounts,
hashlock: None,
cltv_buffer_secs: Some(3600),
});
let response = client.send_payment(request).await?;
println!("Payment routed: {}", response.into_inner().payment_id);
Create Invoice
Create a Lightning invoice for receiving payment.
(2026-09-25) On Lithium a payment naming this invoice's secret is held to what the invoice promised: the hash it committed to, at least its amount, and a deadline no earlier than
expiry + cltv_buffer; one that falls short, or names a secret this node never issued, is refused on arrival. An invoice whose deadline lies past the dispute window of every channel the payer could use is unpayable, so keepexpiry_timeout_secs + cltv_buffer_secsinside the bound of the channels the payer holds with this node (payment_deadline_bound_secson Get Channel Terms).
Method: CreateInvoice
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network |
asset_id | string | YES | Asset to receive |
amount | DecimalString | NO | Amount (omit for zero-amount invoice) |
hashlock | Hashlock | NO | Optional hashlock |
expiry_timeout_secs | uint64 | NO | Invoice validity window in seconds (BOLT-11 x). Unchanged — only the payment-send RPCs renamed this field. |
cltv_buffer_secs | uint64 | NO | Added 2026-05-10. Extra HTLC lifetime past invoice expiry the receiver requires for safe settlement (BOLT-11 c). |
Response:
| Field | Type | Description |
|---|---|---|
invoice | Invoice | Invoice details |
Invoice Object:
| Field | Type | Description |
|---|---|---|
network | Network | The network this invoice belongs to |
payment_request | string | Encoded payment request string — the string you share with the payer |
payment_hash | string | Hex-encoded payment hash |
payment_secret | string | Payment secret |
amount | U256String? | Invoice amount, in base units (sats / wei) — not a DecimalString. Absent on an empty invoice |
asset_id | string | Asset identifier |
recipient | string | Recipient node id |
expiry_timestamp | Timestamp? | Invoice validity expiry. Absent when the invoice does not expire |
min_final_cltv_expiry_secs | uint64 | Added 2026-05-10. Minimum CLTV buffer (seconds) the receiver requires for the incoming HTLC. The HTLC's effective deadline is expiry_timestamp + min_final_cltv_expiry_secs. |
signature / signable_hash | bytes | Invoice signature and the hash it covers |
Invoice.amountisU256String, notDecimalString— one of the few amount fields in the API that really is in base units. See the note under Estimate Open Channel Fee.
Example Request:
TypeScript
const request = new CreateInvoiceRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setAssetId('0x0000000000000000000000000000000000000000000000000000000000000000')
request.setAmount({ value: '0.001' }) // DecimalString — 0.001 BTC, not satoshis
request.setExpiryTimeoutSecs(3600)
const response = await client.createInvoice(request, {})
const invoice = response.getInvoice()
console.log('Payment request:', invoice.getPaymentRequest())
console.log('Share this with the payer')
Go
req := &pb.CreateInvoiceRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
AssetId: "0x0000000000000000000000000000000000000000000000000000000000000000",
Amount: "1000000",
ExpiryTimeoutSecs: 3600,
}
resp, err := client.CreateInvoice(context.Background(), req)
if err != nil {
log.Fatal(err)
}
invoice := resp.Invoice
log.Println("Payment request:", invoice.PaymentRequest)
log.Println("Share this with the payer")
Rust
let request = tonic::Request::new(CreateInvoiceRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
asset_id: "0x0000000000000000000000000000000000000000000000000000000000000000".to_string(),
amount: "1000000".to_string(),
hashlock: None,
expiry_timeout_secs: 3600,
});
let response = client.create_invoice(request).await?;
let invoice = response.into_inner().invoice.unwrap();
println!("Payment request: {}", invoice.payment_request);
println!("Share this with the payer");
Example (Zero-amount invoice):
TypeScript
const request = new CreateInvoiceRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setAssetId('0x0000000000000000000000000000000000000000000000000000000000000000')
// No amount set - payer decides
const response = await client.createInvoice(request, {})
const invoice = response.getInvoice()
console.log('Zero-amount invoice:', invoice.getPaymentRequest())
Go
req := &pb.CreateInvoiceRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
AssetId: "0x0000000000000000000000000000000000000000000000000000000000000000",
// No amount set - payer decides
}
resp, err := client.CreateInvoice(context.Background(), req)
if err != nil {
log.Fatal(err)
}
invoice := resp.Invoice
log.Println("Zero-amount invoice:", invoice.PaymentRequest)
Rust
let request = tonic::Request::new(CreateInvoiceRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
asset_id: "0x0000000000000000000000000000000000000000000000000000000000000000".to_string(),
amount: String::new(), // No amount set - payer decides
hashlock: None,
expiry_timeout_secs: 0,
});
let response = client.create_invoice(request).await?;
let invoice = response.into_inner().invoice.unwrap();
println!("Zero-amount invoice: {}", invoice.payment_request);
Decode Invoice
Decode a Lightning invoice to view its details.
Method: DecodeInvoice
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network |
payment_request | string | YES | Encoded payment request |
Response:
| Field | Type | Description |
|---|---|---|
invoice | Invoice | Decoded invoice details |
Example Request:
TypeScript
const request = new DecodeInvoiceRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setPaymentRequest('lnbc10m1...')
const response = await client.decodeInvoice(request, {})
const invoice = response.getInvoice()
console.log('Amount:', invoice.getAmount())
console.log('Asset:', invoice.getAssetId())
console.log('Expires:', invoice.getExpiry())
Go
req := &pb.DecodeInvoiceRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
PaymentRequest: "lnbc10m1...",
}
resp, err := client.DecodeInvoice(context.Background(), req)
if err != nil {
log.Fatal(err)
}
invoice := resp.Invoice
log.Println("Amount:", invoice.Amount)
log.Println("Asset:", invoice.AssetId)
log.Println("Expires:", invoice.Expiry)
Rust
let request = tonic::Request::new(DecodeInvoiceRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
payment_request: "lnbc10m1...".to_string(),
});
let response = client.decode_invoice(request).await?;
let invoice = response.into_inner().invoice.unwrap();
println!("Amount: {}", invoice.amount);
println!("Asset: {}", invoice.asset_id);
println!("Expires: {:?}", invoice.expiry);
Estimate Pay Invoice Fee
Estimate routing fee for paying an invoice.
Method: EstimatePayInvoiceFee
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network |
payment_request | string | YES | Invoice to pay |
max_total_cltv_secs | uint64 | NO | Added 2026-05-10. Optional clamp on the route's max-total-CLTV (seconds). For atomic-swap-correct timing pass a value matching the invoice's cltv_buffer_secs; for permissive multi-hop routing omit. |
Response:
| Field | Type | Description |
|---|---|---|
fee | DecimalString | Estimated routing fee |
Example Request:
TypeScript
const request = new EstimatePayInvoiceFeeRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setPaymentRequest('lnbc10m1...')
const response = await client.estimatePayInvoiceFee(request, {})
console.log('Routing fee:', response.getFee(), 'BTC')
Go
req := &pb.EstimatePayInvoiceFeeRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
PaymentRequest: "lnbc10m1...",
}
resp, err := client.EstimatePayInvoiceFee(context.Background(), req)
if err != nil {
log.Fatal(err)
}
log.Println("Routing fee:", resp.Fee, "BTC")
Rust
let request = tonic::Request::new(EstimatePayInvoiceFeeRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
payment_request: "lnbc10m1...".to_string(),
});
let response = client.estimate_pay_invoice_fee(request).await?;
println!("Routing fee: {} BTC", response.into_inner().fee);
Pay Invoice
Pay a Lightning invoice.
Method: PayInvoice
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network |
payment_request | string | YES | Invoice to pay |
max_total_cltv_secs | uint64 | NO | Added 2026-05-10. Optional clamp on the route's max-total-CLTV (seconds). For atomic-swap-correct timing pass a value matching the invoice's cltv_buffer_secs; for permissive multi-hop routing omit. |
Response:
| Field | Type | Description |
|---|---|---|
payment_id | string | Payment identifier |
Example Request:
TypeScript
const request = new PayInvoiceRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setPaymentRequest('lnbc10m1...')
const response = await client.payInvoice(request, {})
console.log('Payment sent:', response.getPaymentId())
Go
req := &pb.PayInvoiceRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
PaymentRequest: "lnbc10m1...",
}
resp, err := client.PayInvoice(context.Background(), req)
if err != nil {
log.Fatal(err)
}
log.Println("Payment sent:", resp.PaymentId)
Rust
let request = tonic::Request::new(PayInvoiceRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
payment_request: "lnbc10m1...".to_string(),
});
let response = client.pay_invoice(request).await?;
println!("Payment sent: {}", response.into_inner().payment_id);
Estimate Pay Empty Invoice Fee
Estimate fee for paying a zero-amount invoice with a specific amount.
Method: EstimatePayEmptyInvoiceFee
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network |
payment_request | string | YES | Zero-amount invoice |
amount | Amount | YES | Amount to pay |
max_total_cltv_secs | uint64 | NO | Added 2026-05-10. Optional clamp on the route's max-total-CLTV (seconds). For atomic-swap-correct timing pass a value matching the invoice's cltv_buffer_secs; for permissive multi-hop routing omit. |
Response:
| Field | Type | Description |
|---|---|---|
fee | DecimalString | Estimated routing fee |
Example Request:
TypeScript
const request = new EstimatePayEmptyInvoiceFeeRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setPaymentRequest('lnbc1...')
request.setAmount({ exact: { amount: { value: '0.02' } } })
const response = await client.estimatePayEmptyInvoiceFee(request, {})
console.log('Fee:', response.getFee())
Go
req := &pb.EstimatePayEmptyInvoiceFeeRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
PaymentRequest: "lnbc1...",
Amount: &pb.Amount{Amount: &pb.Amount_Exact_{Exact: &pb.Amount_Exact{Amount: &pb.DecimalString{Value: "0.02"}}}},
}
resp, err := client.EstimatePayEmptyInvoiceFee(context.Background(), req)
if err != nil {
log.Fatal(err)
}
log.Println("Fee:", resp.Fee)
Rust
let request = tonic::Request::new(EstimatePayEmptyInvoiceFeeRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
payment_request: "lnbc1...".to_string(),
amount: Some(Amount { amount: Some(amount::Amount::Exact(amount::Exact { amount: Some(DecimalString { value: "0.02".to_string() }) })) }),
});
let response = client.estimate_pay_empty_invoice_fee(request).await?;
println!("Fee: {}", response.into_inner().fee);
Pay Empty Invoice
Pay a zero-amount invoice with a specific amount.
Method: PayEmptyInvoice
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network |
payment_request | string | YES | Zero-amount invoice |
amount | Amount | YES | Amount to pay |
max_total_cltv_secs | uint64 | NO | Added 2026-05-10. Optional clamp on the route's max-total-CLTV (seconds). For atomic-swap-correct timing pass a value matching the invoice's cltv_buffer_secs; for permissive multi-hop routing omit. |
Response:
| Field | Type | Description |
|---|---|---|
payment_id | string | Payment identifier |
Example Request:
TypeScript
const request = new PayEmptyInvoiceRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setPaymentRequest('lnbc1...')
request.setAmount({ exact: { amount: { value: '0.02' } } })
const response = await client.payEmptyInvoice(request, {})
console.log('Payment sent:', response.getPaymentId())
Go
req := &pb.PayEmptyInvoiceRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
PaymentRequest: "lnbc1...",
Amount: &pb.Amount{Amount: &pb.Amount_Exact_{Exact: &pb.Amount_Exact{Amount: &pb.DecimalString{Value: "0.02"}}}},
}
resp, err := client.PayEmptyInvoice(context.Background(), req)
if err != nil {
log.Fatal(err)
}
log.Println("Payment sent:", resp.PaymentId)
Rust
let request = tonic::Request::new(PayEmptyInvoiceRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
payment_request: "lnbc1...".to_string(),
amount: Some(Amount { amount: Some(amount::Amount::Exact(amount::Exact { amount: Some(DecimalString { value: "0.02".to_string() }) })) }),
});
let response = client.pay_empty_invoice(request).await?;
println!("Payment sent: {}", response.into_inner().payment_id);
Resolve Hashlock Payment
Claim a hashlock payment by revealing the preimage.
Method: ResolveHashlockPayment
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network |
payment_id | string | YES | Payment to resolve |
payment_preimage | string | YES | Preimage (32 bytes hex) |
Response: Empty (success confirmation)
Example Request:
TypeScript
const request = new ResolveHashlockPaymentRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setPaymentId('payment_abc123')
request.setPaymentPreimage('0123456789abcdef...')
await client.resolveHashlockPayment(request, {})
console.log('Payment claimed')
Go
req := &pb.ResolveHashlockPaymentRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
PaymentId: "payment_abc123",
PaymentPreimage: "0123456789abcdef...",
}
_, err := client.ResolveHashlockPayment(context.Background(), req)
if err != nil {
log.Fatal(err)
}
log.Println("Payment claimed")
Rust
let request = tonic::Request::new(ResolveHashlockPaymentRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
payment_id: "payment_abc123".to_string(),
payment_preimage: "0123456789abcdef...".to_string(),
});
client.resolve_hashlock_payment(request).await?;
println!("Payment claimed");
Reject Payment
Reject an incoming payment.
Method: RejectPayment
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network |
payment_id | string | YES | Payment to reject |
Response: Empty (success confirmation)
Example Request:
TypeScript
const request = new RejectPaymentRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setPaymentId('payment_abc123')
await client.rejectPayment(request, {})
console.log('Payment rejected')
Go
req := &pb.RejectPaymentRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
PaymentId: "payment_abc123",
}
_, err := client.RejectPayment(context.Background(), req)
if err != nil {
log.Fatal(err)
}
log.Println("Payment rejected")
Rust
let request = tonic::Request::new(RejectPaymentRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
payment_id: "payment_abc123".to_string(),
});
client.reject_payment(request).await?;
println!("Payment rejected");
Register Preimage
Moved (2026-06-11).
RegisterPreimagewas removed fromNodeService. Registering a revealed preimage now lives on the newPreimageService.SettlePreimage(JSON-RPC namespacepreimage), which does everythingRegisterPreimagedid — persist the preimage, claim held channel hashlock payments, arm the force-close — and additionally claims matching on-chain HTLCs on HTLC-capable networks. The request fields are identical (network, hexpayment_preimage).
Set Funding Allowance
Sets the per-asset funding allowance for a peer — how much they may fund, and how much of your balance they may be credited. Replaces any existing allowance.
Funding allowances let you accept incoming channel deposits, hashlock-funded payments, and service-broadcast operations from a known peer without approving each one by hand.
Method: SetFundingAllowance
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Target network |
node_id | string | YES | Peer public key |
asset_allowances | map<string, FundingAllowance> | YES | Per-asset allowances |
FundingAllowance:
| Field | Type | Description |
|---|---|---|
allowed_deposit | DecimalString | Maximum deposit the peer may initiate |
allowed_payment | DecimalString | Maximum balance the peer may be credited out of this side, whatever moved it |
allowed_withdrawal | DecimalString | (2026-09-16) Maximum this node's own channel balance may be reduced by an on-chain update the peer initiates, settling those funds back to this node. 0 means a peer may not move this side at all |
Two of these bound your wallet; the third bounds your channel
allowed_depositandallowed_paymentare about funds entering — how much a peer may fund on your behalf, and how much of what it funded it may take back.allowed_withdrawalis about funds leaving a channel you already hold, on a transaction the peer authors.They are deliberately separate: a node happy to have a channel funded on its behalf is not necessarily happy to have it emptied on its behalf. Setting the first two generously does not grant the third — and
0, the zero value, is a real setting meaning no peer-initiated withdrawal at all.
allowed_paymentcaps more than paymentsThe name is narrower than the rule. It bounds every way your balance can cross to that peer without a payment of your own — a claim out of a deposit, and equally the
BalanceCreditin a co-signed channel update, which is how a service-broadcast operation takes its fee.That covers a fee carved out of a deposit the peer funded and one carved out of a withdrawal it pays back, which is why
allowed_withdrawalneeds no payment field of its own. Size it against the largest such operation you intend to allow — not just against deposits.
Response: Empty.
Example Request:
import { SetFundingAllowanceRequest } from './proto/node_pb'
const request = new SetFundingAllowanceRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setNodeId('02abc123...')
request.getAssetAllowancesMap().set('0x0000000000000000000000000000000000000000000000000000000000000000', {
allowedDeposit: { value: '1.0' },
allowedPayment: { value: '0.5' },
allowedWithdrawal: { value: '0.5' }
})
await client.setFundingAllowance(request, {})
Get Funding Allowance
Returns the current remaining funding allowances for a peer.
Method: GetFundingAllowance
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Target network |
node_id | string | YES | Peer public key |
Response:
| Field | Type | Description |
|---|---|---|
asset_allowances | map<string, FundingAllowance> | Remaining allowance per asset |
Example Request:
import { GetFundingAllowanceRequest } from './proto/node_pb'
const request = new GetFundingAllowanceRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setNodeId('02abc123...')
const response = await client.getFundingAllowance(request, {})
const map = response.getAssetAllowancesMap()
map.forEach((allowance, assetId) => {
console.log(assetId, allowance.toObject())
})
Increase Funding Allowance
Increases an existing funding allowance for a peer by a delta. Each entry in asset_allowances is added to the current allowance for that asset.
Method: IncreaseFundingAllowance
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Target network |
node_id | string | YES | Peer public key |
asset_allowances | map<string, FundingAllowance> | YES | Deltas to add |
Response: Empty.
Example Request:
import { IncreaseFundingAllowanceRequest } from './proto/node_pb'
const request = new IncreaseFundingAllowanceRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setNodeId('02abc123...')
request.getAssetAllowancesMap().set('0x0000000000000000000000000000000000000000000000000000000000000000', {
allowedDeposit: { value: '0.5' },
allowedPayment: { value: '0.25' },
allowedWithdrawal: { value: '0.25' }
})
await client.increaseFundingAllowance(request, {})
Decrease Funding Allowance
Decreases an existing funding allowance for a peer by a delta. Subtracts each entry from the current allowance.
Method: DecreaseFundingAllowance
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Target network |
node_id | string | YES | Peer public key |
asset_allowances | map<string, FundingAllowance> | YES | Deltas to subtract |
Response: Empty.
Example Request:
import { DecreaseFundingAllowanceRequest } from './proto/node_pb'
const request = new DecreaseFundingAllowanceRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setNodeId('02abc123...')
request.getAssetAllowancesMap().set('0x0000000000000000000000000000000000000000000000000000000000000000', {
allowedDeposit: { value: '0.1' },
allowedPayment: { value: '0.05' },
allowedWithdrawal: { value: '0.05' }
})
await client.decreaseFundingAllowance(request, {})
Revoke Funding Allowance
Revokes all funding allowances for a peer. Equivalent to setting every asset's allowance to zero.
Method: RevokeFundingAllowance
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Target network |
node_id | string | YES | Peer public key |
Response: Empty.
Example Request:
import { RevokeFundingAllowanceRequest } from './proto/node_pb'
const request = new RevokeFundingAllowanceRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setNodeId('02abc123...')
await client.revokeFundingAllowance(request, {})
Common Workflows
Open channel and wait for activation
There is no WaitForActiveAssetChannel RPC — that pattern was replaced by event subscriptions. Subscribe to event.SubscribeNodeEvents before calling OpenChannel, then watch for the channel-active update for your new channel_id.
import {
OpenChannelRequest,
EstimateOpenChannelFeeRequest,
} from './proto/node_pb'
import { SubscribeNodeEventsRequest } from './proto/event_pb'
async function openAndWaitForChannel(
node: NodeServiceClient,
events: EventServiceClient,
network: { protocol: number; id: string },
peerNodeId: string,
assetId: string,
amountDecimal: string,
) {
// 1. Subscribe BEFORE opening so we don't miss the activation event.
const sub = new SubscribeNodeEventsRequest()
sub.setNetwork(network)
const stream = events.subscribeNodeEvents(sub, {})
// 2. Open the channel.
const open = new OpenChannelRequest()
open.setNetwork(network)
open.setNodeId(peerNodeId)
open.getAssetAmountsMap().set(assetId, {
exact: { amount: { value: amountDecimal } } // DepositAmount oneof
})
open.setFeeOption({ medium: {} })
const opened = await node.openChannel(open, {})
const channelId = opened.getChannelId()
console.log('OpenChannel broadcast, channel:', channelId, 'tx:', opened.getTxid())
// 3. Wait for the channel to go active for this asset.
await new Promise<void>((resolve, reject) => {
stream.on('data', (evt: any) => {
const upd = evt.getChannelUpdate?.()
if (!upd) return
if (upd.getChannelId?.() !== channelId) return
// Asset-channel status `active` means it's ready to send & receive.
const assetChannel = upd.getAssetChannelsMap?.().get(assetId)
if (assetChannel?.getStatus?.()?.getActive?.()) {
stream.cancel()
resolve()
}
})
stream.on('error', reject)
})
console.log('Channel is active for', assetId)
return channelId
}
The exact field paths above (
channel_update,asset_channels,Active) match the currentevent.protoandchannel.proto. If you regenerate code from a newer proto and the names change, follow the proto rather than this snippet.
Create and pay invoice
TypeScript
async function createAndPayInvoice(
client: NodeServiceClient,
network: Network,
assetId: string,
amount: string
) {
// Create invoice
const createReq = new CreateInvoiceRequest()
createReq.setNetwork(network)
createReq.setAssetId(assetId)
createReq.setAmount(amount)
createReq.setExpiryTimeoutSecs(3600)
const createResp = await client.createInvoice(createReq, {})
const invoice = createResp.getInvoice()
const paymentRequest = invoice.getPaymentRequest()
console.log('Invoice created:', paymentRequest)
// Decode to verify
const decodeReq = new DecodeInvoiceRequest()
decodeReq.setNetwork(network)
decodeReq.setPaymentRequest(paymentRequest)
const decoded = await client.decodeInvoice(decodeReq, {})
console.log('Amount:', decoded.getInvoice()?.getAmount())
// Pay invoice
const payReq = new PayInvoiceRequest()
payReq.setNetwork(network)
payReq.setPaymentRequest(paymentRequest)
const payResp = await client.payInvoice(payReq, {})
console.log('Payment sent:', payResp.getPaymentId())
}
Go
func createAndPayInvoice(
client pb.NodeServiceClient,
network *pb.Network,
assetId string,
amount string,
) error {
// Create invoice
createReq := &pb.CreateInvoiceRequest{
Network: network,
AssetId: assetId,
Amount: amount,
ExpiryTimeoutSecs: 3600,
}
createResp, err := client.CreateInvoice(context.Background(), createReq)
if err != nil {
return err
}
invoice := createResp.Invoice
paymentRequest := invoice.PaymentRequest
log.Println("Invoice created:", paymentRequest)
// Decode to verify
decodeReq := &pb.DecodeInvoiceRequest{
Network: network,
PaymentRequest: paymentRequest,
}
decoded, err := client.DecodeInvoice(context.Background(), decodeReq)
if err != nil {
return err
}
log.Println("Amount:", decoded.Invoice.Amount)
// Pay invoice
payReq := &pb.PayInvoiceRequest{
Network: network,
PaymentRequest: paymentRequest,
}
payResp, err := client.PayInvoice(context.Background(), payReq)
if err != nil {
return err
}
log.Println("Payment sent:", payResp.PaymentId)
return nil
}
Rust
async fn create_and_pay_invoice(
client: &mut NodeServiceClient<Channel>,
network: Network,
asset_id: String,
amount: String,
) -> Result<(), Box<dyn std::error::Error>> {
// Create invoice
let create_req = tonic::Request::new(CreateInvoiceRequest {
network: Some(network.clone()),
asset_id,
amount,
hashlock: None,
expiry_timeout_secs: 3600,
});
let create_resp = client.create_invoice(create_req).await?;
let invoice = create_resp.into_inner().invoice.unwrap();
let payment_request = invoice.payment_request.clone();
println!("Invoice created: {}", payment_request);
// Decode to verify
let decode_req = tonic::Request::new(DecodeInvoiceRequest {
network: Some(network.clone()),
payment_request: payment_request.clone(),
});
let decoded = client.decode_invoice(decode_req).await?;
println!("Amount: {}", decoded.into_inner().invoice.unwrap().amount);
// Pay invoice
let pay_req = tonic::Request::new(PayInvoiceRequest {
network: Some(network),
payment_request,
});
let pay_resp = client.pay_invoice(pay_req).await?;
println!("Payment sent: {}", pay_resp.into_inner().payment_id);
Ok(())
}
Safe channel closure
TypeScript
async function safeCloseChannel(
client: NodeServiceClient,
network: Network,
channelId: string,
assetIds: string[]
) {
// Try cooperative close first
try {
const closeReq = new CloseChannelRequest()
closeReq.setNetwork(network)
closeReq.setChannelId(channelId)
closeReq.setAssetIdsList(assetIds)
closeReq.setFeeOption({ medium: {} })
const response = await client.closeChannel(closeReq, {})
console.log('Channel closed cooperatively:', response.getTxid())
return
} catch (error) {
console.log('Cooperative close failed, trying force close...')
}
// Force close if cooperative fails
const forceReq = new ForceCloseChannelRequest()
forceReq.setNetwork(network)
forceReq.setChannelId(channelId)
forceReq.setAssetIdsList(assetIds)
forceReq.setFeeOption({ high: {} })
const forceResp = await client.forceCloseChannel(forceReq, {})
console.log('Force close initiated:', forceResp.getTxid())
console.log('Wait for dispute period before redeeming')
}
Go
func safeCloseChannel(
client pb.NodeServiceClient,
network *pb.Network,
channelId string,
assetIds []string,
) error {
// Try cooperative close first
closeReq := &pb.CloseChannelRequest{
Network: network,
ChannelId: channelId,
AssetIds: assetIds,
FeeOption: &pb.FeeOption{FeeOption: &pb.FeeOption_Medium_{Medium: &pb.FeeOption_Medium{}}},
}
closeResp, err := client.CloseChannel(context.Background(), closeReq)
if err == nil {
log.Println("Channel closed cooperatively:", closeResp.Txid)
return nil
}
log.Println("Cooperative close failed, trying force close...")
// Force close if cooperative fails
forceReq := &pb.ForceCloseChannelRequest{
Network: network,
ChannelId: channelId,
AssetIds: assetIds,
FeeOption: &pb.FeeOption{FeeOption: &pb.FeeOption_Medium_{Medium: &pb.FeeOption_Medium{}}},
}
forceResp, err := client.ForceCloseChannel(context.Background(), forceReq)
if err != nil {
return err
}
log.Println("Force close initiated:", forceResp.Txid)
log.Println("Wait for dispute period before redeeming")
return nil
}
Rust
async fn safe_close_channel(
client: &mut NodeServiceClient<Channel>,
network: Network,
channel_id: String,
asset_ids: Vec<String>,
) -> Result<(), Box<dyn std::error::Error>> {
// Try cooperative close first
let close_req = tonic::Request::new(CloseChannelRequest {
network: Some(network.clone()),
channel_id: channel_id.clone(),
asset_ids: asset_ids.clone(),
fee_option: Some(FeeOption {
fee_option: Some(fee_option::FeeOption::Medium(Default::default())),
}),
});
match client.close_channel(close_req).await {
Ok(response) => {
println!("Channel closed cooperatively: {}", response.into_inner().txid);
return Ok(());
}
Err(_) => {
println!("Cooperative close failed, trying force close...");
}
}
// Force close if cooperative fails
let force_req = tonic::Request::new(ForceCloseChannelRequest {
network: Some(network),
channel_id,
asset_ids,
fee_option: Some(FeeOption {
fee_option: Some(fee_option::FeeOption::Medium(Default::default())),
}),
});
let force_resp = client.force_close_channel(force_req).await?;
println!("Force close initiated: {}", force_resp.into_inner().txid);
println!("Wait for dispute period before redeeming");
Ok(())
}
Best Practices
- Subscribe to
event.SubscribeNodeEventsbefore opening or paying — channel-active and payment-completed signals come through the stream, not as a return value of the opening RPC. - Estimate fees first — every channel-mutating RPC has a paired
Estimate*variant that takes the same request and returns just the fee. - Try
CloseChannelbeforeForceCloseChannel— cooperative close is cheaper and faster. - Pick reasonable invoice expirations — 1–24 hours is the common range; very short expirations stress retry logic.
- Check capacity before sending — call
EstimateSendPaymentFeeorwatchOnlyNode.GetChannelto confirm the asset side has the balance you need. - Keep hashlock preimages secret until you're ready to release the funds — see
preimage.SettlePreimage(which replacedRegisterPreimage) andResolveHashlockPayment. - Reuse channels via
DepositChannelrather than opening a new one for the same peer / asset.
Channel Lifecycle
1. ConnectToPeer
↓
2. (optionally) AddPeerToZeroConfWhitelist
↓
3. SubscribeNodeEvents ← keep this stream open for steps 4–9
↓
4. OpenChannel
↓
5. wait for ChannelUpdate { asset_channels[…].status = Active }
↓
6. SendPayment / CreateInvoice + PayInvoice / SendChannelPayment
↓
7. DepositChannel (top up either side)
↓
8. WithdrawChannel (pull funds out without closing)
↓
9. CloseChannel → RedeemClosedChannel (if force closed)
Service-backed alternative for steps 4–5 and 7–9: use the Lease API (
liquidity.RequestChannelLiquidity/RequestChannelRelease) instead ofOpenChannel/DepositChannel/WithdrawChannel/CloseChannel. The lease flow handles the same lifecycle but the liquidity service signs and broadcasts the underlying transactions for you.