---
slug: 38-blockchain
title: Blockchain (leer → construir → firmar → enviar → confirmar)
description: Operá on-chain desde un agente — el loop completo en Ethereum, Solana y Algorand — leer la cadena (nonce, fees, saldos, eth_call, params sugeridos), construir la tx (tx_eip1559), firmarla, difundirla y esperar la confirmación, más llamadas a contratos EVM (ABI), firmas de dApp (EIP-191/712), tokens SPL y custodia HD (BIP-39/32, SLIP-0010, keystore V3) — con la clave privada sellada como secret que nunca se materializa, firma y custodia deny-by-default y auditadas. Todo puro-Rust, un solo binario.
example_ids: [blockchain, wallet-hd]
---

# Blockchain: la wallet que el agente usa pero no puede robar

Operá on-chain — leer estado, construir, firmar, difundir y confirmar transacciones — sin que la clave privada se materialice jamás en texto ni se filtre a un LLM. La complejidad (curvas, RLP, checksums, hex-quantities, polling de receipts) la absorbe el lenguaje; lo que escribís es obvio y seguro por construcción.

El diferencial es **firmar/custodiar con seguridad estructural que no se puede desactivar**: la clave es un `secret`, firmar es deny-by-default y auditado, y mover fondos pasa por un gate humano. Y el loop está **completo**: el lado de lectura (nonce, fees, saldos, estado de contratos, receipts) también sale del lenguaje — gateado por la misma capability `net(host)` que HTTP, con **cero** puertas de permiso nuevas. Sólo firmar mueve valor: un agente con `net` pero sin `sign` puede monitorear todo y no gastar nada.

