Standard library / primitives
Blockchain: the wallet an agent uses but cannot steal
Operate on-chain — read state, build, sign, broadcast and confirm transactions, deploy contracts and read their events — 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, logs, 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.
Names follow <family>_<action>: evm_ for every EVM chain (Ethereum, Avalanche C-Chain, the L2s), solana_, algorand_, btc_ (Bitcoin). The action says what you get: tx builds the bytes to sign, tx_raw assembles the signed transaction, then send, wait, address. The names from before v0.6.29 (eth_nonce, tx_eip1559, solana_message, algorand_tx_encode, …) still run as deprecated aliases until v1.0 — the table is at the end of this page.
-- Doc example: sign an Ethereum transaction without the private key ever
-- materializing. The key is a `secret`; signing is deny-by-default (`require sign`)
-- and audited. Building, deriving and verifying are pure. No network — this is the
-- offline core; reading and broadcasting are the net-gated evm_* / solana_* / algorand_*.
intent: "doc example: blockchain signing"
require sign("HOT_KEY")
-- An explicit port is part of the grant: this covers 127.0.0.1:2 and nothing else.
require net("127.0.0.1:2")
-- v0.6.46+: an RPC key in the URL is a secret bound to the node's host
require secret("RPC_KEY") for net("127.0.0.1:2")
require secret("UNBOUND_KEY")
test "an EVM address is derived from the key, and ecrecover closes the circuit"
-- The key arrives sealed as a secret (from .env in real programs). It never
-- turns into a plain string — text(k) is redacted.
let k be as_secret("0000000000000000000000000000000000000000000000000000000000000001", "HOT_KEY")
assert_eq(text(k), "secret(HOT_KEY)")
-- Address is public: deriving it does NOT expose the private key.
assert_eq(evm_address(k), "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf")
-- evm_address also takes 20 raw bytes (and a pubkey, or "0x…" text) → EIP-55 text
assert_eq(evm_address(bytes("7e5f4552091a69125d5dfcb7b8c2659029395bdf", "hex")),
"0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf")
-- An EIP-1559 signing payload by hand: 0x02 || rlp([chainId, nonce, ...]).
-- (evm_tx below does this for you; here the bytes are in plain view.)
let to_addr be bytes("5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", "hex")
let fields be [1, 9, 1000000000, 30000000000, 21000, to_addr, 1000000000000000, bytes([]), []]
let digest be keccak256(bytes([2]) + rlp_encode(fields))
-- Sign it (deny-by-default: needs `require sign("HOT_KEY")`). Deterministic
-- (RFC 6979): the same (key, digest) always yields the same signature.
let sig be secp256k1_sign(digest, k)
assert_eq(length(sig), 65)
assert_eq(sig, secp256k1_sign(digest, k))
-- ecrecover: the recovered public key yields the SAME address.
let pub be secp256k1_recover(digest, sig)
assert_eq(evm_address(pub), "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf")
assert(secp256k1_verify(digest, sig, secp256k1_pubkey(k)))
-- Assemble the signed raw tx by hand: 0x02 || rlp(fields + [v, r, s]).
-- r/s go in as INTEGERS (minimal RLP, no leading zeros), never as 32-byte blobs —
-- bytes_to_int is the exact bridge (256 bits, no float involved).
let v be sig[64]
let r be bytes_to_int(slice(sig, 0, 32))
let s be bytes_to_int(slice(sig, 32, 64))
let raw be bytes([2]) + rlp_encode(fields + [v, r, s])
assert_eq(raw[0], 2)
assert_eq(length(rlp_decode(slice(raw, 1, length(raw)))), 12)
-- int_to_bytes(n, 32) restores the fixed width (closes the r round-trip)
assert_eq(int_to_bytes(r, 32), slice(sig, 0, 32))
test "ed25519 (Solana/Algorand) signs the RAW message — never a pre-hash"
let k be as_secret("0000000000000000000000000000000000000000000000000000000000000001", "HOT_KEY")
let sig be ed25519_sign("transfer 10", k)
let pk be ed25519_pubkey(k)
assert(ed25519_verify("transfer 10", sig, pk))
test "the dApp world: ABI calldata, SIWE (EIP-191) and a permit (EIP-712)"
let k be as_secret("0000000000000000000000000000000000000000000000000000000000000001", "HOT_KEY")
-- Contract calldata: canonical signature (no spaces), exact big integers.
let data be abi_encode("transfer(address,uint256)", ["0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf", 10**24])
assert_eq(decode(slice(data, 0, 4), "hex"), "a9059cbb")
assert_eq(abi_selector("transfer(address,uint256)"), slice(data, 0, 4))
-- Read calldata back — audit what you are about to sign.
let args be abi_decode("(address,uint256)", slice(data, 4, length(data)))
assert_eq(args[1], 10**24)
-- SIWE login: digest → gated signature → the backend recovers the address.
let d191 be eip191_digest("app.example.com wants you to sign in")
let quien be evm_address(secp256k1_recover(d191, secp256k1_sign(d191, k)))
assert_eq(quien, "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf")
-- EIP-712 permit: the typed data is a READABLE map (show it before signing).
let domain be {"name": "USD Coin", "version": "2", "chainId": 1, "verifyingContract": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"}
let types be {"Permit": [{"name": "owner", "type": "address"}, {"name": "spender", "type": "address"},
{"name": "value", "type": "uint256"}, {"name": "nonce", "type": "uint256"}, {"name": "deadline", "type": "uint256"}]}
let permit be {"owner": quien, "spender": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed",
"value": 1000000, "nonce": 0, "deadline": 1893456000}
let d712 be eip712_digest(domain, types, "Permit", permit)
let sig be secp256k1_sign(d712, k)
-- secp256k1_sign gives the raw recovery id v = 0/1; contracts and wallets write 27/28.
assert(sig[64] == 0 or sig[64] == 1)
let wallet_sig be evm_signature(sig)
assert_eq(wallet_sig[64], sig[64] + 27)
assert_eq(slice(wallet_sig, 0, 64), slice(sig, 0, 64))
-- idempotent: a signature that already carries 27/28 comes back unchanged
assert_eq(evm_signature(wallet_sig), wallet_sig)
-- recover takes BOTH conventions: a wallet's 27/28 signature needs no conversion
assert_eq(evm_address(secp256k1_recover(d712, sig)), quien)
assert_eq(evm_address(secp256k1_recover(d712, wallet_sig)), quien)
-- a legacy EIP-155 transaction v (chain_id * 2 + 35 + parity) is not a message signature
assert_error(() => secp256k1_recover(d712, slice(sig, 0, 64) + bytes([37])))
-- any other v is refused by evm_signature too
assert_error(() => evm_signature(slice(sig, 0, 64) + bytes([5])))
test "Solana and Algorand are transactable: solana_tx / algorand_tx build, *_tx_raw assemble"
let k be as_secret("0000000000000000000000000000000000000000000000000000000000000001", "HOT_KEY")
let payer be ed25519_pubkey(k)
-- Solana System transfer: data is u32 LE 2 ‖ u64 LE lamports (int_to_bytes_le).
let params be {
"fee_payer": payer,
"recent_blockhash": payer,
"instructions": [{
"program": "11111111111111111111111111111111",
"accounts": [{"pubkey": payer, "signer": true, "writable": true}],
"data": int_to_bytes_le(2, 4) + int_to_bytes_le(1000000, 8)}]}
let msg be solana_tx(params)
-- the signed wire tx: one signature, or a list of them (one per signer, in order)
let tx be solana_tx_raw(msg, ed25519_sign(msg, k))
assert_eq(tx, solana_tx_raw(msg, [ed25519_sign(msg, k)]))
-- broadcast: solana_send(url, tx) (base64 handled)
assert(length(tx) > length(msg))
-- a v0 message carries the 0x80 version prefix, and the signature covers it
let msg0 be solana_tx(merge(params, {"version": 0}))
assert_eq(msg0[0], 128)
-- address lookup tables are not supported yet: a directed error, not a silent drop
assert_error(() => solana_tx(merge(params, {"version": 0, "lookup_tables": []})))
-- The old two-argument form of solana_tx errors pointing to solana_tx_raw.
assert_error(() => solana_tx(msg, ed25519_sign(msg, k)))
-- Algorand pay: protocol short keys; canonical msgpack (zero fields omitted).
let txn be {"type": "pay", "snd": algorand_address(k), "rcv": payer,
"amt": 123456, "fee": 1000, "fv": 1000, "lv": 2000,
"gh": bytes("0707070707070707070707070707070707070707070707070707070707070707", "hex")}
assert_eq(algorand_address(k), "JS22X5VNPH57LK54ZL6ME2OYLTJGKHWUXCC3LBU7EQNO34FFXIUYKBWSCU")
assert_eq(algorand_address(payer), algorand_address(k))
let to_sign be algorand_tx(txn)
assert_eq(slice(to_sign, 0, 2), bytes("TX", "utf8"))
let stx be algorand_tx_raw(txn, ed25519_sign(to_sign, k))
assert(length(stx) > length(to_sign))
-- the TXID is composable: base32(sha512_256(the bytes to sign))
assert_eq(decode(sha512_256(to_sign), "base32"), "IOXWDKGDSRDOQ36CY4LXE3GWKPATSMJMMZEDCGKEX7R6JHXUNWMQ")
-- The old two-argument form of algorand_tx errors pointing to algorand_tx_raw.
assert_error(() => algorand_tx(txn, ed25519_sign(to_sign, k)))
test "evm_tx assembles the exact SDK bytes (no hand-rolled RLP)"
-- Vector from eth-account (the Ethereum Foundation SDK): same key+digest →
-- same deterministic RFC 6979 signature → the raw tx matches byte-for-byte.
let k be as_secret("1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727", "HOT_KEY")
let tx be evm_tx({"chain_id": 1, "nonce": 7,
"to": "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0",
"value": 100000000000000000, "gas": 21000,
"max_fee": 30000000000, "max_priority": 1500000000})
-- every value-moving number is echoed back — `confirm` them BEFORE signing
assert_eq(tx.max_fee, 30000000000)
assert_eq(tx.to, "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0")
let sig be secp256k1_sign(tx.digest, k)
let raw be evm_tx_raw(tx, sig)
assert_eq(decode(raw, "hex"), "02f87301078459682f008506fc23ac00825208946fac4d18c912343bf86fa7049364dd4e424ab9c088016345785d8a000080c080a054afb059cc17c8b3726db836c4d9093aaabfb6d422c9c190ae8e9c561e285fffa03f3c2eb5d5b609d2544817eb1ce8a4e64dfc8d4dbbc9db06f5ce2f2352b13f41")
-- the tx hash is keccak256 of the signed raw (matches the SDK's too)
assert_eq(decode(keccak256(raw), "hex"), "6fb18223cd52476122a18a2b59a6c9faca40b36937217962a4f81b0da1c79880")
-- evm_tx_raw recomputes the digest from `fields`: a map edited after evm_tx is refused
let edited be merge(tx, {"value": 999})
assert_error(() => evm_tx_raw(edited, sig))
test "data and access_list: a contract call inside the same builder"
let tx be evm_tx({"chain_id": 1, "nonce": 7,
"to": "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0", "value": 0, "gas": 60000,
"max_fee": 30000000000, "max_priority": 1500000000,
"data": abi_encode("transfer(address,uint256)", ["0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf", 5]),
"access_list": [{"address": "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0",
"storage_keys": [int_to_bytes(0, 32)]}]})
-- fields = [chain_id, nonce, max_priority, max_fee, gas, to, value, data, access_list]
assert_eq(slice(tx.fields[7], 0, 4), abi_selector("transfer(address,uint256)"))
assert_eq(length(tx.fields[8]), 1)
test "no fee is ever invented: a missing field errors naming the reader"
let failed be ""
try
let tx be evm_tx({"chain_id": 1, "nonce": 0,
"to": "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0",
"value": 0, "gas": 21000, "max_priority": 1})
recover e
set failed to e
assert(contains(failed, "max_fee"))
assert(contains(failed, "evm_fee_history"))
-- no `to` is not a deployment by accident: the error points to evm_tx_create
set failed to ""
try
let tx be evm_tx({"chain_id": 1, "nonce": 0, "value": 0, "gas": 21000,
"max_fee": 2, "max_priority": 1})
recover e
set failed to e
assert(contains(failed, "evm_tx_create"))
test "deploy a contract: evm_tx_create knows where it lands and who must sign"
let k be as_secret("0000000000000000000000000000000000000000000000000000000000000001", "HOT_KEY")
let me be evm_address(k)
-- init code = the compiled bytecode ‖ the constructor arguments, ABI-encoded WITHOUT a selector
let bytecode be bytes("6080604052", "hex")
let ctor be abi_encode("(address,uint256)", [me, 10**24])
assert_eq(ctor, abi_encode(["address", "uint256"], [me, 10**24]))
assert_eq(abi_decode("(address,uint256)", ctor), [me, 10**24])
let ctx be evm_tx_create({"chain_id": 1, "nonce": 3, "from": me, "value": 0,
"gas": 1500000, "max_fee": 30000000000, "max_priority": 1000000000,
"data": bytecode + ctor})
assert_eq(ctx.to, nothing)
-- CREATE: keccak256(rlp([sender, nonce]))[12:] — the same address, computed by hand
assert_eq(ctx.contract_address, evm_create_address(me, 3))
assert_eq(ctx.contract_address,
evm_address(slice(keccak256(rlp_encode([bytes(slice(me, 2), "hex"), 3])), 12, 32)))
let raw be evm_tx_raw(ctx, secp256k1_sign(ctx.digest, k))
assert_eq(raw[0], 2)
-- a signature from any other key is refused: the address shown would be wrong
let other be as_secret("0000000000000000000000000000000000000000000000000000000000000002", "HOT_KEY")
assert_error(() => evm_tx_raw(ctx, secp256k1_sign(ctx.digest, other)))
-- a creation has no `to`, and its init code cannot be empty
let params be {"chain_id": 1, "nonce": 3, "from": me, "value": 0,
"gas": 1500000, "max_fee": 30000000000, "max_priority": 1000000000, "data": bytecode}
assert_error(() => evm_tx_create(merge(params, {"to": me})))
assert_error(() => evm_tx_create(merge(params, {"data": bytes([])})))
-- no `from`: the contract address depends on who deploys it
assert_error(() => evm_tx_create(remove(params, "from")))
test "CREATE and CREATE2 addresses match the published vectors"
-- CREATE (sender + nonce)
assert_eq(evm_create_address("0x6ac7ea33f8831ea9dcc53393aaa88b25a785dbf0", 0),
"0xcd234A471b72ba2F1Ccf0A70FCABA648a5eeCD8d")
assert_eq(evm_create_address("0x6ac7ea33f8831ea9dcc53393aaa88b25a785dbf0", 1),
"0x343c43A37D37dfF08AE8C4A11544c718AbB4fCF8")
-- CREATE2 (EIP-1014 example 0): deployer, 32-byte salt, keccak256 of the init code
assert_eq(evm_create2_address("0x0000000000000000000000000000000000000000",
int_to_bytes(0, 32), keccak256(bytes([0]))),
"0x4D1A2e2bB4F88F0250f26Ffff098B0b30B26BF38")
-- EIP-2681: a nonce of 2^64 − 1 or more cannot exist
assert_error(() => evm_create_address("0x6ac7ea33f8831ea9dcc53393aaa88b25a785dbf0", 2**64 - 1))
test "events: the topic of an event and a log decoded by parameter name"
-- the event fragment, as it appears in the contract's ABI JSON
let transfer be {"name": "Transfer", "inputs": [
{"name": "from", "type": "address", "indexed": true},
{"name": "to", "type": "address", "indexed": true},
{"name": "value", "type": "uint256", "indexed": false}]}
let topic be abi_event_topic(transfer)
assert_eq(topic, "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")
assert_eq(abi_event_topic("Transfer(address,address,uint256)"), topic)
-- a log as evm_logs / evm_receipt give it: indexed args in topics, the rest in data
let log be {"address": "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0",
"topics": [topic,
"0x0000000000000000000000007e5f4552091a69125d5dfcb7b8c2659029395bdf",
"0x0000000000000000000000005aaeb6053f3e94c9b9a09f33669435e7ef1beaed"],
"data": abi_encode("(uint256)", [1000])}
let ev be abi_decode_log(transfer, log)
assert_eq(ev.from, "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf")
assert_eq(ev.to, "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed")
assert_eq(ev.value, 1000)
-- a log of another event: an error by default, or the default you pass
let approval be merge(log, {"topics": [abi_event_topic("Approval(address,address,uint256)"),
log.topics[1], log.topics[2]]})
assert_error(() => abi_decode_log(transfer, approval))
assert_eq(abi_decode_log(transfer, approval, nothing), nothing)
test "the read side is net-gated: without `require net` it denies, catchable"
let failed be ""
try
-- net("127.0.0.1:2") is granted; port 1 is another grant
let n be evm_chain_id("http://127.0.0.1:1")
recover e
set failed to e
assert(contains(failed, "net"))
test "the read side validates its arguments before any request"
let url be "http://127.0.0.1:2"
let t be abi_event_topic("Transfer(address,address,uint256)")
-- evm_logs: a log has at most 4 topics
assert_error(() => evm_logs(url, {"topics": [t, nothing, nothing, nothing, nothing]}))
-- evm_logs: blockHash OR fromBlock/toBlock, never both
assert_error(() => evm_logs(url, {"blockHash": t, "fromBlock": "latest"}))
-- a block is a tag, a number or the node's canonical "0x…" quantity (hex(n) gives one)
let me be "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"
assert_error(() => evm_balance(url, me, "0x01"))
assert_error(() => evm_balance(url, me, "yesterday"))
-- evm_fee_history(url, blocks?, percentiles?, newest?): 1..1024 blocks, percentiles 0..100
assert_error(() => evm_fee_history(url, 0))
assert_error(() => evm_fee_history(url, 5, [10, 200]))
test "Solana SPL tokens: PDAs and instruction data are pure (no key, no gate)"
-- An SPL TransferChecked instruction's data: tag 12 ‖ amount u64 LE ‖ decimals.
assert_eq(decode(spl_transfer_checked_data(1000000, 6), "hex"), "0c40420f000000000006")
-- The plain Transfer (tag 3 ‖ amount u64 LE)
assert_eq(decode(spl_transfer_data(1000000), "hex"), "0340420f0000000000")
-- The associated token account (ATA) is a PDA — derived, off-curve, byte-exact
-- against the SPL SDK (owner and mint here are 32-byte test pubkeys).
let owner be bytes("0202020202020202020202020202020202020202020202020202020202020202", "hex")
let mint be bytes("0303030303030303030303030303030303030303030303030303030303030303", "hex")
assert_eq(decode(spl_ata(owner, mint), "hex"),
"9958db38c3bb8bf04f6da81d59d3e34f7e17de3ec94b885e2d79050e75ff3f6e")
-- solana_pda(seeds, program) returns {address, bump}; the address is off-curve.
let pda be solana_pda(["metadata", owner], "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
assert_eq(length(pda["address"]), 32)
test "an API key in the RPC URL: bound to the host, never in the params (engine v0.6.46+)"
let k be secret("RPC_KEY", "rk-demo-123")
let url be `http://127.0.0.1:2/v2/{k}` -- loopback: http allowed for a bound secret
let failed be ""
try
evm_rpc(url, "eth_chainId", [k])
recover e
set failed to e
assert(contains(failed, "params[0] is a secret — a secret never goes into an RPC request"))
let u be secret("UNBOUND_KEY", "uk-demo-456")
set failed to ""
try
evm_chain_id(`http://127.0.0.1:2/v2/{u}`)
recover e
set failed to e
assert(contains(failed, "an unbound secret only goes in a header"))
assert(not contains(failed, "uk-demo-456"))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, building, 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) | EVM signature → bytes(65) r‖s‖v, v = 0/1 | require sign |
secp256k1_verify / recover / pubkey | verify, ecrecover (v = 0/1 or 27/28), derive pubkey | pure |
ed25519_sign(message, secret) | Solana/Algorand signature → bytes(64) | require sign |
ed25519_verify / pubkey | verify, derive pubkey | pure |
evm_address(x) | EIP-55 checksummed address; x = a pubkey, a key secret, 20 raw bytes or "0x…" text | pure |
evm_signature(sig) | the same signature with v = 27/28 (what contracts and wallets use) | 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_encode(types, values) | the arguments alone, no selector — constructor arguments (types = "(address,uint256)" or ["address", "uint256"]) | pure |
abi_selector(sig) | bytes(4) selector of a canonical signature | pure |
abi_event_topic(event) / abi_decode_log(event, log, default?) | an event's topic 0 ("0x…"); a log decoded into a map by parameter name | 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 |
evm_tx(params) / evm_tx_raw(tx, sig) | EIP-1559 builder: {digest, fields, + every number echoed}; the signed raw tx (v/r/s handled, the map re-checked) | pure |
evm_tx_create(params) | contract deployment: the evm_tx map plus from and contract_address | pure |
evm_create_address(sender, nonce) / evm_create2_address(deployer, salt, init_code_hash) | where a CREATE / CREATE2 (EIP-1014) deployment lands → EIP-55 text | pure |
solana_tx(params) / solana_tx_raw(msg, sigs) | Solana message (legacy + v0) ready for ed25519_sign; the wire tx ready for solana_send | pure |
algorand_tx(txn) / algorand_tx_raw(txn, sig) | "TX"‖canonical msgpack ready for ed25519_sign; the SignedTxn ready for algorand_send | pure |
algorand_address(pubkey_or_secret) | Algorand base32 address with checksum | pure |
solana_pda(seeds, program) / spl_ata(owner, mint, token_program?) | 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 |
evm_rpc / evm_block_number / evm_nonce / evm_balance / evm_gas_price / evm_chain_id / evm_estimate_gas / evm_call / evm_fee_history / evm_logs | EVM read side (hex-quantities handled, strict) | require net |
evm_send / evm_receipt / evm_wait | 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_wait | 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§
An agent that can sign byte-perfect transactions still needs the nonce, the EIP-1559 fees, the chain id or the recent_blockhash — and hand-rolling JSON-RPC over http_post for them means 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 evm_nonce(url, evm_address(k)) -- eth_getTransactionCount ("pending")
let fees be evm_fee_history(url) -- {base_fee, priority, base_fees, rewards}
-- BUILD: every value-moving field explicit (a missing one errors naming the reader)
let tx be evm_tx({"chain_id": evm_chain_id(url), "nonce": nonce, "to": dest,
"value": 10**17, "gas": 21000, -- 0.1 ETH in wei: 10**17, never 1e17 (a float)
"max_fee": fees["base_fee"] * 2 + fees["priority"], "max_priority": fees["priority"]})
-- tx echoes the numbers (tx.max_fee, tx.value, tx.to) — `confirm` them BEFORE signing
let sig be secp256k1_sign(tx.digest, k) -- the ONE gated door
let raw be evm_tx_raw(tx, sig) -- v/r/s assembled for you
-- SEND and CONFIRM
let hash be evm_send(url, raw) -- "0x…" tx hash
let receipt be evm_wait(url, hash, 1, 120) -- nothing on timeout — never hangs
-- receipt["status"]: 1 = success, 0 = REVERTED (it landed but failed — always check)
-- Read contract state: evm_call returns RAW bytes → abi_decode (90% of contract reads)
let calldata be abi_encode("balanceOf(address)", [owner])
let bal be abi_decode("uint256", evm_call(url, {"to": token, "data": calldata}))[0]
The EVM readers, with every optional argument:
evm_nonce(url, address, block?)(default"pending"),evm_balance(url, address, block?)→ exact wei,evm_call(url, {to, data}, block?)→ raw bytes,evm_estimate_gas(url, tx_map, block?),evm_gas_price(url),evm_chain_id(url),evm_block_number(url)→ integer.evm_fee_history(url, blocks?, percentiles?, newest?)→{base_fee, priority, base_fees, rewards}:base_feeis the NEXT block's,prioritythe median of the first percentile column, and the raw arrays come along — the derivation is transparent, not an oracle.blocksis 1..1024, each percentile 0..100.- A
blockis"latest"(the default, except for the nonce),"earliest","pending","safe","finalized", a number, or the node's own"0x…"quantity — validated canonical, sohex(n)and a value read from the node both work, and"0x01"(a leading zero) is an error. evm_send(url, raw)→ the"0x…"hash;evm_receipt(url, hash)→ the typed receipt ornothing;evm_wait(url, hash, confirmations?, timeout?)→ the receipt after N confirmations, ornothingat the deadline.evm_logs(url, filter)→ logs decoded like receipt logs (see Events).evm_rpc(url, method, params?)is the escape hatch for any method not wrapped: integers go out as hex-quantities, bytes as0x…, and the result comes back as decoded JSON (integers beyond 64 bits stay exact). Themethodis the node's own name —"eth_getTransactionByHash","eth_getLogs"— those do not change.
Receipt decoding is typed: quantities → integers, addresses → EIP-55 text, hashes → "0x…" text, a log's data → bytes.
Solana mirrors the loop (solana_latest_blockhash → solana_tx → ed25519_sign → solana_tx_raw → solana_send → solana_wait; solana_balance/spl_balance for monitoring), and Algorand does too (algorand_params → txn map → algorand_tx → sign → algorand_tx_raw → algorand_send (the binary POST is handled) → algorand_wait). Their escape hatch is solana_rpc(url, method, params?).
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.
An API key in the RPC URL (engine v0.6.46+). Most providers put the key in the path. Keep it a secret and bind it to the provider's host; then the URL that holds it works in evm_rpc and the other JSON-RPC builtins, algod's calls (algorand_params, algorand_account, …), btc_rpc and ws_connect:
require secret("RPC_KEY") for net("eth-mainnet.example.com") -- the key may go only there
require net("eth-mainnet.example.com")
let k be secret("RPC_KEY")
let url be `https://eth-mainnet.example.com/v2/{k}` -- a secret: the key is written at the socket
print(evm_rpc(url, "eth_chainId"))
Without the binding the call is the error evm_rpc: the URL is secret(K), and an unbound secret only goes in a header. If the API takes it in the URL, bind it to the host it goes to: require secret("K") for net("…")`. The key goes only to the hosts of the binding and only over https/wss (rules in Secrets); a JSON-RPC error that echoes it comes back masked (RPC error -32000: bad key [secret K]). The params of a call still cannot hold a secret: evm_rpc: params[0] is a secret — a secret never goes into an RPC request. And with a wildcard net grant, a node on a private address (127.0.0.1, 10.x, a local devnet) has to be named — require net("127.0.0.1:8545")` — see HTTP client.
Build an EVM transaction: evm_tx§
evm_tx(params) takes {chain_id, nonce, to, value, gas, max_fee, max_priority, data?, access_list?}. Every value-moving field is explicit — a missing one is an error that names where to read it (missing "max_fee" … read it with evm_fee_history(url) or state it), and a missing to points to evm_tx_create. data is the calldata of a contract call (abi_encode(...)); access_list is a list of {address, storage_keys} (EIP-2930; any other key is an error). The result is {digest, fields, + every number echoed}: digest is what you sign, fields the transaction as it will be encoded, and the echo (tx.to, tx.value, tx.max_fee, …) is what a confirm shows before the signature exists.
evm_tx_raw(tx, sig) assembles 0x02 ‖ rlp(fields + [v, r, s]) — and first re-checks the map: it recomputes the digest from fields and compares every echoed number against them. A map edited after evm_tx is refused ("value" does not match "fields" — the map was modified after evm_tx/evm_tx_create; build it again instead of editing it): what you confirmed is what gets signed.
Deploy a contract: evm_tx_create§
A deployment is a transaction without to whose data is the contract's init code: the compiled bytecode followed by the constructor arguments, ABI-encoded without a selector — abi_encode(types, values), the inverse of abi_decode.
require net("rpc.example.com")
require sign("HOT_KEY")
let url be "https://rpc.example.com"
let k be secret("HOT_KEY")
let me be evm_address(k)
let fees be evm_fee_history(url)
-- init code = bytecode ‖ constructor args (no selector); types as "(…)" or a list
let init be bytecode + abi_encode("(address,uint256)", [owner, 10**24])
let ctx be evm_tx_create({"chain_id": evm_chain_id(url), "nonce": evm_nonce(url, me),
"from": me, "value": 0, "gas": 1500000,
"max_fee": fees["base_fee"] * 2 + fees["priority"], "max_priority": fees["priority"],
"data": init})
print(ctx.contract_address) -- where it lands: == evm_create_address(me, ctx.nonce)
let hash be evm_send(url, evm_tx_raw(ctx, secp256k1_sign(ctx.digest, k)))
let receipt be evm_wait(url, hash, 1, 120)
-- receipt["contractAddress"] is the same address, and receipt["status"] == 1 means it deployed
evm_tx_create({chain_id, nonce, from, value, gas, max_fee, max_priority, data, access_list?}) returns the evm_tx map with to: nothing, from, and contract_address (EIP-55) — computed before anything is signed, so you can show it in the confirm. It refuses a to, an empty data, and init code over 49 152 bytes (EIP-3860). evm_tx_raw then recovers the signer: a signature from any key other than from is an error, because the address you showed would be wrong; a creation map without from is refused, and contract_address is derived again and must match. The address holds as long as the nonce is the one the chain uses — read it with evm_nonce right before.
The two address formulas are pure builtins too:
evm_create_address(sender, nonce)— CREATE:keccak256(rlp([sender, nonce]))[12:]. A nonce of 2⁶⁴ − 1 or more is an error (EIP-2681).evm_create2_address(deployer, salt, init_code_hash)— CREATE2 (EIP-1014), what factories use:saltis 32 bytes,init_code_hashiskeccak256(init_code).
Events: logs by parameter name§
A contract reports what happened through events. You read them with evm_logs(url, filter) and decode each one with the event's fragment — the entry of the contract's ABI JSON, as the compiler writes it:
require net("rpc.example.com")
let url be "https://rpc.example.com"
let transfer be {"name": "Transfer", "inputs": [
{"name": "from", "type": "address", "indexed": true},
{"name": "to", "type": "address", "indexed": true},
{"name": "value", "type": "uint256", "indexed": false}]}
let head be evm_block_number(url)
let logs be evm_logs(url, {"address": token, "topics": [abi_event_topic(transfer)],
"fromBlock": hex(head - 1000), "toBlock": "latest"})
each lg in logs
let ev be abi_decode_log(transfer, lg) -- {from, to, value}
print(ev.from + " → " + ev.to + ": " + text(ev.value))
- The filter uses the node's wire names and is validated before any request:
address(one or a list),topics(at most 4; each a"0x…"/bytes32,nothing= any, a list = any of them), andfromBlock/toBlockorblockHash, never both. abi_event_topic(event)→ topic 0 as"0x…", from the fragment or from the canonical signature ("Transfer(address,address,uint256)").abi_decode_log(event, log, default?)→ a map by parameter name: the indexed parameters come fromtopics, the rest fromdata. The event must be the fragment (a signature string does not carry the names). A log of another event is an error — ordefaultwhen you pass one, which is how you skip the rest of a mixed list. Two inputs that would get the same name are an error (one value would be lost).
abi_decode_log works the same on the logs of a receipt (evm_receipt, evm_wait) and on the notifications of a live subscription (see WebSocket).
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 — evm_nonce, evm_fee_history, evm_tx, evm_call, evm_wait — works unchanged. The one rule: read the chain id from the node (evm_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) evm_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. evm_tx_raw does it for you; by hand, bytes_to_int(slice(sig, 0, 32)), and int_to_bytes(n, 32) restores the fixed width. |
"v is v" | Two conventions. secp256k1_sign gives the raw recovery id, 0/1 (what evm_tx_raw wants); contracts and wallets use 27/28 — evm_signature(sig) converts. secp256k1_recover accepts both. See below. |
| "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. A uint256 amount needs an exact integer: 1e24 is a float literal (as in Python) and is rejected; write 10**24 or 1_000_000_000_000_000_000_000_000, or int("…") from text (int("0x…") for a node's quantity). Divide amounts with // (exact), never / (a float). |
"evm_tx without to deploys a contract" | No. A missing to is an error that points to evm_tx_create, which computes contract_address from from + nonce and refuses a signature from any other key — the address you print is the one the chain uses. |
| "I can tweak the tx map after building it" | No. evm_tx_raw recomputes the digest from fields and checks every echoed number: an edited map is refused. Build it again. |
| "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 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_tx 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. EVM 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. A grant with a port covers only that port: require net("http://localhost:8545") is net("localhost:8545"). |
"evm_tx 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/nonce/chain_id errors naming the reader (evm_fee_history/evm_estimate_gas/evm_nonce/evm_chain_id) — 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. |
"evm_wait blocks until the tx confirms" | No. The three waiters (evm_wait/solana_wait/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 and EVM L2s | 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 |
Every EVM 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 (eth-account, solana-sdk/solders, algosdk). Solana also has SPL tokens (PDAs, associated accounts, TransferChecked), and all of them have HD custody (see below).
- Solana:
solana_tx({fee_payer, recent_blockhash, instructions, version?})→ the message bytes to sign withed25519_sign. Each instruction is{program, accounts: [{pubkey, signer?, writable?}], data}. A v0 message needs"version": 0— it carries the0x80prefix and the signature covers it;lookup_tablesis not supported yet (a directed error).solana_latest_blockhash(url)returns therecent_blockhashas bytes(32), ready to drop in.solana_tx_raw(msg, sig_or_list)→ the wire transaction (one signature, or a list with one per signer); broadcast withsolana_send(url, tx)(base64 handled) and confirm withsolana_wait(url, signature, timeout?)→{err, confirmation_status, slot}ornothing. 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({type, snd, rcv, amt, fee, fv, lv, gen, gh, note, …})(the protocol's short field names; text addresses are checksum-validated) → the bytes to sign withed25519_sign.algorand_tx_raw(txn, sig)→ the SignedTxn;algorand_send(url, stx)does theapplication/x-binaryPOST for you andalgorand_wait(url, txid, timeout?)confirms. Every algod call takes an optional trailingheadersmap (X-Algo-API-Tokencan be asecret). The TXID is composable:decode(sha512_256(algorand_tx(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, token_program?)derives the associated token account (itself a PDA).spl_transfer_data(amount)/spl_transfer_checked_data(amount, decimals)are the instruction data — feed them intosolana_txlike any other instruction;spl_balance(url, owner, mint, token_program?)→{amount, decimals, ata}. All pure except the balance.
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(evm_address(k), "0x9858EfFD232B4033E47d90003D41EC34EcaEda94")
test "Solana: same seed → SLIP-0010 m/44'/501'/0'/0' (ed25519) == Phantom's address 0"
let seed be mnemonic_to_seed(as_secret(STD, "W"))
let k be hd_derive(seed, "m/44'/501'/0'/0'", "ed25519")
assert_eq(decode(ed25519_pubkey(k), "base58"), "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk")
test "the SLIP-0010 curve rejects a non-hardened index (Solana derivation is hardened-only)"
assert_error(derive_solana_with_bip32_path)require wallet -- creating custody (separate from `sign`)
let phrase be mnemonic_generate(12, "W") -- secret: 12 words, OS entropy (not seedable)
let seed be mnemonic_to_seed(phrase) -- secret: 64-byte BIP-39 seed (optional passphrase)
let ethk be hd_derive(seed, "m/44'/60'/0'/0/0") -- secret: BIP-32 secp256k1 (default) → evm_address/secp256k1_sign
let solk be hd_derive(seed, "m/44'/501'/0'/0'", "ed25519") -- secret: SLIP-0010 (Solana; hardened-only)
print(evm_address(ethk)) -- derive the PUBLIC address (no gate)
-- Algorand's phrase is its OWN 25-word format (NOT BIP-39):
let am be algorand_mnemonic(algo_secret32) -- secret: 25 words (Pera/Defly format)
-- Import an existing wallet from a Geth/MyEtherWallet keystore V3:
let k be keystore_import(json_text, secret("KS_PASS"), "HOT") -- secret; wrong pass → error, no leak
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, 10**24])
-- ...goes into the `data` field of the evm_tx you already know how to build.
-- Reading calldata back (audit what you're about to sign):
let args be abi_decode("(address,uint256)", slice(data, 4, length(data)))
-- SIWE login: the backend verifies with ecrecover
let digest be eip191_digest(siwe_message)
let sig be secp256k1_sign(digest, k)
-- server side: evm_address(secp256k1_recover(digest, sig)) == the user's address
-- (a real wallet's signature carries v = 27/28: secp256k1_recover takes it as is)
-- ERC-2612 permit (gasless approval): readable maps in, digest out, gated signing
let domain be {"name": "USD Coin", "version": "2", "chainId": 1, "verifyingContract": usdc}
let types be {"Permit": [{"name": "owner", "type": "address"}, {"name": "spender", "type": "address"},
{"name": "value", "type": "uint256"}, {"name": "nonce", "type": "uint256"}, {"name": "deadline", "type": "uint256"}]}
let permit be {"owner": my_addr, "spender": router, "value": 1000000, "nonce": 0, "deadline": 1893456000}
let approved be confirm "Sign permit for " + json_encode(permit) + "?" within 15m
when approved
let sig be evm_signature(secp256k1_sign(eip712_digest(domain, types, "Permit", permit), k))
-- the contract's permit(v, r, s): v = sig[64] (27/28), r = slice(sig, 0, 32), s = slice(sig, 32, 64)
eip712_digest 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.
The recovery byte v has two conventions, and you will meet both. secp256k1_sign returns the raw recovery id, 0/1 — what go-ethereum's crypto.Sign, libsecp256k1 and typed EIP-1559 transactions use (evm_tx_raw wants exactly that). Everything a contract verifies — ecrecover, OpenZeppelin's ECDSA.recover, an EIP-2612 permit, an EIP-712 authorization — and every wallet (MetaMask, ethers, viem: personal_sign, signTypedData) writes it as 27/28. evm_signature(sig) converts a Synsema signature for a contract: it adds 27, is idempotent on a signature that already carries 27/28, and refuses any other v. The other way needs nothing: secp256k1_recover accepts 0, 1, 27 and 28 (a v of 35 or more is a legacy EIP-155 transaction value, and the error says so); secp256k1_verify ignores v. Verified against a live ProcessorEndpoint and a permit: with the raw byte the contract reverts, with 27/28 it recovers the signer.
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 withevm_balance).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.
require serve(8080)
require net("rpc.example.com")
require sign("HOT_KEY")
serve on 8080
route "POST /send"
let url be "https://rpc.example.com"
let k be secret("HOT_KEY")
-- READ what the tx needs (no hand-rolled JSON-RPC)
let fees be evm_fee_history(url)
let tx be evm_tx({"chain_id": evm_chain_id(url),
"nonce": evm_nonce(url, evm_address(k)), "to": dest,
"value": 10**17, "gas": 21000,
"max_fee": fees["base_fee"] * 2 + fees["priority"], "max_priority": fees["priority"]})
-- SHOW the fees BEFORE signing (nothing hidden inside a blob)
let approved be confirm "Send 0.1 ETH, max fee " + text(tx.max_fee) + "?" within 15m
when not approved
give fail(403, "not approved")
-- SIGN (the one gated door) and assemble
let sig be secp256k1_sign(tx.digest, k)
-- SEND and CONFIRM (bounded — a stuck tx never hangs the route)
let hash be evm_send(url, evm_tx_raw(tx, sig))
let receipt be evm_wait(url, hash, 1, 120)
give when receipt == nothing then fail(504, "not confirmed in time") otherwise receipt
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. An EVM node's eth_subscribe (newHeads/logs) composes in userland today: ws_connect to the node's WS endpoint, send the subscribe frame, ws_recv the notifications and decode each log with abi_decode_log. 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/evm_call/logs/receipts, Solana blockhash/status, algod params/wait), build (evm_tx, evm_tx_create), sign, send and confirm — plus contract deployment and events, dApp signatures (191/712), SPL tokens, HD custody (BIP-39/32, SLIP-0010, Algorand-25, keystore V3) and a live WebSocket client ship today.
Renamed in v0.6.29§
The old names still run until v1.0: a program that uses one prints a single warning on stderr, and synsema check flags every use. Two of them changed meaning — read those rows before a search-and-replace.
| Before v0.6.29 | Now | Note |
|---|---|---|
eth_rpc · eth_nonce · eth_balance · eth_gas_price · eth_chain_id · eth_estimate_gas · eth_call · eth_fee_history | evm_rpc · evm_nonce · evm_balance · evm_gas_price · evm_chain_id · evm_estimate_gas · evm_call · evm_fee_history | the JSON-RPC method names you pass to evm_rpc stay "eth_call", "eth_getLogs", … |
eth_send_raw · eth_receipt · eth_wait_receipt · eth_address | evm_send · evm_receipt · evm_wait · evm_address | |
tx_eip1559 · tx_eip1559_raw | evm_tx · evm_tx_raw | contract creation is evm_tx_create |
solana_message(params) | solana_tx(params) | ⚠️ solana_tx now builds the message |
solana_tx(message, signatures) | solana_tx_raw(message, signatures) | ⚠️ the old two-argument solana_tx is an error that points here |
solana_confirm | solana_wait | |
algorand_tx_encode(txn) | algorand_tx(txn) | ⚠️ algorand_tx now returns the bytes to sign |
algorand_tx(txn, sig) | algorand_tx_raw(txn, sig) | ⚠️ the old two-argument algorand_tx is an error that points here |
algo_address | algorand_address |