General API
The General API exposes Hydra App's AppService — application-level operations including network discovery, public key retrieval, payment preimage lookup, archive retention, and invite / referral management.
JSON-RPC namespace: app
Endpoints
- Get Networks
- Get Sync Status
- Get Public Key
- Get Preimage
- Archive Prune
- Create Invite
- Redeem Invite
- List Invites
- Get Invite Eligibility
- Get Referral
The Network type
Most other APIs accept a Network to identify a specific blockchain. It has just two fields:
| Field | Type | Description |
|---|---|---|
protocol | Protocol enum | PROTOCOL_BITCOIN (1), PROTOCOL_EVM (2), or PROTOCOL_TRON (3) |
id | string | Network identifier — magic bytes (hex) for Bitcoin, decimal chain ID for EVM and Tron. Staging values: "0a03cf40" (Bitcoin Signet), "11155111" (Ethereum Sepolia), "421614" (Arbitrum Sepolia), "2494104990" (Tron Shasta). |
See the network identifier table in the Setup Guide for every supported network.
Get Networks
Returns the list of blockchain networks that have been initialized and are currently active in this application instance.
Method: GetNetworks
Parameters: None
Response:
| Field | Type | Description |
|---|---|---|
networks | Network[] | Active networks |
Example Request:
import { AppServiceClient } from './proto/AppServiceClientPb'
import { GetNetworksRequest } from './proto/app_pb'
const client = new AppServiceClient('http://localhost:5003')
const response = await client.getNetworks(new GetNetworksRequest(), {})
response.getNetworksList().forEach(n => {
console.log(`Protocol ${n.getProtocol()} / id ${n.getId()}`)
})
Example Response:
{
"networks": [
{ "protocol": 1, "id": "0a03cf40" },
{ "protocol": 2, "id": "11155111" },
{ "protocol": 2, "id": "421614" }
]
}
Get Network Info
(2026-09-20) Describes the networks this instance serves — the chain facts you would otherwise hardcode per chain, plus how each network's channel protocol encodes an HTLC claim window.
Method: GetNetworkInfo
Parameters:
| Field | Type | Description |
|---|---|---|
networks | Network[] | Networks to describe. Empty or omitted asks for every network served. |
Response: { networks: NetworkInfo[] }. A network this instance does not serve is omitted rather than returned empty.
| Field | Type | Description |
|---|---|---|
network | Network | The network being described |
name | string | Human-readable name, e.g. "Arbitrum One" |
symbol | string | Native asset ticker, e.g. "ETH" |
explorer_url | string | Block-explorer base URL |
required_confirmations | uint64 | Confirmations this chain needs for finality, in its own ledger-depth unit |
block_time_ms | uint64 | Average inter-block (or slot/round) interval, in milliseconds |
min_inclusion_abort_window | uint64 | Newly-observed blocks the inclusion wait tolerates before declaring a broadcast transaction lost |
channel_protocol | OffchainProtocol? | LIGHTNING or LITHIUM; absent for a network without payment channels |
cltv_encoding_granularity_secs | uint64? | The unit that protocol encodes min_final_cltv_expiry in; absent with channel_protocol |
This call never touches the chain. Every field is read from what the network knows about itself, so it answers identically whether the node is synced, syncing or offline — which is why it sits in
AppServicerather thanBlockchainService, whose every method queries on-chain data.required_confirmationsandblock_time_msare chain constants, not observations; useGetSyncStatusfor anything live.
cltv_encoding_granularity_secs— read it, do not re-derive it
min_final_cltv_expiryis not carried in seconds everywhere. BOLT-11 carries it as a block count, so a Lightning invoice can only place its claim deadline on a grid of this size and always rounds up to reach the duration that was asked for; Lithium carries seconds and has no grid.Two consequences if you validate an invoice somebody else minted. First, forgive an overshoot of up to one unit — a correct Bitcoin invoice can sit up to 479 s past the deadline you expect. Second, never re-derive the encoded value and demand it back: a value that lands exactly on a grid line plus a second of ordinary timing drift rounds to a whole extra unit, and the honest invoice is then refused. Compare the deadline the invoice carries against the one you expect, with the granularity as your upper slack.
It is not
block_time_ms. A block-count conversion deliberately uses a bound below the nominal interval, so a fast-block period cannot shorten an HTLC below the requested duration. On Bitcoin those are 480 s and 600 s respectively, and they move independently.
Get Sync Status
(2026-09-15) Reports how each network's chain sync is doing right now.
Method: GetSyncStatus
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
networks | Network[] | no | Restrict the report to these networks. Empty reports every initialized network |
Response: networks — one NetworkSyncStatus per network:
| Field | Type | Description |
|---|---|---|
network | Network | The network this status describes |
client_synced | bool | Whether the wallet client has completed a sync of this network since the application started |
node_synced | bool | Whether the channel node has. Always false for a network served by a channel-less wallet client |
last_tick_at | Timestamp | When the sync loop last made forward progress — a block, a transaction update, a peer event |
last_heartbeat_at | Timestamp | When the sync loop last reported itself scheduled. Fires on a fixed interval whatever the chain is doing |
last_synced_at | Timestamp | When this network last completed a sync. Absent before the first one |
consecutive_sync_errors | uint32 | Failures since the last completed sync. Back to zero on the next one |
last_error | string | The most recent failure, cleared by the next completed sync |
sync_task_active | bool | Whether the background sync task is still running |
This is the pull counterpart of the sync events. Syncing / SyncProgress / SyncError / Synced on the client and node event streams tell you when something changes; this tells you where things stand. A caller that was not subscribed when a network degraded, or that reconnected since, reads the current state here instead of reconstructing it from an event history it does not have.
GetNetworks answering normally has never meant a network is following the chain. It lists what is configured. A node whose provider is refusing to serve block headers keeps answering it, and every other call keeps working off the database — which is quietly going stale. This RPC is how that becomes visible without scraping logs.
Reading it
The figures come from state the sync loop keeps as it runs, so this answers while the chain connection is exactly what is broken — which is when it is needed, and when every call that reaches the chain would hang or fail instead.
| What you see | What it means |
|---|---|
last_tick_at stale, last_heartbeat_at fresh | The task is alive; its chain connection is stuck |
last_heartbeat_at stale | The sync task itself is wedged |
sync_task_active: false | The task was aborted or panicked — this network is no longer following the chain |
consecutive_sync_errors > 0 with last_error set | The sync is failing and retrying now; last_error says why |
client_synced: false early in a session | Normal — the first sync has not finished. Balances still read the previous session's database |
Get Public Key
Returns the Ed25519 public key of this application instance. The key uniquely identifies the application and is derived from the user's mnemonic seed. It does not depend on a network.
Method: GetPublicKey
Parameters: None
Response:
| Field | Type | Description |
|---|---|---|
public_key | bytes | Ed25519 public key (32 bytes) |
Example Request:
import { GetPublicKeyRequest } from './proto/app_pb'
const response = await client.getPublicKey(new GetPublicKeyRequest(), {})
const pubkey = response.getPublicKey_asU8()
console.log('Public key (hex):', Buffer.from(pubkey).toString('hex'))
Example Response:
{
"public_key": "AbCdEf0123...="
}
The bytes field is base64 in JSON-RPC; in gRPC it's a raw
bytes.
Get Preimage
Looks up a payment preimage by its hash. Returns the preimage if it has been revealed or registered, or empty if the preimage is not yet known.
Method: GetPreimage
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
payment_hash | bytes | YES | Payment hash to look up (32 bytes) |
Response:
| Field | Type | Description |
|---|---|---|
payment_preimage | bytes (optional) | The preimage (32 bytes), or empty if not yet known |
Example Request:
import { GetPreimageRequest } from './proto/app_pb'
const request = new GetPreimageRequest()
request.setPaymentHash(Buffer.from('a1b2c3...32bytes...', 'hex'))
const response = await client.getPreimage(request, {})
const preimage = response.getPaymentPreimage_asU8()
if (preimage && preimage.length > 0) {
console.log('Preimage:', Buffer.from(preimage).toString('hex'))
} else {
console.log('Preimage not yet known')
}
Example Response:
{ "payment_preimage": "9f8e7d6c..." }
Archive Prune
Redesigned 2026-07-08 — was the synchronous PruneArchive (added 2026-05-26).
Operator-driven retention for archive-side settled history — settled wallet transactions and settled payments — bounded by max age and/or max count. Pending entries are never pruned. As of 2026-07-08 pruning is an asynchronous job, not a single blocking call: you start a job, then poll its status or subscribe to its events. At most one prune job runs per network.
Breaking (2026-07-08): the old
PruneArchiveRPC (withtotal_pruned/per_table) was removed. Migrate toStartArchivePrune+GetArchivePruneStatus(orSubscribeArchivePruneEvents).Pruning a settled payment also deletes its stored preimage — a pruned payment's preimage is no longer served by
GetPreimage.
Start Archive Prune
Method: StartArchivePrune
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network whose archive to prune (Bitcoin and EVM networks each have their own archive instance) |
max_age_secs | uint64 | NO* | Keep only entries younger than this many seconds (from server now) |
max_items | uint64 | NO* | Keep at most this many newest entries per archive table (≥ 1) |
*At least one filter must be set. Filters compose intersectively — an entry is kept only if it satisfies every set filter.
Response: job (ArchivePruneJob) + newly_started (bool). When newly_started is false, a job was already running and its (possibly different) descriptor is returned instead of starting a new one.
Get Archive Prune Status
Method: GetArchivePruneStatus — parameters: network (Network, YES). The authoritative state.
Response: running (RunningArchivePrune, optional — the live job + an ArchivePruneProgress snapshot + cancel_requested + last_progress_at) and last (ArchivePruneRecord, optional — the most recently finished job since process start, with a completed / failed / cancelled outcome). Both unset ⇒ no prune has run since start.
ArchivePruneJob — carried by every status read and every event:
| Field | Type | Description |
|---|---|---|
job_id | uint64 | Process-local and reset on restart — do not persist it |
network | Network | The network whose archive this job prunes |
trigger | ArchivePruneTrigger | ARCHIVE_PRUNE_TRIGGER_MANUAL (1) — an explicit StartArchivePrune; ..._AUTO (2) — the configured settings.auto_prune |
params | ArchivePruneParams | The filters this job runs with (max_age_secs / max_items) |
started_at | Timestamp | When it began |
ArchivePruneProgress — phase, chunks and counts:
| Field | Type | Description |
|---|---|---|
phase | ArchivePrunePhase | ARCHIVE_PRUNE_PHASE_PAYMENTS (1) then ..._TRANSACTIONS (2). Phases run sequentially per network, payments first — and payments only on channel-tier networks |
chunks | uint64 | Bounded chunks completed. A liveness signal: it advances even when nothing qualifies for deletion |
counts | ArchivePruneCounts | { payments, transactions } — logical entries pruned, each counted once however many index rows its deletion touched |
RunningArchivePrune.last_progress_atis when the job last finished a chunk — equal tojob.started_atuntil the first one lands. A value that stops advancing is a stuck chunk, whichchunksalone will not tell you apart from a phase with nothing to do.
ArchivePruneFailure — on a failed outcome:
| Field | Type | Description |
|---|---|---|
reason | ArchivePruneFailureReason | ..._NODE_ERROR (1) a node call failed; ..._NODE_STOPPED (2) the network's node was stopped mid-job; ..._CHUNK_TIMEOUT (3) a single chunk exceeded the server-side deadline |
detail | string | Human-readable, truncated server-side — log it, don't parse it |
A failed job still pruned whatever it got through:
ArchivePruneFailedcarriescountsalongside the failure, as doArchivePruneCancelledandArchivePruneCompleted(which addsduration_ms).
Cancel Archive Prune
Method: CancelArchivePrune — parameters: network (Network, YES). Requests cancellation; the job stops at its next chunk boundary and finishes with a cancelled outcome. Returns cancelled = false when no job is running. Idempotent.
Subscribe Archive Prune Events
Method: SubscribeArchivePruneEvents (server-streaming) — no parameters; the stream covers all networks. Each ArchivePruneEvent carries its ArchivePruneJob and one of started / progress / completed / failed / cancelled / retention_lagging. Delivery is best-effort — on stream end, re-subscribe and reconcile with GetArchivePruneStatus.
retention_lagging (2026-08-26) carries ArchivePruneRetentionLagging { lag_secs: uint64 } — how far past its retention deadline the worst archive phase is, in seconds. The archive is not draining fast enough for the configured policy, so the node is escalating how hard it prunes — at the cost of foreground latency — until the backlog clears. Repeated periodically while it persists.
This is informational, not a failure: the job is still running and still making progress. It is the signal to widen
max_age_secs/max_entries, give the node more I/O, or accept the added latency — a controller that only ever backs off fills the disk instead. Add a branch for it only if you surface prune progress.
Example — start and poll:
import { StartArchivePruneRequest, GetArchivePruneStatusRequest } from './proto/app_pb'
const start = new StartArchivePruneRequest()
start.setNetwork({ protocol: 1, id: '0a03cf40' })
start.setMaxAgeSecs(60 * 60 * 24 * 90) // keep 90 days
start.setMaxItems(100_000) // and at most 100k newest per table
const { job } = (await client.startArchivePrune(start, {})).toObject()
console.log(`prune job ${job.jobId} started`)
// Poll until the running job clears.
const statusReq = new GetArchivePruneStatusRequest()
statusReq.setNetwork({ protocol: 1, id: '0a03cf40' })
for (;;) {
const status = (await client.getArchivePruneStatus(statusReq, {})).toObject()
if (!status.running) {
const last = status.last
console.log('done:', last?.counts, last?.completed ? 'completed' : last?.failed ? 'failed' : 'cancelled')
break
}
console.log(` phase ${status.running.progress?.phase}, ${status.running.progress?.chunks} chunks`)
await new Promise(r => setTimeout(r, 1000))
}
Prefer configuring
settings.auto_pruneinconfig.yaml(periodic auto-prune per network) over calling this by hand — see the Setup Guide. The RPCs are for on-demand / operator-driven runs.
Create Invite
Invites are how mainnet admission works at launch. Redeeming one is what gets an identity admitted, not something you do once you are already in — see Mainnet access is gated.
Added 2026-07-08. Requires referral_config.referral_service_url in config.yaml.
Mints a fresh bearer invite code to share with another user out-of-band. The application signs the server-issued challenge with its identity key internally — no parameters are required.
Method: CreateInvite — Parameters: None.
Response: code (string) — the bearer invite code (URL-safe base64). Anyone holding it can redeem it, so share it privately.
Redeem Invite
Added 2026-07-08.
Redeems an invite code received from another user. The application signs the redemption internally.
Method: RedeemInvite
| Name | Type | Required | Description |
|---|---|---|---|
code | string | YES | The bearer invite code received from an inviter |
Response: referral_public_key (bytes, 32) — the inviter's Ed25519 public key, so the UI can surface "you were invited by X".
List Invites
Added 2026-08-19. Requires referral_config.referral_service_url in config.yaml.
Lists the invite codes this application identity has minted, newest first, each with its current status.
Method: ListInvites
| Name | Type | Required | Description |
|---|---|---|---|
status_filter | InviteStatusFilter | no | Narrows to one status; defaults to unfiltered |
InviteStatusFilter: INVITE_STATUS_FILTER_UNSPECIFIED (0, everything), ..._PENDING (1), ..._REDEEMED (2), ..._EXPIRED (3).
Response: { invites: Invite[] } — empty when the identity has never minted a code, or when none match the filter.
Invite:
| Field | Type | Description |
|---|---|---|
code | string | The bearer code, as returned by CreateInvite |
created_epoch | int32 | The epoch the code was minted in |
created_at | Timestamp | When it was minted |
expires_at | Timestamp? | When it stops being redeemable. Unset = never expires |
status | oneof | Exactly one of pending / redeemed / expired is always set |
InvitePending and InviteExpired are empty markers. InviteRedeemed carries redeemed_by_public_key (bytes, 32 — the invitee this code onboarded) and redeemed_at (Timestamp), so a redeemed invite structurally carries its redeemer — no second lookup.
⚠️ A pending code is still a bearer secretAnyone holding an unredeemed
codecan redeem it. The application proves ownership of its identity key to the referral service before this list is returned, precisely so a code is only ever shown to its creator. Treat the response like credentials: don't log it, don't render a pending code anywhere it can be shoulder-surfed or screenshotted into a support ticket.
Get Invite Eligibility
Added 2026-08-19.
This application identity's standing in the invite program — enough to render quota usage and mint-gating without a doomed mint round-trip.
Method: GetInviteEligibility — Parameters: None.
Response:
| Field | Type | Description |
|---|---|---|
eligibility | InviteEligibility? | Unset = this identity cannot mint invites at all (it can still redeem one) |
current_epoch | int32 | The referral service's current epoch (1-based) — the same value the mint gates evaluate |
InviteEligibility:
| Field | Type | Description |
|---|---|---|
joined_epoch | int32 | The epoch this identity joined the program |
is_seed | bool | true for identities provisioned by the operator rather than onboarded through an invite |
invite_quota | int32 | Lifetime cap on codes this identity may mint |
invites_minted | int32 | Codes minted so far |
created_at | Timestamp | When the eligibility row was created |
Minting is allowed when both hold:
current_epoch > joined_epoch // you cannot invite in the epoch you joined
invites_minted < invite_quota
Quota is spent at mint, not at redeem. An expired or never-redeemed code still counts against
invite_quota— minting codes speculatively burns the allowance permanently.
Unlike
ListInvites, this call is unauthenticated on the referral service side — quotas and epochs are not bearer secrets — so there is no signature round-trip and it is cheap to poll for a UI.
Get Referral
Added 2026-07-08.
Returns the application's current referrer, if one has been set (e.g. via RedeemInvite).
Method: GetReferral — Parameters: None.
Response: referral_public_key (bytes, optional) — the referrer's Ed25519 public key, or absent if none is set.
Common Workflows
Initialize and discover networks
async function initializeApp(client: AppServiceClient) {
const networks = (await client.getNetworks(new GetNetworksRequest(), {})).getNetworksList()
const pubkey = (await client.getPublicKey(new GetPublicKeyRequest(), {})).getPublicKey_asU8()
return {
publicKey: Buffer.from(pubkey).toString('hex'),
networks: networks.map(n => ({ protocol: n.getProtocol(), id: n.getId() }))
}
}
Protocol Types
| Value | Constant | Description |
|---|---|---|
0 | PROTOCOL_UNSPECIFIED | Invalid default — never use |
1 | PROTOCOL_BITCOIN | Bitcoin mainnet, testnet, signet, regtest |
2 | PROTOCOL_EVM | Ethereum, Arbitrum, Polygon, BSC, etc. |
3 | PROTOCOL_TRON | Tron mainnet, Shasta, Nile (TVM) |
Common Network IDs
Bitcoin (PROTOCOL_BITCOIN, magic bytes hex)
id | Network |
|---|---|
f9beb4d9 | Bitcoin Mainnet |
0b110907 | Bitcoin Testnet3 |
0a03cf40 | Bitcoin Signet |
fabfb5da | Bitcoin Regtest |
EVM (PROTOCOL_EVM, decimal chain ID)
id | Network |
|---|---|
1 | Ethereum Mainnet |
42161 | Arbitrum One |
11155111 | Ethereum Sepolia |
421614 | Arbitrum Sepolia |
137 | Polygon |
10 | Optimism |
Tron (PROTOCOL_TRON, decimal chain ID)
id | Network |
|---|---|
728126428 | Tron Mainnet |
2494104990 | Tron Shasta |
3448148188 | Tron Nile |
Best Practices
- Cache the network list. Active networks rarely change; refresh on app start.
- Treat the public key as stable. It's derived from the seed and won't change for the application's lifetime.
- Check
GetPreimageresults for emptiness. A successful response can still mean "not yet known" — it's not an error. - Prefer config-driven
auto_pruneover callingStartArchivePruneby hand. Setsettings.auto_pruneinconfig.yamlfor periodic retention; use the RPCs for on-demand runs, and pollGetArchivePruneStatus(or subscribe) rather than assuming a start call finished.