```synsema
-- Doc example: sign an Ethereum transaction without the private key ever
-- materializing. The key is a `secret`; signing is deny-by-default (`require sign`)
-- and audited. Derivation/verification are pure. No network — this is the offline
-- signing core; broadcast is a plain http_post to your RPC.
intent: "doc example: blockchain signing"
require sign("HOT_KEY")

test "an ETH address is derived from the key, and ecrecover closes the circuit"
    -- The key arrives sealed as a secret (from .env in real programs). It never
    -- turns into a plain string — text(k) is redacted.
    let k be as_secret("0000000000000000000000000000000000000000000000000000000000000001", "HOT_KEY")
    assert_eq(text(k), "secret(HOT_KEY)")

    -- Address is public: deriving it does NOT expose the private key.
    assert_eq(eth_address(k), "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf")

    -- Build an EIP-1559 signing payload: 0x02 || rlp([chainId, nonce, ...]).
    let to_addr be bytes("5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", "hex")
    let fields be [1, 9, 1000000000, 30000000000, 21000, to_addr, 1000000000000000, bytes([]), []]
    let digest be keccak256(bytes([2]) + rlp_encode(fields))

    -- Sign it (deny-by-default: needs `require sign("HOT_KEY")`). Deterministic
    -- (RFC 6979): the same (key, digest) always yields the same signature.
    let sig be secp256k1_sign(digest, k)
    assert_eq(length(sig), 65)
    assert_eq(sig, secp256k1_sign(digest, k))

    -- ecrecover: the recovered public key yields the SAME address.
    let pub be secp256k1_recover(digest, sig)
    assert_eq(eth_address(pub), "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf")

    -- Assemble the signed raw tx, ready to broadcast: 0x02 || rlp(fields + [v, r, s]).
    -- r/s go in as INTEGERS (minimal RLP, no leading zeros), never as 32-byte blobs —
    -- bytes_to_int is the exact bridge (256 bits, no float involved).
    let v be sig[64]
    let r be bytes_to_int(slice(sig, 0, 32))
    let s be bytes_to_int(slice(sig, 32, 64))
    let raw be bytes([2]) + rlp_encode(fields + [v, r, s])
    assert_eq(raw[0], 2)
    assert_eq(length(rlp_decode(slice(raw, 1, length(raw)))), 12)
    -- int_to_bytes(n, 32) restores the fixed width (closes the r round-trip)
    assert_eq(int_to_bytes(r, 32), slice(sig, 0, 32))

test "ed25519 (Solana/Algorand) signs the RAW message — never a pre-hash"
    let k be as_secret("0000000000000000000000000000000000000000000000000000000000000001", "HOT_KEY")
    let sig be ed25519_sign("transfer 10", k)
    let pk be ed25519_pubkey(k)
    assert(ed25519_verify("transfer 10", sig, pk))

test "the dApp world: ABI calldata, SIWE (EIP-191) and a permit (EIP-712)"
    let k be as_secret("0000000000000000000000000000000000000000000000000000000000000001", "HOT_KEY")
    -- Contract calldata: canonical signature (no spaces), exact big integers.
    let data be abi_encode("transfer(address,uint256)", ["0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf", 1000000000000000000000000])
    assert_eq(decode(slice(data, 0, 4), "hex"), "a9059cbb")
    -- Read calldata back — audit what you are about to sign.
    let args be abi_decode("(address,uint256)", slice(data, 4, length(data)))
    assert_eq(args[1], 1000000000000000000000000)
    -- SIWE login: digest → gated signature → the backend recovers the address.
    let d191 be eip191_digest("app.example.com wants you to sign in")
    let quien be eth_address(secp256k1_recover(d191, secp256k1_sign(d191, k)))
    assert_eq(quien, "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf")
    -- EIP-712 permit: the typed data is a READABLE map (show it before signing).
    let domain be {"name": "USD Coin", "version": "2", "chainId": 1, "verifyingContract": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"}
    let types be {"Permit": [{"name": "owner", "type": "address"}, {"name": "spender", "type": "address"},
        {"name": "value", "type": "uint256"}, {"name": "nonce", "type": "uint256"}, {"name": "deadline", "type": "uint256"}]}
    let permit be {"owner": quien, "spender": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed",
        "value": 1000000, "nonce": 0, "deadline": 1893456000}
    let d712 be eip712_digest(domain, types, "Permit", permit)
    let sig be secp256k1_sign(d712, k)
    assert_eq(eth_address(secp256k1_recover(d712, sig)), quien)

test "Solana and Algorand are transactable: message/tx bytes come out ready"
    let k be as_secret("0000000000000000000000000000000000000000000000000000000000000001", "HOT_KEY")
    let payer be ed25519_pubkey(k)
    -- Solana System transfer: data is u32 LE 2 ‖ u64 LE lamports (int_to_bytes_le).
    let msg be solana_message({
        "fee_payer": payer,
        "recent_blockhash": payer,
        "instructions": [{
            "program": "11111111111111111111111111111111",
            "accounts": [{"pubkey": payer, "signer": true, "writable": true}],
            "data": int_to_bytes_le(2, 4) + int_to_bytes_le(1000000, 8)}]})
    let tx be solana_tx(msg, ed25519_sign(msg, k))
    -- broadcast (userland): decode(tx, "base64") → sendTransaction via http_post
    assert(length(decode(tx, "base64")) > 0)
    -- Algorand pay: protocol short keys; canonical msgpack (zero fields omitted).
    let txn be {"type": "pay", "snd": algo_address(k), "rcv": payer,
        "amt": 123456, "fee": 1000, "fv": 1000, "lv": 2000,
        "gh": bytes("0707070707070707070707070707070707070707070707070707070707070707", "hex")}
    let listo be algorand_tx_encode(txn)
    let stx be algorand_tx(txn, ed25519_sign(listo, k))
    assert(length(stx) > length(listo))

test "read side builders: tx_eip1559 assembles the exact SDK bytes (no hand-rolled RLP)"
    -- Vector from eth-account (the Ethereum Foundation SDK): same key+digest →
    -- same deterministic RFC 6979 signature → the raw tx matches byte-for-byte.
    let k be as_secret("1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727", "HOT_KEY")
    let tx be tx_eip1559({"chain_id": 1, "nonce": 7,
        "to": "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0",
        "value": 100000000000000000, "gas": 21000,
        "max_fee": 30000000000, "max_priority": 1500000000})
    -- every value-moving number is echoed back — `confirm` them BEFORE signing
    assert_eq(tx["max_fee"], 30000000000)
    assert_eq(tx["to"], "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0")
    let sig be secp256k1_sign(tx["digest"], k)
    let raw be tx_eip1559_raw(tx, sig)
    assert_eq(decode(raw, "hex"), "02f87301078459682f008506fc23ac00825208946fac4d18c912343bf86fa7049364dd4e424ab9c088016345785d8a000080c080a054afb059cc17c8b3726db836c4d9093aaabfb6d422c9c190ae8e9c561e285fffa03f3c2eb5d5b609d2544817eb1ce8a4e64dfc8d4dbbc9db06f5ce2f2352b13f41")
    -- the tx hash is keccak256 of the signed raw (matches the SDK's too)
    assert_eq(decode(keccak256(raw), "hex"), "6fb18223cd52476122a18a2b59a6c9faca40b36937217962a4f81b0da1c79880")

test "no fee is ever invented: a missing max_fee errors naming the reader"
    let failed be ""
    try
        let tx be tx_eip1559({"chain_id": 1, "nonce": 0,
            "to": "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0",
            "value": 0, "gas": 21000, "max_priority": 1})
    recover e
        set failed to e
    assert(contains(failed, "max_fee"))
    assert(contains(failed, "eth_fee_history"))

test "the read side is net-gated: without `require net` it denies, catchable"
    let failed be ""
    try
        let n be eth_chain_id("http://127.0.0.1:1")
    recover e
        set failed to e
    assert(contains(failed, "net"))

test "Solana SPL tokens: PDAs and instruction data are pure (no key, no gate)"
    -- An SPL TransferChecked instruction's data: tag 12 ‖ amount u64 LE ‖ decimals.
    assert_eq(decode(spl_transfer_checked_data(1000000, 6), "hex"), "0c40420f000000000006")
    -- The associated token account (ATA) is a PDA — derived, off-curve, byte-exact
    -- against the SPL SDK (owner and mint here are 32-byte test pubkeys).
    let owner be bytes("0202020202020202020202020202020202020202020202020202020202020202", "hex")
    let mint be bytes("0303030303030303030303030303030303030303030303030303030303030303", "hex")
    assert_eq(decode(spl_ata(owner, mint), "hex"),
        "9958db38c3bb8bf04f6da81d59d3e34f7e17de3ec94b885e2d79050e75ff3f6e")
    -- solana_pda(seeds, program) returns {address, bump}; the address is off-curve.
    let pda be solana_pda(["metadata", owner], "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
    assert_eq(length(pda["address"]), 32)
```

