Wallet API
The Wallet API exposes Hydra App's WalletService — deposit address management, on-chain and off-chain balances, and transaction history per network.
JSON-RPC namespace: wallet
Endpoints
- Get Deposit Address
- Get Unique Deposit Address
- Release Deposit Address
- Get Balances
- Get Balance
- Get Transactions
- Get Transaction
- Rescan Transactions
Get Deposit Address
Returns a deposit address for the specified network. The address can be shared with others to receive on-chain funds. Each call may return the same or a new address depending on the protocol.
Method: GetDepositAddress
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Target network |
Response:
| Field | Type | Description |
|---|---|---|
address | string | Deposit address for this wallet |
Example Request:
import { WalletServiceClient } from './proto/WalletServiceClientPb'
import { GetDepositAddressRequest } from './proto/wallet_pb'
const client = new WalletServiceClient('http://localhost:5003')
const request = new GetDepositAddressRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
const response = await client.getDepositAddress(request, {})
console.log('Deposit address:', response.getAddress())
Example Response:
{ "address": "bc1qxyz..." }
Get Unique Deposit Address
Returns a unique, never-before-used deposit address. Only supported on UTXO-based protocols (e.g., Bitcoin). Each call returns a fresh address from the HD derivation chain. If there are previously released addresses in the free pool, the lowest available index is reused; otherwise a new derivation index is revealed.
Method: GetUniqueDepositAddress
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Target network (must be UTXO-based) |
Response:
| Field | Type | Description |
|---|---|---|
address | string | A fresh address guaranteed to be unique |
Example Request:
import { GetUniqueDepositAddressRequest } from './proto/wallet_pb'
const request = new GetUniqueDepositAddressRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
const response = await client.getUniqueDepositAddress(request, {})
console.log('Fresh address:', response.getAddress())
Example Response:
{ "address": "bc1qfreshaddress..." }
Release Deposit Address
Releases a previously acquired unique deposit address back to the pool. Must only be called when the associated invoice has expired without receiving any on-chain payment. The server verifies that no funds have been received at the address before reinserting it into the free pool.
Returns an error if the address has received funds or was not previously acquired via
GetUniqueDepositAddress.
Method: ReleaseDepositAddress
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Target network |
address | string | YES | The address to release back to the free pool |
Response: Empty.
Example Request:
import { ReleaseDepositAddressRequest } from './proto/wallet_pb'
const request = new ReleaseDepositAddressRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setAddress('bc1qfreshaddress...')
await client.releaseDepositAddress(request, {})
console.log('Address released')
Example Response:
{}
Get Balances
Get all asset balances for a specific network.
Method: GetBalances
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network to query balances for |
Network Object:
| Field | Type | Description |
|---|---|---|
protocol | Protocol enum | PROTOCOL_BITCOIN = 1, PROTOCOL_EVM = 2, PROTOCOL_TRON = 3 |
id | string | Magic bytes (hex) for Bitcoin, decimal chain ID for EVM and Tron |
Response:
| Field | Type | Description |
|---|---|---|
balances | map<string, Balance> | Map of asset_id → Balance |
Balance Object:
| Field | Type | Description |
|---|---|---|
onchain | OnchainBalance | Onchain balance details |
offchain | OffchainBalance | Offchain (Lightning) balance details |
OnchainBalance:
| Field | Type | Description |
|---|---|---|
confirmed | DecimalString | Confirmed on-chain and available for spending |
trusted_pending | DecimalString | Pending, but usable for spending on-chain |
pending | DecimalString | Pending confirmation; usable once it reaches the finality depth |
OffchainBalance:
| Field | Type | Description |
|---|---|---|
free_local | DecimalString | Local side, free to spend |
free_remote | DecimalString | Remote side, free to receive |
pending_local | DecimalString | Pending confirmation on the local side (channels opening or updating) |
pending_remote | DecimalString | Pending confirmation on the remote side |
unavailable_local | DecimalString | Local, unavailable — channels inactive, closed, or closing |
unavailable_remote | DecimalString | Remote, unavailable |
paying_local | DecimalString | Locked in pending channel payments sent to the counterparty |
paying_remote | DecimalString | Locked in pending channel payments received from the counterparty |
unspendable_local_reserve | DecimalString | Local, reserved for dispute punishment / closure fees |
unspendable_remote_reserve | DecimalString | Remote, same |
redeemable_local | DecimalString | (2026-06-17) Local amount redeemable on-chain when the channel is redeemable, else "0" |
redeemable_remote | DecimalString | (2026-06-17) Remote counterpart |
max_sendable | DecimalString | (2026-08-29) Largest amount a single outbound payment can move right now |
max_receivable | DecimalString | (2026-08-29) Largest amount a single inbound payment can deliver right now |
max_withdrawable_local | DecimalString | (2026-09-16) Most that can be withdrawn on-chain from the local side while the channel keeps existing |
max_withdrawable_remote | DecimalString | (2026-09-16) The counterparty-side counterpart. Zero on Lightning |
withdrawing_local | DecimalString | (2026-09-19) Local amount leaving the channel in the on-chain update currently in flight |
withdrawing_remote | DecimalString | (2026-09-19) The counterparty-side counterpart |
withdrawing_localandpending_localboth mean "waiting on a confirmation" — in opposite directions.pending_localis arriving and becomes spendable off-chain;withdrawing_localis departing, reaches the wallet when its transaction confirms, and is never spendable off-chain again. It counts toward what you own, and deliberately not toward what a close could still pay out — it is already leaving by another route. Adding the two together double-counts a balance that is going two different places.
⚠️ Size an on-chain withdrawal offmax_withdrawable_localNot off
free_local, and never offfree_local + unspendable_local_reserve. The reserve is what a surviving channel has to keep, so every backend refuses a withdrawal that eats it — only a cooperative close pays the reserve out.max_withdrawable_localequalsfree_localunless the protocol bounds it lower: a Lightning splice-out pays its own transaction fee out of the balance it is splicing, so there it sits that much belowfree_local.
max_withdrawable_remoteis what the other side could take out in an operation this one initiates — zero on Lightning, where a splice-out pays out only the side that broadcasts it.
⚠️ Size single payments offmax_sendable, notfree_local
free_localis your whole spendable local balance.max_sendableis what one payment can carry — equal tofree_localunless the channel protocol enforces a per-payment ceiling below the balance. On a Lightning channel whose value grew past themax_htlc_value_in_flightnegotiated at open,free_localkeeps growing with every deposit whilemax_sendablestays near the original channel value.A balance check that passes while the payment still fails is almost always this.
max_receivableis the receiving-direction counterpart.
redeemable_local/redeemable_remoteare a view intounavailable_local/unavailable_remote, not additional categories — don't add them into a total.
Example Request:
TypeScript
import { WalletServiceClient } from './proto/WalletServiceClientPb'
import { GetBalancesRequest } from './proto/wallet_pb'
const client = new WalletServiceClient('http://localhost:5003')
const request = new GetBalancesRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
const response = await client.getBalances(request, {})
const balances = response.getBalancesMap()
Go
import (
pb "github.com/hydra/hydra-go/proto"
)
client := pb.NewWalletServiceClient(conn)
req := &pb.GetBalancesRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
}
resp, err := client.GetBalances(context.Background(), req)
if err != nil {
log.Fatal(err)
}
balances := resp.Balances
Rust
use hydra_app::wallet_service_client::WalletServiceClient;
use hydra_app::{GetBalancesRequest, Network};
let mut client = WalletServiceClient::new(channel);
let request = tonic::Request::new(GetBalancesRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
});
let response = client.get_balances(request).await?;
let balances = response.into_inner().balances;
Example Response:
{
"balances": {
"0x0000000000000000000000000000000000000000000000000000000000000000": {
"onchain": {
"confirmed": { "value": "0.5" },
"trustedPending": { "value": "0" },
"pending": { "value": "0.01" }
},
"offchain": {
"freeLocal": { "value": "0.1" },
"freeRemote": { "value": "0.05" },
"pendingLocal": { "value": "0" },
"pendingRemote": { "value": "0" },
"unavailableLocal": { "value": "0" },
"unavailableRemote": { "value": "0" },
"payingLocal": { "value": "0" },
"payingRemote": { "value": "0" },
"unspendableLocalReserve": { "value": "0.001" },
"unspendableRemoteReserve": { "value": "0.001" },
"redeemableLocal": { "value": "0" },
"redeemableRemote": { "value": "0" },
"maxSendable": { "value": "0.1" },
"maxReceivable": { "value": "0.05" }
}
}
}
}
Every amount is a
DecimalStringin the asset's whole units —0.5BTC, not50000000satoshis. There is no satoshi/wei form anywhere in the API.This is the JSON-RPC wire shape: camelCase keys, every amount a
{ "value": … }object. Over gRPC the field names are the proto's own snake_case, as the tables above spell them. See Wire encoding.
Get Balance
Get the balance of a specific asset on a network.
Method: GetBalance
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network to query |
asset_id | string | YES | Asset identifier (e.g., "0x0000000000000000000000000000000000000000000000000000000000000000", "0x...") |
Response:
| Field | Type | Description |
|---|---|---|
balance | Balance | Balance details for the asset |
Example Request:
TypeScript
const request = new GetBalanceRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setAssetId('0x0000000000000000000000000000000000000000000000000000000000000000')
const response = await client.getBalance(request, {})
const balance = response.getBalance()
Go
req := &pb.GetBalanceRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
AssetId: "0x0000000000000000000000000000000000000000000000000000000000000000",
}
resp, err := client.GetBalance(context.Background(), req)
if err != nil {
log.Fatal(err)
}
balance := resp.Balance
Rust
let request = tonic::Request::new(GetBalanceRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
asset_id: "0x0000000000000000000000000000000000000000000000000000000000000000".to_string(),
});
let response = client.get_balance(request).await?;
let balance = response.into_inner().balance;
Example Response:
{
"balance": {
"onchain": {
"confirmed": { "value": "0.5" },
"trustedPending": { "value": "0" },
"pending": { "value": "0.01" }
},
"offchain": {
"freeLocal": { "value": "0.1" },
"freeRemote": { "value": "0.05" },
"pendingLocal": { "value": "0" },
"pendingRemote": { "value": "0" },
"unavailableLocal": { "value": "0" },
"unavailableRemote": { "value": "0" },
"payingLocal": { "value": "0" },
"payingRemote": { "value": "0" },
"unspendableLocalReserve": { "value": "0.001" },
"unspendableRemoteReserve": { "value": "0.001" },
"redeemableLocal": { "value": "0" },
"redeemableRemote": { "value": "0" },
"maxSendable": { "value": "0.1" },
"maxReceivable": { "value": "0.05" }
}
}
}
Get Transactions
Get all transactions for a network.
Method: GetTransactions
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network to query transactions for |
pagination | PaginationRequest | no | limit (clamped to 1..1000, default 100) + cursor. Omit cursor for the first page |
Response:
| Field | Type | Description |
|---|---|---|
transactions | Transaction[] | Array of transactions, newest first |
pagination | PaginationResponse | next_cursor (absent/empty at the end) + has_more |
This call is paginated. Without a
paginationblock you get the newest 100 transactions, not the whole history. Page by feeding the response'snext_cursorback as the next request'spagination.cursoruntilhas_moreis false.
Transaction Object:
| Field | Type | Description |
|---|---|---|
id | string | Transaction ID (hex-encoded hash) |
status | TxStatus enum | TX_STATUS_IN_MEMPOOL (1), TX_STATUS_PENDING_CONFIRMATIONS (2), TX_STATUS_COMPLETED (3), TX_STATUS_FAILED (4) |
confirmations | uint64 | Number of block confirmations |
block_height | uint64? | Block the transaction was included in, if confirmed |
timestamp | Timestamp | When the transaction was first seen or confirmed |
fee_rate | FeeRate | The transaction's fee rate — see Fee structures |
effective_fee | DecimalString? | (2026-09-15) The fee the transaction paid, in the network's native currency. Absent while the transaction is unpriced (still in the mempool) |
fee_payer | FeePayer | (2026-09-15) Who bore it: FEE_PAYER_WALLET, FEE_PAYER_COUNTERPARTY, FEE_PAYER_SHARED, or FEE_PAYER_UNSPECIFIED when the record does not say |
spent | map<string, DecimalString> | asset_id → amount this wallet spent |
received | map<string, DecimalString> | asset_id → amount this wallet received |
operations | Operations | oneof: account (AccountOperations, EVM/Tron) or utxo (UtxoOperations, Bitcoin) |
smart_operations | SmartOperation[] | Contract calls / deployments |
token_operations | TokenOperation[] | Token operations (currently set_token_allowance) |
channel_operations | ChannelOperation[] | Channel opening / deposit / withdrawal / closing / settlement |
htlc_operations | HtlcOperation[] | On-chain HTLC lock / claim / refund |
There is no
txidorasset_transfersfield. The transaction id isid, and what moved is thespent/receivedmaps plus the typed*_operationslists. Older revisions of this page showed anasset_transfersarray that the API never returned.
effective_fee is the one fee figure that exists whatever the transaction's shape. fee_rate is a rate, not a cost. A UTXO transaction carries no fee operation at all, and on an account-based chain the AccountOperation::Fee entry is a deliberate upper-bound reserve until the transaction is mined — pessimistic on purpose, so a pending balance never overstates what is available. effective_fee is the chain's own figure, and its presence is what says the number is final.
effective_fee is the transaction's fee, not automatically yours. An incoming transfer's fee was paid by whoever sent it, and charging it to this wallet would be wrong by the whole amount. Read fee_payer before treating it as a cost:
fee_payer | What it means |
|---|---|
FEE_PAYER_WALLET | This wallet funded the transaction and paid the whole fee |
FEE_PAYER_COUNTERPARTY | Somebody else funded it. This wallet paid nothing toward the fee |
FEE_PAYER_SHARED | Funded by more than one party, so part of it is this wallet's and the transaction does not say how much. A dual-funded channel open, and any transaction spending a jointly-owned output — a channel closure among them |
FEE_PAYER_UNSPECIFIED | Not recorded: a transaction stored before this was tracked, or one whose inputs could not all be attributed. Not a claim that nobody paid |
On EVM and Tron this is exact — one account funds a transaction, so the fee is wholly the sender's and never SHARED.
Bitcoin: operations is your side of the transaction only. UtxoOperations lists the inputs this wallet spent and the outputs paying back to it; a payment's counterparty output belongs to somebody else and is not there. The amount paid out is spent − received − effective_fee — valid only where fee_payer is FEE_PAYER_WALLET, since otherwise the fee came out of somebody else's inputs and is not part of what this wallet spent.
ChannelOperation variants — channel_opening, channel_deposit, channel_withdrawal, channel_closing, channel_settlement, channel_settlement_confirmed. Each carries channel_id and counterparty; all but channel_settlement also carry amounts (asset_id → { local, remote }). channel_settlement carries assets (the asset ids involved) and an optional dispute_deadline.
HtlcOperation variants — htlc_lock (htlc_id, recipient, refund_address, asset_id, amount), htlc_claim (htlc_id, preimage, recipient, asset_id, amount), htlc_refund (htlc_id, refund_address, asset_id, amount).
Example Request:
TypeScript
const request = new GetTransactionsRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
const response = await client.getTransactions(request, {})
const transactions = response.getTransactionsList()
Go
req := &pb.GetTransactionsRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
}
resp, err := client.GetTransactions(context.Background(), req)
if err != nil {
log.Fatal(err)
}
transactions := resp.Transactions
Rust
let request = tonic::Request::new(GetTransactionsRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
});
let response = client.get_transactions(request).await?;
let transactions = response.into_inner().transactions;
Example Response:
{
"transactions": [
{
"id": "abc123...",
"status": "TX_STATUS_COMPLETED",
"confirmations": "6",
"blockHeight": "212345",
"timestamp": "2026-09-03T10:30:00Z",
"feeRate": { "maxFeePerUnit": { "value": "4" }, "priorityFeePerUnit": { "value": "0" } },
"spent": { "0x0000000000000000000000000000000000000000000000000000000000000000": { "value": "0.01" } },
"operations": { "utxo": { "operations": [] } }
}
],
"pagination": { "nextCursor": "eyJ0cyI6MTc1...", "hasMore": true }
}
uint64fields (confirmations,blockHeight) are JSON strings, per the protobuf JSON mapping — 64-bit integers do not survive a JavaScript number. Fields at their default (the emptyreceivedmap, the empty operation lists) are omitted, not sent as{}/[].
Get Transaction
Get details of a specific transaction by its ID.
Method: GetTransaction
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network where transaction occurred |
txid | string | YES | Transaction ID |
Response:
| Field | Type | Description |
|---|---|---|
transaction | Transaction | Transaction details |
Example Request:
TypeScript
const request = new GetTransactionRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setTxid('abc123...')
const response = await client.getTransaction(request, {})
const transaction = response.getTransaction()
Go
req := &pb.GetTransactionRequest{
Network: &pb.Network{
Protocol: pb.Protocol_PROTOCOL_BITCOIN,
Id: "0a03cf40",
},
Txid: "abc123...",
}
resp, err := client.GetTransaction(context.Background(), req)
if err != nil {
log.Fatal(err)
}
transaction := resp.Transaction
Rust
let request = tonic::Request::new(GetTransactionRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
txid: "abc123...".to_string(),
});
let response = client.get_transaction(request).await?;
let transaction = response.into_inner().transaction;
Example Response:
{
"transaction": {
"id": "abc123...",
"status": "TX_STATUS_COMPLETED",
"confirmations": "6",
"blockHeight": "212345",
"timestamp": "2026-09-03T10:30:00Z",
"feeRate": { "maxFeePerUnit": { "value": "4" }, "priorityFeePerUnit": { "value": "0" } },
"spent": { "0x0000000000000000000000000000000000000000000000000000000000000000": { "value": "0.01" } },
"operations": { "utxo": { "operations": [] } }
}
}
Rescan Transactions
Added 2026-09-11.
Re-reads the wallet's on-chain transaction history for a network and persists what it finds.
This is operator-driven recovery, not part of normal operation. The ordinary sync reaches back only a confirmation window, so a transaction the live subscriptions never delivered — a provider outage, a node offline across the window — is never revisited on its own. Balances are read from the chain and are unaffected either way; what a rescan repairs is the history.
Method: RescanTransactions
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Target network |
from_block | uint64 | no | First block to read. Omitted reaches as far back as the network's history source serves in one bounded pass |
Response:
| Field | Type | Description |
|---|---|---|
from_block | uint64 | First block the pass actually read |
to_block | uint64 | Last block the pass read |
adopted | uint64 | Transactions persisted, whether or not the wallet already held them |
skipped | uint64 | Transactions read but which no wallet record could be built for |
skipped > 0means the range was not fully adopted. A skipped transaction does not end the rescan, so this counter — not an error — is what tells you the pass was incomplete; the node's log names the individual transactions. A backend whose row write cannot fail always reports0.
The pass is bounded: it reads one range and returns. It is not a background job and has no progress stream — compare
from_block/to_blockin the response against the range you wanted and call again for what is left.
Example Request:
import { RescanTransactionsRequest } from './proto/wallet_pb'
const request = new RescanTransactionsRequest()
request.setNetwork({ protocol: 2, id: '421614' })
request.setFromBlock(190_000_000)
const res = (await client.rescanTransactions(request, {})).toObject()
console.log(`read ${res.fromBlock}..${res.toBlock}: adopted ${res.adopted}, skipped ${res.skipped}`)
Example Response:
{ "fromBlock": "190000000", "toBlock": "190004312", "adopted": "3" }
skippedis absent here because it is zero — the JSON encoding omits fields at their default.
Asset ID Formats
An asset_id is always the canonical string form the node emits. Input is more forgiving than output — the token-standard prefix parses case-insensitively — but compare against the lowercase form below, since that is what every response carries.
Bitcoin
- Native (BTC):
"0x0000000000000000000000000000000000000000000000000000000000000000"— the 32-byte zero hash
EVM
- Native (ETH, and the native coin of any EVM chain):
"0x0000000000000000000000000000000000000000"— the 20-byte zero address - Tokens:
"<standard>:<contract address>"—erc20:0x…, and for id-bearing standardserc721:0x…:<id>,erc1155:0x…:<id>,erc6909:0x…:<id>
Tron
- Native (TRX):
"T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb"— the zero address in base58check - Tokens:
"trc20:T…","trc10:<id>", andtrc721/trc1155/trc6909with the same:<id>suffix as EVM
The native asset is the zero address of its own protocol, never a ticker.
"BTC","ETH"and"TRX"aresymbolvalues on theAssettype, not asset ids, and are not accepted where anasset_idis expected.
Common Patterns
Check if user has sufficient balance
TypeScript
async function hasSufficientBalance(
client: WalletServiceClient,
network: Network,
assetId: string,
requiredAmount: string,
balanceType: 'onchain' | 'offchain'
): Promise<boolean> {
const request = new GetBalanceRequest()
request.setNetwork(network)
request.setAssetId(assetId)
const response = await client.getBalance(request, {})
const balance = response.getBalance()
// Off-chain: size a single payment off max_sendable, not free_local.
const available = balanceType === 'onchain'
? balance.getOnchain()?.getConfirmed()?.getValue()
: balance.getOffchain()?.getMaxSendable()?.getValue()
return new Big(available || '0').gte(new Big(requiredAmount))
}
Go
func hasSufficientBalance(
client pb.WalletServiceClient,
network *pb.Network,
assetId string,
requiredAmount string,
balanceType string,
) (bool, error) {
req := &pb.GetBalanceRequest{
Network: network,
AssetId: assetId,
}
resp, err := client.GetBalance(context.Background(), req)
if err != nil {
return false, err
}
balance := resp.Balance
var available string
// Off-chain: size a single payment off MaxSendable, not FreeLocal.
if balanceType == "onchain" {
available = balance.Onchain.Confirmed.Value
} else {
available = balance.Offchain.MaxSendable.Value
}
// Amounts are decimal strings in whole units — parse as decimals, not ints.
requiredDec, _, err := big.ParseFloat(requiredAmount, 10, 128, big.ToNearestEven)
if err != nil {
return false, err
}
availableDec, _, err := big.ParseFloat(available, 10, 128, big.ToNearestEven)
if err != nil {
return false, err
}
return availableDec.Cmp(requiredDec) >= 0, nil
}
Rust
async fn has_sufficient_balance(
client: &mut WalletServiceClient<Channel>,
network: Network,
asset_id: String,
required_amount: String,
balance_type: &str,
) -> Result<bool, Box<dyn std::error::Error>> {
let request = tonic::Request::new(GetBalanceRequest {
network: Some(network),
asset_id,
});
let response = client.get_balance(request).await?;
let balance = response.into_inner().balance.unwrap();
// Off-chain: size a single payment off max_sendable, not free_local.
let available = if balance_type == "onchain" {
balance.onchain.unwrap().confirmed.unwrap().value
} else {
balance.offchain.unwrap().max_sendable.unwrap().value
};
// Amounts are decimal strings in whole units.
let required: Decimal = required_amount.parse()?;
let available_amount: Decimal = available.parse()?;
Ok(available_amount >= required)
}
Monitor transaction confirmations
TypeScript
async function waitForConfirmations(
client: WalletServiceClient,
network: Network,
txid: string,
requiredConfirmations: number
): Promise<void> {
while (true) {
const request = new GetTransactionRequest()
request.setNetwork(network)
request.setTxid(txid)
const response = await client.getTransaction(request, {})
const tx = response.getTransaction()
if (tx.getConfirmations() >= requiredConfirmations) {
return
}
await new Promise(resolve => setTimeout(resolve, 30000)) // Wait 30s
}
}
Go
func waitForConfirmations(
client pb.WalletServiceClient,
network *pb.Network,
txid string,
requiredConfirmations uint64,
) error {
for {
req := &pb.GetTransactionRequest{
Network: network,
Txid: txid,
}
resp, err := client.GetTransaction(context.Background(), req)
if err != nil {
return err
}
tx := resp.Transaction
if tx.Confirmations >= requiredConfirmations {
return nil
}
time.Sleep(30 * time.Second) // Wait 30s
}
}
Rust
async fn wait_for_confirmations(
client: &mut WalletServiceClient<Channel>,
network: Network,
txid: String,
required_confirmations: u64,
) -> Result<(), Box<dyn std::error::Error>> {
loop {
let request = tonic::Request::new(GetTransactionRequest {
network: Some(network.clone()),
txid: txid.clone(),
});
let response = client.get_transaction(request).await?;
let tx = response.into_inner().transaction.unwrap();
if tx.confirmations >= required_confirmations {
return Ok(());
}
tokio::time::sleep(tokio::time::Duration::from_secs(30)).await; // Wait 30s
}
}
Error Handling
| Error Code | Description | Solution |
|---|---|---|
INVALID_ARGUMENT | Invalid network or asset_id | Check asset ID format for the network |
NOT_FOUND | Transaction not found | Verify txid is correct |
UNAVAILABLE | Service temporarily unavailable | Retry with exponential backoff |
Best Practices
- Prefer the balance stream to polling —
SubscribeClientEventspushes aBalanceUpdatewhenever a balance moves. Poll only as a reconciliation backstop, and cache for 10–30 seconds if you do. - Use
GetBalancesfor multiple assets — one round trip instead of one per asset. - Check both onchain and offchain — a channel payment spends the off-chain balance; opening or funding a channel spends the on-chain one.
- Size single payments off
max_sendable/max_receivable, notfree_local/free_remote— see the warning above. - Don't spend
pending— it is not yet confirmed to the finality depth.trusted_pendingis the part that is usable early (your own change). - Never add
redeemable_localinto a total — it is a view intounavailable_local, not a separate bucket. - Page
GetTransactions— the default page is 100 rows, not the whole history.