Common Patterns
This guide covers common data structures and patterns you'll encounter when working with the Hydra API.
Fee Structures
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, fees on the Lease API, anything denominated in an asset |
U256String | Raw 256-bit unsigned integer in base units | FeeRate.max_fee_per_unit, FeeRate.priority_fee_per_unit, ChainFee.base_fee_per_unit, ChainFee.effective_fee_per_unit |
The rule of thumb: U256String only shows up inside fee-rate / gas-price types, where the unit is sat/vbyte (Bitcoin) or wei per gas (EVM). Everywhere else you see a string-based amount, it's DecimalString and human-readable.
// FeeRate uses U256String — base-unit values
const txFeeRate = {
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' } }
}
}
DualFundAmount
For dual-funded channel operations:
interface DualFundAmount {
local: DecimalString // Amount from local party
remote: DecimalString // Amount from remote party
selfDeposit: DecimalString // Total self deposit
}
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 of onchain_fee_payment, offchain_fee_payment, or (for liquidity provisioning only) dual_fund_fee_payment.
// 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
}
| 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 |
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
txFeeRate: FeeRate
}
Example — open a channel with 0.001 BTC client-side, offchain fee:
const req = {
network: { protocol: 1, id: '0a03cf40' }, // Bitcoin Signet
operation: {
open: {
assetLiquidity: {
BTC: {
serverAmount: { value: '0' }, // not asking the service to provide BTC
clientAmount: { value: '0.001' }, // we're providing it
},
},
},
},
// leaseDurationSeconds omitted — server_amount is 0
txFeeRate: {
maxFeePerUnit: { value: '50' },
priorityFeePerUnit: { value: '5' },
},
paymentNetwork: { protocol: 1, id: '0a03cf40' },
paymentAssetId: 'BTC',
offchainFeePayment: {},
}
const { channelId, txid } = await liquidity.requestChannelLiquidity(req, {})
RequestChannelRelease (withdraw or close)
interface ChannelReleaseOperation {
// exactly one:
withdraw?: { channelId: string; assetIds: string[] }
cooperativeClose?: { channelId: string }
}
interface RequestChannelReleaseRequest extends LeaseFeePayment {
network: Network
operation: ChannelReleaseOperation
txFeeRate: FeeRate
// dualFundFeePayment is NOT valid here
}
RequestChannelLeaseExtension (extend an asset's lease)
interface RequestChannelLeaseExtensionRequest extends LeaseFeePayment {
network: Network
channelId: string
assetId: string
leaseExtensionSeconds: number // delta — added to the current expiry
// dualFundFeePayment is NOT 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
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?: {}
inactive?: {}
activeSending?: {}
activeReceiving?: {}
active?: {}
recovering?: {}
}
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:
txFeeRate: { value: '10' } // ❌ FeeRate isn't a DecimalString
Correct:
txFeeRate: {
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(),
}
}