Latch / Docs
guides

Transaction signing

Sign and submit on-chain transactions through a Privy wallet, under a policy you define — your wallet key is never shared with your agent.

A transaction-signing latch lets your agent submit on-chain transactions under a policy you control, without ever holding the wallet key. Signing is done through a Privy server wallet: the funded wallet lives in Privy, the latch's enclave asks it to sign only after your policy passes, and the signed transaction is forwarded to the chain. Works on Rialo (ed25519) and EVM / Ethereum (EIP-1559).

This guide uses Privy as the signer. The pipeline, the request flow, and the rialo_tx_signers / evm_tx_signers filters are the same whichever signer a latch uses.

The model: two keys, two jobs

There are two keys, and keeping them straight is the whole idea.

  • Your wallet is a Privy server wallet. It holds the funds and is the sole on-chain signer. Its key stays inside Privy — it is never shared with your agent, and the latch never holds it either.
  • Your agent has its own authentication key — a signing key that is not the wallet key, holds no funds, and can be revoked at any time. The agent signs each request with it to prove the request is genuinely from your agent.

For every transaction, your agent:

  1. builds the transaction it wants to make, as a semantic JSON object;
  2. compiles it to its canonical on-chain form — the Rialo message, or the EIP-1559 signing hash — and signs those bytes with its authentication key. (It signs the compiled transaction, not the JSON — and those are the exact same bytes the enclave recomputes and Privy ultimately signs.)
  3. POSTs the semantic JSON transaction + that signature to the latch's /proxy endpoint.

Inside the enclave, the latch evaluates your policy pipeline (which reads the semantic JSON), recompiles the transaction and checks the authentication signature over those bytes against the *_tx_signers allowlist, and — only if everything passes — asks Privy to sign the exact same bytes, then forwards the signed transaction to the chain. Because the filters inspect the JSON and the signatures cover the compiled form the JSON produces, a filter gates exactly what gets signed.

The authentication key proves who is asking; your policy decides what is allowed. Anything outside the policy is denied before it is ever signed.

1. Create the Privy signer secret

In the dashboard, New secret → Privy signer, and paste your Privy App ID and App Secret. (Or POST /admin/secrets with type: "privy_signer" and credential: '{"appId":"…","appSecret":"…"}'.)

Latch enrolls the secret for you: it generates a P-256 authorization key, creates one Privy wallet per chain type (Rialo and EVM) owned by that key, and returns the two wallet addresses — a 0x EVM address and an ed25519 Rialo address. The EVM wallet is a single secp256k1 keypair that works across every EVM chain (the chainId is part of each transaction, not the wallet). The wallet keys stay in Privy; the P-256 key is held for the latch and never leaves the enclave at request time.

Fund the address for the chain you're using — it is the account that pays fees and holds the assets you're transacting with.

2. Create the latch and its policy

Create a TEE latch whose upstream is the chain's RPC endpoint (the Rialo RPC, or your EVM JSON-RPC URL), and give it the Privy signer secret. Then build the pipeline:

  • A signer filterrialo_tx_signers or evm_tx_signers. This does two things: it allowlists your agent's authentication key (its base58 ed25519 pubkey for Rialo, or its 0x address for EVM), and it sets the chain type the latch signs for. Use ["*"] to make the latch permissionless — then no authentication signature is required and the rest of the pipeline is the only gate.
  • Payload filters — constrain which transactions are allowed (see below).
{ "type": "evm_tx_signers", "name": "Allowed authorizers",
  "signers": ["0x<your agent's authentication address>"] }

The Rialo counterpart allowlists the agent's base58 ed25519 pubkey instead:

{ "type": "rialo_tx_signers", "name": "Allowed authorizers",
  "signers": ["<your agent's authentication pubkey (base58)>"] }

3. The transaction schema

The request body is { "transaction": {…}, "signatures": … }, POSTed to the proxy (next section). A payload filter reads $.transaction.*, so it can gate any field of the transaction below — and, per the model above, it gates exactly what gets signed. Construct the transaction for the chain type the latch's signer filter selects.

EVM (EIP-1559)

Field Type Notes
chainId number or 0x-hex Required, non-zero (EIP-155).
nonce number or 0x-hex The signer account's transaction count.
maxPriorityFeePerGas, maxFeePerGas number or 0x-hex EIP-1559 gas.
gasLimit number or 0x-hex Required, non-zero.
to 0x-hex (20 bytes) Recipient, or the contract for a call.
value number or 0x-hex Wei.
data see below Calldata — raw or typed.
from 0x-hex (20 bytes), optional Sender pin; if set, must equal the wallet address.

data is either a raw 0x-hex string (opaque calldata) or a typed object { "selector": "0x…" (4 bytes), "args": [{ "type": …, "value": … }] } that the enclave ABI-encodes — the typed form is what lets a filter read $.transaction.data.args[*].value. Arg types: address, uintN, intN, bool, bytesN. signatures is an array of 0x-hex ECDSA signatures.

{
  "transaction": {
    "chainId": 11155111, "nonce": 0,
    "maxPriorityFeePerGas": "0x3b9aca00", "maxFeePerGas": "0x77359400", "gasLimit": 60000,
    "to": "0x<ERC-20 contract>", "value": "0x0",
    "data": { "selector": "0xa9059cbb", "args": [
      { "type": "address", "value": "0x<recipient>" },
      { "type": "uint256", "value": "250000" }
    ]}
  },
  "signatures": ["0x<authentication ECDSA signature>"]
}