## El modelo de seguridad (por qué esto es distinto)

- **La clave nunca se materializa.** No hay builtin que devuelva una clave privada. Pasás un `secret` (del `.env`, o sellado en runtime con `as_secret`); se consume del lado Rust para firmar y se borra de memoria. `text(k)` / `json_encode` / una línea de log muestran `secret(NOMBRE)`, jamás el valor.
- **Firmar es gateado + auditado.** `secp256k1_sign` / `ed25519_sign` exigen `require sign("NOMBRE_CLAVE")` (con scope al name del secret de la clave) y escriben una entrada de audit persistente (curva, timestamp, programa, `file:line` — nunca la clave). Sin la capability → el mismo error deny-by-default que cualquier otra. Dentro de un `sandbox` (capabilities vaciadas) → firmar está denegado aunque tengas la clave en mano. Todo lo demás — hashear, codificar, verificar, derivar la pubkey/dirección — es **puro**. El host puede además limitar **cuántas** firmas hace cada clave por proceso: `SYNSEMA_SIGN_CEILING="HOT_KEY:100"` (pares `clave:cantidad` separados por comas) — la firma N+1 falla con error catchable y una entrada `denied_by=ceiling` en `sign.log`; sin la variable, el comportamiento no cambia.
- **Crear custodia también es gateado + auditado.** Generar o importar material de clave (`mnemonic_generate`, `hd_derive`, `keystore_import`, …) exige `require wallet` — una capability deny-by-default *distinta* de `sign` (crear una clave ≠ gastar con ella), auditada en `wallet.log`, denegada en `sandbox`. Todo builtin de custodia devuelve un `secret`; el mnemónico/seed/clave nunca se vuelve un valor plano.
- **Determinista y anti-malleabilidad.** secp256k1 usa RFC 6979 (nonce determinista — sin la catástrofe de reuso de nonce) y emite firmas low-s. ed25519 es determinista por RFC 8032.

## Qué te da el lenguaje

| Builtin | Para qué | ¿Gateado? |
|---|---|---|
| `keccak256(x)` / `sha512_256(x)` | hashes de Ethereum / Algorand → bytes(32) | puro |
| `bytes(text, "base58"/"base32")`, `decode(b, …)` | encoding de direcciones Solana / Algorand | puro |
| `bech32_encode/decode` | Avalanche X/P, segwit (bech32 y bech32m) | puro |
| `secp256k1_sign(digest, secret)` | firma ETH/Avalanche → bytes(65) `r‖s‖v` | **`require sign`** |
| `secp256k1_verify / recover / pubkey` | verificar, ecrecover, derivar pubkey | puro |
| `ed25519_sign(message, secret)` | firma Solana/Algorand → bytes(64) | **`require sign`** |
| `ed25519_verify / pubkey` | verificar, derivar pubkey | puro |
| `eth_address(pubkey_or_secret)` | dirección con checksum EIP-55 | puro |
| `rlp_encode / rlp_decode` | encoding de tx (legacy + EIP-1559); decode es canónico-estricto | puro |
| `bytes_to_int(b)` / `int_to_bytes(n, size?)` | bytes big-endian ↔ entero exacto (r/s ↔ enteros RLP) | puro |
| `int_to_bytes_le(n, size)` | little-endian de ancho fijo (data de instrucciones Solana: u32/u64 LE) | puro |
| `abi_encode(sig, values)` / `abi_decode(types, data)` | calldata de contratos: selector + args ↔ valores; decode estricto (offsets hostiles/padding sucio → error) | puro |
| `abi_selector(sig)` | selector bytes(4) de una firma canónica | puro |
| `eip191_digest(message)` | digest de personal_sign / SIWE → bytes(32) | puro |
| `eip712_digest(domain, types, primary, message)` | digest de typed-data desde **maps legibles** (permits, órdenes de DEX, meta-tx) | puro |
| `solana_message(params)` / `solana_tx(msg, sigs)` | message de Solana (legacy + v0) listo para `ed25519_sign`; tx wire lista para `sendTransaction` | puro |
| `algorand_tx_encode(txn)` / `algorand_tx(txn, sig)` | `"TX"‖msgpack canónico` listo para `ed25519_sign`; SignedTxn listo para POST a algod | puro |
| `algo_address(pubkey_or_secret)` | dirección base32 de Algorand con checksum | puro |
| `solana_pda(seeds, program)` / `spl_ata(owner, mint)` | dirección derivada de programa Solana (off-curve) / associated token account | puro |
| `spl_transfer_data(amount)` / `spl_transfer_checked_data(amount, decimals)` | data de instrucciones SPL Token (tag 3 / tag 12) | puro |
| `mnemonic_generate / mnemonic_to_seed / hd_derive` | generar una wallet, derivar claves (BIP-39/BIP-32/SLIP-0010) → cada una un `secret` | **`require wallet`** |
| `algorand_mnemonic / algorand_mnemonic_to_key` | frase de 25 palabras de Algorand ↔ clave (NO es BIP-39) → `secret` | **`require wallet`** |
| `keystore_import / keystore_export` | importar/exportar un keystore V3 de Geth/MyEtherWallet | **`require wallet`** |
| `tx_eip1559(params)` / `tx_eip1559_raw(tx, sig)` | builder EIP-1559: `{digest, fields, + eco de cada número}`; ensamblar la raw firmada (v/r/s resueltos) | puro |
| `eth_rpc / eth_nonce / eth_balance / eth_gas_price / eth_chain_id / eth_estimate_gas / eth_call / eth_fee_history` | lado de lectura EVM (hex-quantities resueltos, estricto) | **`require net`** |
| `eth_send_raw / eth_receipt / eth_wait_receipt` | broadcast + receipt tipado + espera de confirmación acotada | **`require net`** |
| `solana_rpc / solana_latest_blockhash / solana_balance / spl_balance` | lado de lectura Solana (blockhash como bytes(32), lamports exactos, saldo SPL vía el ATA derivado) | **`require net`** |
| `solana_send / solana_confirm` | broadcast (base64 resuelto) + espera de status acotada | **`require net`** |
| `algorand_params / algorand_account / algorand_send / algorand_wait` | REST de algod: params sugeridos (fee POR BYTE y min_fee), cuenta, broadcast binario, espera acotada | **`require net`** |

