# walletsforce — architecture A general-purpose **EVM account pool**. You give it signer accounts and a chain; you call `submit(tx)`. It routes the tx to an account, serializes that account's nonce lane, signs, broadcasts, confirms, and replaces stuck txs. > **The core idea:** on an EVM chain each account sends through a strictly > sequential **nonce** — 1 account = 1 nonce lane = the single-account throughput > ceiling, and one stuck tx blocks everything behind it (head-of-line blocking). > **N accounts = N independent lanes**, so you break that ceiling and confine > head-of-line blocking to one lane. This document is the map: the landscape, the request/reconcile sequences, every component and its interface, the data types, and the two deployment shapes. --- ## 1. Landscape Three things you wire together: a **Pool** (submit surface), a **Supervisor** (reconcile loop), and the **Substrate** they both run over. Build the substrate **once** and inject it into both, so a submit and its confirmation meet over one working set. ```mermaid flowchart TB caller["YOUR SERVICE"] subgraph app["one account set (one chain)"] direction TB pool["WalletForcePool
submit surface (reactive)
route → nonce → gas → fees → sign → broadcast
on / waitForConfirmation"] sup["Supervisor
reconcile loop (one timer)
confirm · replace-stuck · refresh balance · refill"] subgraph sub["Substrate — built once, shared by both"] direction LR cache["PoolCache
nonce lanes · in-flight
· routing bindings"] store["PoolStore
durable record
(memory / sqlite / your own)"] bus["EventBus
on / off
waitForConfirmation"] accts["accounts
Map<addr, ManagedAccount>
one nonce lane each (×N)"] end end signer["Signer — seam 1
custody
LocalKey · HDWallet · KMS"] fee["FeeOracle — seam 2
price + bump
Legacy · Eip1559"] chain["ChainClient — seam 3
read RPC + broadcast
ViemChainClient"] funder["Funder — optional
TreasuryFunder
treasury → drained accts"] evm["⛓ EVM CHAIN"] caller -->|"submit(req, opts)"| pool pool --> sub sup --> sub sub --> signer sub --> fee sub --> chain sup -.-> funder chain --> evm funder -->|sends gas| evm bus -.->|"broadcast · confirmed · reverted …"| caller ``` **Two deployment shapes** fall out of the Pool/Supervisor/Substrate split: | Mode | Cache / Store / Bus | Topology | |---|---|---| | **individual** (default) | in-memory | one process runs Pool **and** Supervisor | | **group** | shared (Redis cache / bus, SQL store) | many submit pods run Pools; **one** pod runs the Supervisor | > Run the Supervisor in exactly **one** place per account set — the reconcile tick > must never run concurrently across pods. --- ## 2. Components & responsibilities | Component | Responsibility | |---|---| | **`WalletForcePool`** | The submit surface: validate → route → nonce → gas → fees → sign → broadcast. **Reactive** — no background loop. Exposes the event API (`on`/`waitForConfirmation`). | | **`Supervisor`** | The reconcile loop: on a timer, confirm/replace stuck txs, refresh balances, and (if given a `Funder`) refill drained accounts. You `start()` it, in exactly one place. | | **`Substrate`** | The shared runtime injected into both: `{ cache, store, bus, accounts }`. Built once by `createSubstrate(config, opts?)`. | | **`ManagedAccount`** | One per address — the unit of parallelism. Owns its send pipeline, its confirm/replace step, and its in-flight cap. Built once and shared (not duplicated per object). | | **NonceLane** *(in `PoolCache.withNonce`)* | Async mutex + nonce allocator for one account. Serializes allocation+broadcast; advances the cursor **only on success** (no gaps). In-memory = in-process mutex; Redis = per-account lock (serializes across pods). | | **`Router` / `WalletSelector`** | Picks the account per request. Sticky (ref-counted) for an `orderingKey`; least-in-flight otherwise. | | **`Signer`** *(seam 1)* | Custody. Signs a fully-specified tx; returns raw bytes. | | **`FeeOracle`** *(seam 2)* | Gas pricing + replacement-bump policy. | | **`ChainClient`** *(seam 3)* | The single chokepoint for node I/O (reads + broadcast + error classification). | | **`PoolCache`** | Fast working set: nonce lanes, in-flight txs, per-account gauge, sticky bindings. Coordination point in group mode. Not the source of truth. | | **`PoolStore`** | Durable record (source of truth). `pool.start()` rebuilds the cache from it. `InMemoryStore` (default) or `SqliteStore` (WAL, crash recovery), or bring your own. | | **`EventBus`** | Lifecycle event port shared by both, so a waiter on the submit side resolves when the Supervisor confirms. | | **`Funder`** *(optional)* | Auto-refills drained accounts from a treasury account. Bundled: `TreasuryFunder`. | | **`PoolRegistry`** | Thin multi-chain wrapper: one `WalletForcePool` per chain id. | --- ## 3. Sequences ### 3.1 Submit — resolves on **broadcast** (mempool), not on confirmation ```mermaid sequenceDiagram autonumber participant C as Caller participant P as WalletForcePool participant R as Router participant K as PoolCache (nonce lane) participant F as FeeOracle participant S as Signer participant N as ChainClient participant D as PoolStore participant B as EventBus C->>P: submit(req, {idempotencyKey, orderingKey?}) P->>P: zod-validate req + opts P->>K: listAccounts → healthy candidates P->>R: route(candidates, req, opts) alt orderingKey present R->>K: bindOrdering(key, prefer) — sticky, ref+1 else unordered R->>R: Selector.pick (least-inflight) end R-->>P: account Note over P,K: withNonce(account) — mutex serializes this lane K->>F: estimate(ctx) K->>N: estimateGas(tx) unless gasLimit given K->>D: upsertTransaction(status "pending") — write-ahead K->>S: signTransaction(signableTx) K->>N: sendRawTransaction(raw) → hash K->>K: putTx(inflight), advance cursor K->>B: handle(broadcast) B-->>C: on("broadcast") fires P-->>C: resolves {account, nonce, hash, fees} ``` > `pending` is an internal write-ahead status — persisted before broadcast so a > crash mid-send is recoverable — and is **never emitted**. If the lane's `fn` > throws (cap hit, broadcast error), the cursor does **not** advance and the sticky > ref is released, so no nonce gap and no leaked binding. ### 3.2 Reconcile tick — the Supervisor, every `confirmTickMs` ```mermaid sequenceDiagram autonumber participant T as Supervisor (timer) participant A as ManagedAccount participant K as PoolCache participant N as ChainClient participant F as FeeOracle participant S as Signer participant D as PoolStore participant B as EventBus participant U as Funder (optional) loop every confirmTickMs (ONE pod only) T->>A: confirmTick() for each account A->>K: listTxs(account) loop each in-flight tx A->>N: getTransactionReceipt(hash) alt reverted A->>D: upsert reverted, dropTx A->>B: handle(reverted) — rejects waiter else confirmed (depth ≥ confirmations) A->>D: upsert confirmed, dropTx A->>B: handle(confirmed) — resolves waiter else mined (depth < confirmations) A->>B: handle(mined) once else null and stuck (age ≥ stuckAfterMs) alt attempts ≥ maxAttempts A->>B: handle(failed) else replaceable A->>F: bump(previousFees) A->>S: re-sign SAME nonce, higher fee A->>N: sendRawTransaction → new hash A->>B: handle(replaced) — emit-only, row stays broadcast end end end T->>N: getBalance(addr) — refreshBalances opt balance < minBalanceWei T->>K: mark unhealthy (Router drops it) T->>U: maybeTopUp(addr) end end ``` > **Don't wait on `hash`.** A stuck tx is replaced by re-signing the *same nonce* > with bumped fees, so its hash changes. The stable identifier is the > **`idempotencyKey`** — `waitForConfirmation(key)` correlates on that, resolving on > `confirmed` and rejecting on `reverted`/`failed`. ### 3.3 Boot — `pool.start()` = `restore()` ```mermaid sequenceDiagram autonumber participant P as WalletForcePool participant D as PoolStore participant K as PoolCache participant R as Router participant N as ChainClient P->>D: loadAccounts + loadActiveTransactions (non-terminal rows) P->>N: getTransactionCount(addr, "latest") P->>K: seedNonce(addr, chainNonce) (advance-only) loop each active tx P->>K: putTx (re-track in-flight) opt orderingKey P->>R: bind(orderingKey, account) (re-pin + ref) end end Note over P: returns # of txs re-tracked;
the Supervisor tick picks them up ``` --- ## 4. Interfaces ### 4.1 Build the shared runtime — `createSubstrate` ```ts function createSubstrate(config: WalletConfig, opts?: SubstrateOptions): Substrate; function createStore(cfg: StoreConfig): PoolStore; interface Substrate { cache: PoolCache; store: PoolStore; bus: EventBus; /** The owned account set — built ONCE and shared by pool + supervisor. */ accounts: Map; } interface SubstrateOptions { store?: StoreConfig | PoolStore; // default { kind: "memory" } cache?: PoolCache; // default in-memory bus?: EventBus; // default in-memory over the cache } type StoreConfig = { kind: "memory" } | { kind: "sqlite"; path: string }; ``` ### 4.2 Submit surface — `WalletForcePool` ```ts class WalletForcePool { constructor(config: WalletPoolConfig, substrate: Substrate); start(): Promise; // = restore(): rebuild from store; returns # re-tracked stop(): Promise; // graceful (reactive pool; nothing to flush) restore(): Promise; submit(req: TxRequest, opts: SubmitOptions): Promise; // resolves on BROADCAST /** Resolves on `confirmed`; rejects on `reverted`/`failed`/timeout. Correlates by * idempotencyKey (NOT hash). Register right after submit: observes FUTURE events. */ waitForConfirmation(idempotencyKey: string, opts?: { timeoutMs?: number }): Promise; reattach(tx: ReattachInput): Promise; // resume tracking a tx signed elsewhere on(event: TxEvent, cb: (rec: TxEventRecord) => void): void; off(event: TxEvent, cb: (rec: TxEventRecord) => void): void; wallets(): Promise; stats(): Promise<{ wallets: WalletState[]; stickyKeys: number }>; // stickyKeys → leak gauge } ``` ### 4.3 Reconcile loop — `Supervisor` ```ts class Supervisor { constructor(config: SupervisorConfig, substrate: Substrate, funder?: Funder); start(): void; // start the confirm / replace / refresh (/ refill) loop stop(): Promise; // stop scheduling; lets the in-flight tick finish } ``` ### 4.4 Config split — policy only (runtime lives in the Substrate) ```ts // Shared identity + account inputs. Both Pool and Supervisor build accounts from this. interface WalletConfig { ownerId: string; // static-partition owner (this instance owns exactly `signers`) chainId: number; signers: Signer[]; // the owned account set — one nonce lane each chainClient: ChainClient; feeOracle: FeeOracle; confirmations?: number; // depth before "confirmed". default 1 stuckAfterMs?: number; // unmined this long → bump + replace. default 30000 maxAttempts?: number; // replacement attempts before "failed". default 5 maxInflightPerAccount?: number; // queued+unconfirmed cap; submit() throws above it. default 512 logger?: Logger; } interface WalletPoolConfig extends WalletConfig { selector?: WalletSelector; // default: LeastInflightSelector } interface SupervisorConfig extends WalletConfig { confirmTickMs?: number; // receipt poll cadence. default 4000 minBalanceWei?: bigint; // below this → account unhealthy, dropped from rotation onLowBalance?: (w: { address: Address; balanceWei: bigint }) => void; } ``` ### 4.5 Seam 1 — `Signer` (custody) ```ts interface Signer { readonly address: Address; /** Sign EXACTLY the given tx. Deterministic, no hidden state. Returns raw bytes. */ signTransaction(tx: SignableTx): Promise; } // Bundled: LocalKeySigner (in-process key; dev / low-value), // HDWalletSigner via deriveHDSigners(mnemonic, n) (N lanes from one seed, BIP-44). // Production: implement over a KMS/HSM/remote signing service. ``` ### 4.6 Seam 2 — `FeeOracle` (price + bump) ```ts interface FeeContext { chainId: number; attempt: number; client: ChainClient } // attempt: 0 first send interface FeeOracle { estimate(ctx: FeeContext): Promise; /** Replacement fees. MUST beat the node's rule (>= ~12.5% over `previous`). */ bump(previous: FeeFields, ctx: FeeContext): Promise; } // Bundled: LegacyFeeOracle, Eip1559FeeOracle. Default bump: +12.5% (DEFAULT_BUMP_NUM/DEN). ``` ### 4.7 Seam 3 — `ChainClient` (read RPC + broadcast) ```ts interface ChainClient { getTransactionCount(addr: Address, tag: "pending" | "latest"): Promise; estimateGas(tx: SignableTx): Promise; getBalance(addr: Address): Promise; getBaseFeePerGas(): Promise; // null on legacy chains sendRawTransaction(raw: Hex): Promise; // idempotent: re-send is safe getTransactionReceipt(hash: Hash): Promise; // null = not yet mined getBlockNumber(): Promise; classifyError(err: unknown): RpcErrorClass; } // Bundled: ViemChainClient (over viem/actions). `classifyRpcError` helper exported. ``` ### 4.8 Routing — `Router` / `WalletSelector` ```ts interface WalletSelector { pick(candidates: WalletState[], req: TxRequest): Address; // among healthy candidates } interface Router { route(candidates: WalletState[], req: TxRequest, opts: SubmitOptions): Promise
; bind(orderingKey: string, account: Address): Promise; // re-pin + acquire a ref (reattach/restore) release(orderingKey: string): Promise; // release one ref; evict at zero size(): Promise; // sticky bindings (leak observability) } // Bundled: DefaultRouter (sticky-by-orderingKey, ref-counted) + LeastInflightSelector. ``` ### 4.9 Substrate ports — `PoolCache` / `PoolStore` / `EventBus` ```ts interface PoolCache { // nonce lane (cross-pod coordination primitive) withNonce(account: Address, fn: (nonce: number) => Promise): Promise; // cursor advances only if fn resolves seedNonce(account: Address, atLeast: number): Promise; // advance-only (boot / nonce-drift) peekNonce(account: Address): Promise; // in-flight working set (confirm / replace / backpressure) putTx(rec: TransactionRecord): Promise; dropTx(account: Address, nonce: number): Promise; listTxs(account: Address): Promise; countTxs(account: Address): Promise; // enforces the in-flight cap // per-account gauge (routing / health) putAccount(rec: AccountRecord): Promise; listAccounts(ownerId: string, chainId: number): Promise; // sticky routing bindings (shared so all pods agree per orderingKey) bindOrdering(ownerId: string, orderingKey: string, prefer: Address): Promise
; releaseOrdering(ownerId: string, orderingKey: string): Promise; orderingCount(ownerId: string): Promise; } interface PoolStore { upsertAccount(rec: AccountRecord): Promise; loadAccounts(ownerId: string, chainId: number): Promise; upsertTransaction(rec: TransactionRecord): Promise; // write-ahead + every status change loadActiveTransactions(ownerId: string, chainId: number): Promise; // recovery set } // Bundled: InMemoryStore (drops terminal rows to stay bounded) + SqliteStore (WAL, history retained). interface EventBus { on(event: TxEvent, cb: (rec: TxEventRecord) => void): void; off(event: TxEvent, cb: (rec: TxEventRecord) => void): void; waitForConfirmation(idempotencyKey: string, opts?: { timeoutMs?: number }): Promise; handle(rec: TxEventRecord): void; // lifecycle sink for ManagedAccounts } // Bundled: InMemoryEventBus (single process). A Redis-backed impl fans events across pods (group mode). ``` ### 4.10 Optional — `Funder` (auto-refill) ```ts interface Funder { maybeTopUp(addr: Address): Promise } interface TreasuryFunderOptions { signer: Signer; // the treasury account — keep SEPARATE from the pool signers chainClient: ChainClient; feeOracle: FeeOracle; // reuse the pool's oracle chainId: number; targetBalanceWei: bigint; // top drained accounts up to (at least) this — set above minBalanceWei minTreasuryWei?: bigint; // never spend the treasury below this. default 0n logger?: Logger; } // TreasuryFunder: own nonce lane, at most one top-up per recipient in flight, treasury floor. ``` --- ## 5. Data types ```ts type Address = `0x${string}`; // 20-byte type Hex = `0x${string}`; // arbitrary bytes type Hash = `0x${string}`; // 32-byte type FeeFields = | { type: "legacy"; gasPrice: bigint } | { type: "eip1559"; maxFeePerGas: bigint; maxPriorityFeePerGas: bigint }; // What you submit. `data` = calldata → a contract call; omit it for a plain transfer. interface TxRequest { to: Address; data?: Hex; value?: bigint; gasLimit?: bigint } interface SubmitOptions { idempotencyKey: string; orderingKey?: string; metadata?: Record } interface SubmitResult { account: Address; nonce: number; hash: Hash; fees: FeeFields } // A fully-specified tx handed to a Signer. interface SignableTx { chainId: number; nonce: number; to: Address; data?: Hex; value?: bigint; gas: bigint; fees: FeeFields } // Replayed on boot to resume tracking an in-flight tx. interface ReattachInput { idempotencyKey: string; account: Address; nonce: number; hash: Hash; fees: FeeFields; orderingKey?: string; metadata?: Record } // Emitted on every lifecycle transition; the caller persists these. interface TxEventRecord { idempotencyKey: string; orderingKey?: string; account: Address; nonce: number; hash: Hash; status: TxStatus; fees: FeeFields; attempts: number; metadata?: Record; error?: string; at: number } interface WalletState { address: Address; inflightCount: number; nonceCursor: number; balanceWei: bigint; healthy: boolean } interface Receipt { status: "success" | "reverted"; blockNumber: bigint; transactionHash: Hash } type RpcErrorClass = "nonce-drift" | "transient" | "revert" | "fatal"; interface Logger { debug(m: string, x?: unknown): void; info(m: string, x?: unknown): void; warn(m: string, x?: unknown): void; error(m: string, x?: unknown): void } ``` > **Status vs event.** `TxStatus` is what a row is *persisted* as > (`pending | broadcast | mined | confirmed | reverted | failed`). `TxEvent` is > what you *observe* (`broadcast | mined | confirmed | replaced | reverted | > failed`) — no `pending` (internal write-ahead), and `replaced` is emit-only > (a fee-bump notification, never a stored row status). > > Data types are defined as **zod schemas** (exported alongside the inferred types, > e.g. `txRequestSchema`) so the engine validates at its boundaries. Behavioral > interfaces (`Signer`, `ChainClient`, …) are plain TS — zod validates data, not behavior. The persistence model is two tables (see `store/models.ts`): - **`accounts`** — one row per owned account (`AccountRecord`): cursor, balance, health, BIP-44 index. - **`transactions`** — one row per tx across its whole lifecycle (`TransactionRecord`), keyed by `idempotencyKey`. Keeps the full signable so a stuck tx can be re-signed after a crash. Recovery set on boot = rows where `status NOT IN (confirmed, reverted, failed)`. --- ## 6. Ownership contract | walletsforce **guarantees** | **you** own | |---|---| | correct, gap-free nonce per account | idempotency / dedupe on your business unit | | single-writer per account (static partition) | exactly-once *effect* across a crash | | no in-process duplicate sends | choosing durability: in-memory (default) or a durable `PoolStore` | | receipt tracking + stuck-tx replacement | running the Supervisor in exactly one place (group mode) | Durability is a **choice**: the default in-memory store survives nothing; inject the SQLite store (or your own `PoolStore`) and the pool write-aheads before broadcast and rebuilds via `restore()` on boot. Dedupe (exactly-once *effect*) is always yours — check for the `idempotencyKey` before calling `submit`. --- ## 7. Packaging notes - **ESM-only** (`"type": "module"`). Node **≥ 18** for the in-memory path; the SQLite store needs Node **≥ 22.5** (built-in `node:sqlite`). - **`viem` is a `peerDependency`** — the consumer owns a single viem copy (shared types and runtime). `zod` is an internal `dependency` (the public API uses plain objects). - Built with `tsup` → `dist/index.js` + `dist/index.d.ts`; `viem`/`zod` are external. - **Status:** individual + group-*ready* (the substrate split is done and interfaces are defined); the Redis cache / Redis bus / SQL store impls and the Supervisor leader-lease are the remaining group-mode work. ```