---
slug: 38-blockchain
title: Blockchain (read → build → sign → send → confirm)
description: Operate on-chain from an agent — the full loop on Ethereum, Solana and Algorand — read the chain (nonce, fees, balances, eth_call, suggested params), build the tx (tx_eip1559), sign it, broadcast it and wait for confirmation, plus EVM contract calls (ABI), dApp signatures (EIP-191/712), SPL tokens and HD custody (BIP-39/32, SLIP-0010, keystore V3) — with the private key sealed as a secret that never materializes, signing and custody deny-by-default and audited. All pure-Rust, single binary.
example_ids: [blockchain, wallet-hd]
---

# Blockchain: the wallet an agent uses but cannot steal

Operate on-chain — read state, build, sign, broadcast and confirm transactions — without the private key ever materializing in plaintext or leaking to an LLM. The complexity (curves, RLP, checksums, hex-quantities, receipt polling) is absorbed by the language; what you write is obvious and secure by construction.

The differentiator is **signing/custody with structural security you cannot switch off**: the key is a `secret`, signing is deny-by-default and audited, and moving funds passes through a human gate. And the loop is **complete**: the read side (nonce, fees, balances, contract state, receipts) ships too — gated by the same `net(host)` capability as HTTP, with **zero** new permission doors. Only signing moves value: an agent with `net` but no `sign` can monitor everything and spend nothing.

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

## The security model (why this is different)

