Api

Common Patterns

Master complex data structures and avoid common mistakes

This guide covers common data structures and patterns you'll encounter when working with the Hydra API.

Fee Structures

Which fee field do I read?

"Fee" names several different costs, and they are separate fields because they are separate amounts, settled by different parties at different times. A transaction's gas is not the service's commission, and neither is the balance a redemption returns. One field carrying all three could only ever be their sum, which is not a number anybody can reconcile.

Decide by the cost you are asking about, not by the call you happen to be holding:

The cost you wantFieldWhere
What the chain charged to include a transactioneffective_fee (+ fee_payer)Transaction, on every on-chain transaction whatever its shape
What the liquidity service billed for a request you just madefee(2026-10-14) LiquidityRequest (8): on every Submit* answer, on GetLiquidityRequest and on SubscribeLiquidityRequests. What the service bills — a request that ends expired billed nothing. The deprecated RequestChannelLiquidityResponse (3), RequestChannelReleaseResponse (2) and RequestChannelLeaseExtensionResponse (4) carry it too
What the liquidity service billed, historically — including payments that expired, failed or were refundedpayments[].feeGetFeePayments, the only surface that returns a payment no request response ever did
What a redemption recovered from a closed channelredeemedRedeemClosedChannelResponse
An estimate, before committingquoted feeEstimateRequestChannelLiquidityFee and the other estimate calls

Two traps worth stating plainly:

  • fee_rate is a rate, not a cost. It is a price per gas or per weight unit. Multiplying it yourself reproduces an estimate, not what was charged — read effective_fee for that.
  • A service fee and a network fee can be billed in different assets, on different networks. A fee payment carries its own payment_network and payment_asset_id; effective_fee is always in the transaction's own native currency. Do not add them together.

FeeOption (for channel operations)

When opening, depositing, withdrawing, or closing channels, you need to specify a FeeOption. This is a protobuf oneof type that can be one of:

interface FeeOption {
  low?: {}           // Use low fee rate
  medium?: {}        // Use medium fee rate
  high?: {}          // Use high fee rate
  custom?: {         // Use custom fee rate
    feeRate: FeeRate
  }
}

Examples:

// Low fee
const feeOption = { low: {} }

// Medium fee (recommended default)
const feeOption = { medium: {} }

// High fee
const feeOption = { high: {} }

// Custom fee (advanced)
const feeOption = {
  custom: {
    feeRate: {
      priorityFeePerUnit: { value: '1000000000' },  // 1 Gwei
      maxFeePerUnit: { value: '2000000000' }       // 2 Gwei
    }
  }
}

In Practice (grpcWebClient helper):

// The grpcWebClient automatically converts string to FeeOption
await hydraGrpcClient.openChannel(
  network,
  nodeId,
  assetAmounts,
  'medium'  // Automatically converted to { medium: {} }
)

FeeRate (for custom fees and Lease transactions)

FeeRate is the per-unit fee rate used by EVM transactions, Bitcoin transactions (max_fee_per_unit maps to sat/vbyte there), and Lease API payment flows:

interface FeeRate {
  maxFeePerUnit: U256String       // Maximum fee per gas/weight unit
  priorityFeePerUnit: U256String  // Priority fee per gas/weight unit
}

interface U256String {
  value: string  // String representation of uint256
}

Both fields are required (the proto defines them as non-optional). Pass { value: "0" } if you genuinely want zero priority.

Example:

const feeRate = {
  priorityFeePerUnit: { value: '1000000000' },    // 1 Gwei tip
  maxFeePerUnit: { value: '10000000000' }       // 10 Gwei max
}

Common Values (in Wei):

// Low priority
const lowFee = {
  priorityFeePerUnit: { value: '100000000' },     // 0.1 Gwei
  maxFeePerUnit: { value: '1000000000' }        // 1 Gwei
}

// Medium priority (recommended)
const mediumFee = {
  priorityFeePerUnit: { value: '1000000000' },    // 1 Gwei
  maxFeePerUnit: { value: '10000000000' }       // 10 Gwei
}

// High priority
const highFee = {
  priorityFeePerUnit: { value: '2000000000' },    // 2 Gwei
  maxFeePerUnit: { value: '50000000000' }       // 50 Gwei
}

ChainFee (from fee estimates)

blockchain.GetFeeEstimates returns a FeeEstimate with a ChainFee for each priority level:

interface FeeEstimate {
  low: ChainFee
  medium: ChainFee
  high: ChainFee
}

interface ChainFee {
  baseFeePerUnit: U256String       // Current base fee on the network
  feeRate: FeeRate                 // The fee rate (max + priority)
  effectiveFeePerUnit: U256String  // Effective fee per gas/weight unit
}

