Synsemadocsv0.6.xENES

Standard library / primitives

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. Its names are the <family>_<action> pattern the whole blockchain surface follows since v0.6.29: btc_tx builds what you sign, btc_tx_raw assembles the signed transaction, then btc_send and btc_wait — the same shape as evm_tx/evm_tx_raw/evm_send/evm_wait.

bitcoin.syn
-- 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§

BuiltinForGated?
hash160(x)ripemd160(sha256(x)) → bytes(20), the address hashpure
btc_address(pubkey_or_secret, kind?, network?)address: "p2wpkh" (default) / "p2tr" / "p2pkh"; the taproot tweak is internalpure
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 → bytespure
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 tweakrequire sign
schnorr_verify(digest, sig64, xonly32) / schnorr_pubkey(secret)verify / derive the x-only pubkeypure
btc_tx(params)UTXO builder: {digests: [one per input], fee, vsize, + echo}; G28pure
btc_tx_raw(tx, signatures)the signed tx (witness assembled; verifies each signature) → bytespure
psbt_encode(tx)unsigned PSBT (base64) from the btc_tx mappure
psbt_decode(text, network?)audit a PSBT: inputs/outputs/amounts/fee/completepure
psbt_finalize(text)tx bytes if the PSBT comes signed from outsidepure
btc_utxos(url, address)an address's UTXOs (Esplora) → list ready for btc_txrequire net
btc_balance(url, address){confirmed, mempool, total} in exact satsrequire 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); the raw answer keeps integers beyond 64 bits exactrequire 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 instinctThe 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. 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).