skip to content
Skill Issue Dev | Dax the Dev
search
Part of series: Vanta In Practice

btc-tunnel.sh: SSH-jumping into a remote bitcoind for swap testing

Print view

Sections

The atomic-swap CLI I wrote about needs two RPC endpoints: one for the VANTA chain (easy — there’s a vantad running on the desktop dev box) and one for the BTC chain (less easy — I’m not running a full Bitcoin Core node on every laptop I develop on). The Bitcoin chain is real-money mainnet, and a Bitcoin Core full node is a 700+ GB and growing footprint that’s too big to live on a developer machine in 2026.

The answer that landed in commit e624a8e7 on 2026-04-17 — desktop: scripts for BTC RPC tunnel + address watcher + rpc helper — is three tiny shell scripts that forward a remote bitcoind’s RPC port to localhost over an SSH jump host, then wrap the JSON-RPC calls so the swap CLI can speak to a real mainnet node from any laptop.

This post walks the three scripts, explains why they’re written the way they are, and ends with an unkind paragraph about the alternative (just exposing port 8332 directly).

The scripts

There are three of them, all in vanta/vanta-desktop/scripts/:

  • btc-tunnel.sh — set up / tear down / probe the SSH tunnel
  • btc-rpc.sh — make a single JSON-RPC call to the tunneled node
  • btc-watch.sh — poll an address for state changes, with auto-reconnect

They are all bash, all set -euo pipefail, all under 100 lines. Code that looks like 1995. That’s the right tool for what they do.

btc-tunnel.sh: the SSH-jump forward

The architecture: my laptop sits on whatever Wi-Fi I’m on. The bitcoind runs at 10.0.1.89 on my home LAN. I can’t reach 10.0.1.89 from a coffee shop. I can reach a public-facing jump host (a small VPS on a port I won’t share publicly), and the jump host can reach the LAN.

ssh -J jump@public:port lan-host does the routing. ssh -L 8332:127.0.0.1:8332 lan-host does the port forward. Combine them, daemonise the connection with -f -N -M, point a control socket at /tmp/btc-tunnel.sock, and you have a process you can up/down/status against from any shell.

The full flag set, from the script:

ssh_args=(
  -o StrictHostKeyChecking=accept-new
  -o IdentitiesOnly=yes
  -o ServerAliveInterval=30
  -o ServerAliveCountMax=3
  -o ExitOnForwardFailure=yes
  -i "$BTC_TUNNEL_KEY"
  -J "${JUMP_USER}@${JUMP_HOST}:${JUMP_PORT}"
  -L "${BTC_LOCAL_PORT}:127.0.0.1:${BTC_TUNNEL_RPC_PORT}"
  -S "$SOCKET"
)

Five of these flags are load-bearing. Let me unpack them.

StrictHostKeyChecking=accept-new. This is the “trust on first use” mode. It accepts a new host key on first connection but refuses any later mismatch. The strict-no setting (yes) would require pre-populating known_hosts; the strict-no-yes setting (no) would silently accept any host key including a man-in-the-middle. accept-new is the right middle ground for an interactive dev tool.

IdentitiesOnly=yes. Tells SSH to use only the key passed in -i, not whatever else is in ~/.ssh/. Without this, SSH will try every key in your agent, exhaust the server’s MaxAuthTries, and fail with a confusing error.

ServerAliveInterval=30 + ServerAliveCountMax=3. Keep-alive every 30 seconds, kill the connection after 3 missed responses. A residential ISP will silently drop idle connections; this keeps the tunnel up for hours of intermittent use.

ExitOnForwardFailure=yes. If the local port bind fails — say something else is on :8332 already — exit immediately rather than maintaining a half-broken tunnel that can’t actually carry traffic. The default behavior (silently keep the SSH connection up but not the forward) is a great way to spend twenty minutes wondering why your RPC calls hang.

-S "$SOCKET". Control socket. Lets a separate ssh invocation send commands to the same connection (-O check, -O exit). This is what makes is_up() work without parsing ps output:

is_up() {
  ssh -S "$SOCKET" -O check "$BTC_TUNNEL_HOST" >/dev/null 2>&1
}

That’s the whole “is the tunnel alive” check. SSH manages it; we just ask.

btc-tunnel.sh: the RPC probe

Once the tunnel is up, the status command goes one step further — it actually makes an RPC call through the tunnel to verify the remote bitcoind is reachable and synced:

