Api

Wallet API

Deposit addresses, balances, and transaction history

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

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:

NameTypeRequiredDescription
networkNetworkYESTarget network

Response:

FieldTypeDescription
addressstringDeposit 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:

NameTypeRequiredDescription
networkNetworkYESTarget network (must be UTXO-based)

Response:

FieldTypeDescription
addressstringA 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:

NameTypeRequiredDescription
networkNetworkYESTarget network
addressstringYESThe 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:

NameTypeRequiredDescription
networkNetworkYESNetwork to query balances for

Network Object:

FieldTypeDescription
protocolProtocol enumPROTOCOL_BITCOIN = 1, PROTOCOL_EVM = 2, PROTOCOL_TRON = 3
idstringMagic bytes (hex) for Bitcoin, decimal chain ID for EVM and Tron

Response:

FieldTypeDescription
balancesmap<string, Balance>Map of asset_id → Balance

Balance Object:

FieldTypeDescription
onchainOnchainBalanceOnchain balance details
offchainOffchainBalanceOffchain (Lightning) balance details

OnchainBalance:

FieldTypeDescription
confirmedDecimalStringConfirmed on-chain and available for spending
trusted_pendingDecimalStringPending, but usable for spending on-chain
pendingDecimalStringPending confirmation; usable once it reaches the finality depth

OffchainBalance:

FieldTypeDescription
free_localDecimalStringLocal side, free to spend
free_remoteDecimalStringRemote side, free to receive
pending_localDecimalStringPending confirmation on the local side (channels opening or updating)
pending_remoteDecimalStringPending confirmation on the remote side
unavailable_localDecimalStringLocal, unavailable — channels inactive, closed, or closing
unavailable_remoteDecimalStringRemote, unavailable
paying_localDecimalStringLocked in pending channel payments sent to the counterparty
paying_remoteDecimalStringLocked in pending channel payments received from the counterparty
unspendable_local_reserveDecimalStringLocal, reserved for dispute punishment / closure fees
unspendable_remote_reserveDecimalStringRemote, same
redeemable_localDecimalString(2026-06-17) Local amount redeemable on-chain when the channel is redeemable, else "0"
redeemable_remoteDecimalString(2026-06-17) Remote counterpart
max_sendableDecimalString(2026-08-29) Largest amount a single outbound payment can move right now
max_receivableDecimalString(2026-08-29) Largest amount a single inbound payment can deliver right now

⚠️ Size single payments off max_sendable, not free_local

free_local is your whole spendable local balance. max_sendable is what one payment can carry — equal to free_local unless the channel protocol enforces a per-payment ceiling below the balance. On a Lightning channel whose value grew past the max_htlc_value_in_flight negotiated at open, free_local keeps growing with every deposit while max_sendable stays near the original channel value.

A balance check that passes while the payment still fails is almost always this. max_receivable is the receiving-direction counterpart.

redeemable_local / redeemable_remote are a view into unavailable_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 DecimalString in the asset's whole units0.5 BTC, not 50000000 satoshis. 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:

NameTypeRequiredDescription
networkNetworkYESNetwork to query
asset_idstringYESAsset identifier (e.g., "0x0000000000000000000000000000000000000000000000000000000000000000", "0x...")

Response:

FieldTypeDescription
balanceBalanceBalance 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:

NameTypeRequiredDescription
networkNetworkYESNetwork to query transactions for
paginationPaginationRequestnolimit (clamped to 1..1000, default 100) + cursor. Omit cursor for the first page

Response:

FieldTypeDescription
transactionsTransaction[]Array of transactions, newest first
paginationPaginationResponsenext_cursor (absent/empty at the end) + has_more

This call is paginated. Without a pagination block you get the newest 100 transactions, not the whole history. Page by feeding the response's next_cursor back as the next request's pagination.cursor until has_more is false.

Transaction Object:

