Synsema docsENES

Bitcoin: cada satoshi contabilizado

Bitcoin no es una cadena de cuentas como Ethereum o Solana — es la matriz UTXO, y sus footguns son los más caros del ecosistema. El fee es implícito (inputs − outputs): olvidar el output de vuelto dona todo el remanente a los mineros (le pasó a gente real, con cientos de BTC). Se firma una vez por input, cada uno sobre un sighash distinto (BIP-143 segwit v0, BIP-341 taproot). Firmar sin ver los montos era posible hasta BIP-143.

Synsema convierte cada uno de esos footguns en un error estructural imposible de cometer. Y cierra la historia PSBT: el agente prepara la transacción, el humano la firma en su hardware wallet — custodia fría con agente autónomo, sin que la clave exista siquiera en la máquina del agente.

Hereda el modelo de seguridad de Blockchain — la clave es un secret que nunca se materializa, firmar es deny-by-default y auditado, y el lado de lectura pasa por la misma capability net(host) que HTTP. Bitcoin no agrega ninguna puerta de permiso nueva: schnorr_sign usa la MISMA capability sign que secp256k1/ed25519, wif_import usa wallet, el read-side usa net. Sólo firmar mueve valor.

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"))

El invariante central: G28 — cada satoshi contabilizado

En UTXO el fee no es un campo — es la diferencia entre lo que entra y lo que sale. btc_tx te obliga a declararlo y verifica el invariante sum(inputs) == sum(outputs) + fee. Si no cierra, el error nombra la diferencia exacta y dónde suele estar el bug:

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

-- Olvidar el output de vuelto: 60000 de input, 50000 de salida, 500 de fee.
-- Faltan 9500 sats — que en Bitcoin real se DONAN a los mineros en silencio.
let tx be btc_tx({
    "inputs": [{"txid": txid_del_utxo, "vout": 0, "amount": 60000,
                "address": mi_direccion, "pubkey": pk}],
    "outputs": [{"address": destino, "amount": 50000}],
    "fee": 500})
-- ERROR: G28 violated — inputs (60000 sats) exceed outputs + fee (50500 sats)
--        by 9500 sats. Did you forget the change output? ...

El vuelto es un output más, explícito: se lo agregás a mano (Synsema no elige tus UTXOs ni tu change — la coin-selection es un pozo de privacidad; el caller decide). Con el output de vuelto, el invariante cierra y el builder devuelve los digests a firmar. El eco muestra fee, vsize, total_in, total_out y cada monto antes de firmar, para pasarlos por un confirm.

El loop completo: leer → construir → firmar → enviar → confirmar

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

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

-- LEER: los UTXOs son la entrada directa del builder (cierra leer→construir)
let utxos be btc_utxos(url, mi_addr)              -- [{txid, vout, amount, confirmations}]
let fees be btc_fee_estimates(url)               -- {"1": sat/vB, "6": …} — números crudos
-- CONSTRUIR: cada satoshi contabilizado; el vuelto es un output explícito
let tx be btc_tx({
    "inputs": [{"txid": utxos[0]["txid"], "vout": utxos[0]["vout"],
                "amount": utxos[0]["amount"], "address": mi_addr,
                "pubkey": secp256k1_pubkey(k)}],
    "outputs": [{"address": destino, "amount": 20000},
                {"address": mi_addr, "amount": utxos[0]["amount"] - 20000 - 500}],  -- vuelto
    "fee": 500})
-- MOSTRAR fee y montos ANTES de firmar (nada escondido en un blob)
let ok be confirm "¿Enviar 20000 sats, fee " + text(tx["fee"]) + "?" within 15m
when not ok
    give fail(403, "not approved")
-- FIRMAR (la ÚNICA puerta): una firma por input, en el mismo orden
let sig be secp256k1_sign(tx["digests"][0], k)
let raw be btc_tx_raw(tx, [sig])                 -- witness ensamblado (DER + SIGHASH_ALL)
-- ENVIAR y CONFIRMAR (acotado — una tx atascada jamás cuelga)
let txid be btc_send(url, raw)
let info be btc_wait(url, txid, 1, 600)          -- nothing al vencer

btc_tx_raw verifica cada firma contra la clave del UTXO antes de ensamblar — una firma con la clave equivocada jamás sale al aire. La firma la produce secp256k1_sign (ECDSA DER + low-s garantizado; vos nunca tocás DER). El txid que devuelve btc_send se re-chequea contra los bytes difundidos: si el nodo miente otro txid, es error.

Taproot: firma Schnorr con el tweak interno