Example:

const resp = await blockchain.getFeeEstimates(req, {})
const fe = resp.getFeeEstimate()
console.log('Medium fee rate:', fe?.getMedium()?.getFeeRate()?.toObject())
console.log('Effective:', fe?.getMedium()?.getEffectiveFeePerUnit()?.getValue())

When you need a FeeRate for a custom transaction, the easiest path is: call GetFeeEstimates, pick a priority, and use chainFee.getFeeRate() directly.

Amount Structures

DecimalString — the single most-misunderstood type

Every monetary amount in the Hydra API is a DecimalString:

interface DecimalString {
  value: string  // e.g., '1.5', '0.001', '0.00000001'
}

The rule, once and for all: DecimalString.value is always in whole-asset, human-readable units. Never satoshis. Never wei. Never base units. The string "0.001" means 0.001 of the asset, full stop.

This is the opposite of what most chain-native APIs do. If you've used Bitcoin Core or web3.js, your reflex will be to multiply by 10^decimals. Don't. Hydra does that conversion internally.

Worked examples per asset family

Assetvalue: "0.001" meansvalue: "100" meansvalue: "0.00000001" means
BTC (8 decimals)0.001 BTC = 100,000 sats100 BTC0.00000001 BTC = 1 sat
ETH (18 decimals)0.001 ETH = 10¹⁵ wei100 ETH0.00000001 ETH = 10¹⁰ wei
USDC (6 decimals)0.001 USDC100 USDC0.00000001 USDC (sub-cent — usually meaningless)
HDN (18 decimals)0.001 HDN100 HDN0.00000001 HDN

The asset's decimals field (from AssetService.GetAsset) tells you the smallest meaningful fraction, but does not affect what you put in DecimalString.value — it's purely informational, useful for UI rounding.

When you have a base-unit amount and need to convert

If your input came from a chain-native source (a raw transaction, a contract event, a satoshi count from a faucet), you must convert to human units before passing it to Hydra:

import Big from 'big.js'

// 50,000 sats → "0.0005" BTC
const sats = '50000'
const btc = new Big(sats).div(new Big(10).pow(8)).toString()
// → "0.0005"

// 1.5 × 10¹⁸ wei → "1.5" ETH
const wei = '1500000000000000000'
const eth = new Big(wei).div(new Big(10).pow(18)).toString()
// → "1.5"

When you have a DecimalString from Hydra and need base units

The reverse: if you're feeding a Hydra-returned amount into a chain RPC or a contract call, you'll need base units:

import Big from 'big.js'

// "0.0005" BTC → 50,000 sats
const btc = '0.0005'
const sats = new Big(btc).times(new Big(10).pow(8)).toFixed(0)
// → "50000"

Use a decimal library, not floats. parseFloat("0.1") + parseFloat("0.2") is 0.30000000000000004, not 0.3. Across many trades that error compounds. JavaScript: big.js or decimal.js. Go: math/big's *big.Rat or shopspring/decimal. Rust: rust_decimal.

DecimalString vs U256String — when each appears

Both wrap a string. They are not interchangeable.

TypeMeansWhere it shows up
DecimalStringHuman-readable decimal value of an assetAll Amount fields, balances, prices, lease fees — anything denominated in an asset
U256StringRaw 256-bit unsigned integer in base unitsFee rates and the four exceptions below

U256String appears in exactly nine places across the whole schema, and they fall into three groups:

FieldsUnit
FeeRate.max_fee_per_unit, FeeRate.priority_fee_per_unit, ChainFee.base_fee_per_unit, ChainFee.effective_fee_per_unitPer unit — sat/vbyte on Bitcoin, wei per gas on EVM
TransactionRequest.fee_units, TransactionRequest.fee, ChainTransaction.effective_feeGas units / weight units, and the total fee in base units
Invoice.amount, TokenPermit.valueThe asset's smallest unit — satoshis, wei, the token's own base unit

The last row is the one that catches people: those two are amounts, sitting where you would expect a DecimalString, and they are not.

  • Invoice.amount is what the invoice encodes, and the encoding is base-unit. CreateInvoiceRequest.amount — the amount you ask for — is a DecimalString, so the same number changes representation across the call.
  • TokenPermit.value is what the owner's signature commits to, so it must be the exact integer that goes to the contract; re-deriving it from a decimal would risk signing a different number. Note that EstimateTokenPermitFee.value is a whole-unit DecimalString, because nothing has been signed yet.

Everywhere else, a string-based amount is a DecimalString and human-readable.