- **The key never materializes.** There is no builtin that returns a private key. You pass a `secret` (from `.env` or sealed at runtime with `as_secret`); it is consumed Rust-side to sign, then zeroed. `text(k)` / `json_encode` / a log line all show `secret(NAME)`, never the value.
- **Signing is gated + audited.** `secp256k1_sign` / `ed25519_sign` require `require sign("KEY_NAME")` (scoped to the key's secret name) and write a persistent audit entry (curve, timestamp, program, `file:line` — never the key). No capability → the same deny-by-default error as any other capability. Inside a `sandbox` (capabilities emptied) → signing is denied even with the key in hand. Everything else — hashing, encoding, verification, deriving the public key/address — is **pure**. The host can additionally cap **how many** signatures each key makes per process: `SYNSEMA_SIGN_CEILING="HOT_KEY:100"` (comma-separated `key:count` pairs) — signature N+1 fails with a catchable error and a `denied_by=ceiling` entry in `sign.log`; without the variable, behavior is unchanged.
- **Creating custody is gated + audited too.** Generating or importing key material (`mnemonic_generate`, `hd_derive`, `keystore_import`, …) needs `require wallet` — a *separate* deny-by-default permission from `sign` (creating a key ≠ spending with it), audited in `wallet.log`, denied in `sandbox`. Every custody builtin returns a `secret`; the mnemonic/seed/key never becomes a plain value.
- **Deterministic & malleability-safe.** secp256k1 uses RFC 6979 (deterministic nonce — no nonce-reuse catastrophe) and emits low-s signatures. ed25519 is deterministic by RFC 8032.

## What the language gives you

| Builtin | Purpose | Gated? |
|---|---|---|
| `keccak256(x)` / `sha512_256(x)` | Ethereum / Algorand hashes → bytes(32) | pure |
| `bytes(text, "base58"/"base32")`, `decode(b, …)` | Solana / Algorand address encoding | pure |
| `bech32_encode/decode` | Avalanche X/P, segwit (bech32 & bech32m) | pure |
| `secp256k1_sign(digest, secret)` | ETH/Avalanche signature → bytes(65) `r‖s‖v` | **`require sign`** |
| `secp256k1_verify / recover / pubkey` | verify, ecrecover, derive pubkey | pure |
| `ed25519_sign(message, secret)` | Solana/Algorand signature → bytes(64) | **`require sign`** |
| `ed25519_verify / pubkey` | verify, derive pubkey | pure |
| `eth_address(pubkey_or_secret)` | EIP-55 checksummed address | pure |
| `rlp_encode / rlp_decode` | tx encoding (legacy + EIP-1559); decode is canonical-strict | pure |
| `bytes_to_int(b)` / `int_to_bytes(n, size?)` | big-endian bytes ↔ exact integer (r/s ↔ RLP ints) | pure |
| `int_to_bytes_le(n, size)` | little-endian fixed width (Solana instruction data: u32/u64 LE) | pure |
| `abi_encode(sig, values)` / `abi_decode(types, data)` | contract calldata: selector + args ↔ values; decode is strict (hostile offsets/dirty padding error) | pure |
| `abi_selector(sig)` | bytes(4) selector of a canonical signature | pure |
| `eip191_digest(message)` | personal_sign / SIWE digest → bytes(32) | pure |
| `eip712_digest(domain, types, primary, message)` | typed-data digest from **readable maps** (permits, DEX orders, meta-tx) | pure |
| `solana_message(params)` / `solana_tx(msg, sigs)` | Solana message (legacy + v0) ready for `ed25519_sign`; wire tx ready for `sendTransaction` | pure |
| `algorand_tx_encode(txn)` / `algorand_tx(txn, sig)` | `"TX"‖canonical msgpack` ready for `ed25519_sign`; SignedTxn ready to POST to algod | pure |
| `algo_address(pubkey_or_secret)` | Algorand base32 address with checksum | pure |
| `solana_pda(seeds, program)` / `spl_ata(owner, mint)` | Solana program-derived address (off-curve) / associated token account | pure |
| `spl_transfer_data(amount)` / `spl_transfer_checked_data(amount, decimals)` | SPL Token instruction data (tag 3 / tag 12) | pure |
| `mnemonic_generate / mnemonic_to_seed / hd_derive` | generate a wallet, derive keys (BIP-39/BIP-32/SLIP-0010) → each a `secret` | **`require wallet`** |
| `algorand_mnemonic / algorand_mnemonic_to_key` | Algorand 25-word phrase ↔ key (NOT BIP-39) → `secret` | **`require wallet`** |
| `keystore_import / keystore_export` | import/export a Geth/MyEtherWallet V3 keystore | **`require wallet`** |
| `tx_eip1559(params)` / `tx_eip1559_raw(tx, sig)` | EIP-1559 builder: `{digest, fields, + every number echoed}`; assemble the signed raw tx (v/r/s handled) | pure |
| `eth_rpc / eth_nonce / eth_balance / eth_gas_price / eth_chain_id / eth_estimate_gas / eth_call / eth_fee_history` | EVM read side (hex-quantities handled, strict) | **`require net`** |
| `eth_send_raw / eth_receipt / eth_wait_receipt` | broadcast + typed receipt + bounded confirmation wait | **`require net`** |
| `solana_rpc / solana_latest_blockhash / solana_balance / spl_balance` | Solana read side (blockhash as bytes(32), exact lamports, SPL balance via the derived ATA) | **`require net`** |
| `solana_send / solana_confirm` | broadcast (base64 handled) + bounded status wait | **`require net`** |
| `algorand_params / algorand_account / algorand_send / algorand_wait` | algod REST: suggested params (fee per-byte AND min_fee), account, binary broadcast, bounded wait | **`require net`** |

## The read side: close the loop without hand-rolling JSON-RPC

Before this shipped, an agent could sign byte-perfect transactions but had to hand-roll
JSON-RPC over `http_post` to get the nonce, the EIP-1559 fees, the chain id or the
`recent_blockhash` — encoding hex-quantities by hand and decoding results defensively.
That's exactly where tokens and correctness go to die, so the language absorbed it:

```synsema
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 eth_nonce(url, eth_address(k))       -- eth_getTransactionCount ("pending")
let fees be eth_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 tx_eip1559({"chain_id": eth_chain_id(url), "nonce": nonce, "to": dest,
    "value": 100000000000000000, "gas": 21000,
    "max_fee": fees["base_fee"] * 2, "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 tx_eip1559_raw(tx, sig)                -- v/r/s assembled for you
-- SEND and CONFIRM
let hash be eth_send_raw(url, raw)                -- "0x…" tx hash
let receipt be eth_wait_receipt(url, hash, 1, 120)  -- nothing on timeout — never hangs
-- receipt["status"]: 1 = success, 0 = REVERTED (it landed but failed — always check)

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

Solana mirrors it (`solana_latest_blockhash` → `solana_message` → `ed25519_sign` →
`solana_tx` → `solana_send` → `solana_confirm`; `solana_balance`/`spl_balance` for
monitoring), and Algorand does too (`algorand_params` → txn map → `algorand_tx_encode`
→ sign → `algorand_tx` → `algorand_send` (the binary POST is handled) →
`algorand_wait`). The escape hatches `eth_rpc(url, method, params?)` /
`solana_rpc(url, method, params?)` cover any method not wrapped.

**An RPC node is untrusted input.** It can lie, be compromised, or return garbage —
so every decode is strict: a malformed or non-canonical hex-quantity, a response over
16 MiB, a shape that doesn't match, a mismatched JSON-RPC id → a **catchable error**,
never a panic, never silently wrong data. Errors name the **host** only, never the
full URL (RPC API keys usually live in the URL path). Which node you *trust* is your
decision — Synsema gives you the primitive, not the trust.

## EVM L2s: Base, Arbitrum, Optimism, Polygon — out of the box

An L2 is the **same EVM wire**: point the `url` at the L2's RPC and everything above —
`eth_nonce`, `eth_fee_history`, `tx_eip1559`, `eth_call`, `eth_wait_receipt` — works
unchanged. The one rule: **read the chain id from the node** (`eth_chain_id(url)`), never
hardcode it — that's what makes the same program correct on mainnet, Base (8453),
Optimism (10), Arbitrum (42161) or Polygon (137), and what EIP-155 replay protection
signs over.

One honest caveat about **cost**: on OP-stack chains (Base, Optimism) `eth_estimate_gas`
covers only the **L2 execution** — the transaction also pays an **L1 data fee** that the
node adds at inclusion time. It arrives in the receipt as `l1Fee` / `l1GasUsed` /
`l1GasPrice` (decoded to exact ints like every other quantity), so the true total is
`gasUsed × effectiveGasPrice + l1Fee` — visible, never hidden inside a hex string.
Arbitrum uses a different model and folds its L1 component into `gasUsed` directly (no
extra field). If your agent reports costs, read them from the receipt, not the estimate.

## Instinct vs. reality (read this before you sign anything)

Cross-chain crypto has sharp edges. These cause most real bugs:

| Your instinct | The reality |
|---|---|
| "keccak256 is SHA3-256" | **No.** Ethereum uses Keccak *before* NIST standardization; the padding differs. `keccak256("")` is `c5d24601…`, not the `a7ffc6f8…` of SHA3-256. `keccak256` gives you the Ethereum one. |
| "hash the message, then ed25519-sign it" | **No.** ed25519 signs the **raw message** (it hashes internally, RFC 8032). Pre-hashing double-hashes and produces a wrong signature. `ed25519_sign` takes the message directly; only secp256k1 takes a 32-byte digest. |
| "signing is just a computation like hashing" | **No.** Signing authorizes moving value — it is the most dangerous operation in the language. It needs `require sign("KEY")` and writes an audit entry. Deny-by-default, denied inside `sandbox`. |
| "the key is a hex string I pass around" | **No.** The key is a `secret`. It never becomes a plain string; passing a raw string is refused. The plaintext is interpreted only Rust-side. |
| "r and s are 32-byte blobs I paste into the tx" | **No.** The signed tx encodes r/s as RLP **integers** (minimal, leading zeros stripped). Pasting the raw 32 bytes makes ~1 in 128 transactions invalid — a heisenbug. Use `bytes_to_int(slice(sig, 0, 32))`; `int_to_bytes(n, 32)` restores the fixed width. |
| "any way I write the ABI signature works" | **No.** The signature is **canonical**: no spaces, no parameter names — `"transfer(address,uint256)"`, never `"transfer(address to, uint256 amount)"`. A wrong signature is a wrong selector: the call silently hits a nonexistent function. `abi_encode`/`abi_selector` reject non-canonical forms (and normalize `uint`→`uint256`). |
| "I can pass a token amount as a float" | **No.** `1e24` as a float is not exact — a uint256 amount needs **exact integers** (Synsema promotes to big ints automatically: `1000000000000000000000000` just works). Floats are rejected with a clear error. |
| "Algorand msgpack keeps my zero fields" | **No.** Algorand requires **canonical** msgpack: keys sorted, zero/empty/false fields **omitted** — otherwise the network rejects the tx or the TXID differs. `algorand_tx_encode` emits canonical by construction (`amt: 0` disappears; that's correct). |
| "Solana keeps my accounts in the order I list them" | **No.** The runtime rules order them: fee payer first, then writable signers, readonly signers, writable non-signers, readonly non-signers (each bucket sorted by pubkey bytes, like the official SDK). `solana_message` reorders for you — indices in the compiled message point at the reordered table. |
| "I derive a Solana account with a normal BIP-32 path" | **No.** Solana uses **SLIP-0010** over ed25519, which is **hardened-only**: `hd_derive(seed, "m/44'/501'/0'/0'", "ed25519")`. A non-hardened index errors. ETH is the default `"secp256k1"` (BIP-32). |
| "Algorand's seed phrase is a 24-word BIP-39 one" | **No.** Algorand's wallet phrase is its **own 25-word format** (checksum via sha512_256 over the key). Use `algorand_mnemonic` / `algorand_mnemonic_to_key`; feeding it to `mnemonic_to_seed` is wrong. |
| "custody and signing are the same permission" | **No.** Creating custody (mnemonics/HD/keystore) needs **`require wallet`**; signing needs **`require sign`**. An agent can derive addresses without being able to spend. Both deny-by-default, audited, denied in `sandbox`. |
| "the mnemonic/seed is a string I can print" | **No.** It's a `secret` — `text()`/`json_encode`/a log line show `secret(NAME)`. Back a phrase up **on purpose** with `reveal()` (gated by `reveal("NAME")` + audited). A wrong checksum/passphrase errors without ever echoing the material. |
| "the read side needs its own permission" | **No.** It's the same **`net(host)`** as `http_*` — zero new capability doors. Broadcasting is `net`-gated too (the signature already happened; without a valid one the node rejects the bytes). `sign` stays the only door that moves value. |
| "`tx_eip1559` fills sensible gas/fee defaults" | **No.** A builder that invents a fee is blind-signing with extra steps. Every value-moving field is **explicit** — a missing `max_fee`/`gas`/`value` errors naming the reader (`eth_fee_history`/`eth_estimate_gas`) — and the result map **echoes** every number so a `confirm` can show them before you sign. |
| "a receipt means my transfer succeeded" | **No.** A receipt/status means **inclusion**. Check `receipt["status"]` (0 = reverted) and Solana `status["err"]` (`nothing` = ok) — a tx can land AND fail. |
| "`eth_wait_receipt` blocks until the tx confirms" | **No.** The three waiters (`eth_wait_receipt`/`solana_confirm`/`algorand_wait`) are **bounded** polls: they return `nothing` at the timeout (default 60 s), like `ws_recv` — an unconfirmed tx never hangs the agent. `algorand_wait` errors on a pool rejection (that's definitive, waiting more would lie). |
| "a network blip mid-wait kills the waiter" | **No.** After a first successful poll, a **transient** failure (dropped connection, HTTP 5xx) is retried until the deadline, with one `synsema: warning:` notice on stderr. If the deadline expires while the node is still failing, the **error** surfaces instead of `nothing` — "unconfirmed" and "node stopped answering" are different truths. A dead node or wrong URL still fails fast on the **first** poll, and definitive answers (4xx, invalid JSON, node RPC errors) error immediately. |
| "Algorand's suggested `fee` is the flat fee I put in the txn" | **No.** algod's `fee` is **per byte** (often 0); the real flat minimum is `min-fee` (1000 µAlgo). `algorand_params` returns **both** (`fee` and `min_fee`) so neither classic mistake — rejected tx or overpaid fee — survives. |
| "`spl_balance` on a missing token account is 0" | **No.** A missing ATA is a **catchable error** — a wrong owner/mint would silently read 0 forever. On success the map includes the derived `ata` so you can verify which account was read. |
| "a weird RPC value gets patched up for me" | **No.** Strict decode, same doctrine as `rlp_decode`/`abi_decode`: `"0x01"` (leading zero), the wrong JSON shape, a >16 MiB body, a mismatched id → catchable error. Bad data from a hostile node never silently becomes a number. |

## Chains covered

| Chain | Curve | Signing hash | Address |
|---|---|---|---|
| Ethereum | secp256k1 | keccak256 | EIP-55 hex + RLP/EIP-1559 |
| Avalanche C-Chain | secp256k1 | keccak256 | same as Ethereum (it's EVM) |
| Avalanche X/P | secp256k1 | sha256 | bech32 |
| Solana | ed25519 | — (internal) | base58 |
| Algorand | ed25519 | sha512_256 | base32 |

Ethereum, Avalanche C-Chain, **Solana and Algorand are end-to-end**: build the transaction, sign it, and assemble the exact wire bytes in Synsema — verified byte-for-byte against the official SDKs (solana-sdk/solders, algosdk). Solana also has **SPL tokens** (PDAs, associated accounts, TransferChecked), and all four chains have **HD custody** (see below).

- **Solana:** `solana_message({fee_payer, recent_blockhash, instructions, version?})` → bytes to sign with `ed25519_sign` (v0 needs `"version": 0`; the signature covers the version prefix); `solana_latest_blockhash(url)` returns the `recent_blockhash` as bytes(32), ready to drop in. `solana_tx(msg, sig_or_list)` → wire format; broadcast with `solana_send(url, tx)` (base64 handled) and confirm with `solana_confirm(url, signature)`. A System Program transfer is `program: "11111111111111111111111111111111"` with `data: int_to_bytes_le(2, 4) + int_to_bytes_le(lamports, 8)`.
- **Algorand:** `algorand_params(url)` reads the suggested params (`fee` per-byte AND `min_fee`, `fv`/`lv`, `gh` as bytes, `gen`) straight into the txn map: `algorand_tx_encode({type, snd, rcv, amt, fee, fv, lv, gen, gh, note, …})` (the protocol's short field names; text addresses are checksum-validated) → bytes to sign with `ed25519_sign`. `algorand_tx(txn, sig)` → SignedTxn; `algorand_send(url, stx)` does the `application/x-binary` POST for you and `algorand_wait(url, txid)` confirms. The TXID is composable: `decode(sha512_256(algorand_tx_encode(txn)), "base32")`.
- **Solana SPL tokens:** `solana_pda(seeds, program)` → `{address, bump}` (findProgramAddress; the address is off-curve, so no private key can exist for it). `spl_ata(owner, mint)` derives the associated token account (itself a PDA). `spl_transfer_checked_data(amount, decimals)` is the instruction data — feed it into `solana_message` like any other instruction. All pure.

## Custody: generate a wallet, don't paste a key

Until now the key arrived as a hex string in `.env`. Now an agent **generates** a wallet from a seed phrase, backs it up, and derives accounts for every chain — like Metamask or Phantom — with the same structural security as signing: everything is a `secret` that never materializes, and creating custody is **deny-by-default (`require wallet`)** and audited (in `wallet.log`).

`wallet` is a *separate* permission from `sign`: **`wallet` creates keys, `sign` moves value.** An agent can derive addresses for reporting without ever being able to spend. Both are scoped, audited, and denied inside `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                                   -- 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) → eth_address/secp256k1_sign
let solk be hd_derive(seed, "m/44'/501'/0'/0'", "ed25519")  -- secret: SLIP-0010 (Solana; hardened-only)
print(eth_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
```

Derived secrets carry a **derived name** so scopes stay tight: `mnemonic_to_seed` of a `"W"` mnemonic is `W.seed`, `hd_derive` is `W/path`. Grant `reveal("W*")` (or the exact name) to back a phrase up; the same derived name is what `wallet`/`sign` scope against.

## The dApp world: contracts, logins, permits

Everything a dApp user does daily, an agent can do — with the anti-blind-signing property that the typed data is a **readable map** you can `show`/`confirm` *before* signing:

```synsema
-- ERC-20 transfer calldata (uint256 amounts are exact big integers, never floats)
let data be abi_encode("transfer(address,uint256)", [dest, 1000000000000000000000000])
-- ...goes into the `data` field of the EIP-1559 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: eth_address(secp256k1_recover(digest, sig)) == the user's address

-- 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 secp256k1_sign(eip712_digest(domain, types, "Permit", permit), k)
```

`eip712_digest` handles nested structs, arrays, and the optional domain fields (fixed EIP order). A missing or **extra** field in the message errors naming the field — you never sign data you didn't read.

## Composing the flagship

The real product is the composition, in one binary, that no other language gives you:

- **`cron`** runs your monitoring for real (a scheduled job that reads balances via `http_get` to your RPC).
- **`chart()`** is negotiated: a human sees the dashboard SVG, an agent with `Accept: text/markdown` reads the same data.
- **`confirm` / `approve`** gate a fund transfer *before* signing and broadcasting.

```synsema
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 eth_fee_history(url)
    let tx be tx_eip1559({"chain_id": eth_chain_id(url),
        "nonce": eth_nonce(url, eth_address(k)), "to": dest,
        "value": 100000000000000000, "gas": 21000,
        "max_fee": fees["base_fee"] * 2, "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 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
```

The key never materialized; signing required `sign("HOT_KEY")` and was audited; reading, broadcasting and confirming went through `net`; the fees passed through a human `confirm` before the signature existed.

> **Note on scheduled/agent signing:** a top-level `secret` is *redacted* when it crosses into a `cron` job or spawned agent (safe — no leak — but unusable there). Resolve the key **inside** the task: `let k be secret("HOT_KEY")` (or `as_secret(...)`) in the job body, exactly as the flagship route does.

For **live feeds** — subscribe to an RPC, an exchange, a mempool — Synsema has a general WebSocket client (`ws_connect`/`ws_send`/`ws_recv`/`ws_close`, gated by `net`), not specific to blockchain. `eth_subscribe` (newHeads/logs) composes in userland today: `ws_connect` to the node's WS endpoint, send the subscribe frame, `ws_recv` the notifications. See **[WebSocket](/en/0.6.x/39-websocket)**.

## Not yet (documented, not debt)

Solana **address lookup tables** (passing `lookup_tables` errors clearly), Avalanche X/P serialization, and typed WS subscriptions (`eth_subscribe` helpers — composable in userland today, see above) are planned next. **Bitcoin has shipped** — its own UTXO matrix (hash160, BIP-143/341 sighash, the G28 builder, Schnorr taproot signing, the Esplora read side, PSBT): see **[Bitcoin](/en/0.6.x/38a-bitcoin)**. The full loop — read (nonce/fees/balances/`eth_call`/receipts, Solana blockhash/confirm, algod params/wait), build (`tx_eip1559`), sign, send and confirm — plus contract calls, dApp signatures (191/712), SPL tokens, HD custody (BIP-39/32, SLIP-0010, Algorand-25, keystore V3) and a live WebSocket client ship today.