probe_rpc() {
  curl -s --max-time 5 --user "${BTC_RPC_USER}:${BTC_RPC_PASS}" \
    --data-binary '{"jsonrpc":"1.0","id":"s","method":"getblockchaininfo","params":[]}' \
    -H 'content-type:text/plain;' "http://127.0.0.1:${BTC_LOCAL_PORT}/"
}

The output gets parsed by an inline Python one-liner that prints the chain, block height, header height, sync state, and verification progress in one terse line. Why Python and not jq? Because jq isn’t preinstalled on a fresh macOS, and Python 3 is. Portability wins over elegance here.

The getblockchaininfo RPC is the standard “is this node alive and what does it know” call. If it returns a coherent JSON body, the tunnel is end-to-end working. If it doesn’t, you get a clear error and you know which layer to debug — the SSH connection (tunnel up but RPC dead) or the local port (tunnel down, no RPC at all).

btc-rpc.sh: the one-shot wrapper

This one is so small it fits in the post:

wallet_scoped=0
if [ "${1:-}" = "--wallet" ]; then
  wallet_scoped=1
  shift
fi

method="${1:?method required}"
params="${2:-[]}"
path=""
[ "$wallet_scoped" = "1" ] && path="/wallet/${BTC_RPC_WALLET}"

curl -s --user "${BTC_RPC_USER}:${BTC_RPC_PASS}" \
  --data-binary "{\"jsonrpc\":\"1.0\",\"id\":\"cli\",\"method\":\"${method}\",\"params\":${params}}" \
  -H 'content-type:text/plain;' \
  "${BTC_RPC_URL}${path}" | python3 -m json.tool

The whole point is to give me a one-line shorthand for bitcoind debugging that doesn’t require remembering the JSON-RPC envelope. From any shell with the tunnel up:

$ btc-rpc.sh getblockchaininfo
$ btc-rpc.sh --wallet getbalance
$ btc-rpc.sh --wallet getnewaddress '["swap-test","bech32"]'
$ btc-rpc.sh getrawtransaction '["<txid>", true]'

The --wallet flag is the difference between core RPCs (chain state, mempool) and wallet-scoped RPCs (balance, send, sign). Bitcoin Core changed the RPC URL convention in v0.18 — wallet RPCs route to /wallet/<name>, core RPCs route to /. The wrapper handles that distinction by setting path and concatenating it onto BTC_RPC_URL.

The python3 -m json.tool at the end is a pretty-printer. Two seconds of latency on the JSON pretty-print is the right amount of overhead for terminal readability.

btc-watch.sh: the address watcher

This is the one I use most. When you’re testing an HTLC, you fund a P2WSH output, broadcast the funding transaction, wait for it to confirm, then build the spending transaction. “Wait for it to confirm” is what btc-watch.sh automates:

addr="${1:?address requiredsee usage in header}"
log="${2:-/tmp/btc-watch.log}"