## El lado de lectura: cerrar el loop sin hand-rollear JSON-RPC

Antes de esto, un agente firmaba transacciones byte-perfectas pero tenía que hand-rollear
JSON-RPC sobre `http_post` para leer el nonce, los fees EIP-1559, el chain id o el
`recent_blockhash` — codificando hex-quantities a mano y decodificando resultados a la
defensiva. Ahí es exactamente donde mueren los tokens y la corrección, así que el lenguaje
lo absorbió:

```synsema
require net("rpc.example.com")
require sign("HOT_KEY")

let url be "https://rpc.example.com"
let k be secret("HOT_KEY")
-- LEER lo que la tx necesita
let nonce be eth_nonce(url, eth_address(k))       -- eth_getTransactionCount ("pending")
let fees be eth_fee_history(url)                  -- {base_fee, priority, base_fees, rewards}
-- CONSTRUIR: cada campo que mueve valor es explícito (uno faltante errora nombrando el lector)
let tx be tx_eip1559({"chain_id": eth_chain_id(url), "nonce": nonce, "to": destino,
    "value": 100000000000000000, "gas": 21000,
    "max_fee": fees["base_fee"] * 2, "max_priority": fees["priority"]})
-- tx ecoa los números (tx["max_fee"], tx["value"], tx["to"]) — pasalos por `confirm` ANTES de firmar
let sig be secp256k1_sign(tx["digest"], k)        -- la ÚNICA puerta gateada
let raw be tx_eip1559_raw(tx, sig)                -- v/r/s ensamblados por vos
-- ENVIAR y CONFIRMAR
let hash be eth_send_raw(url, raw)                -- tx hash "0x…"
let receipt be eth_wait_receipt(url, hash, 1, 120)  -- nothing al vencer — jamás cuelga
-- receipt["status"]: 1 = éxito, 0 = REVERTIDA (aterrizó pero falló — chequealo siempre)

-- Leer estado de contratos: eth_call devuelve bytes CRUDOS → abi_decode (el 90% de las lecturas)
let calldata be abi_encode("balanceOf(address)", [dueno])
let saldo be abi_decode("uint256", eth_call(url, {"to": token, "data": calldata}))[0]
```

Solana lo espeja (`solana_latest_blockhash` → `solana_message` → `ed25519_sign` →
`solana_tx` → `solana_send` → `solana_confirm`; `solana_balance`/`spl_balance` para
monitoreo), y Algorand también (`algorand_params` → map del txn → `algorand_tx_encode`
→ firmar → `algorand_tx` → `algorand_send` (el POST binario está resuelto) →
`algorand_wait`). Las escotillas `eth_rpc(url, method, params?)` /
`solana_rpc(url, method, params?)` cubren cualquier método sin wrapper.

**Un nodo RPC es input no confiable.** Puede mentir, estar comprometido o devolver
basura — por eso todo decode es estricto: un hex-quantity malformado o no canónico,
una respuesta de más de 16 MiB, una forma que no coincide, un id JSON-RPC ajeno →
**error atrapable**, jamás un panic, jamás data incorrecta en silencio. Los errores
nombran sólo el **host**, nunca el URL completo (las API keys de RPC suelen viajar en
el path). En qué nodo *confiás* es tu decisión — Synsema te da la primitiva, no la
confianza.

## L2s de EVM: Base, Arbitrum, Optimism, Polygon — sin tocar nada

Un L2 es el **mismo wire EVM**: apuntá el `url` al RPC del L2 y todo lo de arriba —
`eth_nonce`, `eth_fee_history`, `tx_eip1559`, `eth_call`, `eth_wait_receipt` — funciona
sin cambios. La única regla: **leé el chain id del nodo** (`eth_chain_id(url)`), jamás lo
hardcodees — eso es lo que hace que el mismo programa sea correcto en mainnet, Base
(8453), Optimism (10), Arbitrum (42161) o Polygon (137), y es lo que firma la protección
anti-replay de EIP-155.

