Synsema docsENES

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.

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

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 ETH/Avalanche → bytes(65) r‖s‖vrequire sign
secp256k1_verify / recover / pubkeyverificar, ecrecover, derivar pubkeypuro
ed25519_sign(message, secret)firma Solana/Algorand → bytes(64)require sign
ed25519_verify / pubkeyverificar, derivar pubkeypuro
eth_address(pubkey_or_secret)dirección con checksum EIP-55puro
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_selector(sig)selector bytes(4) de una firma canónicapuro
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 sendTransactionpuro
algorand_tx_encode(txn) / algorand_tx(txn, sig)"TX"‖msgpack canónico listo para ed25519_sign; SignedTxn listo para POST a algodpuro
algo_address(pubkey_or_secret)dirección base32 de Algorand con checksumpuro
solana_pda(seeds, program) / spl_ata(owner, mint)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
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_historylado de lectura EVM (hex-quantities resueltos, estricto)require net
eth_send_raw / eth_receipt / eth_wait_receiptbroadcast + 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_confirmbroadcast (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

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ó:

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_blockhashsolana_messageed25519_sign

solana_txsolana_sendsolana_confirm; solana_balance/spl_balance para

monitoreo), y Algorand también (algorand_params → map del txn → algorand_tx_encode

→ firmar → algorand_txalgorand_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 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. 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 uintuint256).
"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 secrettext()/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

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

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

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

-- 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:

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.

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