last_state=""
while true; do
  ensure_tunnel
  resp=$(rpc listunspent "[0, 9999999, [\"${addr}\"]]" || echo '{}')
  state=$(echo "$resp" | python3 -c "
import sys,json
try:
  r = json.load(sys.stdin).get('result') or []
  if not r: print('EMPTY'); sys.exit()
  parts = ['%s|%.8f|%d' % (u['txid'], u['amount'], u['confirmations']) for u in r]
  print(';'.join(sorted(parts)))
except Exception as e:
  print('ERR:'+str(e))
")
  if [ "$state" != "$last_state" ]; then
    # log + display the change
    last_state="$state"
  fi
  sleep "$BTC_WATCH_INTERVAL"
done

Three design choices in here that took longer than they should have to get right.

listunspent with minconf=0. Includes mempool. The HTLC funding transaction shows up first in the mempool with confirmations=0, then gains confirmations as blocks are mined. You want to know about both states. The default listunspent arguments are [1, 9999999] (confirmed-only); we override with [0, 9999999, [addr]] to include mempool and filter by address.

State diffing. The watcher prints when the state changes, not on every poll. Otherwise the log is unreadable. The state representation is txid|amount|confirmations, joined with ; and sorted. Sorted because listunspent doesn’t guarantee output order; without sorting, two consecutive polls of the same UTXO set could produce different state strings.

ensure_tunnel. Before each RPC poll, check that the tunnel’s still up. If it’s not, try to bring it back up:

ensure_tunnel() {
  if rpc getblockcount '[]' '' >/dev/null 2>&1; then return 0; fi
  log_line "rpc unreachable — attempting tunnel up"
  if [ -x "${HERE}/btc-tunnel.sh" ]; then
    "${HERE}/btc-tunnel.sh" up || log_line "tunnel up failed"
  else
    log_line "btc-tunnel.sh not found next to this script; cannot auto-reconnect"
  fi
  sleep 2
}

The script is supposed to run for hours during a long swap test. If my coffee shop’s Wi-Fi drops and reconnects, the tunnel breaks. Without ensure_tunnel, the watcher would silently fail every 10 seconds. With it, the tunnel comes back up automatically and the polling resumes. The first time this saved me a swap test was the moment I knew the script was worth committing.

On exposing 8332 directly

WARNING: Do not put port 8332 on the public internet. Do not put it on a “VPN-only” subnet that you can’t audit. Do not assume rate-limiting at your router is enough.

If you read tutorials online — I have, you have, we all have — you’ll find advice that says “just expose your bitcoind RPC port through your router.” This is bad advice, and I’m going to be direct about why.

The bitcoind RPC exposes wallet operations behind HTTP basic auth. If RPCPASSWORD ever leaks (in a CI log, in a screenshot, in a .env file in a git history, in a commit message) the attacker has full access to your wallet. They can sign transactions. They can drain your funds. There is no “I locked my wallet” safety net here — the unlock is part of the same RPC and it accepts a passphrase that can also be brute-forced once the connection is open.

Even with no wallet, the RPC exposes call patterns that can be used to fingerprint your node, drain its mempool data, and probe for vulnerabilities. Bitcoin Core has a hardening guide for a reason.

The SSH tunnel architecture solves all of this in one move. The RPC port is bound to 127.0.0.1 on the bitcoind host. The only path to it is over an authenticated SSH connection. The jump host doesn’t see the RPC traffic — it sees only the encrypted SSH stream. Your laptop talks to the jump host using key-based auth (the IdentitiesOnly flag). The RPC password lives in .env.local and never leaves your machine.

If your authoring environment doesn’t have an SSH-jump architecture available, the second-best is to run a separate bitcoind on regtest mode, just for the development workflow. Mainnet RPC should not be on a public IP. Ever.

Pulling it together: a swap test

The end-to-end swap-test flow looks like this, on a fresh terminal:

# 1. Bring up the tunnel.
$ ./btc-tunnel.sh up
tunnel up: 127.0.0.1:8332 -> [email protected]:8332

# 2. Sanity check.
$ ./btc-tunnel.sh status
forward: 127.0.0.1:8332 -> [email protected]:8332
chain=main blocks=874632 headers=874632 synced=True progress=1.000000

# 3. Generate a fresh receiving address for the swap.
$ ./btc-rpc.sh --wallet getnewaddress '["swap-test","bech32"]'
"bc1qjh9pjnqs5486d08yg4aafdlphwl3rc6ls0lf7w"

# 4. Start watching the address in another window.
$ ./btc-watch.sh bc1qjh9pjnqs5486d08yg4aafdlphwl3rc6ls0lf7w &

# 5. Run the swap CLI from a third window.
$ vanta-swap participate --amount 0.001 --hash <h> ...

The watcher logs the funding transaction the moment it hits the mempool, and again every time it gains a confirmation. The CLI broadcasts the spending transaction. The watcher logs the spend.

That whole loop takes about 30 seconds end to end on a happy mainnet. Without the tunnel scripts, it’d take twenty minutes per iteration of fumbling with bitcoin-cli arguments and SSH commands.

What I changed my mind about

I wrote the first version of btc-tunnel.sh as a one-liner pasted into Notion. It worked. I copy-pasted it dozens of times before I realised that ssh would silently exit if the home host was momentarily unreachable, leaving me typing into a tunneled port that wasn’t listening to anything.

The version that ships does three things the one-liner didn’t: it uses a control socket so is_up is reliable, it sets ExitOnForwardFailure so the script crashes loudly instead of looking up, and it has a restart subcommand because manually re-running up after a network drop is the kind of friction that makes you stop using the tool.

The general lesson — and this is the kind of thing I’d put in a “scripts I keep around” post — is that the dev tools you use daily deserve the same care you give production code. Not the same test coverage. But the same observability. A 60-line bash script with set -euo pipefail, control sockets, and a clear status mode is a different beast from a 60-line bash script that just sshs and prays.

Further reading

Hire me — book a 30-min call $ book →