Una salvedad honesta sobre el **costo**: en las cadenas OP-stack (Base, Optimism)
`eth_estimate_gas` cubre sólo la **ejecución L2** — la transacción además paga un **fee de
data en L1** que el nodo agrega al incluirla. Llega en el receipt como `l1Fee` /
`l1GasUsed` / `l1GasPrice` (decodificados a int exacto como cualquier otra cantidad), así
que el total real es `gasUsed × effectiveGasPrice + l1Fee` — visible, nunca escondido en
un string hex. Arbitrum usa otro modelo y pliega su componente L1 directamente en
`gasUsed` (sin campo extra). Si tu agente reporta costos, leelos del receipt, no del
estimate.

La cripto cross-chain tiene bordes filosos. Estos causan la mayoría de los bugs reales:

| Tu instinto | La realidad |
|---|---|
| "keccak256 es SHA3-256" | **No.** Ethereum usa Keccak *antes* de la estandarización NIST; el padding difiere. `keccak256("")` es `c5d24601…`, no el `a7ffc6f8…` de SHA3-256. `keccak256` te da el de Ethereum. |
| "hasheo el mensaje y después lo firmo con ed25519" | **No.** ed25519 firma el **mensaje crudo** (hashea internamente, RFC 8032). Pre-hashear hace doble-hash y produce una firma incorrecta. `ed25519_sign` toma el mensaje directo; solo secp256k1 toma un digest de 32 bytes. |
| "firmar es un cómputo como hashear" | **No.** Firmar autoriza mover valor — es la operación más peligrosa del lenguaje. Exige `require sign("CLAVE")` y escribe un audit. Deny-by-default, denegado dentro de `sandbox`. |
| "la clave es un string hex que paso por ahí" | **No.** La clave es un `secret`. Nunca se vuelve un string plano; pasar un string crudo se rechaza. El plaintext se interpreta solo del lado Rust. |
| "r y s son blobs de 32 bytes que pego en la tx" | **No.** La tx firmada codifica r/s como **enteros** RLP (mínimos, sin ceros a la izquierda). Pegar los 32 bytes crudos invalida ~1 de cada 128 transacciones — un heisenbug. Usá `bytes_to_int(slice(sig, 0, 32))`; `int_to_bytes(n, 32)` restaura el ancho fijo. |
| "la firma ABI se escribe como quiera" | **No.** La firma es **canónica**: sin espacios ni nombres de parámetro — `"transfer(address,uint256)"`, jamás `"transfer(address to, uint256 amount)"`. Una firma mal escrita es un selector distinto: la llamada pega en silencio contra una función inexistente. `abi_encode`/`abi_selector` rechazan las formas no canónicas (y normalizan `uint`→`uint256`). |
| "un monto de tokens lo paso como float" | **No.** `1e24` como float no es exacto — un monto uint256 necesita **enteros exactos** (Synsema promueve a enteros grandes solo: `1000000000000000000000000` anda directo). Los floats se rechazan con error claro. |
| "el msgpack de Algorand conserva mis campos en cero" | **No.** Algorand exige msgpack **canónico**: claves ordenadas y campos cero/vacíos/false **omitidos** — si no, la red rechaza la tx o el TXID difiere. `algorand_tx_encode` emite canónico por construcción (`amt: 0` desaparece; eso es lo correcto). |
| "Solana respeta el orden en que listo las cuentas" | **No.** Las ordenan las reglas del runtime: fee payer primero, después signers escribibles, signers readonly, no-signers escribibles, no-signers readonly (cada bucket ordenado por los bytes de la pubkey, como la SDK oficial). `solana_message` reordena por vos — los índices del message compilado apuntan a la tabla reordenada. |
| "derivo una cuenta de Solana con un path BIP-32 normal" | **No.** Solana usa **SLIP-0010** sobre ed25519, que es **solo-hardened**: `hd_derive(seed, "m/44'/501'/0'/0'", "ed25519")`. Un índice no-hardened da error. ETH es el default `"secp256k1"` (BIP-32). |
| "la frase semilla de Algorand es una de 24 palabras BIP-39" | **No.** La frase de wallet de Algorand es su **propio formato de 25 palabras** (checksum vía sha512_256 sobre la clave). Usá `algorand_mnemonic` / `algorand_mnemonic_to_key`; pasarla a `mnemonic_to_seed` está mal. |
| "custodia y firma son el mismo permiso" | **No.** Crear custodia (mnemónicos/HD/keystore) exige **`require wallet`**; firmar exige **`require sign`**. Un agente puede derivar direcciones sin poder gastar. Ambas deny-by-default, auditadas, denegadas en `sandbox`. |
| "el mnemónico/seed es un string que puedo imprimir" | **No.** Es un `secret` — `text()`/`json_encode`/una línea de log muestran `secret(NOMBRE)`. Respaldá una frase **a propósito** con `reveal()` (gateado por `reveal("NOMBRE")` + auditado). Un checksum/passphrase incorrecto da error sin jamás ecoar el material. |
| "el lado de lectura necesita su propio permiso" | **No.** Es el mismo **`net(host)`** de `http_*` — cero puertas de capability nuevas. El broadcast también es `net` (la firma ya ocurrió; sin una válida el nodo rechaza los bytes). `sign` sigue siendo la única puerta que mueve valor. |
| "`tx_eip1559` me pone defaults razonables de gas/fee" | **No.** Un builder que inventa un fee es blind-signing con pasos extra. Cada campo que mueve valor es **explícito** — uno faltante errora nombrando el lector (`eth_fee_history`/`eth_estimate_gas`) — y el map resultante **ecoa** cada número para que un `confirm` los muestre antes de firmar. |
| "un receipt significa que mi transfer salió bien" | **No.** Un receipt/status significa **inclusión**. Chequeá `receipt["status"]` (0 = revertida) y el `status["err"]` de Solana (`nothing` = ok) — una tx puede aterrizar Y fallar. |
| "`eth_wait_receipt` bloquea hasta que confirme" | **No.** Los tres waiters (`eth_wait_receipt`/`solana_confirm`/`algorand_wait`) son polls **acotados**: devuelven `nothing` al vencer (default 60 s), como `ws_recv` — una tx sin confirmar jamás cuelga al agente. `algorand_wait` errora ante un rechazo del pool (es definitivo; seguir esperando sería mentir). |
| "un blip de red en medio de la espera mata al waiter" | **No.** Después de un primer poll exitoso, una falla **transitoria** (conexión caída, HTTP 5xx) se reintenta hasta el deadline, con un aviso `synsema: warning:` único en stderr. Si el deadline vence con el nodo todavía fallando, sube el **error** en vez de `nothing` — "no confirmó" y "el nodo dejó de responder" son verdades distintas. Un nodo muerto o una URL equivocada siguen fallando rápido en el **primer** poll, y las respuestas definitivas (4xx, JSON inválido, errores RPC del nodo) erroran de inmediato. |
| "el `fee` sugerido de Algorand es el flat que pongo en el txn" | **No.** El `fee` de algod es **por byte** (usualmente 0); el mínimo flat real es `min-fee` (1000 µAlgo). `algorand_params` devuelve **los dos** (`fee` y `min_fee`) para que ninguno de los dos errores clásicos — tx rechazada o fee inflado — sobreviva. |
| "`spl_balance` sobre una cuenta de token inexistente da 0" | **No.** Un ATA inexistente es un **error atrapable** — un owner/mint equivocado leería 0 en silencio para siempre. En éxito el map incluye el `ata` derivado para que verifiques qué cuenta se leyó. |
| "un valor RPC raro me lo arreglan" | **No.** Decode estricto, la misma doctrina de `rlp_decode`/`abi_decode`: `"0x01"` (cero a la izquierda), la forma JSON equivocada, un body de más de 16 MiB, un id ajeno → error atrapable. La basura de un nodo hostil jamás se vuelve un número en silencio. |

