Bot Quickstart
This guide is the shortest path from zero to a placed order. It's designed so you (or an LLM agent like Claude Code) can produce a working bot from a single page.
Prerequisites: A Hydra App running locally, configured with Bitcoin Signet, Ethereum Sepolia, and Arbitrum Sepolia (the staging defaults). If you haven't done that yet, do the Setup Guide first. This page assumes the app is reachable at
http://localhost:5003.
What you'll build
A daemon-mode bot that:
- Connects to Hydra App over gRPC.
- Subscribes to client + node + market events before doing anything.
- Funds itself on-chain (one-time, manual).
- Uses the Lease API to provision channels for the trading pair.
- Places a market buy order on the BTC/USDC pair.
- Logs the fill from the event stream.
Total: ~150 lines of TypeScript (or Go/Rust).
The bot's life cycle
┌──────────────────────────────────────────┐
│ Hydra App running, daemon mode │
│ (MNEMONIC + PASSWORD set in .env) │
└──────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ 1. Connect & verify │
│ app.GetNetworks → which networks are active │
│ wallet.GetBalances → current balances │
└──────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ 2. Subscribe streams (KEEP RUNNING for the bot's life) │
│ event.SubscribeClientEvents │
│ event.SubscribeNodeEvents │
│ orderbook.SubscribeMarketEvents (per pair) │
└──────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ 3. Make sure you have on-chain funds │
│ wallet.GetDepositAddress → display, send testnet sats │
│ wallet.GetBalances → wait until on-chain > 0 │
└──────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ 4. Provision channels for the trading pair │
│ liquidity.RequestChannelLiquidity │
│ • for the SENDING currency (your offchain balance) │
│ • for the RECEIVING currency (inbound capacity) │
└──────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ 5. Trade │
│ orderbook.GetMarketsInfo → confirm pair is active │
│ orderbook.CreateOrder → place limit/market order │
│ (event stream delivers fills) │
└──────────────────────────────────────────────────────────┘
If you don't want to manage steps 3 and 4 yourself, use
swap.SimpleSwapinstead — Hydra App handles funding and channel setup automatically. That path is shown at the end of this page.
Network identifiers used below
For Bitcoin Signet and Ethereum Sepolia (the staging defaults):
Bitcoin Signet: { protocol: 1, id: "0a03cf40" }
Ethereum Sepolia: { protocol: 2, id: "11155111" }
USDC on Sepolia: asset_id "ERC20:0x8cd0dA3d001b013336918b8Bc4e56D9DDa1347E0"
BTC on Signet: asset_id "BTC"
Step 1 — Project skeleton
// package.json
// {
// "name": "hydra-bot",
// "type": "module",
// "dependencies": {
// "@grpc/grpc-js": "^1.10.0",
// "google-protobuf": "^3.21.0"
// }
// }
//
// You'll also need the generated proto bindings — run protoc against
// the .proto files in /proto and import the generated *_pb files.
import { AppServiceClient } from './proto/AppServiceClientPb'
import { WalletServiceClient } from './proto/WalletServiceClientPb'
import { LiquidityServiceClient } from './proto/LiquidityServiceClientPb'
import { OrderbookServiceClient } from './proto/OrderbookServiceClientPb'
import { EventServiceClient } from './proto/EventServiceClientPb'
const ENDPOINT = 'http://localhost:5003'
const app = new AppServiceClient(ENDPOINT)
const wallet = new WalletServiceClient(ENDPOINT)
const liquidity = new LiquidityServiceClient(ENDPOINT)
const orderbook = new OrderbookServiceClient(ENDPOINT)
const events = new EventServiceClient(ENDPOINT)
const SIGNET = { protocol: 1, id: '0a03cf40' }
const SEPOLIA = { protocol: 2, id: '11155111' }
const USDC_ID = 'ERC20:0x8cd0dA3d001b013336918b8Bc4e56D9DDa1347E0'
const BTC_ID = 'BTC'
Step 2 — Connect & verify
import { GetNetworksRequest } from './proto/app_pb'
const networks = (await app.getNetworks(new GetNetworksRequest(), {})).getNetworksList()
console.log(`✓ App reachable. Active networks: ${networks.length}`)
networks.forEach(n => console.log(` protocol=${n.getProtocol()} id=${n.getId()}`))
If this fails: the app isn't running, or it's listening on a different port. Check
docker compose psandsettings.server_portinconfig.yaml.
Step 3 — Subscribe to event streams before doing anything
This is the single most-skipped step in bot code. If you place an order or kick off a SimpleSwap before the stream is subscribed, you can miss the corresponding event and the bot will hang or deadlock.
import { SubscribeClientEventsRequest, SubscribeNodeEventsRequest } from './proto/event_pb'
import { SubscribeMarketEventsRequest } from './proto/orderbook_pb'
// Client events for each network we care about (one subscription per network)
for (const net of [SIGNET, SEPOLIA]) {
const req = new SubscribeClientEventsRequest()
req.setNetwork(net)
const stream = events.subscribeClientEvents(req, {})
stream.on('data', evt => console.log(`[client:${net.id}]`, evt.toObject()))
stream.on('error', err => console.error(`[client:${net.id}] stream error`, err))
const nodeReq = new SubscribeNodeEventsRequest()
nodeReq.setNetwork(net)
const nodeStream = events.subscribeNodeEvents(nodeReq, {})
nodeStream.on('data', evt => console.log(`[node:${net.id}]`, evt.toObject()))
}
// Market events for the BTC/USDC pair
const marketReq = new SubscribeMarketEventsRequest()
marketReq.setBase({ protocol: SIGNET.protocol, networkId: SIGNET.id, assetId: BTC_ID })
marketReq.setQuote({ protocol: SEPOLIA.protocol, networkId: SEPOLIA.id, assetId: USDC_ID })
const marketStream = orderbook.subscribeMarketEvents(marketReq, {})
marketStream.on('data', evt => console.log('[market]', evt.toObject()))
// Give the streams a moment to attach before continuing
await new Promise(r => setTimeout(r, 500))
Step 4 — Make sure you have on-chain funds
A fresh wallet has zero balance. Display deposit addresses, then poll until on-chain balances appear.
import { GetDepositAddressRequest, GetBalancesRequest } from './proto/wallet_pb'
async function depositAddress(network: { protocol: number; id: string }) {
const req = new GetDepositAddressRequest()
req.setNetwork(network)
const resp = await wallet.getDepositAddress(req, {})
return resp.getAddress()
}
const btcAddr = await depositAddress(SIGNET)
const usdcAddr = await depositAddress(SEPOLIA)
console.log('Send signet BTC to: ', btcAddr)
console.log('Send sepolia USDC to:', usdcAddr)
// Poll until BTC arrives. Replace with event-driven logic for production.
while (true) {
const req = new GetBalancesRequest()
req.setNetwork(SIGNET)
const balances = (await wallet.getBalances(req, {})).getBalancesMap()
const btc = balances.get(BTC_ID)
if (btc && parseFloat(btc.getOnchain()?.getUsable()?.getValue() ?? '0') > 0) break
await new Promise(r => setTimeout(r, 5000))
}
console.log('✓ BTC funded')
Get testnet sats from a Signet faucet (e.g.
https://signet.bc-2.jp/). Get Sepolia USDC from a Circle faucet.
Step 5 — Provision channels via the Lease API
You can't trade without channels. The simplest route: ask liquidity to set them up. We'll provision an outbound BTC channel by depositing some of your on-chain BTC.
import {
RequestChannelLiquidityRequest,
ChannelLiquidityRequestOperation,
AssetLiquidity,
OffchainFeePayment,
} from './proto/liquidity_pb'
const liqReq = new RequestChannelLiquidityRequest()
liqReq.setNetwork(SIGNET)
// Open a new channel, deposit 0.001 BTC client-side. server_amount=0
// because we're funding it ourselves.
const open = new ChannelLiquidityRequestOperation.Open()
const al = new AssetLiquidity()
al.setServerAmount({ value: '0' })
al.setClientAmount({ value: '0.001' })
open.getAssetLiquidityMap().set(BTC_ID, al)
const op = new ChannelLiquidityRequestOperation()
op.setOpen(open)
liqReq.setOperation(op)
liqReq.setTxFeeRate({
maxFeePerUnit: { value: '50' },
priorityFeePerUnit: { value: '5' },
})
liqReq.setPaymentNetwork(SIGNET)
liqReq.setPaymentAssetId(BTC_ID)
liqReq.setOffchainFeePayment(new OffchainFeePayment())
const liqResp = await liquidity.requestChannelLiquidity(liqReq, {})
console.log(`✓ Channel ${liqResp.getChannelId()} provisioned (tx ${liqResp.getTxid()})`)
The node-event stream you subscribed in step 3 will emit channel-state events as the channel comes online. Wait until you see the channel become active before trading. For brevity this guide doesn't show that wait — see the Node API "Common Workflows" for the pattern.
Step 6 — Place a market order
Sell 0.0005 BTC for USDC at the current best price.
import {
CreateOrderRequest,
OrderVariant,
OrderSide,
OrderAmount,
} from './proto/orderbook_pb'
const market = new OrderVariant.MarketOrder()
market.setBase({ protocol: SIGNET.protocol, networkId: SIGNET.id, assetId: BTC_ID })
market.setQuote({ protocol: SEPOLIA.protocol, networkId: SEPOLIA.id, assetId: USDC_ID })
market.setSide(OrderSide.ORDER_SIDE_SELL)
const amt = new OrderAmount()
amt.setBase({ amount: { value: '0.0005' } }) // 0.0005 BTC
market.setAmount(amt)
const variant = new OrderVariant()
variant.setMarketOrder(market)
const orderReq = new CreateOrderRequest()
orderReq.setOrderVariant(variant)
const orderResp = await orderbook.createOrder(orderReq, {})
console.log(`✓ Order placed: ${orderResp.getOrderId()}`)
// Fill arrives via the market stream subscribed in step 3.
The market-event stream emits a Trade / fill event when the order matches. Your event-handler from step 3 will print it.
Alternative: skip steps 4 + 5 with SimpleSwap
If you don't want to fund and provision channels by hand, swap.SimpleSwap does it for you in one call. Hydra App handles the on-chain deposits, leases, and order placement; you wait on the SubscribeSimpleSwaps stream for status updates.
import { SimpleSwapRequest, SubscribeSimpleSwapsRequest } from './proto/swap_pb'
// Subscribe FIRST
const swapStream = swap.subscribeSimpleSwaps(new SubscribeSimpleSwapsRequest(), {})
swapStream.on('data', upd => console.log('[simple-swap]', upd.toObject()))
// Then kick it off
const req = new SimpleSwapRequest()
req.setSendingCurrency({ protocol: SIGNET.protocol, networkId: SIGNET.id, assetId: BTC_ID })
req.setReceivingCurrency({ protocol: SEPOLIA.protocol, networkId: SEPOLIA.id, assetId: USDC_ID })
req.setAmount({ from: { amount: { value: '0.0005' } } })
req.setPriceChangeTolerance({ value: '0.02' }) // 2 % slippage
req.setWithdrawSendingFunds(false)
req.setWithdrawReceivingFunds(false)
const resp = await swap.simpleSwap(req, {})
console.log('SimpleSwap kicked off:', resp.getOutput()?.toObject())
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Order placed but no fill events | Subscribed to the market stream after placing the order | Always subscribe first |
FAILED_PRECONDITION: channel not active on order | Channel is still confirming on-chain | Wait for the node-event stream to emit the activation event |
INVALID_ARGUMENT: lease_duration_seconds required | Set server_amount > 0 without a duration | Either set a duration or set server_amount = "0" |
RESOURCE_EXHAUSTED from Lease API | Liquidity service has no inventory for that asset | Try a different asset, or self-fund via node.OpenChannel |
| Bot hangs after restart | The streams from the previous process are gone but you didn't re-subscribe | Subscribe on every startup |
| Amounts off by 10⁸ or 10¹⁸ | You converted to base units (sats, wei) | DecimalString is human-readable — "0.001" is 0.001 BTC, not 1000 sats |
Bot uses chain_id / name fields | Old client code | The current Network shape is { protocol, id } — see the identifier table |
Recommended bot architecture
For anything beyond a one-shot script:
- One long-lived gRPC channel. Connect once, share the channel across all stub clients.
- Streams in dedicated tasks/goroutines/promises. Never block the main loop on a stream.
- In-memory state mirrors. Keep the latest balances, orderbook snapshot, channel set in memory; refresh from events, not polling.
- Persist your order IDs. On restart, reconcile your in-memory state with
orderbook.GetAllOwnOrdersbefore trading again. - Idempotency at the application level. Hydra App's
Send*andCreateOrderRPCs are not idempotent. Track a client-side request key and check viaGetTransactions/GetAllOwnOrdersbefore retrying after aDEADLINE_EXCEEDED. - Backoff with jitter on
UNAVAILABLEandDEADLINE_EXCEEDED. Don't retryINVALID_ARGUMENTorFAILED_PRECONDITIONblindly. - Metrics. Hydra App exposes Prometheus metrics on
settings.metrics_port— scrape them.
Where to go from here
- Orderbook API —
LimitOrder,AddLiquidity, market data - Swap API — full
SimpleSwapreference including the price-change-tolerance semantics - Node API — direct channel control if leases aren't enough
- Lease API — full reference for service-backed liquidity provisioning
- Events API — every event type the streams can emit
- Errors — full error catalog with retry guidance
- Common Patterns — fee structures, helper functions, type definitions