Synsemadocsv0.6.xENES

Biblioteca estándar

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

Operá on-chain — leer estado, construir, firmar, difundir y confirmar transacciones, deployar contratos y leer sus eventos — 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, logs, 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.

Los nombres siguen <familia>_<acción>: evm_ para toda cadena EVM (Ethereum, Avalanche C-Chain, las L2), solana_, algorand_, btc_ (Bitcoin). La acción dice qué obtenés: tx construye los bytes a firmar, tx_raw ensambla la transacción firmada, y después send, wait, address. Los nombres de antes de v0.6.29 (eth_nonce, tx_eip1559, solana_message, algorand_tx_encode, …) siguen andando como alias deprecados hasta v1.0 — la tabla está al final de esta página.

blockchain.syn
-- 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. Building, deriving and verifying are pure. No network — this is the
-- offline core; reading and broadcasting are the net-gated evm_* / solana_* / algorand_*.
intent: "doc example: blockchain signing"
require sign("HOT_KEY")
-- An explicit port is part of the grant: this covers 127.0.0.1:2 and nothing else.
require net("127.0.0.1:2")
-- v0.6.46+: an RPC key in the URL is a secret bound to the node's host
require secret("RPC_KEY") for net("127.0.0.1:2")
require secret("UNBOUND_KEY")

test "an EVM 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(evm_address(k), "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf")
    -- evm_address also takes 20 raw bytes (and a pubkey, or "0x…" text) → EIP-55 text
    assert_eq(evm_address(bytes("7e5f4552091a69125d5dfcb7b8c2659029395bdf", "hex")),
        "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf")

    -- An EIP-1559 signing payload by hand: 0x02 || rlp([chainId, nonce, ...]).
    -- (evm_tx below does this for you; here the bytes are in plain view.)
    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(evm_address(pub), "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf")
    assert(secp256k1_verify(digest, sig, secp256k1_pubkey(k)))

    -- Assemble the signed raw tx by hand: 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", 10**24])
    assert_eq(decode(slice(data, 0, 4), "hex"), "a9059cbb")
    assert_eq(abi_selector("transfer(address,uint256)"), slice(data, 0, 4))
    -- 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], 10**24)
    -- 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 evm_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)
    -- secp256k1_sign gives the raw recovery id v = 0/1; contracts and wallets write 27/28.
    assert(sig[64] == 0 or sig[64] == 1)
    let wallet_sig be evm_signature(sig)
    assert_eq(wallet_sig[64], sig[64] + 27)
    assert_eq(slice(wallet_sig, 0, 64), slice(sig, 0, 64))
    -- idempotent: a signature that already carries 27/28 comes back unchanged
    assert_eq(evm_signature(wallet_sig), wallet_sig)
    -- recover takes BOTH conventions: a wallet's 27/28 signature needs no conversion
    assert_eq(evm_address(secp256k1_recover(d712, sig)), quien)
    assert_eq(evm_address(secp256k1_recover(d712, wallet_sig)), quien)
    -- a legacy EIP-155 transaction v (chain_id * 2 + 35 + parity) is not a message signature
    assert_error(() => secp256k1_recover(d712, slice(sig, 0, 64) + bytes([37])))
    -- any other v is refused by evm_signature too
    assert_error(() => evm_signature(slice(sig, 0, 64) + bytes([5])))

