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.
-- 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.envor sealed at runtime withas_secret); it is consumed Rust-side to sign, then zeroed.text(k)/json_encode/ a log line all showsecret(NAME), never the value. - Signing is gated + audited.
secp256k1_sign/ed25519_signrequirerequire 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 asandbox(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-separatedkey:countpairs) — signature N+1 fails with a catchable error and adenied_by=ceilingentry insign.log; without the variable, behavior is unchanged. - Creating custody is gated + audited too. Generating or importing key material (
mnemonic_generate,hd_derive,keystore_import, …) needsrequire wallet— a separate deny-by-default permission fromsign(creating a key ≠ spending with it), audited inwallet.log, denied insandbox. Every custody builtin returns asecret; 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:
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 withed25519_sign(v0 needs"version": 0; the signature covers the version prefix);solana_latest_blockhash(url)returns therecent_blockhashas bytes(32), ready to drop in.solana_tx(msg, sig_or_list)→ wire format; broadcast withsolana_send(url, tx)(base64 handled) and confirm withsolana_confirm(url, signature). A System Program transfer isprogram: "11111111111111111111111111111111"withdata: int_to_bytes_le(2, 4) + int_to_bytes_le(lamports, 8). - Algorand:
algorand_params(url)reads the suggested params (feeper-byte ANDmin_fee,fv/lv,ghas 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 withed25519_sign.algorand_tx(txn, sig)→ SignedTxn;algorand_send(url, stx)does theapplication/x-binaryPOST for you andalgorand_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 intosolana_messagelike 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.
-- 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 -- 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:
-- 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:
cronruns your monitoring for real (a scheduled job that reads balances viahttp_getto your RPC).chart()is negotiated: a human sees the dashboard SVG, an agent withAccept: text/markdownreads the same data.confirm/approvegate a fund transfer before signing and broadcasting.
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-levelsecretis redacted when it crosses into acronjob or spawned agent (safe — no leak — but unusable there). Resolve the key inside the task:let k be secret("HOT_KEY")(oras_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.
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. 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.