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 |
⚠️ 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 |
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.
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.