test "Solana and Algorand are transactable: solana_tx / algorand_tx build, *_tx_raw assemble"
    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 params be {
        "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 msg be solana_tx(params)
    -- the signed wire tx: one signature, or a list of them (one per signer, in order)
    let tx be solana_tx_raw(msg, ed25519_sign(msg, k))
    assert_eq(tx, solana_tx_raw(msg, [ed25519_sign(msg, k)]))
    -- broadcast: solana_send(url, tx) (base64 handled)
    assert(length(tx) > length(msg))
    -- a v0 message carries the 0x80 version prefix, and the signature covers it
    let msg0 be solana_tx(merge(params, {"version": 0}))
    assert_eq(msg0[0], 128)
    -- address lookup tables are not supported yet: a directed error, not a silent drop
    assert_error(() => solana_tx(merge(params, {"version": 0, "lookup_tables": []})))
    -- The old two-argument form of solana_tx errors pointing to solana_tx_raw.
    assert_error(() => solana_tx(msg, ed25519_sign(msg, k)))

    -- Algorand pay: protocol short keys; canonical msgpack (zero fields omitted).
    let txn be {"type": "pay", "snd": algorand_address(k), "rcv": payer,
        "amt": 123456, "fee": 1000, "fv": 1000, "lv": 2000,
        "gh": bytes("0707070707070707070707070707070707070707070707070707070707070707", "hex")}
    assert_eq(algorand_address(k), "JS22X5VNPH57LK54ZL6ME2OYLTJGKHWUXCC3LBU7EQNO34FFXIUYKBWSCU")
    assert_eq(algorand_address(payer), algorand_address(k))
    let to_sign be algorand_tx(txn)
    assert_eq(slice(to_sign, 0, 2), bytes("TX", "utf8"))
    let stx be algorand_tx_raw(txn, ed25519_sign(to_sign, k))
    assert(length(stx) > length(to_sign))
    -- the TXID is composable: base32(sha512_256(the bytes to sign))
    assert_eq(decode(sha512_256(to_sign), "base32"), "IOXWDKGDSRDOQ36CY4LXE3GWKPATSMJMMZEDCGKEX7R6JHXUNWMQ")
    -- The old two-argument form of algorand_tx errors pointing to algorand_tx_raw.
    assert_error(() => algorand_tx(txn, ed25519_sign(to_sign, k)))

test "evm_tx 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 evm_tx({"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 evm_tx_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")
    -- evm_tx_raw recomputes the digest from `fields`: a map edited after evm_tx is refused
    let edited be merge(tx, {"value": 999})
    assert_error(() => evm_tx_raw(edited, sig))

test "data and access_list: a contract call inside the same builder"
    let tx be evm_tx({"chain_id": 1, "nonce": 7,
        "to": "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0", "value": 0, "gas": 60000,
        "max_fee": 30000000000, "max_priority": 1500000000,
        "data": abi_encode("transfer(address,uint256)", ["0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf", 5]),
        "access_list": [{"address": "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0",
            "storage_keys": [int_to_bytes(0, 32)]}]})
    -- fields = [chain_id, nonce, max_priority, max_fee, gas, to, value, data, access_list]
    assert_eq(slice(tx.fields[7], 0, 4), abi_selector("transfer(address,uint256)"))
    assert_eq(length(tx.fields[8]), 1)

test "no fee is ever invented: a missing field errors naming the reader"
    let failed be ""
    try
        let tx be evm_tx({"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, "evm_fee_history"))
    -- no `to` is not a deployment by accident: the error points to evm_tx_create
    set failed to ""
    try
        let tx be evm_tx({"chain_id": 1, "nonce": 0, "value": 0, "gas": 21000,
            "max_fee": 2, "max_priority": 1})
    recover e
        set failed to e
    assert(contains(failed, "evm_tx_create"))

test "deploy a contract: evm_tx_create knows where it lands and who must sign"
    let k be as_secret("0000000000000000000000000000000000000000000000000000000000000001", "HOT_KEY")
    let me be evm_address(k)
    -- init code = the compiled bytecode ‖ the constructor arguments, ABI-encoded WITHOUT a selector
    let bytecode be bytes("6080604052", "hex")
    let ctor be abi_encode("(address,uint256)", [me, 10**24])
    assert_eq(ctor, abi_encode(["address", "uint256"], [me, 10**24]))
    assert_eq(abi_decode("(address,uint256)", ctor), [me, 10**24])
    let ctx be evm_tx_create({"chain_id": 1, "nonce": 3, "from": me, "value": 0,
        "gas": 1500000, "max_fee": 30000000000, "max_priority": 1000000000,
        "data": bytecode + ctor})
    assert_eq(ctx.to, nothing)
    -- CREATE: keccak256(rlp([sender, nonce]))[12:] — the same address, computed by hand
    assert_eq(ctx.contract_address, evm_create_address(me, 3))
    assert_eq(ctx.contract_address,
        evm_address(slice(keccak256(rlp_encode([bytes(slice(me, 2), "hex"), 3])), 12, 32)))
    let raw be evm_tx_raw(ctx, secp256k1_sign(ctx.digest, k))
    assert_eq(raw[0], 2)
    -- a signature from any other key is refused: the address shown would be wrong
    let other be as_secret("0000000000000000000000000000000000000000000000000000000000000002", "HOT_KEY")
    assert_error(() => evm_tx_raw(ctx, secp256k1_sign(ctx.digest, other)))
    -- a creation has no `to`, and its init code cannot be empty
    let params be {"chain_id": 1, "nonce": 3, "from": me, "value": 0,
        "gas": 1500000, "max_fee": 30000000000, "max_priority": 1000000000, "data": bytecode}
    assert_error(() => evm_tx_create(merge(params, {"to": me})))
    assert_error(() => evm_tx_create(merge(params, {"data": bytes([])})))
    -- no `from`: the contract address depends on who deploys it
    assert_error(() => evm_tx_create(remove(params, "from")))

test "CREATE and CREATE2 addresses match the published vectors"
    -- CREATE (sender + nonce)
    assert_eq(evm_create_address("0x6ac7ea33f8831ea9dcc53393aaa88b25a785dbf0", 0),
        "0xcd234A471b72ba2F1Ccf0A70FCABA648a5eeCD8d")
    assert_eq(evm_create_address("0x6ac7ea33f8831ea9dcc53393aaa88b25a785dbf0", 1),
        "0x343c43A37D37dfF08AE8C4A11544c718AbB4fCF8")
    -- CREATE2 (EIP-1014 example 0): deployer, 32-byte salt, keccak256 of the init code
    assert_eq(evm_create2_address("0x0000000000000000000000000000000000000000",
        int_to_bytes(0, 32), keccak256(bytes([0]))),
        "0x4D1A2e2bB4F88F0250f26Ffff098B0b30B26BF38")
    -- EIP-2681: a nonce of 2^64 − 1 or more cannot exist
    assert_error(() => evm_create_address("0x6ac7ea33f8831ea9dcc53393aaa88b25a785dbf0", 2**64 - 1))

test "events: the topic of an event and a log decoded by parameter name"
    -- the event fragment, as it appears in the contract's ABI JSON
    let transfer be {"name": "Transfer", "inputs": [
        {"name": "from", "type": "address", "indexed": true},
        {"name": "to", "type": "address", "indexed": true},
        {"name": "value", "type": "uint256", "indexed": false}]}
    let topic be abi_event_topic(transfer)
    assert_eq(topic, "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")
    assert_eq(abi_event_topic("Transfer(address,address,uint256)"), topic)
    -- a log as evm_logs / evm_receipt give it: indexed args in topics, the rest in data
    let log be {"address": "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0",
        "topics": [topic,
            "0x0000000000000000000000007e5f4552091a69125d5dfcb7b8c2659029395bdf",
            "0x0000000000000000000000005aaeb6053f3e94c9b9a09f33669435e7ef1beaed"],
        "data": abi_encode("(uint256)", [1000])}
    let ev be abi_decode_log(transfer, log)
    assert_eq(ev.from, "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf")
    assert_eq(ev.to, "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed")
    assert_eq(ev.value, 1000)
    -- a log of another event: an error by default, or the default you pass
    let approval be merge(log, {"topics": [abi_event_topic("Approval(address,address,uint256)"),
        log.topics[1], log.topics[2]]})
    assert_error(() => abi_decode_log(transfer, approval))
    assert_eq(abi_decode_log(transfer, approval, nothing), nothing)

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

test "the read side validates its arguments before any request"
    let url be "http://127.0.0.1:2"
    let t be abi_event_topic("Transfer(address,address,uint256)")
    -- evm_logs: a log has at most 4 topics
    assert_error(() => evm_logs(url, {"topics": [t, nothing, nothing, nothing, nothing]}))
    -- evm_logs: blockHash OR fromBlock/toBlock, never both
    assert_error(() => evm_logs(url, {"blockHash": t, "fromBlock": "latest"}))
    -- a block is a tag, a number or the node's canonical "0x…" quantity (hex(n) gives one)
    let me be "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"
    assert_error(() => evm_balance(url, me, "0x01"))
    assert_error(() => evm_balance(url, me, "yesterday"))
    -- evm_fee_history(url, blocks?, percentiles?, newest?): 1..1024 blocks, percentiles 0..100
    assert_error(() => evm_fee_history(url, 0))
    assert_error(() => evm_fee_history(url, 5, [10, 200]))

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 plain Transfer (tag 3 ‖ amount u64 LE)
    assert_eq(decode(spl_transfer_data(1000000), "hex"), "0340420f0000000000")
    -- 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)

test "an API key in the RPC URL: bound to the host, never in the params (engine v0.6.46+)"
    let k be secret("RPC_KEY", "rk-demo-123")
    let url be `http://127.0.0.1:2/v2/{k}`                   -- loopback: http allowed for a bound secret
    let failed be ""
    try
        evm_rpc(url, "eth_chainId", [k])
    recover e
        set failed to e
    assert(contains(failed, "params[0] is a secret — a secret never goes into an RPC request"))
    let u be secret("UNBOUND_KEY", "uk-demo-456")
    set failed to ""
    try
        evm_chain_id(`http://127.0.0.1:2/v2/{u}`)
    recover e
        set failed to e
    assert(contains(failed, "an unbound secret only goes in a header"))
    assert(not contains(failed, "uk-demo-456"))

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

Qué te da el lenguaje§

BuiltinPara qué¿Gateado?
keccak256(x) / sha512_256(x)hashes de Ethereum / Algorand → bytes(32)puro
bytes(text, "base58"/"base32"), decode(b, …)encoding de direcciones Solana / Algorandpuro
bech32_encode/decodeAvalanche X/P, segwit (bech32 y bech32m)puro
secp256k1_sign(digest, secret)firma EVM → bytes(65) r‖s‖v, v = 0/1require sign
secp256k1_verify / recover / pubkeyverificar, ecrecover (v = 0/1 o 27/28), derivar pubkeypuro
ed25519_sign(message, secret)firma Solana/Algorand → bytes(64)require sign
ed25519_verify / pubkeyverificar, derivar pubkeypuro
evm_address(x)dirección con checksum EIP-55; x = una pubkey, un secret de clave, 20 bytes crudos o texto "0x…"puro
evm_signature(sig)la misma firma con v = 27/28 (lo que usan contratos y wallets)puro
rlp_encode / rlp_decodeencoding de tx (legacy + EIP-1559); decode es canónico-estrictopuro
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_encode(types, values)los argumentos solos, sin selector — argumentos del constructor (types = "(address,uint256)" o ["address", "uint256"])puro
abi_selector(sig)selector bytes(4) de una firma canónicapuro
abi_event_topic(event) / abi_decode_log(event, log, default?)el topic 0 de un evento ("0x…"); un log decodificado a un map por nombre de parámetropuro
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
evm_tx(params) / evm_tx_raw(tx, sig)builder EIP-1559: {digest, fields, + eco de cada número}; la raw firmada (v/r/s resueltos, el map re-chequeado)puro
evm_tx_create(params)deploy de contratos: el map de evm_tx más from y contract_addresspuro
evm_create_address(sender, nonce) / evm_create2_address(deployer, salt, init_code_hash)dónde cae un deploy CREATE / CREATE2 (EIP-1014) → texto EIP-55puro
solana_tx(params) / solana_tx_raw(msg, sigs)message de Solana (legacy + v0) listo para ed25519_sign; la tx wire lista para solana_sendpuro
algorand_tx(txn) / algorand_tx_raw(txn, sig)"TX"‖msgpack canónico listo para ed25519_sign; el SignedTxn listo para algorand_sendpuro
algorand_address(pubkey_or_secret)dirección base32 de Algorand con checksumpuro
solana_pda(seeds, program) / spl_ata(owner, mint, token_program?)dirección derivada de programa Solana (off-curve) / associated token accountpuro
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_derivegenerar una wallet, derivar claves (BIP-39/BIP-32/SLIP-0010) → cada una un secretrequire wallet
algorand_mnemonic / algorand_mnemonic_to_keyfrase de 25 palabras de Algorand ↔ clave (NO es BIP-39) → secretrequire wallet
keystore_import / keystore_exportimportar/exportar un keystore V3 de Geth/MyEtherWalletrequire wallet
evm_rpc / evm_block_number / evm_nonce / evm_balance / evm_gas_price / evm_chain_id / evm_estimate_gas / evm_call / evm_fee_history / evm_logslado de lectura EVM (hex-quantities resueltos, estricto)require net
evm_send / evm_receipt / evm_waitbroadcast + receipt tipado + espera de confirmación acotadarequire net
solana_rpc / solana_latest_blockhash / solana_balance / spl_balancelado de lectura Solana (blockhash como bytes(32), lamports exactos, saldo SPL vía el ATA derivado)require net
solana_send / solana_waitbroadcast (base64 resuelto) + espera de status acotadarequire net
algorand_params / algorand_account / algorand_send / algorand_waitREST de algod: params sugeridos (fee POR BYTE y min_fee), cuenta, broadcast binario, espera acotadarequire net

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

Un agente que firma transacciones byte-perfectas igual necesita el nonce, los fees EIP-1559, el chain id o el recent_blockhash — y hand-rollear JSON-RPC sobre http_post para leerlos es codificar hex-quantities a mano y decodificar resultados a la defensiva. Justo ahí se mueren los tokens y la corrección, así que el lenguaje lo absorbió:

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

let url be "https://rpc.example.com"
let k be secret("HOT_KEY")
-- READ what the tx needs
let nonce be evm_nonce(url, evm_address(k))       -- eth_getTransactionCount ("pending")
let fees be evm_fee_history(url)                  -- {base_fee, priority, base_fees, rewards}
-- BUILD: every value-moving field explicit (a missing one errors naming the reader)
let tx be evm_tx({"chain_id": evm_chain_id(url), "nonce": nonce, "to": dest,
    "value": 10**17, "gas": 21000,               -- 0.1 ETH in wei: 10**17, never 1e17 (a float)
    "max_fee": fees["base_fee"] * 2 + fees["priority"], "max_priority": fees["priority"]})
-- tx echoes the numbers (tx.max_fee, tx.value, tx.to) — `confirm` them BEFORE signing
let sig be secp256k1_sign(tx.digest, k)           -- the ONE gated door
let raw be evm_tx_raw(tx, sig)                    -- v/r/s assembled for you
-- SEND and CONFIRM
let hash be evm_send(url, raw)                    -- "0x…" tx hash
let receipt be evm_wait(url, hash, 1, 120)        -- nothing on timeout — never hangs
-- receipt["status"]: 1 = success, 0 = REVERTED (it landed but failed — always check)

-- Read contract state: evm_call returns RAW bytes → abi_decode (90% of contract reads)
let calldata be abi_encode("balanceOf(address)", [owner])
let bal be abi_decode("uint256", evm_call(url, {"to": token, "data": calldata}))[0]

Los lectores EVM, con cada argumento opcional:

El decode del receipt es tipado: quantities → enteros, direcciones → texto EIP-55, hashes → texto "0x…", la data de un log → bytes.

Solana replica el loop (solana_latest_blockhash → solana_tx → ed25519_sign → solana_tx_raw → solana_send → solana_wait; solana_balance/spl_balance para monitorear), y Algorand también (algorand_params → map del txn → algorand_tx → firmar → algorand_tx_raw → algorand_send (el POST binario está resuelto) → algorand_wait). Su escotilla de escape es solana_rpc(url, method, params?).

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

Una API key en la URL del RPC (motor v0.6.46+). La mayoría de los proveedores pone la clave en el path. Tenela como secret y ligala al host del proveedor; entonces la URL que la contiene funciona en evm_rpc y los demás builtins JSON-RPC, las llamadas a algod (algorand_params, algorand_account, …), btc_rpc y ws_connect:

require secret("RPC_KEY") for net("eth-mainnet.example.com")   -- the key may go only there
require net("eth-mainnet.example.com")
let k be secret("RPC_KEY")
let url be `https://eth-mainnet.example.com/v2/{k}`             -- a secret: the key is written at the socket
print(evm_rpc(url, "eth_chainId"))

Sin la ligadura la llamada es el error evm_rpc: the URL is secret(K), and an unbound secret only goes in a header. If the API takes it in the URL, bind it to the host it goes to: require secret("K") for net("…")`. La clave va sólo a los hosts de la ligadura y sólo por https/wss (reglas en Secretos); un error JSON-RPC que la devuelve vuelve enmascarado (RPC error -32000: bad key [secret K]). Los params de una llamada siguen sin poder llevar un secret: evm_rpc: params[0] is a secret — a secret never goes into an RPC request. Y con un permiso net con comodín, un nodo en una dirección privada (127.0.0.1, 10.x, una devnet local) tiene que nombrarse — require net("127.0.0.1:8545")` — ver Cliente HTTP.

Construir una transacción EVM: evm_tx§

evm_tx(params) toma {chain_id, nonce, to, value, gas, max_fee, max_priority, data?, access_list?}. Cada campo que mueve valor es explícito — uno faltante es un error que dice de dónde leerlo (missing "max_fee" … read it with evm_fee_history(url) or state it), y un to faltante apunta a evm_tx_create. data es el calldata de una llamada a contrato (abi_encode(...)); access_list es una lista de {address, storage_keys} (EIP-2930; cualquier otra clave es error). El resultado es {digest, fields, + eco de cada número}: digest es lo que firmás, fields la transacción tal como se va a codificar, y el eco (tx.to, tx.value, tx.max_fee, …) es lo que un confirm muestra antes de que exista la firma.

evm_tx_raw(tx, sig) ensambla 0x02 ‖ rlp(fields + [v, r, s]) — y antes re-chequea el map: recalcula el digest desde fields y compara cada número del eco contra ellos. Un map editado después de evm_tx se rechaza ("value" does not match "fields" — the map was modified after evm_tx/evm_tx_create; build it again instead of editing it): lo que confirmaste es lo que se firma.

Deployar un contrato: evm_tx_create§

Un deploy es una transacción sin to cuya data es el init code del contrato: el bytecode compilado seguido de los argumentos del constructor, codificados en ABI sin selector — abi_encode(types, values), la inversa de abi_decode.

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

let url be "https://rpc.example.com"
let k be secret("HOT_KEY")
let me be evm_address(k)
let fees be evm_fee_history(url)
-- init code = bytecode ‖ constructor args (no selector); types as "(…)" or a list
let init be bytecode + abi_encode("(address,uint256)", [owner, 10**24])
let ctx be evm_tx_create({"chain_id": evm_chain_id(url), "nonce": evm_nonce(url, me),
    "from": me, "value": 0, "gas": 1500000,
    "max_fee": fees["base_fee"] * 2 + fees["priority"], "max_priority": fees["priority"],
    "data": init})
print(ctx.contract_address)          -- where it lands: == evm_create_address(me, ctx.nonce)
let hash be evm_send(url, evm_tx_raw(ctx, secp256k1_sign(ctx.digest, k)))
let receipt be evm_wait(url, hash, 1, 120)
-- receipt["contractAddress"] is the same address, and receipt["status"] == 1 means it deployed

evm_tx_create({chain_id, nonce, from, value, gas, max_fee, max_priority, data, access_list?}) devuelve el map de evm_tx con to: nothing, from y contract_address (EIP-55) — calculada antes de firmar nada, así la podés mostrar en el confirm. Rechaza un to, una data vacía y un init code de más de 49 152 bytes (EIP-3860). Después evm_tx_raw recupera al firmante: una firma de cualquier clave que no sea la de from es error, porque la dirección que mostraste estaría mal; un map de creación sin from se rechaza, y contract_address se deriva de nuevo y tiene que coincidir. La dirección vale mientras el nonce sea el que usa la cadena — leelo con evm_nonce justo antes.

Las dos fórmulas de dirección también son builtins puros:

Eventos: logs por nombre de parámetro§

Un contrato cuenta lo que pasó con eventos. Los leés con evm_logs(url, filter) y decodificás cada uno con el fragmento del evento — la entrada del ABI JSON del contrato, tal como la escribe el compilador:

require net("rpc.example.com")

let url be "https://rpc.example.com"
let transfer be {"name": "Transfer", "inputs": [
    {"name": "from", "type": "address", "indexed": true},
    {"name": "to", "type": "address", "indexed": true},
    {"name": "value", "type": "uint256", "indexed": false}]}
let head be evm_block_number(url)
let logs be evm_logs(url, {"address": token, "topics": [abi_event_topic(transfer)],
    "fromBlock": hex(head - 1000), "toBlock": "latest"})
each lg in logs
    let ev be abi_decode_log(transfer, lg)    -- {from, to, value}
    print(ev.from + " → " + ev.to + ": " + text(ev.value))

abi_decode_log funciona igual sobre los logs de un receipt (evm_receipt, evm_wait) y sobre las notificaciones de una suscripción en vivo (ver WebSocket).

L2 EVM: Base, Arbitrum, Optimism, Polygon — de fábrica§

Una L2 es el mismo wire EVM: apuntá la url al RPC de la L2 y todo lo de arriba — evm_nonce, evm_fee_history, evm_tx, evm_call, evm_wait — anda sin cambios. La única regla: leé el chain id del nodo (evm_chain_id(url)), nunca 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 de replay de EIP-155.

Una salvedad honesta sobre el costo: en las cadenas OP-stack (Base, Optimism) evm_estimate_gas cubre sólo la ejecución L2 — la transacción además paga un fee de datos L1 que el nodo suma al incluirla. Llega en el receipt como l1Fee / l1GasUsed / l1GasPrice (decodificados a enteros exactos como cualquier otra quantity), 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.

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

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

Tu instintoLa 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. evm_tx_raw lo hace por vos; a mano, bytes_to_int(slice(sig, 0, 32)), e int_to_bytes(n, 32) restaura el ancho fijo.
"v es v"Dos convenciones. secp256k1_sign da el recovery id crudo, 0/1 (lo que quiere evm_tx_raw); contratos y wallets usan 27/28 — evm_signature(sig) convierte. secp256k1_recover acepta las dos. Ver más abajo.
"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. Un monto uint256 necesita un entero exacto: 1e24 es un literal float (como en Python) y se rechaza; escribí 10**24 o 1_000_000_000_000_000_000_000_000, o int("…") desde texto (int("0x…") para la quantity de un nodo). Dividí montos con // (exacto), nunca con / (un float).
"evm_tx sin to deploya un contrato"No. Un to faltante es un error que apunta a evm_tx_create, que calcula contract_address desde from + nonce y rechaza la firma de cualquier otra clave — la dirección que imprimís es la que usa la cadena.
"puedo retocar el map de la tx después de construirla"No. evm_tx_raw recalcula el digest desde fields y chequea cada número del eco: un map editado se rechaza. Construilo de nuevo.
"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 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_tx 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. EVM 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. Un grant con puerto cubre sólo ese puerto: require net("http://localhost:8545") es net("localhost:8545").
"evm_tx 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 — un max_fee/gas/nonce/chain_id faltante errora nombrando el lector (evm_fee_history/evm_estimate_gas/evm_nonce/evm_chain_id) — 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.
"evm_wait bloquea hasta que confirme"No. Los tres waiters (evm_wait/solana_wait/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§

CadenaCurvaHash de firmaDirección
Ethereum y L2 EVMsecp256k1keccak256hex EIP-55 + RLP/EIP-1559
Avalanche C-Chainsecp256k1keccak256idéntica a Ethereum (es EVM)
Avalanche X/Psecp256k1sha256bech32
Solanaed25519— (interno)base58
Algoranded25519sha512_256base32

Toda cadena EVM, 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 (eth-account, solana-sdk/solders, algosdk). Solana además tiene tokens SPL (PDAs, associated accounts, TransferChecked), y todas tienen custodia HD (más abajo).

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.

wallet-hd.syn
-- 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(evm_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)
require wallet                                   -- creating custody (separate from `sign`)
let phrase be mnemonic_generate(12, "W")         -- secret: 12 words, OS entropy (not seedable)
let seed be mnemonic_to_seed(phrase)             -- secret: 64-byte BIP-39 seed (optional passphrase)
let ethk be hd_derive(seed, "m/44'/60'/0'/0/0")  -- secret: BIP-32 secp256k1 (default) → evm_address/secp256k1_sign
let solk be hd_derive(seed, "m/44'/501'/0'/0'", "ed25519")  -- secret: SLIP-0010 (Solana; hardened-only)
print(evm_address(ethk))                         -- derive the PUBLIC address (no gate)
-- Algorand's phrase is its OWN 25-word format (NOT BIP-39):
let am be algorand_mnemonic(algo_secret32)       -- secret: 25 words (Pera/Defly format)
-- Import an existing wallet from a Geth/MyEtherWallet keystore V3:
let k be keystore_import(json_text, secret("KS_PASS"), "HOT")  -- secret; wrong pass → error, no leak

Los secrets derivados llevan un nombre derivado para que los scopes queden ajustados: mnemonic_to_seed de un mnemónico "W" es W.seed, hd_derive es W/path. Otorgá reveal("W*") (o el nombre exacto) para respaldar una frase; ese mismo nombre derivado es contra el que hacen scope wallet/sign.

El mundo dApp: contratos, logins, permits§

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

-- ERC-20 transfer calldata (uint256 amounts are exact big integers, never floats)
let data be abi_encode("transfer(address,uint256)", [dest, 10**24])
-- ...goes into the `data` field of the evm_tx you already know how to build.
-- Reading calldata back (audit what you're about to sign):
let args be abi_decode("(address,uint256)", slice(data, 4, length(data)))

-- SIWE login: the backend verifies with ecrecover
let digest be eip191_digest(siwe_message)
let sig be secp256k1_sign(digest, k)
-- server side: evm_address(secp256k1_recover(digest, sig)) == the user's address
-- (a real wallet's signature carries v = 27/28: secp256k1_recover takes it as is)

-- ERC-2612 permit (gasless approval): readable maps in, digest out, gated signing
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": my_addr, "spender": router, "value": 1000000, "nonce": 0, "deadline": 1893456000}
let approved be confirm "Sign permit for " + json_encode(permit) + "?" within 15m
when approved
    let sig be evm_signature(secp256k1_sign(eip712_digest(domain, types, "Permit", permit), k))
    -- the contract's permit(v, r, s): v = sig[64] (27/28), r = slice(sig, 0, 32), s = slice(sig, 32, 64)

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.

El byte de recuperación v tiene dos convenciones, y te vas a cruzar con las dos. secp256k1_sign devuelve el recovery id crudo, 0/1 — lo que usan crypto.Sign de go-ethereum, libsecp256k1 y las transacciones tipadas EIP-1559 (evm_tx_raw quiere exactamente eso). Todo lo que verifica un contrato — ecrecover, ECDSA.recover de OpenZeppelin, un permit EIP-2612, una autorización EIP-712 — y toda wallet (MetaMask, ethers, viem: personal_sign, signTypedData) lo escriben como 27/28. evm_signature(sig) convierte una firma de Synsema para un contrato: suma 27, es idempotente sobre una firma que ya trae 27/28, y rechaza cualquier otro v. Al revés no hace falta nada: secp256k1_recover acepta 0, 1, 27 y 28 (un v de 35 o más es el valor de una transacción legacy EIP-155, y el error lo dice); secp256k1_verify ignora v. Verificado contra un ProcessorEndpoint vivo y un permit: con el byte crudo el contrato revierte, con 27/28 recupera al firmante.

Componiendo el flagship§

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

require serve(8080)
require net("rpc.example.com")
require sign("HOT_KEY")

serve on 8080
    route "POST /send"
        let url be "https://rpc.example.com"
        let k be secret("HOT_KEY")
        -- READ what the tx needs (no hand-rolled JSON-RPC)
        let fees be evm_fee_history(url)
        let tx be evm_tx({"chain_id": evm_chain_id(url),
            "nonce": evm_nonce(url, evm_address(k)), "to": dest,
            "value": 10**17, "gas": 21000,
            "max_fee": fees["base_fee"] * 2 + fees["priority"], "max_priority": fees["priority"]})
        -- SHOW the fees BEFORE signing (nothing hidden inside a blob)
        let approved be confirm "Send 0.1 ETH, max fee " + text(tx.max_fee) + "?" within 15m
        when not approved
            give fail(403, "not approved")
        -- SIGN (the one gated door) and assemble
        let sig be secp256k1_sign(tx.digest, k)
        -- SEND and CONFIRM (bounded — a stuck tx never hangs the route)
        let hash be evm_send(url, evm_tx_raw(tx, sig))
        let receipt be evm_wait(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 existiera la firma.

Nota sobre firmar desde cron/agentes: un secret de nivel superior se redacta cuando cruza a un job de cron o a un agente spawneado (seguro — no filtra — pero inutilizable ahí). Resolvé la clave dentro de la 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, un exchange, un mempool — Synsema tiene un cliente WebSocket general (ws_connect/ws_send/ws_recv/ws_close, gateado por net), no específico de blockchain. El eth_subscribe (newHeads/logs) de un nodo EVM se compone en userland hoy: ws_connect al endpoint WS del nodo, mandar el frame de suscripción, ws_recv las notificaciones y decodificar cada log con abi_decode_log. Ver WebSocket.

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

Las address lookup tables de Solana (pasar lookup_tables da un error claro), la serialización de Avalanche X/P y las suscripciones WS tipadas (helpers de eth_subscribe — componibles en userland hoy, ver arriba) son lo que viene. Bitcoin ya salió — su propia matriz UTXO (hash160, sighash BIP-143/341, el builder G28, firma Schnorr taproot, el lado de lectura Esplora, PSBT): ver Bitcoin. El loop completo — leer (nonce/fees/saldos/evm_call/logs/receipts, blockhash/status de Solana, params/wait de algod), construir (evm_tx, evm_tx_create), firmar, enviar y confirmar — más deploy de contratos y eventos, firmas de dApp (191/712), tokens SPL, custodia HD (BIP-39/32, SLIP-0010, Algorand-25, keystore V3) y un cliente WebSocket en vivo salen hoy.

Renombrados en v0.6.29§

Los nombres viejos siguen andando hasta v1.0: un programa que usa uno imprime un único aviso en stderr, y synsema check marca cada uso. Dos cambiaron de significado — leé esas filas antes de un buscar-y-reemplazar.

Antes de v0.6.29AhoraNota
eth_rpc · eth_nonce · eth_balance · eth_gas_price · eth_chain_id · eth_estimate_gas · eth_call · eth_fee_historyevm_rpc · evm_nonce · evm_balance · evm_gas_price · evm_chain_id · evm_estimate_gas · evm_call · evm_fee_historylos nombres de método JSON-RPC que le pasás a evm_rpc siguen siendo "eth_call", "eth_getLogs", …
eth_send_raw · eth_receipt · eth_wait_receipt · eth_addressevm_send · evm_receipt · evm_wait · evm_address
tx_eip1559 · tx_eip1559_rawevm_tx · evm_tx_rawla creación de contratos es evm_tx_create
solana_message(params)solana_tx(params)⚠️ solana_tx ahora construye el message
solana_tx(message, signatures)solana_tx_raw(message, signatures)⚠️ el solana_tx viejo de dos argumentos es un error que apunta acá
solana_confirmsolana_wait
algorand_tx_encode(txn)algorand_tx(txn)⚠️ algorand_tx ahora devuelve los bytes a firmar
algorand_tx(txn, sig)algorand_tx_raw(txn, sig)⚠️ el algorand_tx viejo de dos argumentos es un error que apunta acá
algo_addressalgorand_address