## Cadenas cubiertas

| Cadena | Curva | Hash de firma | Dirección |
|---|---|---|---|
| Ethereum | secp256k1 | keccak256 | hex EIP-55 + RLP/EIP-1559 |
| Avalanche C-Chain | secp256k1 | keccak256 | idéntica a Ethereum (es EVM) |
| Avalanche X/P | secp256k1 | sha256 | bech32 |
| Solana | ed25519 | — (interno) | base58 |
| Algorand | ed25519 | sha512_256 | base32 |

Ethereum, Avalanche C-Chain, **Solana y Algorand quedan end-to-end**: construir la transacción, firmarla y ensamblar los bytes exactos del wire en Synsema — verificado byte a byte contra las SDKs oficiales (solana-sdk/solders, algosdk). Solana además tiene **tokens SPL** (PDAs, associated accounts, TransferChecked), y las cuatro cadenas tienen **custodia HD** (más abajo).

- **Solana:** `solana_message({fee_payer, recent_blockhash, instructions, version?})` → bytes para firmar con `ed25519_sign` (v0 con `"version": 0`; la firma cubre el prefijo de versión); `solana_latest_blockhash(url)` devuelve el `recent_blockhash` como bytes(32), listo para enchufar. `solana_tx(msg, sig_o_lista)` → wire format; el broadcast es `solana_send(url, tx)` (base64 resuelto) y la confirmación `solana_confirm(url, signature)`. Un transfer del System Program es `program: "11111111111111111111111111111111"` con `data: int_to_bytes_le(2, 4) + int_to_bytes_le(lamports, 8)`.
- **Algorand:** `algorand_params(url)` lee los params sugeridos (`fee` por byte Y `min_fee`, `fv`/`lv`, `gh` como bytes, `gen`) directo al map del txn: `algorand_tx_encode({type, snd, rcv, amt, fee, fv, lv, gen, gh, note, …})` (las claves cortas del protocolo; las direcciones text se validan por checksum) → bytes para firmar con `ed25519_sign`. `algorand_tx(txn, sig)` → SignedTxn; `algorand_send(url, stx)` hace el POST `application/x-binary` por vos y `algorand_wait(url, txid)` confirma. El TXID es componible: `decode(sha512_256(algorand_tx_encode(txn)), "base32")`.
- **Tokens SPL de Solana:** `solana_pda(seeds, program)` → `{address, bump}` (findProgramAddress; la dirección queda off-curve, así que no puede existir clave privada para ella). `spl_ata(owner, mint)` deriva el associated token account (que es a su vez un PDA). `spl_transfer_checked_data(amount, decimals)` es la data de la instrucción — se mete en `solana_message` como cualquier otra. Todo puro.