Rialo

Field Type Notes
feePayer base58 pubkey The Rialo wallet address; pays the fee and fills the first signer slot.
validFrom number or decimal string Replay-window start (unix ms).
configHashPrefix number or decimal string (u64) From the getRecentValidatorConfigHash RPC.
occ bool Optimistic-concurrency flag.
instructions array Each: programId (base58), accounts ([{ pubkey, isSigner, isWritable }]), and data.

An instruction's data is either a typed object { "prefixHex": "…", "args": [{ "type": …, "value": … }] } (a leading tag/discriminator plus encoded scalar args) or "dataBase64": "…" (opaque bytes). Arg types: u8, u16, u32, u64, i64, bool, pubkey (base58), bytes (base64), string. signatures is a map { "<base58 pubkey>": "<base64 signature>" }.

{
  "transaction": {
    "feePayer": "<Rialo wallet address>",
    "validFrom": 1730000000000, "configHashPrefix": "8888927001659606077", "occ": false,
    "instructions": [{
      "programId": "11111111111111111111111111111111",
      "accounts": [
        { "pubkey": "<feePayer>",  "isSigner": true,  "isWritable": true },
        { "pubkey": "<recipient>", "isSigner": false, "isWritable": true }
      ],
      "data": { "prefixHex": "02000000", "args": [ { "type": "u64", "value": 1000000 } ] }
    }]
  },
  "signatures": { "<authentication pubkey (base58)>": "<base64 signature>" }
}

Constrain it

A payload filter gates any of those fields. "Only an ERC-20 transfer on this token, to an allowlisted recipient, of at most N" (EVM):

{ "type": "payload", "name": "Only transfer, to allowlisted recipients, up to a cap", "rules": [
  { "path": "$.transaction.to",                 "operator": "equals",             "value": "0x<token contract>" },
  { "path": "$.transaction.data.selector",      "operator": "equals",             "value": "0xa9059cbb" },
  { "path": "$.transaction.data.args[0].value", "operator": "in",                 "value": ["0x<recipient 1>", "0x<recipient 2>"] },
  { "path": "$.transaction.data.args[1].value", "operator": "less_than_or_equal", "value": "1000000000000000000" }
]}

The amount and the cap are compared as big integers, so large values keep full precision. In the request JSON a uint256 is a quoted string"value": "1000000000000000000", as above — because it exceeds 2⁵³ and would lose precision as a bare number. In the dashboard's filter value field just type the digits (1000000000000000000, no quotes); the editor keeps a cap beyond 2⁵³ as a string for you.

A different transaction on Rialo — "a single native transfer, to an allowlisted recipient, of at most N" (note it's a System-program instruction, not an ERC-20 call):

{ "type": "payload", "name": "Capped transfer to allowlisted recipients", "rules": [
  { "path": "$.transaction.instructions", "operator": "max_length", "value": 1 },
  { "path": "$.transaction.instructions[0].programId",          "operator": "equals", "value": "11111111111111111111111111111111" },
  { "path": "$.transaction.instructions[0].accounts[1].pubkey", "operator": "in",     "value": ["<recipient 1>", "<recipient 2>"] },
  { "path": "$.transaction.instructions[0].data.args[0].value", "operator": "less_than_or_equal", "value": 1000000 }
]}

Gate by recipient, amount, contract or program, and transaction shape — a rule that fails denies the request before it is signed.

Limitations

  • Argument types (both chain types): scalar/static only. EVM supports address, uintN, intN, bool, bytesNnot dynamic ABI types (string, dynamic bytes, arrays, tuples) — and you pass the 4-byte selector explicitly (no function-name resolution). Rialo supports u8u64/i64, bool, pubkey, bytes, string scalars, or opaque dataBase64; no nested/non-scalar arg types.
  • EVM transaction type: EIP-1559 (type 2) only. Legacy (type 0) / EIP-2930 (type 1), and newer types — blob (EIP-4844) and set-code (EIP-7702) — plus ERC-4337 UserOperations, are not supported. Rialo uses legacy messages only (no versioned messages / address-lookup-tables).

4. Make a request

Call the latch's proxy with the semantic transaction plus your agent's authentication signature over it:

POST /proxy/rpc
Authorization: Bearer lat_…
Content-Type: application/json

The authentication signature must be over the exact bytes the enclave recompiles (the Rialo message, or the EIP-1559 signing hash) — if they diverge, the enclave rejects the request. Build and sign requests with the runnable example in examples/txsign-demo/ (build-and-send-rialo.mjs / build-and-send-evm.mjs), which compiles and signs exactly what it sends; the two build-and-send-* scripts work unchanged whether the latch signs with Privy or a held key — only the secret differs. On success the response is the chain's sendTransaction / eth_sendRawTransaction result.

Safety and revoking access

  • The wallet key stays in Privy and is never shared with your agent. The latch never holds it.
  • Revoke an agent by removing its authentication key from the *_tx_signers allowlist — the funded wallet is unaffected, and the agent can no longer authorize anything.
  • The authentication signature is an off-chain approval only. It is verified in the enclave and never embedded in the broadcast transaction; the Privy wallet is the sole on-chain signer.