Gastar desde P2TR key-path (BIP-86) usa Schnorr BIP-340, con el tweak de taproot (BIP-341) aplicado internamente — vos nunca tweakeás una clave a mano (el error clásico de implementación):

let k be secret("COLD")
let mi_addr be btc_address(k, "p2tr")            -- la dirección es la clave TWEAKED
let tx be btc_tx({
    "inputs": [{"txid": txid, "vout": 0, "amount": 50000, "address": mi_addr}],
    "outputs": [{"address": destino, "amount": 49700}],
    "fee": 300})
-- "taproot" aplica el tweak BIP-341 de key-path adentro de schnorr_sign
let sig be schnorr_sign(tx["digests"][0], k, "taproot")
let raw be btc_tx_raw(tx, [sig])

schnorr_sign usa la misma capability sign que secp256k1/ed25519 — cero puertas nuevas. Es determinista (aux-rand fijo en 32 bytes cero), byte-exacto contra los vectores oficiales de BIP-340/341. Sin el modo "taproot", schnorr_sign(digest, k) firma BIP-340 plano (sin tweak).

PSBT: el agente prepara, el humano firma en frío

La historia flagship de custodia: el agente arma la transacción y la exporta como PSBT (BIP-174); un humano la firma en su hardware wallet (Ledger/Trezor/Coldcard/Sparrow) — la clave jamás existió en la máquina del agente — y el agente recibe el PSBT firmado, lo finaliza y lo difunde.

-- El agente PREPARA (puro, sin `sign`: no hay clave acá)
let tx be btc_tx({"inputs": [...], "outputs": [...], "fee": 300})
let psbt be psbt_encode(tx)                      -- base64, importable en Sparrow/Ledger/…
-- ...el humano firma en frío y devuelve el PSBT firmado...
-- El agente AUDITA lo que va a difundir (nunca a ciegas)
let audit be psbt_decode(psbt_firmado)           -- {inputs, outputs, amounts, fee, complete}
let ok be confirm "¿Difundir? fee " + text(audit["fee"]) + " sats" within 15m
when ok
    let raw be psbt_finalize(psbt_firmado)       -- bytes de la tx firmada
    let txid be btc_send(url, raw)

psbt_decode te da el fee implícito del PSBT y cada monto/dirección para pasarlos por show/confirm antes de firmar o difundir un PSBT ajeno. Ninguna otra lib de agentes tiene esto de primera clase.

Qué te da el lenguaje

BuiltinPara qué¿Gateado?
hash160(x)ripemd160(sha256(x)) → bytes(20), el hash de direccionespuro
btc_address(pubkey_o_secret, kind?, network?)dirección: "p2wpkh" (default) / "p2tr" / "p2pkh"; el tweak taproot es internopuro
btc_address_decode(text){kind, network, program, encoding} — checksum + variante bech32/bech32m estrictos (BIP-350)puro
btc_script(address)el scriptPubKey de una dirección estándar → bytespuro
btc_txid(raw)dSHA256 sin witness, byte-reversed (la forma de exploradores/RPC)puro
schnorr_sign(digest32, secret, "taproot"?)firma BIP-340 → bytes(64); "taproot" aplica el tweak BIP-341require sign
schnorr_verify(digest, sig64, xonly32) / schnorr_pubkey(secret)verificar / derivar la x-only pubkeypuro
btc_tx(params)builder UTXO: {digests: [uno por input], fee, vsize, + eco}; G28puro
btc_tx_raw(tx, signatures)la tx firmada (witness ensamblado; verifica cada firma) → bytespuro
psbt_encode(tx)PSBT unsigned (base64) desde el map de btc_txpuro
psbt_decode(text, network?)auditar un PSBT: inputs/outputs/amounts/fee/completepuro
psbt_finalize(text)bytes de la tx si el PSBT viene firmado de afuerapuro
btc_utxos(url, address)los UTXOs de una dirección (Esplora) → lista lista para btc_txrequire net
btc_balance(url, address){confirmed, mempool, total} en sats exactosrequire net
btc_fee_estimates(url)objetivo-de-bloques → sat/vB (números crudos, no un oráculo)require net
btc_send(url, raw)broadcast → txid (re-chequeado contra los bytes)require net
btc_wait(url, txid, confirmations?, timeout?)espera de confirmación acotada (nothing al vencer)require net
btc_rpc(url, method, params?, auth?)JSON-RPC de Bitcoin Core; auth.pass puede ser un secret (Basic auth)require net
wif_import(text, label?)importar una clave WIF → secret (no existe el export inverso)require wallet