## Custodia: generá una wallet, no pegues una clave

Hasta ahora la clave llegaba como un string hex en el `.env`. Ahora un agente **genera** una wallet desde una frase semilla, la respalda, y deriva cuentas para cada cadena — como Metamask o Phantom — con la misma seguridad estructural que firmar: todo es un `secret` que nunca se materializa, y crear custodia es **deny-by-default (`require wallet`)** y auditado (en `wallet.log`).

`wallet` es un permiso *aparte* de `sign`: **`wallet` crea claves, `sign` mueve valor.** Un agente puede derivar direcciones para reportar sin poder gastar nunca. Ambos con scope, auditados, y denegados dentro de `sandbox`.

```synsema
-- Doc example: HD custody. An agent generates a wallet from a seed phrase and
-- derives accounts for several chains — like Metamask/Phantom — but every result
-- is a `secret` that never materializes, creating custody is deny-by-default
-- (`require wallet`) and audited, and the phrase comes back only through a gated
-- `reveal`. No network: this is the offline custody core.
intent: "doc example: HD wallets / custody"
require wallet("W*")             -- scope: create custody only from secrets named W*
require reveal("W")

-- A known test mnemonic → its addresses match the reference SDKs (ethers, Phantom).
-- We use the canonical all-"abandon" phrase so the vectors are public.
let STD be "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"

-- Deriving from a secret OUTSIDE the wallet("W*") scope → denied (faithful scope,
-- like net(host) for HTTP). The wallet capability names the SOURCE secret.
task seed_from_unscoped_secret()
    give mnemonic_to_seed(as_secret(STD, "OTHER"))

-- A BIP-32 path can't derive ed25519: Solana is SLIP-0010 (hardened-only).
task derive_solana_with_bip32_path()
    let seed be mnemonic_to_seed(as_secret(STD, "W"))
    give hd_derive(seed, "m/44'/501'/0'/0", "ed25519")

test "custody from a secret outside the wallet scope is denied (deny-by-default)"
    assert_error(seed_from_unscoped_secret)

test "generate a fresh 12-word wallet — the phrase is a secret, backed up on purpose"
    let phrase be mnemonic_generate(12, "W")
    -- it is a secret: text()/print never show the words.
    assert(not contains(text(phrase), "abandon"))
    assert(starts_with(text(phrase), "secret("))
    -- reveal() (gated by reveal("W") + audited) backs it up deliberately.
    assert_eq(length(split(reveal(phrase), " ")), 12)

test "ETH: BIP-39 seed → BIP-32 m/44'/60'/0'/0/0 == the ethers vector"
    let seed be mnemonic_to_seed(as_secret(STD, "W"))
    let k be hd_derive(seed, "m/44'/60'/0'/0/0")
    assert_eq(eth_address(k), "0x9858EfFD232B4033E47d90003D41EC34EcaEda94")

test "Solana: same seed → SLIP-0010 m/44'/501'/0'/0' (ed25519) == Phantom's address 0"
    let seed be mnemonic_to_seed(as_secret(STD, "W"))
    let k be hd_derive(seed, "m/44'/501'/0'/0'", "ed25519")
    assert_eq(decode(ed25519_pubkey(k), "base58"), "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk")

test "the SLIP-0010 curve rejects a non-hardened index (Solana derivation is hardened-only)"
    assert_error(derive_solana_with_bip32_path)
```

```synsema
require wallet                                   -- crear custodia (aparte de `sign`)
let phrase be mnemonic_generate(12, "W")         -- secret: 12 palabras, entropía del SO (no sembrable)
let seed be mnemonic_to_seed(phrase)             -- secret: seed BIP-39 de 64 bytes (passphrase opcional)
let ethk be hd_derive(seed, "m/44'/60'/0'/0/0")  -- secret: BIP-32 secp256k1 (default) → eth_address/secp256k1_sign
let solk be hd_derive(seed, "m/44'/501'/0'/0'", "ed25519")  -- secret: SLIP-0010 (Solana; solo-hardened)
print(eth_address(ethk))                         -- derivar la dirección PÚBLICA (sin gate)
-- La frase de Algorand es su PROPIO formato de 25 palabras (NO es BIP-39):
let am be algorand_mnemonic(algo_secret32)       -- secret: 25 palabras (formato Pera/Defly)
-- Importar una wallet existente desde un keystore V3 de Geth/MyEtherWallet:
let k be keystore_import(json_text, secret("KS_PASS"), "HOT")  -- secret; pass incorrecta → error, sin fuga
```

Los secrets derivados llevan un **name derivado** para que los scopes queden ajustados: `mnemonic_to_seed` de un mnemónico `"W"` es `W.seed`, `hd_derive` es `W/path`. Concedé `reveal("W*")` (o el name exacto) para respaldar una frase; ese mismo name derivado es contra lo que scopean `wallet`/`sign`.

## El mundo dApp: contratos, logins, permits

Todo lo que un usuario de dApps hace a diario, un agente lo puede hacer — con la propiedad anti-blind-signing de que el typed-data es un **map legible** que podés pasar por `show`/`confirm` *antes* de firmar:

