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: 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
| Builtin | Para qué | ¿Gateado? |
|---|---|---|
hash160(x) | ripemd160(sha256(x)) → bytes(20), el hash de direcciones | puro |
btc_address(pubkey_o_secret, kind?, network?) | dirección: "p2wpkh" (default) / "p2tr" / "p2pkh"; el tweak taproot es interno | puro |
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 → bytes | puro |
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-341 | require sign |
schnorr_verify(digest, sig64, xonly32) / schnorr_pubkey(secret) | verificar / derivar la x-only pubkey | puro |
btc_tx(params) | builder UTXO: {digests: [uno por input], fee, vsize, + eco}; G28 | puro |
btc_tx_raw(tx, signatures) | la tx firmada (witness ensamblado; verifica cada firma) → bytes | puro |
psbt_encode(tx) | PSBT unsigned (base64) desde el map de btc_tx | puro |
psbt_decode(text, network?) | auditar un PSBT: inputs/outputs/amounts/fee/complete | puro |
psbt_finalize(text) | bytes de la tx si el PSBT viene firmado de afuera | puro |
btc_utxos(url, address) | los UTXOs de una dirección (Esplora) → lista lista para btc_tx | require net |
btc_balance(url, address) | {confirmed, mempool, total} en sats exactos | require 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 instinto | La 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).