Synsema docsENES

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.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)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 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 (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).