FieldTypeDescription
idstringTransaction ID (hex-encoded hash)
statusTxStatus enumTX_STATUS_IN_MEMPOOL (1), TX_STATUS_PENDING_CONFIRMATIONS (2), TX_STATUS_COMPLETED (3), TX_STATUS_FAILED (4)
confirmationsuint64Number of block confirmations
block_heightuint64?Block the transaction was included in, if confirmed
timestampTimestampWhen the transaction was first seen or confirmed
fee_rateFeeRateThe transaction's fee rate — see Fee structures
spentmap<string, DecimalString>asset_id → amount this wallet spent
receivedmap<string, DecimalString>asset_id → amount this wallet received
operationsOperationsoneof: account (AccountOperations, EVM/Tron) or utxo (UtxoOperations, Bitcoin)
smart_operationsSmartOperation[]Contract calls / deployments
token_operationsTokenOperation[]Token operations (currently set_token_allowance)
channel_operationsChannelOperation[]Channel opening / deposit / withdrawal / closing / settlement
htlc_operationsHtlcOperation[]On-chain HTLC lock / claim / refund

There is no txid or asset_transfers field. The transaction id is id, and what moved is the spent / received maps plus the typed *_operations lists. Older revisions of this page showed an asset_transfers array that the API never returned.

ChannelOperation variantschannel_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 variantshtlc_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 }
}

uint64 fields (confirmations, blockHeight) are JSON strings, per the protobuf JSON mapping — 64-bit integers do not survive a JavaScript number. Fields at their default (the empty received map, the empty operation lists) are omitted, not sent as {} / [].


Get Transaction

Get details of a specific transaction by its ID.

Method: GetTransaction

Parameters:

NameTypeRequiredDescription
networkNetworkYESNetwork where transaction occurred
txidstringYESTransaction ID

Response:

FieldTypeDescription
transactionTransactionTransaction 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:

NameTypeRequiredDescription
networkNetworkYESTarget network
from_blockuint64noFirst block to read. Omitted reaches as far back as the network's history source serves in one bounded pass

Response:

FieldTypeDescription
from_blockuint64First block the pass actually read
to_blockuint64Last block the pass read
adopteduint64Transactions persisted, whether or not the wallet already held them
skippeduint64Transactions read but which no wallet record could be built for

skipped > 0 means 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 reports 0.

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_block in 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" }

skipped is 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 standards erc721:0x…:<id>, erc1155:0x…:<id>, erc6909:0x…:<id>

Tron

  • Native (TRX): "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb" — the zero address in base58check
  • Tokens: "trc20:T…", "trc10:<id>", and trc721 / trc1155 / trc6909 with the same :<id> suffix as EVM

The native asset is the zero address of its own protocol, never a ticker. "BTC", "ETH" and "TRX" are symbol values on the Asset type, not asset ids, and are not accepted where an asset_id is 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 CodeDescriptionSolution
INVALID_ARGUMENTInvalid network or asset_idCheck asset ID format for the network
NOT_FOUNDTransaction not foundVerify txid is correct
UNAVAILABLEService temporarily unavailableRetry with exponential backoff

Best Practices

  1. Prefer the balance stream to pollingSubscribeClientEvents pushes a BalanceUpdate whenever a balance moves. Poll only as a reconciliation backstop, and cache for 10–30 seconds if you do.
  2. Use GetBalances for multiple assets — one round trip instead of one per asset.
  3. Check both onchain and offchain — a channel payment spends the off-chain balance; opening or funding a channel spends the on-chain one.
  4. Size single payments off max_sendable / max_receivable, not free_local / free_remote — see the warning above.
  5. Don't spend pending — it is not yet confirmed to the finality depth. trusted_pending is the part that is usable early (your own change).
  6. Never add redeemable_local into a total — it is a view into unavailable_local, not a separate bucket.
  7. Page GetTransactions — the default page is 100 rows, not the whole history.

← Back to API Reference | Next: Pricing API →


Copyright © 2025