Api

Node API

Channel and payment management

The Node API provides Lightning Network channel management and payment operations.

JSON-RPC namespace: node

Endpoints

Peer Management

Zero-Conf Whitelist

Peer Blacklist

Node Configuration

Channel Operations

Batch Channel Operations

Payment Operations

Invoice Management

Funding Allowances


Connect to Peer

Connect to a Lightning Network peer.

Method: ConnectToPeer

Parameters:

NameTypeRequiredDescription
networkNetworkYESNetwork to connect on
peer_urlstringYESPeer 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:

NameTypeRequiredDescription
networkNetworkYESNetwork to query

Response:

FieldTypeDescription
node_idsstring[]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:

NameTypeRequiredDescription
networkNetworkYESTarget network
node_idstringYESPeer 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:

NameTypeRequiredDescription
networkNetworkYESTarget network
node_idstringYESPeer public key (hex)

Response:

FieldTypeDescription
is_connectedboolTrue 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:

NameTypeRequiredDescription
networkNetworkYESTarget network
watchtower_urlstringYESThe 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.yaml can declare a watchtowers list; the node dials those itself once its initial chain sync completes and keeps them attached across restarts. Use ConnectToWatchtower to attach one at runtime, and GetConnectedWatchtowers to 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:

NameTypeRequiredDescription
networkNetworkYESTarget network

Response:

FieldTypeDescription
node_idsstring[]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 watchtowers configured 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:

NameTypeRequiredDescription
networkNetworkYESTarget network

Response:

FieldTypeDescription
node_idsstring[]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:

NameTypeRequiredDescription
networkNetworkYESTarget network
node_idstringYESPeer 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:

NameTypeRequiredDescription
networkNetworkYESTarget network
node_idstringYESPeer 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:

NameTypeRequiredDescription
networkNetworkYESTarget network

Response:

FieldTypeDescription
node_idsstring[]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:

NameTypeRequiredDescription
networkNetworkYESTarget network
node_idstringYESThe 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:

NameTypeRequiredDescription
networkNetworkYESTarget network
node_idstringYESThe 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:

NameTypeRequiredDescription
networkNetworkYESTarget network
asset_idstringYESAsset whose policy you want to read — required because several policy values are denominated in the asset's smallest unit

Response:

