RPC calls and latency
A sign’s own work (the key lock, reading and writing the audit log) takes about 20 ms. Everything else is RPC round trips, one after another. Against rpc1.z.ink each round trip took about 157 ms in the traces below, so the number of calls, and whether each one needs a new connection, decide how long a sign takes.
Connections
Rpc::new (src/rpc.rs) hands every caller in a process the same HTTP client for a given RPC URL, so they share one connection pool. In serve that means every request thread and every key; a CLI command is its own process, so it starts with an empty pool every time.
stateDiagram-v2
[*] --> Opening: a call finds no usable idle connection
Opening --> InUse: TCP and TLS handshake (about two extra round trips)
InUse --> Idle: the call returns
Idle --> InUse: the next call within 2 minutes, after checking the server has not closed it
Idle --> Closed: idle for 2 minutes (MAX_IDLE)
Idle --> Closed: the server closed it (found when it is next taken from the pool)
InUse --> Closed: transport error or the 15 s call timeout (RPC_TIMEOUT)
Idle --> Closed: more than 3 idle for one host, or 10 in all
Closed --> [*]
- Opening costs about two extra round trips: the first call on a new connection took 475 to 500 ms against 157 ms for the rest.
- Idle time is
MAX_IDLE, 2 minutes (the library,ureq, defaults to 15 s, shorter than an agent’s usual pause between signs). Before reusing an idle connection,ureqchecks whether the server has closed it and opens a new one if so, so a server that closes sooner costs a handshake, not an error. The server could still close it just as it is reused; the first call of a sign is always a read, neversendTransaction. - How many:
ureqkeeps at most 3 idle connections per host and 10 in all. Signs running at the same time inserveeach take a connection; beyond 3 at once, the extra ones are closed after use and the next burst opens them again. - The process ends, and its connections with it. Restarting
servestarts cold.
The genesis hash
The chain check compares the RPC’s genesis hash with the cluster’s. A process reads it once per RPC URL and reuses it for 5 minutes (GENESIS_TTL), so most signs make no getGenesisHash call, and a URL that starts answering for another chain is refused within 5 minutes. A CLI command reads it every time.
A sign’s calls
sequenceDiagram
autonumber
participant S as Signer
participant R as RPC
opt the genesis hash is not cached (first sign, every 5 min, every CLI command)
S->>R: getGenesisHash
end
opt the payload has partial signers / creates token accounts
S->>R: getMultipleAccounts (new accounts must not exist, and token-account owners)
end
S->>R: getLatestBlockhash
S->>R: getBalance (the key, before)
S->>R: simulateTransaction
S->>R: getFeeForMessage
opt the simulation says BlockhashNotFound (signing only)
S->>R: getLatestBlockhash, simulateTransaction (once, with a fresh blockhash)
end
opt a confirm key: after the person approves
S->>R: getBalance, getLatestBlockhash, simulateTransaction, getFeeForMessage (a fresh blockhash)
end
S->>R: getBlockHeight (finalized: is the blockhash still valid?)
opt the blockhash is already past its last valid height
S->>R: the same four calls with a fresh blockhash, then getBlockHeight again
end
opt the signature is already in the audit log
S->>R: the same four calls with a fresh blockhash
end
Note over S: write the sign-intent entry
S->>R: sendTransaction (skipPreflight, maxRetries 0)
loop wait 400 ms, then 800 ms, then 1 s, until an outcome is proven (at most 150 s)
S->>R: getSignatureStatuses
opt not landed yet
S->>R: getBlockHeight (finalized), and past the blockhash's last valid height: getSignatureStatuses with history
end
opt every third check
S->>R: sendTransaction (rebroadcast)
end
end
S->>R: getTransaction (the exact fee and balance change, up to 10 tries)
Note over S: write the final sign entry
check stops after getFeeForMessage: it neither sends nor polls. getTransaction stays even though it costs a round trip: the daily cap charges what a transaction actually cost, not what it was simulated to cost.
Measured
Two signs by one session key through serve, about 27 s apart, against rpc1.z.ink (traces from 2026-11-09):
| Build | First sign | Sign after the pause | confirm | Calls in that sign |
|---|---|---|---|---|
| Before the connection changes | 2.76 s | 2.79 s | 1.48 s | 9 |
| One pool per URL, genesis cached | 2.80 s | 2.70 s (a new connection: 15 s idle limit) | 1.48 s | 8 |
| Plus 2 min idle, first check at 400 ms | 2.19 s | 1.70 s | 0.88 s | 8 |
The 1.70 s is now five reads before sending (about 800 ms), the send, the 400 ms wait, the status check and getTransaction (about 880 ms), and about 20 ms of local work.
Tracing a sign
Run with SA_FORGE_SIGNER_LOG=debug to see every RPC call with its method and duration (Tracing), and SA_FORGE_SIGNER_LOG_FORMAT=json to filter it:
jq -c 'select(.fields.message == "close")
| {span: .span.name, method: .span.method, busy: .fields."time.busy"}' trace.jsonl
just bench times the audit log’s reads on synthetic logs; it does not touch the RPC.
If a trace shows a problem
| What the trace shows | Likely cause | What to change |
|---|---|---|
| The first call of a sign after a pause takes about 3 times the others | The server closes idle connections sooner than 2 minutes | A light keep-alive call (such as getHealth) while serve is idle, or accept the handshake |
| A transport error on the first call after a pause | The server closed the connection just as it was reused | Retry a read once on a new connection; never retry sendTransaction blindly (a rebroadcast of the same signed transaction is safe, which the poll loop already does) |
Handshakes during bursts of parallel signs in serve | More than 3 connections in use at once | Raise max_idle_connections_per_host |
| Every call takes 150 ms or more | Network distance to the RPC | A closer RPC (Security on trusting it) |
| The five reads before sending dominate | They run one after another | Batch them as JSON-RPC batch requests (blockhash, balance and block height; then simulation and fee), if the RPC accepts batches: about 3 fewer round trips |
| Several status checks before the transaction lands | A congested cluster or slow slots | Tune the poll schedule (FIRST_POLL, POLL) |
| A CLI loop is slow on every sign | Each CLI command starts cold: a handshake and getGenesisHash | Run the loop through serve |
These are listed under “RPC latency” in the Plan’s backlog; none is planned until a trace shows the need.