// FeeRate uses U256String — base-unit values
const feeRate = {
  maxFeePerUnit:      { value: '50' },   // 50 sat/vbyte (Bitcoin)
                                          //  OR 50 wei/gas (EVM, but you'd usually use much larger)
  priorityFeePerUnit: { value: '5' },
}

// Amount uses DecimalString — human-readable
const amount = {
  exact: { amount: { value: '0.001' } },  // 0.001 BTC = 100,000 sats
}

Common bug: filling DecimalString.value with "100000" thinking it's sats. That's actually 100,000 BTC. On testnet you'll get INVALID_ARGUMENT: insufficient balance. On mainnet that's the kind of bug that empties wallets.

Orderbook prices

Orderbook prices (LimitOrder.price, Trade.price, Trade.final_price, etc.) are also DecimalString, expressed as quote-per-base in human units.

For a BTC/USDC pair (base=BTC, quote=USDC):

priceMeaning
"67000"1 BTC = 67,000 USDC
"67000.5"1 BTC = 67,000.5 USDC
"0.000015"1 BTC = 0.000015 USDC (almost certainly a bug — invert your base/quote)

For an ETH/BTC pair (base=ETH, quote=BTC):

priceMeaning
"0.04"1 ETH = 0.04 BTC
"25"1 ETH = 25 BTC (a bug; you've inverted)

If your bot ever computes "is this trade profitable?" it must know which side is base and which is quote. Don't infer from asset names — pull OrderbookCurrency.base and OrderbookCurrency.quote from the market info and use those.

Negative amounts and zero

UseAllowed?
value: "0"✅ Valid wherever a non-negative amount is expected (e.g. AssetLiquidity.server_amount when only client funds the channel)
value: "-1"❌ Always rejected with INVALID_ARGUMENT
value: "" (empty)❌ Rejected — set the field explicitly to "0" if you mean zero
value: "1e-8" (scientific)❌ Rejected — the parser expects plain decimal notation; use "0.00000001"
Leading zeros ("0.0010")✅ Accepted; canonicalised on the server side

Precision-arithmetic checklist for bots

  1. Never use parseFloat / Number() / f64 on DecimalString.value for arithmetic. Display-only is fine.
  2. Compare amounts as strings or via a decimal library. "0.10" === "0.1" is false in plain JS; both libraries normalise.
  3. Round at output, not throughout the pipeline. Carry full precision; only round when displaying to a human.
  4. Watch for trailing zeros from servers. "0.10000000" and "0.1" are equal in value; if you're using strings as map keys, normalise first.
  5. Sanity-check magnitudes. A bot that's about to send "50000" BTC instead of "0.0005" BTC should hit a guard rail (e.g. "reject single trades > 1 BTC unless explicitly authorised").

Amount

For operations that send funds (client.SendTransaction, node.OpenChannel, node.DepositChannel, node.SendChannelPayment, etc.):

interface Amount {
  exact?: {          // Send exact amount
    amount: DecimalString
  }
  all?: {}           // Send all available balance
}

The proto type is Amount (defined in balance.proto). Older client code may call this SendAmount — that's a legacy name; the wire shape is identical.

Examples:

// Send exact amount
const amount = {
  exact: {
    amount: { value: '1.5' }
  }
}

// Send all available
const amount = {
  all: {}
}

WithdrawAmount

For withdrawing assets from a channel — both sides can withdraw in the same on-chain transaction:

interface WithdrawAmount {
  selfWithdrawal: Amount          // Amount to withdraw to your wallet
  counterpartyWithdrawal: Amount  // Amount to withdraw to the counterparty's wallet
}

Example:

const withdrawAmount = {
  selfWithdrawal: {
    exact: { amount: { value: '0.5' } }
  },
  counterpartyWithdrawal: {
    exact: { amount: { value: '0.0' } }
  }
}

DepositAmount

node.DepositChannel takes a DepositAmount, a oneof with a third arm Amount does not have:

interface DepositAmount {
  all?: {}                    // Deposit the entire available balance
  exact?: {                   // Deposit a specific amount (single-funded)
    amount: DecimalString
  }
  dualFunded?: {              // Explicit balance distribution
    local: DecimalString        // Channel balance assigned to you
    remote: DecimalString       // Channel balance assigned to the counterparty
    selfDeposit: DecimalString  // What actually leaves your wallet
  }
}

local + remote is the channel's resulting balance split; selfDeposit is your share of the funding. They differ whenever the counterparty contributes — that is what makes it dual-funded. There is no DualFundAmount type; the arm is dual_funded inside DepositAmount.

Lease Patterns

The old RentalService is gone — service-backed channel liquidity is now provided by LiquidityService (the Lease API). The shapes are different.

Three operations, one fee-payment model

All three Lease requests share a common fee-payment shape: pick exactly one fee-payment arm.

(2026-10-14) Each is submitted with its Submit* RPC, which answers once your node has settled the fee, and is then followed until it is final — see Submitting and following a request. The deprecated Request* RPCs take the same requests.

// Common fee-payment fields, present on all three Lease requests:
interface LeaseFeePayment {
  paymentNetwork: Network
  paymentAssetId: string

  // exactly one of:
  onchainFeePayment?:
    | { utxo: { refundAddress: string } }
    | { account: { senderAddress: string; refundAddress?: string } }
  offchainFeePayment?: {}
  dualFundFeePayment?: {}              // channel liquidity requests only
  sponsoredDepositFeePayment?: {}      // channel liquidity requests only
  sponsoredWithdrawalFeePayment?: {}   // release requests only

  quoteId?: string                     // from the matching Estimate*Fee call
}
VariantAvailable onWhen to use
offchainFeePaymentLiquidity / Release / Lease ExtensionYou have offchain balance — fastest, lowest overhead
onchainFeePayment.utxoAll three (Bitcoin-style)Fee paid on-chain, refund address required
onchainFeePayment.accountAll three (EVM-style)Fee paid on-chain from an EVM account
dualFundFeePaymentLiquidity onlySettle the fee inside the dual-fund flow when client_amount > 0
sponsoredDepositFeePaymentLiquidity only(2026-09-11) You hold the token but no gas — the service broadcasts and takes its fee out of the deposit
sponsoredWithdrawalFeePaymentRelease only(2026-09-11) You hold a channel balance but no gas — the service broadcasts and takes its fee out of what is released

There is no txFeeRate on any Lease request. Earlier revisions of this page showed one; the service picks its own on-chain fee and charges you the service fee. Pass the quoteId from the matching Estimate*Fee call to be billed at the figure you showed the user — see Quotes.

SubmitChannelLiquidityRequest (provision a channel)

interface RequestChannelLiquidityRequest extends LeaseFeePayment {
  network: Network
  operation: ChannelLiquidityRequestOperation  // open / deposit / deposit_any / open_or_deposit
  leaseDurationSeconds?: number  // required when any asset has server_amount > 0
  clientRequestId?: string       // your name for the request, 1 to 64 characters
}

Example — lease 0.1 BTC of inbound capacity on a new channel for 30 days, offchain fee:

const network = { protocol: 1, id: '0a03cf40' }                              // Bitcoin Signet
const BTC = '0x0000000000000000000000000000000000000000000000000000000000000000'

const req = {
  network,
  operation: {
    open: {
      assetLiquidity: {
        // Keyed by ASSET ID, never by ticker.
        [BTC]: {
          serverAmount: { value: '0.1' },            // the service provides 0.1 BTC
          clientAmount: { value: '0' },
        },
      },
    },
  },
  leaseDurationSeconds: 30 * 24 * 60 * 60,         // required: serverAmount > 0
  paymentNetwork: network,
  paymentAssetId: BTC,
  offchainFeePayment: {},
  clientRequestId: 'lease-btc-0001',               // persist it; a retry reuses it
}

// Price it first, then bill at that price.
const { fee, quoteId } = await liquidity.estimateRequestChannelLiquidityFee(req, {})
// Open liquidity.SubscribeLiquidityRequests BEFORE this call to follow the request.
// On a retry, send this same object again: a fresh quoteId would make it a different request.
const { request } = await liquidity.submitChannelLiquidityRequest({ ...req, quoteId }, {})
// request.feeSettled: the fee is settled. The request is done once it is final.

Every Submit* call answers { request: LiquidityRequest }, and GetLiquidityRequest and SubscribeLiquidityRequests return the same LiquidityRequest — see the Lease API:

interface LiquidityRequest {
  paymentUuid: string                  // the service's id: its fee payment's payment_uuid
  clientRequestId?: string
  network: Network
  operation: FeePaymentOperation       // what was committed: provision / release / leaseExtension
  rail: FeePaymentRail                 // ONCHAIN / OFFCHAIN / DUAL_FUND / SPONSORED_DEPOSIT / SPONSORED_WITHDRAWAL
  paymentNetwork: Network
  paymentAssetId: string
  fee: DecimalString                   // what the service bills, in paymentAssetId
  feeDeadline?: Timestamp              // absent on DUAL_FUND
  feeSettlement?: LiquidityFeeTransfer // absent until settled, and on the rails the service broadcasts
  createdAt: Timestamp
  updatedAt: Timestamp

  // exactly one of — completed, failed, unresolved and expired are final:
  awaitingFee?: {}
  feeSettled?: {}                      // what Submit* answers with, normally
  feeSettlementFailed?: { reason: string }   // not final: the fee may still arrive
  completed?: {                        // at most one arm; none when the report was missed
    channelLiquidity?: { channelId: string; txid: string }
    channelRelease?: { channelId: string; txid: string }
    channelLeaseExtension?: { channelId: string; assetId: string; leaseExpiry: Timestamp }
  }
  failed?: { reason?: string; refund?: LiquidityFeeRefund }   // reason: absent when the report was missed
  unresolved?: { reason?: string }     // the outcome is unknown: read the channel
  expired?: {}                         // nothing was billed
}

type LiquidityFeeTransfer =            // how a fee, or its refund, moved
  | { onchain: { txid: string } }
  | { offchain: { paymentId: string } }

interface LiquidityFeeRefund {         // goes back the way the fee came
  amount: DecimalString                // on chain, the transaction carries this less its estimated cost
  transfer: LiquidityFeeTransfer
}

SubmitChannelReleaseRequest (withdraw or close)

interface ChannelReleaseOperation {
  // exactly one:
  withdraw?: {
    channelId: string
    // asset_id → per-side amounts; at least one side per asset.
    assetAmounts: Record<string, { serverAmount?: Amount; clientAmount?: Amount }>
  }
  cooperativeClose?: {
    channelId: string
    assetIds: string[]   // empty closes EVERY asset channel
  }
}

interface RequestChannelReleaseRequest extends LeaseFeePayment {
  network: Network
  operation: ChannelReleaseOperation
  // dualFundFeePayment / sponsoredDepositFeePayment are NOT valid here;
  // sponsoredWithdrawalFeePayment is valid ONLY here
  clientRequestId?: string   // your name for the request, 1 to 64 characters
}

// Answers { request: LiquidityRequest }; completed, its arm is channelRelease.
const { request } = await liquidity.submitChannelReleaseRequest(releaseReq, {})

withdraw.assetAmounts replaced a plain assetIds[] list on 2026-07-23 — you now say how much and whose balance moves. See ChannelReleaseOperation.

SubmitChannelLeaseExtensionRequest (extend an asset's lease)

interface RequestChannelLeaseExtensionRequest extends LeaseFeePayment {
  network: Network
  channelId: string
  assetId: string
  leaseExtensionSeconds: number   // delta — added to the current expiry
  // only onchain / offchain fee payment are valid here
  clientRequestId?: string        // your name for the request, 1 to 64 characters
}

// Answers { request: LiquidityRequest }; completed, its arm is channelLeaseExtension.
const { request } = await liquidity.submitChannelLeaseExtensionRequest(extensionReq, {})

leaseExtensionSeconds is a delta, not a target absolute expiry. Common bug: passing Date.now() + days * 86400 instead of just days * 86400.

Rules of thumb

  1. Always estimate first. Each operation has a paired Estimate* RPC that takes the same request and returns just the fee.
  2. Set leaseDurationSeconds only if needed. Required when any asset has server_amount > 0; otherwise omit.
  3. Use deposit / open_or_deposit to reuse channels. Saves the cost of opening a new one.
  4. dualFundFeePayment is only valid on a channel liquidity request. Setting it on Release or Lease Extension returns INVALID_ARGUMENT.
  5. Submit under a clientRequestId, and wait for a final state. (2026-10-14) A Submit* answer of feeSettled is not the end: on the on-chain rail the service acts once the fee transfer has confirmed. A call cut off is sent again unchanged under the same id.

See the Lease API reference for the full proto-shape and per-RPC examples in TypeScript / Go / Rust.

Market/Orderbook Patterns

⚠️ OrderAmount echoes the denomination you placed the order in

OrderAmount is a protobuf oneof of { base: { amount: DecimalString } | quote: { amount: DecimalString } }. It is a oneof precisely so it can carry either denomination — the side of an order (buy/sell) does not determine which side is set. When you create a LimitOrder or MarketOrder, you choose base or quote freely (a buy can be sized in either, and so can a sell). On server-issued response orders, the remaining_amount / unmatched_amount / failed_amount come back in the same variant you submitted, reduced as the order fills.

Always discriminate on the oneof variant actually set — never on the side:

  • Python: WhichOneof('amount') (hasattr is useless on oneofs — it's always true).
  • TypeScript: getAmountCase() / hasBase() / hasQuote().
  • Go: type-switch on *pb.OrderAmount_Base_ vs *pb.OrderAmount_Quote_.
  • Rust: match amount.amount { Some(order_amount::Amount::Base(_)) | Some(order_amount::Amount::Quote(_)) => … }.

Don't confuse this with LiquidityPosition.amount in the public orderbook (GetOrderbook). That field is a single scalar reporting offered liquidity, so it genuinely is base-on-sell / quote-on-buy. The remaining/unmatched/failed amounts on your own order do not follow that rule — they follow your placement choice. Full discussion in the Orderbook API doc.

OrderbookCurrency

Identifies an asset in the orderbook:

interface OrderbookCurrency {
  protocol: Protocol     // BITCOIN or EVM
  networkId: string      // Network ID
  assetId: string       // Asset ID
}

Example:

const baseCurrency = {
  protocol: Protocol.PROTOCOL_EVM,
  networkId: '11155111',  // Ethereum Sepolia
  assetId: '<asset_id>',  // Token contract for ERC-20s, or asset.GetNativeAsset().id for native ETH
}

const quoteCurrency = {
  protocol: Protocol.PROTOCOL_BITCOIN,
  networkId: '0a03cf40',  // Bitcoin Signet
  assetId: '<asset_id>',  // Use asset.GetNativeAsset({ network: signet }).id for native BTC
}

// Initialize market
await orderbook.initMarket({ base: baseCurrency, quote: quoteCurrency }, {})

SwapAmount

For swap operations:

interface SwapAmount {
  from?: { amount: DecimalString }  // Specify input amount
  to?: { amount: DecimalString }    // Specify output amount
}

Example:

// Swap exactly 1.0 ETH for BTC
const swapAmount = {
  from: { amount: { value: '1.0' } }
}

// Get exactly 0.1 BTC for ETH
const swapAmount = {
  to: { amount: { value: '0.1' } }
}

Channel Status Patterns

ChannelStatus (enum)

enum ChannelStatus {
  INACTIVE = 0,
  ACTIVE = 1,
  UPDATING = 2,
  CLOSED = 3,
  CLOSED_REDEEMABLE = 4
}

AssetChannelStatus (detailed status)

Each asset in a channel has a detailed status:

interface AssetChannelStatus {
  cooperativelyOpening?: {}
  opening?: {}
  cooperativelyUpdating?: {}
  updating?: {}
  cooperativelyClosing?: {}
  closing?: { closedAtBlock: string }
  forceClosing?: {
    forceClosedAtBlock?: string
    disputer: Disputer
    disputeDeadline?: Deadline
  }
  closedRedeemable?: {}
  closed?: { forceClosed: boolean }  // forceClosed added 2026-05-03
  inactive?: {}
  activeSending?: {}
  activeReceiving?: {}
  active?: {}
  recovering?: {}
}
// closed.forceClosed: false = cooperative close (on lithium the channel
// slot can be reused for further deposits); true = unilateral/disputed
// close (slot is permanently dead — open a new channel).

Common Mistakes to Avoid

❌ Wrong: Passing string directly as fee

await hydraGrpcClient.depositChannel(network, channelId, amounts, 'medium')
// This might work with helper, but raw gRPC call needs object

✅ Correct: Using FeeOption object

const request = {
  network,
  channelId,
  assetAmounts,
  feeOption: { medium: {} }  // Proper FeeOption structure
}

❌ Wrong: FeeRate with single value

const feeRate = { value: '10' }  // Incorrect structure

✅ Correct: FeeRate with proper fields

const feeRate = {
  priorityFeePerUnit: { value: '1000000000' },
  maxFeePerUnit: { value: '2000000000' }
}

❌ Wrong: Amount as number

const amount = 1.5  // Numbers lose precision

✅ Correct: Amount as DecimalString

const amount = { value: '1.5' }  // Always use string

❌ Wrong: Missing nested structure

const sendAmount = { amount: '1.5' }  // Missing exact/all wrapper

✅ Correct: Proper Amount structure

const amount = {
  exact: {
    amount: { value: '1.5' }
  }
}

Helper Functions

The grpcWebClient provides helpful conversions:

// Converts string fee rate to FeeOption
private convertFeeRateToFeeOption(feeRate: string): FeeOption {
  switch (feeRate) {
    case 'low': return { low: {} }
    case 'medium': return { medium: {} }
    case 'high': return { high: {} }
    default: return { medium: {} }
  }
}

// Automatically transforms asset amounts
// From: { assetId: { exact: { amount: '1.0' } } }
// To:   { assetId: { exact: { amount: { value: '1.0' } } } }

Common Error Messages

"fee rate is required"

Cause: A FeeRate was passed as a single value instead of the two-field shape.

Wrong:

feeRate: { value: '10' }   // ❌ FeeRate isn't a DecimalString

Correct:

feeRate: {
  maxFeePerUnit:      { value: '50' },   // ✅ U256String
  priorityFeePerUnit: { value: '5' },    // ✅ U256String
}

Both fields are required. Use { value: '0' } if you genuinely want zero priority.

"requestEncoder(...).finish is not a function"

Cause: Using placeholder encoder instead of proper protobuf encoder

Wrong:

await this.grpcCall(
  'hydra_app.NodeService',
  'OpenChannel',
  request,
  (req) => new Uint8Array()  // ❌ Placeholder encoder
)

Correct:

import { OpenChannelRequest, OpenChannelResponse } from '@/proto/node'

await this.grpcCall<OpenChannelRequest, OpenChannelResponse>(
  'hydra_app.NodeService',
  'OpenChannel',
  request,
  OpenChannelRequest.encode  // ✅ Proper protobuf encoder
)

"insufficient funds"

Cause: Not enough balance in the specified account

Solution:

// Check balance before operation
const balances = await hydraGrpcClient.getBalances(network)
const ethBalance = balances.balances.find(b => b.assetId === '0x0000...')

if (parseFloat(ethBalance.onchain?.usable?.value || '0') < parseFloat(amountNeeded)) {
  console.error('Insufficient onchain balance')
  // Show error to user or request deposit
}

"peer not found" / "peer not connected"

Cause: Trying to open channel with a peer that isn't connected

Solution:

// Connect to peer first
await hydraGrpcClient.connectToPeer(network, peerUrl)

// Wait a moment for connection to establish
await new Promise(resolve => setTimeout(resolve, 1000))

// Then open channel
await hydraGrpcClient.openChannel(network, nodeId, assetAmounts, 'medium')

TypeScript Type Definitions

Complete Type Hierarchy

// Generated from the .proto files in /proto

/** Network identifier */
interface Network {
  protocol: Protocol      // PROTOCOL_BITCOIN = 1, PROTOCOL_EVM = 2
  id: string              // Magic bytes (hex) for Bitcoin, decimal chain ID for EVM
}

enum Protocol {
  PROTOCOL_UNSPECIFIED = 0,
  PROTOCOL_BITCOIN = 1,
  PROTOCOL_EVM = 2,
}

/** Decimal string — human-readable, NOT base units */
interface DecimalString {
  value: string  // e.g., '1.5', '0.001' — always in whole-asset units
}

/** 256-bit unsigned integer as string */
interface U256String {
  value: string  // e.g., '1000000000' for 1 Gwei
}

/** Fee option for channel operations (oneof type) */
interface FeeOption {
  low?: {}
  medium?: {}
  high?: {}
  custom?: {
    feeRate: FeeRate
  }
}

/** Fee rate (per gas/weight unit) */
interface FeeRate {
  maxFeePerUnit: U256String       // Max fee per gas/weight unit
  priorityFeePerUnit: U256String  // Priority fee per gas/weight unit
}

/** Single ChainFee inside a FeeEstimate */
interface ChainFee {
  baseFeePerUnit: U256String       // Current network base fee
  feeRate: FeeRate                 // Suggested rate
  effectiveFeePerUnit: U256String  // Effective fee per unit
}

/** Result of blockchain.GetFeeEstimates */
interface FeeEstimate {
  low: ChainFee
  medium: ChainFee
  high: ChainFee
}

/** Amount to send (oneof type) — used by SendTransaction, OpenChannel, etc. */
interface Amount {
  exact?: {
    amount: DecimalString
  }
  all?: {}
}

/** Withdrawal amounts (both sides withdraw in the same tx) */
interface WithdrawAmount {
  selfWithdrawal: Amount
  counterpartyWithdrawal: Amount
}

/** Asset amounts map — used by OpenChannel, DepositChannel, etc. */
type AssetAmounts = {
  [assetId: string]: Amount
}

/** Withdrawal amounts map */
type WithdrawAssetAmounts = {
  [assetId: string]: WithdrawAmount
}

/** Used by orderbook and swap RPCs (separate from Network) */
interface OrderbookCurrency {
  protocol: Protocol
  networkId: string  // Same shape as Network.id
  assetId: string
}

Debugging Tips

Inspecting Protobuf Messages

// Log the encoded request before sending
const request = OpenChannelRequest.create({
  network,
  peerUrl: nodeId,
  assetAmounts,
  feeOption: { medium: {} }
})

console.log('Request:', JSON.stringify(request, null, 2))

// Verify encoding works
const encoded = OpenChannelRequest.encode(request).finish()
console.log('Encoded length:', encoded.length)

// Decode to verify
const decoded = OpenChannelRequest.decode(encoded)
console.log('Decoded:', JSON.stringify(decoded, null, 2))

Checking Response Structure

try {
  const response = await hydraGrpcClient.getBalances(network)

  console.log('Response structure:', {
    hasBalances: Array.isArray(response.balances),
    balanceCount: response.balances?.length,
    firstBalance: response.balances?.[0],
    fields: Object.keys(response)
  })
} catch (error: any) {
  console.error('Error details:', {
    message: error.message,
    code: error.code,
    details: error.details,
    stack: error.stack
  })
}

Validating Amount Precision

// DON'T use parseFloat for comparisons
const amount1 = '0.1'
const amount2 = '0.2'
const sum = parseFloat(amount1) + parseFloat(amount2)  // ❌ 0.30000000000000004

// DO use string operations or BigInt for precision
import Big from 'big.js'  // or use a decimal library

const amount1 = new Big('0.1')
const amount2 = new Big('0.2')
const sum = amount1.plus(amount2).toString()  // ✅ '0.3'

Real-World Patterns

Opening a Channel with Error Handling

async function openChannelSafely(
  network: Network,
  nodeId: string,
  assetAmounts: AssetAmounts,
  feeRate: 'low' | 'medium' | 'high'
) {
  try {
    // 1. Check balance
    const balances = await hydraGrpcClient.getBalances(network)
    for (const [assetId, amount] of Object.entries(assetAmounts)) {
      const balance = balances.balances.find(b => b.assetId === assetId)
      const available = parseFloat(balance?.onchain?.usable?.value || '0')
      const needed = parseFloat(amount.exact?.amount?.value || '0')

      if (available < needed) {
        throw new Error(`Insufficient ${assetId}: have ${available}, need ${needed}`)
      }
    }

    // 2. Connect to peer if not connected
    try {
      await hydraGrpcClient.connectToPeer(network, nodeId)
      await new Promise(resolve => setTimeout(resolve, 1000))
    } catch (err: any) {
      if (!err.message.includes('already connected')) {
        throw err
      }
    }

    // 3. Open channel
    const response = await hydraGrpcClient.openChannel(
      network,
      nodeId,
      assetAmounts,
      feeRate
    )

    console.log('✅ Channel opened:', response.channelId)
    return response
  } catch (error: any) {
    console.error('❌ Failed to open channel:', error.message)
    throw error
  }
}

Provisioning channel liquidity (Lease) with fee estimate first

(2026-10-14) openRequests is the helper from the Lease API's Subscribe Liquidity Requests: it opens the stream before the request is submitted, so no change of the request is missed.

async function provisionLiquidity(
  liquidity: LiquidityServiceClient,
  network: { protocol: number; id: string },
  assetId: string,
  serverAmount: string,
  leaseDays: number,
  clientRequestId: string,   // your name for this request: persist it, and reuse it on a retry
) {
  // 1. Build the request — same shape for estimate and submit
  const liqReq = new RequestChannelLiquidityRequest()
  liqReq.setNetwork(network)

  const open = new ChannelLiquidityRequestOperation.Open()
  const al = new AssetLiquidity()
  al.setServerAmount({ value: serverAmount })
  al.setClientAmount({ value: '0' })
  open.getAssetLiquidityMap().set(assetId, al)

  const op = new ChannelLiquidityRequestOperation()
  op.setOpen(open)
  liqReq.setOperation(op)
  liqReq.setLeaseDurationSeconds(leaseDays * 24 * 60 * 60)
  liqReq.setPaymentNetwork(network)
  liqReq.setPaymentAssetId(assetId)
  liqReq.setOffchainFeePayment(new OffchainFeePayment())
  liqReq.setClientRequestId(clientRequestId)

  // 2. Estimate first
  const est = await liquidity.estimateRequestChannelLiquidityFee(liqReq, {})
  console.log(`Service fee: ${est.getFee()?.getValue()} ${assetId}`)

  // 3. (Optional) confirm with operator / threshold check
  if (parseFloat(est.getFee()?.getValue() ?? '0') > 0.001) {
    throw new Error('fee exceeds threshold')
  }

  // 4. Subscribe, then submit, then follow the request until it is final
  const requests = await openRequests(liquidity, network)
  try {
    const submitted = (await liquidity.submitChannelLiquidityRequest(liqReq, {})).getRequest()!
    const final = await requests.waitFinal(submitted)
    if (!final.hasCompleted()) {
      throw new Error(`request ${final.getPaymentUuid()} ended without completing`)
    }
    const result = final.getCompleted()!.getChannelLiquidity()   // absent if the report was missed
    return {
      channelId: result?.getChannelId(),
      txid: result?.getTxid(),
    }
  } finally {
    requests.close()
  }
}

See Also


Copyright © 2025