FieldTypeDescription
reserveReservePolicyChannel-reserve parameters. Drives the deposit sizing algebra deposit = (free + fixed) / (1 − rate).
dispute_periodDisputePeriodPolicy?(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.

FieldTypeDescription
proposed_secsuint64The 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_secsuint64The 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_period and the bound this puts on a payment's deadline as payment_deadline_bound_secs (see Get Channel Terms). A Lightning channel's period is its to_self_delay and 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.

FieldTypeDescription
proposed_proportional_millionthsuint64?(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_millionthsuint64Lower 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_millionthsuint64Upper bound: the largest reserve ratio this node accepts on its own side. Range: [0, 1_000_000)
min_absoluteDecimalStringAbsolute minimum reserve. The runtime reserve is at least this, whatever the proportional rule says
fee_channel_reserveDecimalStringFixed-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 against proposed_, not against a bound

counterparty_proportional_millionths and max_self_proportional_millionths are 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_millionths from both ends and take the larger.

⚠️ Absent and zero are different

The field is optional, and 0 is 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_millionths only 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:

NameTypeRequiredDescription
networkNetworkYESTarget network
channel_idstringYESThe 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:

FieldTypeDescription
termsChannelTermsThe 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.

FieldTypeDescription
dispute_periodDisputePeriodThe 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_secsuint64?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_channelsmap<string, AssetChannelTerms>The terms each asset channel is held to, keyed by asset id as Channel.asset_channels is

DisputePeriod

FieldTypeDescription
own_secsuint64?What this node waits after closing the channel unilaterally. Absent while not known yet
peer_secsuint64?What the peer waits after closing the channel unilaterally. Absent while not known yet

AssetChannelTerms

FieldTypeDescription
inboundDirectionalPolicy?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
outboundDirectionalPolicy?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_selfForwardingFee?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_peerForwardingFee?What the peer keeps forwarding over this channel, and what an arriving payment paid. Absent under the same rule
reserve_selfReserveTermsHow the unspendable reserve on this node's side is sized
reserve_peerReserveTermsHow the reserve on the peer's side is sized
max_pending_payments_totaluint64?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.

FieldTypeDescription
proportional_millionthsuint32Proportional component of what the forwarder keeps. Both protocols charge one, so 0 means zero. See the note below on which amount it multiplies
baseDecimalStringFlat 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.

FieldTypeDescription
max_payment_amountDecimalString?The most a single payment may carry in this direction. Absent = this side announced no ceiling and the spendable balance alone binds
min_payment_amountDecimalString?The least a single payment may carry. Absent = no floor
max_pending_paymentsuint64?Payments this side admits in flight in this direction. Unilateral on both protocols — never merged. Absent where the node cannot read it
cltv_expiry_deltaDeadline?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.

FieldTypeDescription
proportional_millionthsuint32Proportional term against the asset channel's total liquidity. Zero where the reserve does not scale with the channel
flat_floorDecimalStringFlat floor. The reserve is never below this

⚠️ A direction says what is ADMITTED, not what it COSTS

A 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 figure

The 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 p and an arriving amount A that is p·A against p·A/(1+p) — identical to first order, differing by p²·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 / _peer are 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 inbound bounds 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: 0 and the realized figure as flat_floor. A deposit there adds no reserve at all.

⚠️ max_pending_payments_total is not the sum of the two directions

A 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 / outbound absent means that side has not announced yet, not that it charges nothing. Treating an absent outbound as 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 Terms

Get 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.

NameTypeRequiredDescription
networkNetworkYESTarget network
counterpartystring?noNarrow 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_idstring?noNarrow to one channel
asset_idstring?noNarrow to one asset

Response:

FieldTypeDescription
policiesAssetChannelPolicy[]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.

FieldTypeDescription
counterpartystringThe counterparty node, in that protocol's own encoding
channel_idstringThe channel these terms belong to
asset_idstringThe asset within the channel

Estimate Open Channel Fee

Estimate the onchain fee for opening a new channel.

Method: EstimateOpenChannelFee

Parameters:

NameTypeRequiredDescription
networkNetworkYESNetwork to open channel on
node_idstringYESPeer's node ID
asset_amountsmap<string, DepositAmount>YESMap of asset_id → amount to allocate
fee_optionFeeOptionYESFee priority, or a custom rate
termsChannelOpenTerms?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:

VariantPayloadMeaning
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:

VariantPayloadMeaning
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)

⚠️ DecimalString amounts are human-readable; U256String fee rates are not

Every amount here is a DecimalString: "0.1" is 0.1 BTC. Putting "10000000" there does not mean 0.1 BTC — it means ten million BTC. FeeRate is the opposite: it is U256String and 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.proto and balance.proto are authoritative.

⚠️ Asking for the whole balance as exact is a refusal, not a quote

An on-chain fee is paid on top of the amount, so an exact amount 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 all instead. It takes the fee out of the amount rather than adding it, and returns the real fee for the resulting transaction.

Response:

FieldTypeDescription
feeDecimalStringEstimated 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:

NameTypeRequiredDescription
networkNetworkYESNetwork to open on
node_idstringYESPeer's node ID
asset_amountsmap<string, DepositAmount>YESAssets to allocate
fee_optionFeeOptionYESFee priority, or a custom rate
termsChannelOpenTerms?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.

FieldTypeDescription
min_dispute_period_secsuint64The 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_deltaDeadline?(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)
assetsmap<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.

FieldTypeDescription
reserveReserveTerms?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_paymentsuint64The most payments this node admits in flight from the peer (Lightning's max_accepted_htlcs). Zero keeps the configured value
max_pending_payments_totaluint64Payments admitted in flight across both directions against one budget. Ignored by a protocol with no channel-wide bound. Zero keeps the configured value
forwarding_feeForwardingFee?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 (see min_dispute_period_secs on the liquidity operations).

Response:

FieldTypeDescription
txidstringFunding transaction ID
channel_idstringChannel 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:

NameTypeRequiredDescription
networkNetworkYESNetwork
channel_idstringYESChannel to deposit to
asset_amountsmap<string, DepositAmount>YESAssets to deposit
fee_optionFeeOptionYESFee priority, or a custom rate

Response:

FieldTypeDescription
feeDecimalStringEstimated 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:

NameTypeRequiredDescription
networkNetworkYESNetwork
channel_idstringYESChannel to deposit to
asset_amountsmap<string, DepositAmount>YESAssets to deposit
fee_optionFeeOptionYESFee priority, or a custom rate

Response:

FieldTypeDescription
txidstringDeposit 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:

NameTypeRequiredDescription
networkNetworkYESNetwork
channel_idstringYESChannel to withdraw from
asset_amountsmap<string, WithdrawAmount>YESAmounts to withdraw
fee_optionFeeOptionYESFee priority, or a custom rate

WithdrawAmount Object:

FieldTypeDescription
self_withdrawalAmountAmount you withdraw
counterparty_withdrawalAmountAmount the counterparty withdraws

Response:

FieldTypeDescription
feeDecimalStringEstimated 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:

NameTypeRequiredDescription
networkNetworkYESNetwork
channel_idstringYESChannel to withdraw from
asset_amountsmap<string, WithdrawAmount>YESAmounts to withdraw
fee_optionFeeOptionYESFee priority, or a custom rate

Response:

FieldTypeDescription
txidstringWithdrawal 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:

FieldTypeDescription
feeDecimalStringEstimated 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:

NameTypeRequiredDescription
networkNetworkYESNetwork
channel_idstringYESChannel to update
asset_updatesmap<string, ChannelUpdateAmount>YESasset_id → what that asset's update asks for
fee_optionFeeOptionYESFee priority, or a custom rate

Response:

FieldTypeDescription
txidstringThe update transaction ID

ChannelUpdateAmount

Three parts, each independently optional:

FieldTypeDescription
self_contributionChannelContributionWhat your wallet contributes
peer_contributionChannelContributionWhat the counterparty's wallet contributes
creditBalanceCreditWhat crosses between the two sides once both have settled

ChannelContribution — a oneof over the direction that side's wallet moves:

ArmPayloadMeaning
depositAmountOut of the wallet, into the channel
withdrawAmountOut of the channel, into the wallet

BalanceCredit — a oneof over which way balance crosses:

ArmPayloadMeaning
to_peerDecimalStringCredit this much of your balance to the counterparty
to_selfDecimalStringCredit this much of theirs to you

Unset is not zero — it is cheaper

Leaving a contribution or a credit unset 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

credit is 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_peer is bounded by allowed_payment on that peer's funding allowance.

The two contributions must not cancel

The 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 on peer_contribution, where the peer's wallet is not visible to your node; it is rejected.
  • withdraw: { all: {} } on a side that also pays a credit — 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.update carries a BatchUpdateChannel with the same channel_id + asset_updates pair.

Prefer DepositChannel / WithdrawChannel when 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:

NameTypeRequiredDescription
networkNetworkYESNetwork
channel_idstringYESChannel to close
asset_idsstring[]YESAssets to settle
settlement_creditsmap<string, BalanceCredit>NOMap 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_optionFeeOptionYESFee priority, or a custom rate

Response:

FieldTypeDescription
feeDecimalStringEstimated 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:

NameTypeRequiredDescription
networkNetworkYESNetwork
channel_idstringYESChannel to close
asset_idsstring[]YESAssets to settle
settlement_creditsmap<string, BalanceCredit>NOMap 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_optionFeeOptionYESFee priority, or a custom rate

Response:

FieldTypeDescription
txidstringClosing 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:

NameTypeRequiredDescription
networkNetworkYESNetwork
channel_idstringYESChannel to force close
asset_idsstring[]YESAssets to settle
fee_optionFeeOptionYESFee priority, or a custom rate

Response:

FieldTypeDescription
feeDecimalStringEstimated 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_PRECONDITION while 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:

NameTypeRequiredDescription
networkNetworkYESNetwork
channel_idstringYESChannel to force close
asset_idsstring[]YESAssets to settle
fee_optionFeeOptionYESFee priority, or a custom rate

Response:

FieldTypeDescription
txidstringForce 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:

NameTypeRequiredDescription
networkNetworkYESNetwork
channel_idstringYESClosed channel
asset_idsstring[]YESAssets to redeem
fee_optionFeeOptionYESFee priority, or a custom rate

Response:

FieldTypeDescription
feeDecimalStringEstimated 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:

NameTypeRequiredDescription
networkNetworkYESNetwork
channel_idstringYESClosed channel
asset_idsstring[]YESAssets to redeem
fee_optionFeeOptionYESFee priority, or a custom rate

Response:

FieldTypeDescription
txidstringRedemption transaction ID
redeemedmap<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 close over a slot with a payment still in flight, a force_close on a channel holding the inbound of a swap this node is still running (FAILED_PRECONDITION, naming the swap), and a redeem naming 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:

ArmPayloadEquivalent single call
openBatchOpenChannelOpenChannel
depositBatchDepositChannelDepositChannel
withdrawBatchWithdrawChannelWithdrawChannel
closeBatchCloseChannelCloseChannel
force_closeBatchForceCloseChannelForceCloseChannel
redeemBatchRedeemChannelRedeemClosedChannel
updateBatchUpdateChannel — { 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:

NameTypeRequiredDescription
networkNetworkYESTarget network
operationsBatchChannelOperation[]YESOperations to execute atomically
fee_optionFeeOptionYESFee priority, or a custom rate

Response:

FieldTypeDescription
txidstringSingle transaction ID for the entire batch
opened_channelsBatchOpenedChannel[]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:

FieldTypeDescription
feeDecimalStringThe estimated total on-chain fee for the whole batch, in the network's native asset

The estimate returns only the fee — no txid, no opened_channels. Those exist on BatchChannelOperations, 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:

NameTypeRequiredDescription
networkNetworkYESTarget network
channelSimulatedChannelYESHypothetical channel snapshot
operationSimulatedChannelOperation enumYESWhich operation to price
fee_optionFeeOptionYESFee priority, or a custom rate

Response:

FieldTypeDescription
feeDecimalStringEstimated fee, in the network's native currency

SimulatedChannel

{ assets: SimulatedAssetChannel[] } — one entry per asset the hypothetical channel would hold:

FieldTypeDescription
asset_idstringThe asset
onchain_self_balanceDecimalStringYour currently committed on-chain balance
onchain_counterparty_balanceDecimalStringThe counterparty's currently committed on-chain balance
latest_self_balanceDecimalStringYour latest off-chain balance
latest_counterparty_balanceDecimalStringThe counterparty's latest off-chain balance
num_htlcsuint64Pending HTLCs — they enlarge the commitment transaction, so they move the fee

SimulatedChannelOperation

An enum, not a message:

ValueConstant
0SIMULATED_CHANNEL_OPERATION_UNSPECIFIED — invalid default
1SIMULATED_CHANNEL_OPERATION_OPEN
2SIMULATED_CHANNEL_OPERATION_DEPOSIT
3SIMULATED_CHANNEL_OPERATION_WITHDRAW
4SIMULATED_CHANNEL_OPERATION_COOPERATIVE_CLOSE
5SIMULATED_CHANNEL_OPERATION_FORCE_CLOSE
6SIMULATED_CHANNEL_OPERATION_CONFIRM_SETTLEMENT

SimulatedChannelOp is 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:

NameTypeRequiredDescription
networkNetworkYESTarget network
operationsSimulatedChannelOp[]YESSimulated operations to estimate
fee_optionFeeOptionYESFee priority, or a custom rate

SimulatedChannelOp pairs a snapshot with the operation to price against it:

FieldTypeDescription
channelSimulatedChannelThe hypothetical channel
operationSimulatedChannelOperation enumWhat to price

Response:

FieldTypeDescription
feeDecimalStringEstimated 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 hashlock naming 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 with hashlock) holds a payment to that invoice's terms only when the payment pays the invoice. Without a hashlock the 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:

NameTypeRequiredDescription
networkNetworkYESNetwork
channel_idstringYESChannel to use
asset_amountsmap<string, Amount>YESAssets to send
hashlockHashlockNOOptional hashlock for HTLC
cltv_buffer_secsuint64NORenamed 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:

ArmPayloadMeaning
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_hash field on Hashlock. 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:

FieldTypeDescription
payment_idstringPayment 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:

NameTypeRequiredDescription
networkNetworkYESNetwork
recipient_node_idstringYESRecipient's node ID
asset_amountsmap<string, Amount>YESAssets to send
hashlockHashlockNOOptional hashlock
cltv_buffer_secsuint64NORenamed from expiry_timeout_secs (2026-05-10). HTLC effective lifetime, seconds (BOLT-11 c).
max_total_cltv_secsuint64NOAdded 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:

FieldTypeDescription
feesmap<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 hashlock naming 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:

NameTypeRequiredDescription
networkNetworkYESNetwork
recipient_node_idstringYESRecipient's node ID
asset_amountsmap<string, Amount>YESAssets to send
hashlockHashlockNOOptional hashlock
cltv_buffer_secsuint64NORenamed from expiry_timeout_secs (2026-05-10). HTLC effective lifetime, seconds (BOLT-11 c).
max_total_cltv_secsuint64NOAdded 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:

FieldTypeDescription
payment_idstringPayment 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 keep expiry_timeout_secs + cltv_buffer_secs inside the bound of the channels the payer holds with this node (payment_deadline_bound_secs on Get Channel Terms).

Method: CreateInvoice

Parameters:

NameTypeRequiredDescription
networkNetworkYESNetwork
asset_idstringYESAsset to receive
amountDecimalStringNOAmount (omit for zero-amount invoice)
hashlockHashlockNOOptional hashlock
expiry_timeout_secsuint64NOInvoice validity window in seconds (BOLT-11 x). Unchanged — only the payment-send RPCs renamed this field.
cltv_buffer_secsuint64NOAdded 2026-05-10. Extra HTLC lifetime past invoice expiry the receiver requires for safe settlement (BOLT-11 c).

Response:

FieldTypeDescription
invoiceInvoiceInvoice details

Invoice Object:

FieldTypeDescription
networkNetworkThe network this invoice belongs to
payment_requeststringEncoded payment request string — the string you share with the payer
payment_hashstringHex-encoded payment hash
payment_secretstringPayment secret
amountU256String?Invoice amount, in base units (sats / wei) — not a DecimalString. Absent on an empty invoice
asset_idstringAsset identifier
recipientstringRecipient node id
expiry_timestampTimestamp?Invoice validity expiry. Absent when the invoice does not expire
min_final_cltv_expiry_secsuint64Added 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_hashbytesInvoice signature and the hash it covers

Invoice.amount is U256String, not DecimalString — 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:

NameTypeRequiredDescription
networkNetworkYESNetwork
payment_requeststringYESEncoded payment request

Response:

FieldTypeDescription
invoiceInvoiceDecoded 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:

NameTypeRequiredDescription
networkNetworkYESNetwork
payment_requeststringYESInvoice to pay
max_total_cltv_secsuint64NOAdded 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:

FieldTypeDescription
feeDecimalStringEstimated 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:

NameTypeRequiredDescription
networkNetworkYESNetwork
payment_requeststringYESInvoice to pay
max_total_cltv_secsuint64NOAdded 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:

FieldTypeDescription
payment_idstringPayment 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:

NameTypeRequiredDescription
networkNetworkYESNetwork
payment_requeststringYESZero-amount invoice
amountAmountYESAmount to pay
max_total_cltv_secsuint64NOAdded 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:

FieldTypeDescription
feeDecimalStringEstimated 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:

NameTypeRequiredDescription
networkNetworkYESNetwork
payment_requeststringYESZero-amount invoice
amountAmountYESAmount to pay
max_total_cltv_secsuint64NOAdded 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:

FieldTypeDescription
payment_idstringPayment 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:

NameTypeRequiredDescription
networkNetworkYESNetwork
payment_idstringYESPayment to resolve
payment_preimagestringYESPreimage (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:

NameTypeRequiredDescription
networkNetworkYESNetwork
payment_idstringYESPayment 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). RegisterPreimage was removed from NodeService. Registering a revealed preimage now lives on the new PreimageService.SettlePreimage (JSON-RPC namespace preimage), which does everything RegisterPreimage did — 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, hex payment_preimage).

See the HTLC & Preimage API → Settle 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:

NameTypeRequiredDescription
networkNetworkYESTarget network
node_idstringYESPeer public key
asset_allowancesmap<string, FundingAllowance>YESPer-asset allowances

FundingAllowance:

FieldTypeDescription
allowed_depositDecimalStringMaximum deposit the peer may initiate
allowed_paymentDecimalStringMaximum balance the peer may be credited out of this side, whatever moved it
allowed_withdrawalDecimalString(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_deposit and allowed_payment are about funds entering — how much a peer may fund on your behalf, and how much of what it funded it may take back. allowed_withdrawal is 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_payment caps more than payments

The 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 BalanceCredit in 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_withdrawal needs 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:

NameTypeRequiredDescription
networkNetworkYESTarget network
node_idstringYESPeer public key

Response:

FieldTypeDescription
asset_allowancesmap<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:

NameTypeRequiredDescription
networkNetworkYESTarget network
node_idstringYESPeer public key
asset_allowancesmap<string, FundingAllowance>YESDeltas 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:

NameTypeRequiredDescription
networkNetworkYESTarget network
node_idstringYESPeer public key
asset_allowancesmap<string, FundingAllowance>YESDeltas 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:

NameTypeRequiredDescription
networkNetworkYESTarget network
node_idstringYESPeer 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 current event.proto and channel.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

  1. Subscribe to event.SubscribeNodeEvents before opening or paying — channel-active and payment-completed signals come through the stream, not as a return value of the opening RPC.
  2. Estimate fees first — every channel-mutating RPC has a paired Estimate* variant that takes the same request and returns just the fee.
  3. Try CloseChannel before ForceCloseChannel — cooperative close is cheaper and faster.
  4. Pick reasonable invoice expirations — 1–24 hours is the common range; very short expirations stress retry logic.
  5. Check capacity before sending — call EstimateSendPaymentFee or watchOnlyNode.GetChannel to confirm the asset side has the balance you need.
  6. Keep hashlock preimages secret until you're ready to release the funds — see preimage.SettlePreimage (which replaced RegisterPreimage) and ResolveHashlockPayment.
  7. Reuse channels via DepositChannel rather 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 of OpenChannel/DepositChannel/WithdrawChannel/CloseChannel. The lease flow handles the same lifecycle but the liquidity service signs and broadcasts the underlying transactions for you.


← Back to API Reference | Next: Lease API →


Copyright © 2025