Prototype Notice: Lobby is currently in active development. APIs, features, and behaviours described in this document may change in future releases. Please refer to the GitHub repository for the latest updates.
Do Not use Lobby for transferring real money on EVM accounts. This software is intended for testing and development purposes only.
Author Note: This
README.mdis written with the help of anLLM, I have corrected any possible loose text to ensure that the instructions and contents remain clear.
Version: 0.1.0 (Prototype)
Last Updated: April 21 2026
Target Audience: Contributors, Devs intrested in learning EVM transaction mechanics and LLMs.
Lobby is a high-performance, low-latency blockchain transaction service written in rust and designed for developers who need reliable, concurrent transaction processing at scale on EVM-compatible networks.
Sub-second internal processing — Nonce assignment and signing are typically complete in under one second through optimised actor-based concurrency.
Concurrent pipeline architecture — Process thousands of transactions simultaneously via actor-based sharding, with deterministic routing ensuring sequential nonce assignment per address while maintaining parallelism across different accounts.
Automatic recovery — Built-in retry logic for transient failures, nonce mismatch detection with automatic sync and re-sign, and stale nonce cleanup via background processes.
Multi-chain support — Single API for Ethereum mainnet, Polygon, Arbitrum, Hoodi testnet, and other EVM-compatible networks.
Real-time status tracking — Poll transaction progress from submission through on-chain confirmation with detailed status at every pipeline stage.
- Rust 1.70+ with Cargo
- Docker and Docker Compose
- Access to EVM RPC endpoints (Alchemy, Infura, or self-hosted nodes)
Locketis a Rust app that I use to generate EVM-compatible keys for my testing environments.
- Clone
Locketfor EVM compatible keys generation
# Clone and run Locket for test account generation
git clone https://github.com/romanticNomad/Locket.git
cd Locket
# using 5 as an example, you may use any (positive) number
# doing so generates the N keys and puts them in a `accounts.json` file.
cargo run -- accounts 5
# move out of the locket repo
cd ..Expected outcome
Generated 5 account(s) in accounts.json- Clone the repository
git clone https://github.com/romanticNomad/Lobby.git
cd Lobby- Move the
accounts.jsonkeys totest_keys.json
mv ../Locket/accounts.json test_keys.json- Run the Makefile setup
make lobbyupRunning the above command would:
- Copy the database URLs from
.env.exampleto.env. - Build and run Docker containers for
PostgreSQL,RedisandRedisInsights. - Generates the
api_keysfrom the copied test_keys and puts them in the.env, only safe for test environments.
Expected outcome
Setting up Environment
cp .env.example .env
cd database && docker compose up -d
[+] up 7/7
✔ Network database_default Created 0.0s
✔ Volume database_lobby_redis_data Created 0.0s
✔ Volume database_lobby_redisinsight_data Created 0.0s
✔ Volume database_lobby_pgdata Created 0.0s
✔ Container lobby-redis Started 0.3s
✔ Container lobby-postgres Started 0.4s
✔ Container lobby-redis-insight Started 0.4s
cd database && sqlx migrate run
Applied 20260128093012/migrate nonce (6.281891ms)
Applied 20260203055307/migrate broadcast (3.831075ms)
Applied 20260203062332/migrate sign (4.943295ms)
Applied 20260210152024/migrate relayhost (4.213486ms)
Applied 20260225203657/migrate validate (4.286634ms)
Generating API keys for the test-accounts
⏳ Compiling and generating keys: (76s)
Generated API keys appended to .env
Lobby Setup complete.- After running
make lobbyup, you need to manually add yourRPC_ENDPOINT(s) in the.envfile created during setup to run any kind of transaction.
Boilerplate for the endpoint-format is present in the .env.example file.
Skip this step if you have used the automatic setup.
- Clone the repository
git clone https://github.com/romanticNomad/Lobby.git
cd Lobby- Move the
accounts.jsonkeys totest_keys.json
mv ../Locket/accounts.json test_keys.jsontest_keys.json will be created in the project root (ensure it's in this format):
{
"account_N": {
"pvt_key": "0x<64_hex_characters>",
"pub_key": "0x<128_hex_characters>",
"address": "0x<40_hex_characters>",
}
}- Set up
PostgreSQLandRedisdatabases
# append the database url(s) to .env
cp .env.example .env
# start docker
cd database
docker compose up -d
# run sqlx migrations
sqlx migrate run
cd ..- Generate API keys using the
generate_api_keysbinary (included with lobby)
# It will automatically generate lobby-formatted API keys and add them to .env
# It is assumed that the keys are already generated and stored in test_keys.json (step-1)
cargo run --release --bin generate_api_keys- manually add your
RPC_ENDPOINT(s) in the.env(.env configuration shows in the following step)
Minimal .env configuration in the project root:
# urls
export DATABASE_URL="postgresql://lobby:lobby_dev_password@localhost:5432/lobby-db"
export SERVER_ADDR="0.0.0.0:3000"
export REDIS_URL="redis://localhost:6379"
# log level
export RUST_LOG="INFO"
# API key format: <token>:<client_id>:<from_address>
# There must be at least 1 API key(s) generated, depending on the number `N` you choose for key generation in Locket.
export LOBBY_API_KEY_1="<api-key-generated-by-the-generate_api_key-binary>"
# RPC endpoints (comma-separated for multiple endpoints per chain)
export RPC_ENDPOINT_1="https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY,https://mainnet.infura.io/v3/YOUR_KEY"
export RPC_ENDPOINT_560048="https://eth-hoodi.g.alchemy.com/v2/YOUR_KEY,https://hoodi.infura.io/v3/YOUR_KEY"
# shards and semaphore settings (can be customized according to requirements)
export NONCE_SHARDS=100
export SIGN_SHARDS=10000
export BROADCAST_SHARDS=100
export VALIDATOR_SHARDS=400
export PIPELINE_CONCURRENCY=10000
export ACTOR_BUFFER_SIZE=10000source .env
cargo run --release --bin lobbyExpected startup output:
INFOi [info]: "relation \"_sqlx_migrations\" already exists, skipping"
INFOi [info]: api_keys loaded: 5
INFOi [info]: custody accounts loaded: 5
INFOi [info]: rpc_endpoints loaded: {ChainId(560048): 2, ChainId(1): 2}
INFOi [info]: StatusRegistry loaded | artifacts_count: 0
INFOi [info]: cortex online
INFOi [info]: bots spawned: monitoring status
INFOi [info]: lobby listening at: | address: 0.0.0.0:3000- For standard test-mainnet gas parameters
- Value = 0.01 eth
- Use the API of a valid
addressfrom thetest_keys.jsonfor testing.
curl -X POST http://localhost:3000/v1/transactions \
-H "Authorization: Bearer <api-key>" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"method": "eth_sendRawTransaction",
"params": [{
"from": "<your_address>",
"to": "<recipient_address>",
"value": "0x2386f26fc10000",
"chainId": "0x88bb0",
"gas": "0x5208",
"maxFeePerGas": "0xba43b7400",
"maxPriorityFeePerGas": "0x77359400"
}],
"id": 1
}'- On successful acceptance, lobby sends an immidiate message to the client: Success Response (202 Accepted):
{
"jsonrpc": "2.0",
"result": {
"execution_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "accepted"
},
"id": 1
}for live status update, you may use the terminal of redis-insights
note: Port for redis-insights is5540.
- You would need a valid
API-KeyandExecutionIdto recieve transaction status
curl -X GET http://localhost:3000/status/{execution_id} \
-H "Authorization: Bearer <api-key>"- 3.1 Concurrency Management in Lobby
- 3.2 Pipeline Workflow
- 3.3 RPC Controls and Load Balancing
- 3.4 Error Handling
- 3.5 Private Key Security
Lobby employs a multi-layered concurrency model combining the actor pattern, sharding, and database-level atomicity.
Every mutable state in Lobby is owned exclusively by a single long-lived Tokio task (the engine). External code interacts via typed messages over MPSC channels with oneshot reply channels for request/response patterns.
Each actor follows a consistent three-file structure:
mod.rs — Public API with spawn function:
pub fn spawn_nonce_actor(db: PgPool, buffer_size: usize) -> NonceHandle {
let (tx, rx) = mpsc::channel(buffer_size);
let engine = NonceEngine::new(db, rx);
tokio::spawn(async move { engine.run().await });
NonceHandle::new(tx)
}engine.rs — Event loop processing commands serially:
pub async fn run(mut self) {
while let Some(cmd) = self.rx.recv().await {
match cmd {
NonceCommand::Reserve { reply, .. } => {
let result = self.handle_reserve(...).await;
let _ = reply.send(result);
}
}
}
}handle.rs — Cloneable sender implementing traits:
#[derive(Clone)]
pub struct NonceHandle { tx: mpsc::Sender<NonceCommand> }
#[async_trait]
impl NonceManager for NonceHandle {
async fn reserve(&self, ...) -> Result<TxNonce, LocalError> {
let (reply, rx) = oneshot::channel();
self.tx.send(NonceCommand::Reserve { ..., reply }).await?;
rx.await?
}
}This design eliminates data races by construction — no shared mutexes or atomics over actor state.
A tokio::sync::Semaphore bounds concurrent pipeline executions (default: 17). If all permits are exhausted, submit() blocks for a configurable timeout before returning BackpressureTimeout (HTTP 503). This prevents unbounded concurrency from overwhelming the database connection pool, RPC rate limits, or system memory.
Rather than single-actor bottlenecks or fully concurrent access, Lobby runs N sharded actor instances with deterministic routing:
| Actor | Sharding Key | Purpose |
|---|---|---|
| Nonce | ByAddress(&from_address) |
Sequential nonce assignment per address |
| Sign | ByExecutionId(&execution_id) |
Stateless load balancing for ECDSA operations |
| Broadcast | ByChainId(&chain_id) |
Per-chain RPC state isolation |
| Validator | ByChainId(&chain_id) |
Per-chain validation isolation |
Routing uses DefaultHasher: shard_index = hash(key) % N. Same key maps to same actor ensuring sequential ordering; different keys run in true parallel.
PostgreSQL atomic operations with FOR UPDATE SKIP LOCKED ensure TOCTOU-safe operations. All insertions use the pattern:
INSERT INTO table (execution_id, revision, ..., state)
SELECT $1, COALESCE((SELECT MAX(revision) FROM table WHERE execution_id=$1), 0) + 1, ..., 'state'
WHERE NOT EXISTS (
SELECT 1 FROM table
WHERE execution_id = $1
AND (state = 'finalized' OR (state = 'reserved' AND updated_at > now() - interval '2 minutes'))
)Lease-based idempotency provides 2-5 minute windows for operation completion. Revision-based audit trails record every state change immutably.
Every transaction flows through a five-stage pipeline orchestrated by the Cortex:
Client Submit
|
v
┌─────────────────────────────────────────────────────────────┐
│LOBBY PIPELINE │
├─────────────────────────────────────────────────────────────┤
│ 0. Authorization → Validates 'Authorization' header │
│ 1. RelayHost → Tx normalization & record persisting │
│ 2. Nonce Reserve → Assign sequential nonce │
│ 3. Sign → Generate EIP-1559 signature │
│ 4. Broadcast → Submit to blockchain RPC │
│ 5. Validator → Confirm on-chain inclusion │
└─────────────────────────────────────────────────────────────┘
|
v
Confirmed or Failed
Stage 0 — Authorization: Lobby's HTTP request requires an Authorization header
-H "Authorization: Bearer <api-key>" \The API key is required to be in the following format:
# API key format: <token>:<client_id>:<from_address>.
# where <token> is of the format 'lobby_live_{first 9 bytes of an Uuid}'.
# Example API_KEY_1:
LOBBY_API_KEY_1="lobby_live_5d6ea411d:07976c80-2601-4f1b-a500-a85a6353d681:0xfea6645d31443cf7cf96ef1db7823eb9365f98bf"- This allows the
authmodule to authorizes the HTTP request by mapping the<token>to the custodyClientConfig. - For transaction to succeed the
from_addressin the API-KEY must also be present in thetest_keys.jsonin the default format.
"account1": {
"pvt_key": "0x1b83d15d0c9c4d84d501d...",
"pub_key": "0x04e4a701883b403f553d4...",
"address": "0xaeb0ca871fe7aaaf3c797..."
}Stage 1 — RelayHost: Validates transaction fields (gas limits, fee sanity, supported chains) and persists to relay_host.transaction_intents. Idempotent by execution_id.
Stage 2 — Nonce Reserve: Atomically selects next available nonce with three priorities: (1) reuse released nonces, (2) next sequential nonce, (3) zero for new addresses. Single INSERT ... SELECT ... WHERE NOT EXISTS query eliminates TOCTOU windows.
Stage 3 — Sign: Loads private key from custody, signs using EIP-1559 RLP encoding with secp256k1 ECDSA. Returns SignedTransaction with embedded nonce.
Stage 4 — Broadcast: Submits signed transaction via RPC. Detects NonceTooLow errors and triggers automatic recovery. Uses sticky session endpoint affinity for nonce consistency.
Stage 5 — Validator: Polls eth_getTransactionReceipt until confirmation or timeout. Finalizes nonce on success; releases or consumes nonce on failure/timeout.
The HTTP handler responds immediately with execution_id and status: "accepted". The pipeline runs entirely in a spawned background task, enabling fire-and-forget submission with subsequent status polling via GET /status/:execution_id.
StatusRegistry provides two-layer storage: in-memory DashMap for O(1) reads and Redis for distributed persistence. Status transitions include:
PermitAcquired→Accepted→NonceReserved→Signed→Broadcasted→ConfirmedOnChain- Transient:
NonceMismatchDetected(during auto-recovery) - Terminal failure:
Failed { stage, reason } - Terminal timeout:
ValidatorTimedOut
The utils::rpc module provides high-throughput RPC operations with intelligent load balancing and endpoint health management.
Separate EndpointPool instances for Broadcast and Validator actors prevent broadcast traffic from affecting validator polling. Each endpoint is registered to both pools with separate provider instances and metrics for true isolation:
RpcProviderStack {
broadcast: EndpointRegistry,
broadcast_semaphore: Semaphore(32),
validator: EndpointRegistry,
validator_semaphore: Semaphore(32),
}Endpoint IDs follow the pattern <actor>-<chain_id>-<index> enabling per-actor metric tracking.
WeightedLeastResponseTime: Uses a roulette wheel algorithm based on endpoint health and response time:
score = (1000.0 / avg_response_time_ms) * traffic_multiplier
Health states apply multipliers: Healthy = 1.0, Degraded = 0.5, Unhealthy = 0.0. Selection is O(n) with lock-free metric reads via atomic ring buffers.
StickySession: Index-based selection for endpoint affinity. Critical for nonce consistency — reading nonce from the same endpoint used for broadcasting avoids replication lag issues. Falls back to weighted selection if the sticky endpoint is unhealthy.
Three health states tracked per endpoint:
- Healthy: Full traffic weight (error rate < 15%)
- Degraded: Reduced traffic (error rate 15-40%)
- Unhealthy: Zero traffic, circuit breaker active (error rate > 40%)
Circuit breaker activates on consecutive failures with exponential backoff: 10s → 30s → 60s. Auto-resets on successful request after expiry.
Metrics use lock-free atomic operations with 128-sample rolling windows for response times. Health calculations require minimum 10 requests to prevent volatile early readings.
execute_unary<F, Fut, R>: Primary generic execution function:
pub async fn execute_unary<F, Fut, R>(
&self,
actor: SelectActor, // Broadcast or Validator
chain_id: ChainId,
sticky_index: Option<usize>, // Endpoint affinity
timeout: Duration,
operation: F, // Closure taking provider
) -> Result<R, LobbyRpcError>Automatically records success/failure metrics and handles permit acquisition/relinquishment.
acquire_unary_context: Advanced context API for manual metric recording or sticky session management:
pub async fn acquire_unary_context(
&self,
actor: SelectActor,
chain_id: &ChainId,
sticky_index: Option<usize>,
timeout: Duration,
) -> Result<(UnaryContext, OwnedSemaphorePermit), LobbyRpcError>Returns a UnaryContext with methods: record_success(), record_failure(), provider(), metrics().
acquire_healthy_endpoint: Fetches best endpoint index for sticky session initialization. Uses the Validator pool (highest traffic = most accurate health data):
pub async fn acquire_healthy_endpoint(
&self,
chain_id: ChainId,
timeout: Duration,
) -> Result<Option<usize>, LobbyRpcError>send_raw_transaction: Broadcasts signed transactions with configurable load balancing strategy.
get_transaction_count: Retrieves pending nonce. Sticky session recommended for consistency.
get_transaction_receipt: Queries transaction receipt for validation.
get_block_number: Retrieves current block number for confirmation counting.
Lobby categorizes errors as terminating (fatal, abort immediately) or non-terminating (transient, eligible for retry).
Terminating errors indicate deterministic failures that will not succeed on retry:
| Error | Cause | Action |
|---|---|---|
MissingProvider |
Chain not configured in RPC endpoints | Operator must add configuration |
FromAddressMismatch |
Transaction from does not match API key bound address |
Client must use correct key |
ValidationFailure |
Gas limit zero, excessive fees, unsupported chain | Client must fix parameters |
InsufficientFunds |
Account balance below transaction value + gas | Fund the account |
NonceTooLow (after recovery) |
On-chain nonce permanently ahead after sync attempt | Manual intervention required |
All terminating errors immediately abort the pipeline without further retry attempts.
Non-terminating errors trigger the retry_with_backoff mechanism using full-jitter exponential backoff:
window = min(max_delay, base_delay * 2^(attempt-1))
sleep = rand(0, window)
RetryDecision Pattern:
pub enum RetryDecision<E> {
Retry(E), // Transient, backoff and retry
FailImmediately(E), // Fatal, abort now
}Operations return Result<T, RetryDecision<E>> to signal retry eligibility. Each retry emits a WARN event; final failure emits ERROR.
When broadcast detects NonceTooLow (divergence between database and on-chain nonce):
- Consume incorrect nonce:
resolve(execution_id, Consumed)marks nonce so it is not recycled. - Revert sign state:
signer.revert(execution_id)allows re-signing for sameexecution_id. - Sync with on-chain:
nonce.sync(chain_id, from_address, execution_id, nonce_on_chain)reserves the authoritative nonce. - Re-sign: Sign transaction with corrected nonce.
- Retry broadcast: Single retry attempt with newly signed transaction.
A guard variable (nonce_retry_attempted) ensures recovery runs at most once per execution, preventing infinite loops.
Ethereum processes transactions strictly in nonce order. Missing nonces block all higher nonces indefinitely.
Sweeper Bot: Runs every 30 seconds to release stale reserved nonces (leases > 2 minutes 5 seconds). This handles gaps from crashes between nonce reservation and broadcast.
Scanner Bot (Deprecated): Previously responsible for resolving timed_out transactions by polling RPC for eventual inclusion. This component is currently deprecated and non-functional. Future releases will include updated gap handling mechanisms.
Nonce cleanup rules by failure stage:
| Failure Stage | Nonce Action |
|---|---|
| RelayHost or Nonce Reserve | None (never reserved) |
| Sign | resolve(Released) |
| Broadcast (hard fail) | resolve(Released) |
| Validator NotIncluded | resolve(Released) |
| Validator Timeout | resolve(Consumed) (not reusable, may still confirm) |
| Validator Included | resolve(Finalized) |
Private keys are stored in plaintext in test_keys.json and held unencrypted in memory by the JsonPolicyEngine. This implementation is appropriate only for testnet accounts and local development.
The file is .gitignored but must be manually managed by the operator.
Future production versions will implement AWS KMS-backed envelope encryption:
- Data Encryption Key (DEK): Each private key is encrypted at rest using a DEK.
- Customer Master Key (CMK): The DEK itself is encrypted by an AWS KMS CMK.
- Boot-time decryption: Lobby calls AWS KMS
Decryptto obtain plaintext DEK, held only in process memory, which is then used to decrypt the private key (in memory) during runtime. - Runtime security: Plaintext private keys are held in memory for ensuring low-latency signing; all key material is zeroized on shutdown.
Benefits:
- Private keys are never stored in plaintext outside the running process
- KMS access is auditable via AWS CloudTrail
- Lobby refuses to start if KMS is unreachable (boot-time security check)
- Key rotation is possible by re-encrypting blobs without code changes
Note: AWS Envelope Encryption will not be implemented in the prototype version of Lobby.
Built with Rust, Tokio, Axum, PostgreSQL, and Redis.
Designed for developers who need reliable, low-latency blockchain transaction infrastructure.