```synsema
-- calldata de un transfer ERC-20 (los montos uint256 son enteros exactos, nunca floats)
let data be abi_encode("transfer(address,uint256)", [destino, 1000000000000000000000000])
-- ...va en el campo `data` de la tx EIP-1559 que ya sabés construir.
-- Leer calldata de vuelta (auditar lo que estás por firmar):
let args be abi_decode("(address,uint256)", slice(data, 4, length(data)))

-- Login SIWE: el backend verifica con ecrecover
let digest be eip191_digest(mensaje_siwe)
let sig be secp256k1_sign(digest, k)
-- del lado del server: eth_address(secp256k1_recover(digest, sig)) == la dirección del usuario

-- Permit ERC-2612 (approval sin gas): maps legibles, digest, firma gateada
let domain be {"name": "USD Coin", "version": "2", "chainId": 1, "verifyingContract": usdc}
let types be {"Permit": [{"name": "owner", "type": "address"}, {"name": "spender", "type": "address"},
    {"name": "value", "type": "uint256"}, {"name": "nonce", "type": "uint256"}, {"name": "deadline", "type": "uint256"}]}
let permit be {"owner": mi_addr, "spender": router, "value": 1000000, "nonce": 0, "deadline": 1893456000}
let aprobado be confirm "¿Firmar permit " + json_encode(permit) + "?" within 15m
when aprobado
    let sig be secp256k1_sign(eip712_digest(domain, types, "Permit", permit), k)
```

`eip712_digest` soporta structs anidados, arrays y los campos opcionales del domain (orden fijo del EIP). Un campo faltante o **de más** en el message es error nombrando el campo — nunca firmás data que no leíste.

## Componer el flagship

El producto real es la composición, en un solo binario, que ningún otro lenguaje te da:

- **`cron`** corre tu monitoreo de verdad (un job programado que lee saldos vía `http_get` a tu RPC).
- **`chart()`** es negociado: el humano ve el dashboard SVG, un agente con `Accept: text/markdown` lee los mismos datos.
- **`confirm` / `approve`** gatean un movimiento de fondos *antes* de firmar y hacer broadcast.

```synsema
route "POST /enviar"
    let url be "https://rpc.example.com"
    let k be secret("HOT_KEY")
    -- LEER lo que la tx necesita (sin JSON-RPC a mano)
    let fees be eth_fee_history(url)
    let tx be tx_eip1559({"chain_id": eth_chain_id(url),
        "nonce": eth_nonce(url, eth_address(k)), "to": destino,
        "value": 100000000000000000, "gas": 21000,
        "max_fee": fees["base_fee"] * 2, "max_priority": fees["priority"]})
    -- MOSTRAR los fees ANTES de firmar (nada escondido dentro de un blob)
    let aprobado be confirm "¿Enviar 0.1 ETH, max fee " + text(tx["max_fee"]) + "?" within 15m
    when not aprobado
        give fail(403, "not approved")
    -- FIRMAR (la única puerta gateada) y ensamblar
    let sig be secp256k1_sign(tx["digest"], k)
    -- ENVIAR y CONFIRMAR (acotado — una tx atascada jamás cuelga la ruta)
    let hash be eth_send_raw(url, tx_eip1559_raw(tx, sig))
    let receipt be eth_wait_receipt(url, hash, 1, 120)
    give when receipt == nothing then fail(504, "not confirmed in time") otherwise receipt
```

La clave nunca se materializó; firmar exigió `sign("HOT_KEY")` y quedó auditado; leer, difundir y confirmar pasaron por `net`; los fees pasaron por un `confirm` humano antes de que la firma existiera.

> **Nota sobre firma programada/en agentes:** un `secret` del top-level se *redacta* al cruzar a un job de `cron` o a un agente spawneado (seguro — sin fuga — pero inusable ahí). Resolvé la clave **dentro** del task: `let k be secret("HOT_KEY")` (o `as_secret(...)`) en el cuerpo del job, exactamente como hace la ruta flagship.

Para **feeds en vivo** — suscribirse a un RPC, a un exchange, a la mempool — Synsema tiene un cliente WebSocket general (`ws_connect`/`ws_send`/`ws_recv`/`ws_close`, gateado por `net`), no específico de blockchain. `eth_subscribe` (newHeads/logs) se compone hoy en userland: `ws_connect` al endpoint WS del nodo, enviar el frame de subscribe, `ws_recv` las notificaciones. Ver **[WebSocket](/es/0.6.x/39-websocket)**.

## Todavía no (documentado, no es deuda)

Las **address lookup tables** de Solana (pasar `lookup_tables` da error claro), la serialización de Avalanche X/P y las suscripciones WS tipadas (helpers de `eth_subscribe` — componibles hoy en userland, ver arriba) son los próximos pasos. **Bitcoin ya está** — su propia matriz UTXO (hash160, sighash BIP-143/341, builder G28, firma Schnorr taproot, read-side Esplora, PSBT): ver **[Bitcoin](/es/0.6.x/38a-bitcoin)**. El loop completo — leer (nonce/fees/saldos/`eth_call`/receipts, blockhash/confirm de Solana, params/wait de algod), construir (`tx_eip1559`), firmar, enviar y confirmar — más las llamadas a contratos, las firmas de dApp (191/712), los tokens SPL, la custodia HD (BIP-39/32, SLIP-0010, Algorand-25, keystore V3) y un cliente WebSocket en vivo salen hoy.
