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 — 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.
-- 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:
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
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):
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.
-- 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: 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).