Common Patterns
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 want | Field | Where |
|---|---|---|
| What the chain charged to include a transaction | effective_fee (+ fee_payer) | Transaction, on every on-chain transaction whatever its shape |
| What the liquidity service billed for a request you just made | fee | RequestChannelLiquidityResponse (3), RequestChannelReleaseResponse (2), RequestChannelLeaseExtensionResponse (4) |
| What the liquidity service billed, historically — including payments that expired, failed or were refunded | payments[].fee | GetFeePayments, the only surface that returns a payment no request response ever did |
| What a redemption recovered from a closed channel | redeemed | RedeemClosedChannelResponse |
| An estimate, before committing | quoted fee | EstimateRequestChannelLiquidityFee and the other estimate calls |
Two traps worth stating plainly:
fee_rateis 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 — readeffective_feefor that.- A service fee and a network fee can be billed in different assets, on different networks.
A fee payment carries its own
payment_networkandpayment_asset_id;effective_feeis 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
FeeRatefor a custom transaction, the easiest path is: callGetFeeEstimates, pick a priority, and usechainFee.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.valueis always in whole-asset, human-readable units. Never satoshis. Never wei. Never base units. The string"0.001"means0.001of 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
| Asset | value: "0.001" means | value: "100" means | value: "0.00000001" means |
|---|---|---|---|
| BTC (8 decimals) | 0.001 BTC = 100,000 sats | 100 BTC | 0.00000001 BTC = 1 sat |
| ETH (18 decimals) | 0.001 ETH = 10¹⁵ wei | 100 ETH | 0.00000001 ETH = 10¹⁰ wei |
| USDC (6 decimals) | 0.001 USDC | 100 USDC | 0.00000001 USDC (sub-cent — usually meaningless) |
| HDN (18 decimals) | 0.001 HDN | 100 HDN | 0.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")is0.30000000000000004, not0.3. Across many trades that error compounds. JavaScript:big.jsordecimal.js. Go:math/big's*big.Ratorshopspring/decimal. Rust:rust_decimal.
DecimalString vs U256String — when each appears
Both wrap a string. They are not interchangeable.
| Type | Means | Where it shows up |
|---|---|---|
DecimalString | Human-readable decimal value of an asset | All Amount fields, balances, prices, lease fees — anything denominated in an asset |
U256String | Raw 256-bit unsigned integer in base units | Fee rates and the four exceptions below |
U256String appears in exactly nine places across the whole schema, and they fall into three groups:
| Fields | Unit |
|---|---|
FeeRate.max_fee_per_unit, FeeRate.priority_fee_per_unit, ChainFee.base_fee_per_unit, ChainFee.effective_fee_per_unit | Per unit — sat/vbyte on Bitcoin, wei per gas on EVM |
TransactionRequest.fee_units, TransactionRequest.fee, ChainTransaction.effective_fee | Gas units / weight units, and the total fee in base units |
Invoice.amount, TokenPermit.value | The 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.amountis what the invoice encodes, and the encoding is base-unit.CreateInvoiceRequest.amount— the amount you ask for — is aDecimalString, so the same number changes representation across the call.TokenPermit.valueis 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 thatEstimateTokenPermitFee.valueis a whole-unitDecimalString, 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):
price | Meaning |
|---|---|
"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):
price | Meaning |
|---|---|
"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.baseandOrderbookCurrency.quotefrom the market info and use those.
Negative amounts and zero
| Use | Allowed? |
|---|---|
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
- Never use
parseFloat/Number()/f64onDecimalString.valuefor arithmetic. Display-only is fine. - Compare amounts as strings or via a decimal library.
"0.10" === "0.1"isfalsein plain JS; both libraries normalise. - Round at output, not throughout the pipeline. Carry full precision; only round when displaying to a human.
- 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. - 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 inbalance.proto). Older client code may call thisSendAmount— 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 + remoteis the channel's resulting balance split;selfDepositis your share of the funding. They differ whenever the counterparty contributes — that is what makes it dual-funded. There is noDualFundAmounttype; the arm isdual_fundedinsideDepositAmount.
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 RPCs share a common fee-payment shape: pick exactly one fee-payment arm.
// 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?: {} // RequestChannelLiquidity only
sponsoredDepositFeePayment?: {} // RequestChannelLiquidity only
sponsoredWithdrawalFeePayment?: {} // RequestChannelRelease only
quoteId?: string // from the matching Estimate*Fee call
}
| Variant | Available on | When to use |
|---|---|---|
offchainFeePayment | Liquidity / Release / Lease Extension | You have offchain balance — fastest, lowest overhead |
onchainFeePayment.utxo | All three (Bitcoin-style) | Fee paid on-chain, refund address required |
onchainFeePayment.account | All three (EVM-style) | Fee paid on-chain from an EVM account |
dualFundFeePayment | Liquidity only | Settle the fee inside the dual-fund flow when client_amount > 0 |
sponsoredDepositFeePayment | Liquidity only | (2026-09-11) You hold the token but no gas — the service broadcasts and takes its fee out of the deposit |
sponsoredWithdrawalFeePayment | Release 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
txFeeRateon 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 thequoteIdfrom the matchingEstimate*Feecall to be billed at the figure you showed the user — see Quotes.
RequestChannelLiquidity (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
}
Example — open a channel with 0.001 BTC client-side, 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' }, // not asking the service to provide BTC
clientAmount: { value: '0.001' }, // we're providing it
},
},
},
},
// leaseDurationSeconds omitted — serverAmount is 0
paymentNetwork: network,
paymentAssetId: BTC,
offchainFeePayment: {},
}
// Price it first, then bill at that price.
const { fee, quoteId } = await liquidity.estimateRequestChannelLiquidityFee(req, {})
const { channelId, txid } = await liquidity.requestChannelLiquidity({ ...req, quoteId }, {})
RequestChannelRelease (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
}
withdraw.assetAmountsreplaced a plainassetIds[]list on 2026-07-23 — you now say how much and whose balance moves. See ChannelReleaseOperation.
RequestChannelLeaseExtension (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
}
leaseExtensionSecondsis a delta, not a target absolute expiry. Common bug: passingDate.now() + days * 86400instead of justdays * 86400.
Rules of thumb
- Always estimate first. Each operation has a paired
Estimate*RPC that takes the same request and returns just the fee. - Set
leaseDurationSecondsonly if needed. Required when any asset hasserver_amount > 0; otherwise omit. - Use
deposit/open_or_depositto reuse channels. Saves the cost of opening a new one. dualFundFeePaymentis only valid onRequestChannelLiquidity. Setting it on Release or Lease Extension returnsINVALID_ARGUMENT.
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')(hasattris useless ononeofs — 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
async function provisionLiquidity(
liquidity: LiquidityServiceClient,
blockchain: BlockchainServiceClient,
network: { protocol: number; id: string },
assetId: string,
serverAmount: string,
leaseDays: number,
) {
// 1. Get current fee estimates from the chain
const feReq = new GetFeeEstimatesRequest()
feReq.setNetwork(network)
const feResp = await blockchain.getFeeEstimates(feReq, {})
const mediumRate = feResp.getFeeEstimate()?.getMedium()?.getFeeRate()
if (!mediumRate) throw new Error('no fee estimate available')
// 2. Build the request — same shape for estimate and execute
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.setTxFeeRate(mediumRate)
liqReq.setPaymentNetwork(network)
liqReq.setPaymentAssetId(assetId)
liqReq.setOffchainFeePayment(new OffchainFeePayment())
// 3. Estimate first
const est = await liquidity.estimateRequestChannelLiquidityFee(liqReq, {})
console.log(`Service fee: ${est.getFee()?.getValue()} ${assetId}`)
// 4. (Optional) confirm with operator / threshold check
if (parseFloat(est.getFee()?.getValue() ?? '0') > 0.001) {
throw new Error('fee exceeds threshold')
}
// 5. Execute
const result = await liquidity.requestChannelLiquidity(liqReq, {})
return {
channelId: result.getChannelId(),
txid: result.getTxid(),
}
}
See Also
- Getting Started Guide
- Bot Quickstart
- Browser & gRPC-Web Specifics
- Lease API
/proto— the authoritative schema