---
slug: 38a-bitcoin
title: Bitcoin (the UTXO matrix — read → build → sign → send → confirm)
description: Operate on Bitcoin from an agent with the same structural safety as the rest of the stack — addresses and scripts (P2WPKH/P2TR/P2PKH, strict bech32/bech32m), the btc_tx builder where every satoshi is accounted for (UTXO's implicit fee becomes impossible to forget), BIP-340 Schnorr taproot signing behind the SAME `sign` gate, the Esplora read side (utxos, balance, fees, broadcast, bounded confirmation), Bitcoin Core via btc_rpc, and PSBT (BIP-174) for cold custody: the agent PREPARES the transaction, a human signs it on their hardware wallet. The private key sealed as a secret that never materializes. All pure-Rust, one binary.
example_ids: [bitcoin]
---

# Bitcoin: every satoshi accounted for

Bitcoin is not an account chain like Ethereum or Solana — it is the **UTXO matrix**, and its footguns are the most expensive in the ecosystem. The fee is **implicit** (inputs − outputs): forgetting the change output **donates the entire remainder to miners** (it happened to real people, with hundreds of BTC). You sign **once per input**, each over a different sighash (BIP-143 segwit v0, BIP-341 taproot). Signing without seeing the amounts was possible until BIP-143.

Synsema turns each of those footguns into a **structural error impossible to commit**. And it closes the PSBT story: **the agent prepares the transaction, the human signs it on their hardware wallet** — cold custody with an autonomous agent, without the key ever existing on the agent's machine.

It inherits the security model of **[Blockchain](/en/0.6.x/38-blockchain)** — the key is a `secret` that never materializes, signing is deny-by-default and audited, and the read side goes through the same `net(host)` capability as HTTP. Bitcoin adds **no** new permission gate: `schnorr_sign` uses the SAME `sign` capability as secp256k1/ed25519, `wif_import` uses `wallet`, the read side uses `net`. Only signing moves value.

```synsema
-- Bitcoin: the UTXO matrix — every satoshi accounted for (G-invariant), strict
-- addresses (BIP-350), Schnorr taproot behind the SAME `sign` gate, and PSBT for
-- cold custody. All PURE (no network): the read side (btc_utxos/btc_send/btc_wait)
-- is exercised against local mocks in the engine's test suite.

require sign("HOT")

test "addresses: BIP-84 P2WPKH and BIP-86 taproot (the tweak is internal)"
    -- The OFFICIAL vectors of bip-0084/bip-0086 (standard mnemonic, path 0).
    let pk be bytes("0330d54fd0dd420a6e5f8d3624f5f3482cae350f79d5f0753bf5beef9c2d91af3c", "hex")
    assert_eq(btc_address(pk), "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu")
    let xk be bytes("cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115", "hex")
    assert_eq(btc_address(xk, "p2tr"), "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr")
    -- Strict decode: checksum + the RIGHT bech32/bech32m variant (BIP-350), and
    -- hash160(pubkey) IS the program of its address.
    let d be btc_address_decode("bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu")
    assert_eq(d["kind"], "p2wpkh")
    assert_eq(d["network"], "mainnet")
    assert_eq(d["program"], hash160(pk))

test "every satoshi accounted for: the fee is DECLARED and the invariant balances"
    let ins be [{"txid": "0be2a795a30050f74e61f5ff5a16c7a5bca650e7a3af6a0ff04544821697b1cd",
        "vout": 0, "amount": 60000, "address": "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu",
        "pubkey": bytes("0330d54fd0dd420a6e5f8d3624f5f3482cae350f79d5f0753bf5beef9c2d91af3c", "hex")}]
    -- Balanced: 60000 in == 30000 + 29500 out + 500 fee. The change is one more
    -- EXPLICIT output back to your own address.
    let tx be btc_tx({"inputs": ins,
        "outputs": [{"address": "bc1q8c6fshw2dlwun7ekn9qwf37cu2rn755upcp6el", "amount": 30000},
                    {"address": "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu", "amount": 29500}],
        "fee": 500})
    assert_eq(tx["fee"], 500)
    assert_eq(tx["total_in"], 60000)
    assert_eq(length(tx["digests"]), 1)
    -- Forgetting the change output: the error names the EXACT difference — in
    -- real Bitcoin those 29500 sats would be silently donated to miners.
    let e be ""
    try
        let bad be btc_tx({"inputs": ins,
            "outputs": [{"address": "bc1q8c6fshw2dlwun7ekn9qwf37cu2rn755upcp6el", "amount": 30000}],
            "fee": 500})
    recover er
        set e to er
    assert(contains(e, "29500"))
    assert(contains(e, "change output"))
    -- Amounts are exact integer SATS: a float errors with the conversion.
    let e2 be ""
    try
        let bad be btc_tx({"inputs": ins,
            "outputs": [{"address": "bc1q8c6fshw2dlwun7ekn9qwf37cu2rn755upcp6el", "amount": 0.1}],
            "fee": 500})
    recover er
        set e2 to er
    assert(contains(e2, "100_000_000"))

test "taproot: sign per input with the internal tweak → assemble → the exact txid"
    -- The key of BIP-86 path 0 (standard mnemonic); the txid is pinned against an
    -- externally-built vector (embit) — Schnorr (BIP-340) is deterministic here.
    let k be as_secret("41f41d69260df4cf277826a9b65a3717e4eeddbeedf637f212ca096576479361", "HOT")
    let addr be btc_address(k, "p2tr")
    let tx be btc_tx({"inputs": [{"txid": "f0f4d6cee577621446b78b5b293cf2eee962766d682671101cf215edf20ba0a7",
        "vout": 0, "amount": 50000, "address": addr}],
        "outputs": [{"address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "amount": 30000},
                    {"address": "bc1p3qkhfews2uk44qtvauqyr2ttdsw7svhkl9nkm9s9c3x4ax5h60wqwruhk7", "amount": 19700}],
        "fee": 300})
    -- "taproot" applies the BIP-341 key-path tweak INSIDE schnorr_sign.
    let sig be schnorr_sign(tx["digests"][0], k, "taproot")
    let raw be btc_tx_raw(tx, [sig])
    assert_eq(btc_txid(raw), "d98a84d0e281fd79a1b290a87ba19e8f434f392b5b2c47e60c7ea4556bedc3eb")

test "PSBT: the agent prepares and AUDITS; a human signs cold (pure, no key here)"
    let tx be btc_tx({"inputs": [{"txid": "f0f4d6cee577621446b78b5b293cf2eee962766d682671101cf215edf20ba0a7",
        "vout": 0, "amount": 50000,
        "address": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr"}],
        "outputs": [{"address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", "amount": 30000},
                    {"address": "bc1p3qkhfews2uk44qtvauqyr2ttdsw7svhkl9nkm9s9c3x4ax5h60wqwruhk7", "amount": 19700}],
        "fee": 300})
    let psbt be psbt_encode(tx)          -- base64, importable in Sparrow/Ledger/…
    -- Round-trip: the audit view shows the implicit fee and every amount.
    let audit be psbt_decode(psbt)
    assert_eq(audit["fee"], 300)
    assert_eq(audit["total_out"], 49700)
    assert_eq(audit["complete"], false)  -- unsigned: the human hasn't signed yet

test "the gate is scoped: signing with an ungranted key name denies, catchable"
    -- `require sign("HOT")` covers HOT only — another label is denied (and inside
    -- a `sandbox` even HOT would be).
    let e be ""
    try
        let cold be as_secret("41f41d69260df4cf277826a9b65a3717e4eeddbeedf637f212ca096576479361", "COLD")
        let s be schnorr_sign(bytes("00000000000000000000000000000000000000000000000000000000000000ff", "hex"), cold, "taproot")
    recover er
        set e to er
    assert(contains(e, "sign"))
```

## The central invariant: G28 — every satoshi accounted for

In UTXO the fee is not a field — it is the **difference** between what comes in and what goes out. `btc_tx` forces you to declare it and checks the invariant `sum(inputs) == sum(outputs) + fee`. If it doesn't balance, the error names the exact difference and where the bug usually is:

```synsema
require sign("HOT")
let k be secret("HOT")
let pk be secp256k1_pubkey(k)

-- Forgetting the change output: 60000 in input, 50000 out, 500 fee.
-- 9500 sats missing — silently DONATED to miners in real Bitcoin.
let tx be btc_tx({
    "inputs": [{"txid": utxo_txid, "vout": 0, "amount": 60000,
                "address": my_address, "pubkey": pk}],
    "outputs": [{"address": destination, "amount": 50000}],
    "fee": 500})
-- ERROR: G28 violated — inputs (60000 sats) exceed outputs + fee (50500 sats)
--        by 9500 sats. Did you forget the change output? ...
```

The change is **one more output**, explicit: you add it by hand (Synsema does not pick your UTXOs or your change — coin selection is a privacy pit; the caller decides). With the change output, the invariant balances and the builder returns the digests to sign. The echo shows `fee`, `vsize`, `total_in`, `total_out` and every amount **before** signing, to pass through a `confirm`.

## The full loop: read → build → sign → send → confirm

```synsema
require net("blockstream.info")
require sign("HOT")

let url be "https://blockstream.info/api"
let k be secret("HOT")
let my_addr be btc_address(k)                     -- P2WPKH (BIP-84) by default

-- READ: UTXOs are the builder's direct input (closes read→build)
let utxos be btc_utxos(url, my_addr)              -- [{txid, vout, amount, confirmations}]
let fees be btc_fee_estimates(url)               -- {"1": sat/vB, "6": …} — raw numbers
-- BUILD: every satoshi accounted for; change is an explicit output
let tx be btc_tx({
    "inputs": [{"txid": utxos[0]["txid"], "vout": utxos[0]["vout"],
                "amount": utxos[0]["amount"], "address": my_addr,
                "pubkey": secp256k1_pubkey(k)}],
    "outputs": [{"address": destination, "amount": 20000},
                {"address": my_addr, "amount": utxos[0]["amount"] - 20000 - 500}],  -- change
    "fee": 500})
-- SHOW fee and amounts BEFORE signing (nothing hidden in a blob)
let ok be confirm "Send 20000 sats, fee " + text(tx["fee"]) + "?" within 15m
when not ok
    give fail(403, "not approved")
-- SIGN (the ONLY gate): one signature per input, in the same order
let sig be secp256k1_sign(tx["digests"][0], k)
let raw be btc_tx_raw(tx, [sig])                 -- witness assembled (DER + SIGHASH_ALL)
-- SEND and CONFIRM (bounded — a stuck tx never hangs)
let txid be btc_send(url, raw)
let info be btc_wait(url, txid, 1, 600)          -- nothing on timeout
```

`btc_tx_raw` verifies **each signature against the UTXO's key before assembling** — a signature with the wrong key never goes on the wire. The signature comes from `secp256k1_sign` (ECDSA DER + low-s guaranteed; you never touch DER). The `txid` that `btc_send` returns is re-checked against the broadcast bytes: if the node lies with a different txid, that's an error.

## Taproot: Schnorr signing with the internal tweak

Spending from P2TR key-path (BIP-86) uses **BIP-340 Schnorr**, with the taproot tweak (BIP-341) applied **internally** — you never tweak a key by hand (the classic implementation bug):

```synsema
let k be secret("COLD")
let my_addr be btc_address(k, "p2tr")            -- the address is the TWEAKED key
let tx be btc_tx({
    "inputs": [{"txid": txid, "vout": 0, "amount": 50000, "address": my_addr}],
    "outputs": [{"address": destination, "amount": 49700}],
    "fee": 300})
-- "taproot" applies the BIP-341 key-path tweak inside schnorr_sign
let sig be schnorr_sign(tx["digests"][0], k, "taproot")
let raw be btc_tx_raw(tx, [sig])
```

`schnorr_sign` uses the **same** `sign` capability as secp256k1/ed25519 — zero new gates. It is deterministic (aux-rand fixed at 32 zero bytes), byte-exact against the official BIP-340/341 vectors. Without the `"taproot"` mode, `schnorr_sign(digest, k)` signs plain BIP-340 (no tweak).

## PSBT: the agent prepares, the human signs cold

The flagship custody story: the agent builds the transaction and exports it as a **PSBT (BIP-174)**; a human signs it on their **hardware wallet** (Ledger/Trezor/Coldcard/Sparrow) — the key **never existed** on the agent's machine — and the agent receives the signed PSBT, finalizes it and broadcasts it.

```synsema
-- The agent PREPARES (pure, no `sign`: there is no key here)
let tx be btc_tx({"inputs": [...], "outputs": [...], "fee": 300})
let psbt be psbt_encode(tx)                      -- base64, importable in Sparrow/Ledger/…
-- ...the human signs cold and returns the signed PSBT...
-- The agent AUDITS what it will broadcast (never blind)
let audit be psbt_decode(signed_psbt)            -- {inputs, outputs, amounts, fee, complete}
let ok be confirm "Broadcast? fee " + text(audit["fee"]) + " sats" within 15m
when ok
    let raw be psbt_finalize(signed_psbt)        -- signed tx bytes
    let txid be btc_send(url, raw)
```

`psbt_decode` gives you the PSBT's **implicit fee** and every amount/address to pass through `show`/`confirm` before signing or broadcasting someone else's PSBT. No other agent library has this first-class.

## What the language gives you

| Builtin | For | Gated? |
|---|---|---|
| `hash160(x)` | ripemd160(sha256(x)) → bytes(20), the address hash | pure |
| `btc_address(pubkey_or_secret, kind?, network?)` | address: `"p2wpkh"` (default) / `"p2tr"` / `"p2pkh"`; the taproot tweak is internal | pure |
| `btc_address_decode(text)` | `{kind, network, program, encoding}` — strict checksum + bech32/bech32m variant (BIP-350) | pure |
| `btc_script(address)` | the scriptPubKey of a standard address → bytes | pure |
| `btc_txid(raw)` | dSHA256 without witness, byte-reversed (the explorer/RPC form) | pure |
| `schnorr_sign(digest32, secret, "taproot"?)` | BIP-340 signature → bytes(64); `"taproot"` applies the BIP-341 tweak | **`require sign`** |
| `schnorr_verify(digest, sig64, xonly32)` / `schnorr_pubkey(secret)` | verify / derive the x-only pubkey | pure |
| `btc_tx(params)` | UTXO builder: `{digests: [one per input], fee, vsize, + echo}`; G28 | pure |
| `btc_tx_raw(tx, signatures)` | the signed tx (witness assembled; verifies each signature) → bytes | pure |
| `psbt_encode(tx)` | unsigned PSBT (base64) from the `btc_tx` map | pure |
| `psbt_decode(text, network?)` | audit a PSBT: inputs/outputs/amounts/fee/complete | pure |
| `psbt_finalize(text)` | tx bytes if the PSBT comes signed from outside | pure |
| `btc_utxos(url, address)` | an address's UTXOs (Esplora) → list ready for `btc_tx` | **`require net`** |
| `btc_balance(url, address)` | `{confirmed, mempool, total}` in exact sats | **`require net`** |
| `btc_fee_estimates(url)` | block-target → sat/vB (raw numbers, not an oracle) | **`require net`** |
| `btc_send(url, raw)` | broadcast → txid (re-checked against the bytes) | **`require net`** |
| `btc_wait(url, txid, confirmations?, timeout?)` | bounded confirmation wait (nothing on timeout) | **`require net`** |
| `btc_rpc(url, method, params?, auth?)` | Bitcoin Core JSON-RPC; `auth.pass` can be a secret (Basic auth) | **`require net`** |
| `wif_import(text, label?)` | import a WIF key → `secret` (no reverse export exists) | **`require wallet`** |

HD keys for Bitcoin come from the same HD custody as **[Blockchain](/en/0.6.x/38-blockchain)**: `hd_derive(seed, "m/84'/0'/0'/0/0")` (BIP-84, P2WPKH) and `"m/86'/0'/0'/0/0"` (BIP-86, P2TR) feed `btc_address` without materializing the key.

## Instinct vs. reality (read this before signing anything)

| Your instinct | The reality |
|---|---|
| "the fee is a field I put in the tx" | **It's implicit.** In UTXO the fee is `inputs − outputs`. `btc_tx` forces you to declare it and checks `sum(inputs) == sum(outputs) + fee` (G28). If it doesn't balance, the error names the exact difference. |
| "the builder computes the change" | **No.** Change is **one more output**, explicit, to your own address. Forgetting it donates the remainder to miners — which is why G28 catches it before signing. Automatic coin selection is out of scope (you pick it). |
| "I pass amounts in BTC" | **No.** Everything is **exact integer sats**. 1 BTC = 100_000_000 sats. A float (`0.1`) or a decimal is rejected with the conversion in the error — never guessed. |
| "I sign the transaction once" | **No.** You sign **once per input**, each over a different sighash. `btc_tx` returns `digests` (one per input); you pass one signature per input to `btc_tx_raw`, in the same order. |
| "the sighash is a single thing" | **No.** P2WPKH uses BIP-143 (segwit v0), P2TR uses BIP-341 (taproot) — different algorithms. `btc_tx` picks the right one by the UTXO type. Only SIGHASH_ALL/DEFAULT (NONE/SINGLE/ANYONECANPAY are niche footguns, out of scope). |
| "the taproot address is my public key" | **No.** It's the **TWEAKED** key (BIP-341 key-path). `btc_address(k, "p2tr")` applies the tweak internally; `schnorr_sign(…, "taproot")` signs with the tweaked key. You never tweak by hand — the classic implementation bug. |
| "I read the txid straight from the bytes" | **No.** The txid is dSHA256 of the serialization **without witness**, **byte-reversed** for display. `btc_txid` gives you the form explorers and RPC show — avoids the classic "my txid is backwards". |
| "bech32 works for all segwit" | **No.** BIP-350: witness v0 (P2WPKH/P2WSH) uses **bech32**, v1+ (taproot) uses **bech32m**. An address with the wrong variant is **rejected**. Lax decode = burned funds. |
| "a testnet address in a mainnet tx, it's the same key anyway" | **No.** Cross-network → error that **names both networks**. A send to the wrong network burns funds; `btc_tx` catches it structurally. |
| "I paste r and s raw into the witness" | **No.** The P2WPKH witness carries the signature as **DER + SIGHASH_ALL byte**; `btc_tx_raw` DER-encodes it for you (low-s guaranteed by k256). You never touch DER. |
| "a fee larger than what I send is what I meant" | **Almost always a bug.** `fee > sum(outputs)` → error. For the rare legitimate case, `"allow_absurd_fee": true` (explicit opt-in, never silent). |
| "a 100-sat output is fine" | **No.** Below the dust limit (546 P2PKH / 294 P2WPKH / 330 P2TR) it doesn't relay — burned sats. `btc_tx` catches it naming the limit. |
| "I can export a WIF back out" | **No.** `wif_import` exists (gated by `wallet`); the reverse export does **not** — no builtin returns a key. The deliberate backup is `reveal()` of the mnemonic. |
| "Bitcoin needs its own signing permission" | **No.** `schnorr_sign` uses the SAME `sign` capability as secp256k1/ed25519; `wif_import` uses `wallet`; the read side uses `net`. Zero new gates — `sign` is still the only one that spends. |

## Scope

**Spend FROM:** P2WPKH (BIP-84, bech32) and P2TR key-path (BIP-86, bech32m) — the present and future of wallets. **Send TO:** any standard type (P2PKH, P2SH, P2WPKH, P2WSH, P2TR). **Networks:** `"mainnet"` (default), `"testnet"`, `"signet"`, `"regtest"`.

**Out of scope (documented, not debt):** spending from legacy P2PKH/P2SH/multisig/taproot script-path (sending TO them does work); sighash NONE/SINGLE/ANYONECANPAY (directed error if requested); automatic coin selection (you pick it; manual pattern above); Lightning; Ordinals/inscriptions/BRC-20; Core descriptors / full watch-only xpub (PSBT already covers the minimal cold flow).