Las claves HD para Bitcoin salen de la misma custodia HD de Blockchain: hd_derive(seed, "m/84'/0'/0'/0/0") (BIP-84, P2WPKH) y "m/86'/0'/0'/0/0" (BIP-86, P2TR) alimentan btc_address sin materializar la clave.

Instinto vs. realidad (leé esto antes de firmar nada)

Tu instintoLa realidad
"el fee es un campo que pongo en la tx"Es implícito. En UTXO el fee es inputs − outputs. btc_tx te obliga a declararlo y verifica sum(inputs) == sum(outputs) + fee (G28). Si no cierra, el error nombra la diferencia exacta.
"el vuelto lo calcula el builder"No. El vuelto es un output más, explícito, a tu propia dirección. Olvidarlo dona el remanente a los mineros — por eso G28 lo atrapa antes de firmar. La coin-selection automática está fuera de alcance (la elegís vos).
"los montos los paso en BTC"No. Todo es sats enteros exactos. 1 BTC = 100_000_000 sats. Un float (0.1) o un decimal se rechaza con la conversión en el error — nunca se adivina.
"firmo una vez la transacción"No. Se firma una vez por input, cada uno sobre un sighash distinto. btc_tx devuelve digests (uno por input); pasás una firma por input a btc_tx_raw, en el mismo orden.
"el sighash es uno solo"No. P2WPKH usa BIP-143 (segwit v0), P2TR usa BIP-341 (taproot) — algoritmos distintos. btc_tx elige el correcto según el tipo del UTXO. Sólo SIGHASH_ALL/DEFAULT (NONE/SINGLE/ANYONECANPAY son footguns de nicho, fuera de alcance).
"la dirección taproot es mi clave pública"No. Es la clave TWEAKED (BIP-341 key-path). btc_address(k, "p2tr") aplica el tweak internamente; schnorr_sign(…, "taproot") firma con la clave tweakeada. Vos nunca tweakeás a mano — el error clásico de implementación.
"el txid lo leo tal cual de los bytes"No. El txid es dSHA256 del serializado sin witness, byte-reversed para mostrar. btc_txid te da la forma que muestran exploradores y RPC — evita el clásico "mi txid está al revés".
"bech32 sirve para todas las segwit"No. BIP-350: witness v0 (P2WPKH/P2WSH) usa bech32, v1+ (taproot) usa bech32m. Una dirección con la variante equivocada se rechaza. Decode laxo = fondos quemados.
"una dirección de testnet en una tx de mainnet, total es la misma clave"No. Cross-red → error que nombra las dos redes. Un envío a la red equivocada quema fondos; btc_tx lo atrapa por estructura.
"r y s de la firma los pego crudos en el witness"No. El witness P2WPKH lleva la firma en DER + byte SIGHASH_ALL; btc_tx_raw la DER-codifica por vos (low-s garantizado por k256). Vos nunca tocás DER.
"un fee más grande que lo que envío será lo que quise"Casi siempre un bug. fee > sum(outputs) → error. Para el caso legítimo raro, "allow_absurd_fee": true (opt-in explícito, jamás silencioso).
"un output de 100 sats está bien"No. Bajo el dust limit (546 P2PKH / 294 P2WPKH / 330 P2TR) no relaya — sats quemados. btc_tx lo atrapa nombrando el límite.
"una WIF la puedo exportar de vuelta"No. wif_import existe (gateado por wallet); el export inverso no — ningún builtin devuelve una clave. El respaldo deliberado es reveal() del mnemónico.
"Bitcoin necesita su propio permiso de firma"No. schnorr_sign usa la MISMA capability sign que secp256k1/ed25519; wif_import usa wallet; el read-side usa net. Cero puertas nuevas — sign sigue siendo la única que gasta.

Alcance

Se gasta DESDE: P2WPKH (BIP-84, bech32) y P2TR key-path (BIP-86, bech32m) — el presente y el futuro de las wallets. Se envía HACIA: cualquier tipo estándar (P2PKH, P2SH, P2WPKH, P2WSH, P2TR). Redes: "mainnet" (default), "testnet", "signet", "regtest".

Fuera de alcance (documentado, no es deuda): gastar desde legacy P2PKH/P2SH/multisig/taproot script-path (enviar HACIA ellos sí funciona); sighash NONE/SINGLE/ANYONECANPAY (error dirigido si se piden); coin-selection automática (la elegís vos; patrón manual arriba); Lightning; Ordinals/inscriptions/BRC-20; descriptores de Core / xpub watch-only completo (PSBT ya cubre el flujo frío mínimo).