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:
- builds the transaction it wants to make, as a semantic JSON object;
- 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.)
POSTs the semantic JSON transaction + that signature to the latch's/proxyendpoint.
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 filter —
rialo_tx_signersorevm_tx_signers. This does two things: it allowlists your agent's authentication key (its base58 ed25519 pubkey for Rialo, or its0xaddress 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,bytesN— not dynamic ABI types (string, dynamicbytes, arrays, tuples) — and you pass the 4-byteselectorexplicitly (no function-name resolution). Rialo supportsu8–u64/i64,bool,pubkey,bytes,stringscalars, or opaquedataBase64; 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_signersallowlist — 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.