cetane
v0.0.7
Published
A small, modular, account-abstraction-first Ethereum client.
Readme
Cetane
A TypeScript Ethereum client built around accounts and calls. The application uses sendTransaction, sendCalls, readContract, and writeContract. Chain configuration selects execution, account encoding, fee rules, and limits.
Performance is a design constraint: direct codecs, no Ox or viem runtime dependency, cached client metadata, no hidden write retries, and leaf imports that tree-shake. Portable cryptography uses Noble; abitype supplies compile-time ABI inference. Viem is a development-only correctness and performance reference.
Local signing uses native HMAC-SHA256 on compatible Bun and Node runtimes after a known-answer check, with Noble as the portable fallback. Noble still performs deterministic nonce generation, curve arithmetic and low-S normalization. HMAC instances and RFC-6979 working state are not reused between signatures. EOA sends reuse validated unsigned RLP fields when adding the signature, while retaining nonce coordination, chain policy checks and submission-hash verification.
Long hex inputs use native byte decoding for validation when available; inputs through 128 bytes keep the allocation-free check. A capability check detects native decoders that truncate wide characters and enables a strict character guard for those runtimes. With native setFromHex, validation lazily allocates one 64 KiB buffer and checks that every input byte was consumed. Larger inputs and runtimes without that API retain temporary allocation; older runtimes retain the portable validator. The benchmarks include validation in complete sends.
WASM Keccak hashing is an explicit opt-in with an embedded binary and no extra runtime package. Initialize it once before latency-sensitive work:
import { enableWasmHashing } from 'cetane/wasm'
await enableWasmHashing()This enables Keccak for clients, actions and utilities sharing this Cetane module instance. Hash APIs remain synchronous after setup; local ECDSA signing uses the existing Noble implementation. Importing the module alone does not initialize WASM. Concurrent initialization calls share one Promise, and failed initialization leaves the currently active backend unchanged for an explicit retry. After successful setup, another call synchronously reselects this verified WASM backend and returns the original Promise. Initialization rejects if the environment blocks WASM; browser CSP must permit WebAssembly compilation. The backend checks known answers before activation, resets its private hasher on every call and propagates hashing errors. It binds directly to the unmodified Keccak binary from [email protected], with fixed 128 KiB linear memory and a 16 KiB input buffer for streaming larger inputs. Pinned extraction and licensing record its provenance. Measured speed, bundle cost and WASM alternatives cover the tradeoffs.
The optional backend does not erase prior message bytes from its private WASM scratch memory after each hash. Cetane's ECDSA private keys and HMAC state never enter that backend.
Experimental native Keccak hashing is also available for Darwin arm64 Node/Bun:
import { enableNativeHashing } from 'cetane/nativeHashing'
await enableNativeHashing()The native addon loads only on the first enable call and validates known answers before activation. Unsupported platforms reject explicitly; setup failures preserve the active backend and can be retried. This optional leaf ships its binary and pinned offline build sources, without installation scripts or network access. JavaScript and browser entry graphs remain independent. Source, licensing and build details describe the ARM SHA-3 implementation and its platform scope.
At 32 Kernel nonce lanes on local Bun, native hashing improved throughput about 2–4% and reduced CPU per completion about 4–5%. A 60-second-per-version run verified 362,201 operations at roughly 3,000 completions/s. These measurements include the in-process relay, which also uses the selected hashing backend.
Both hashing enablers cache their verified closure and original successful Promise. Calling an already initialized enabler synchronously selects its backend again, without reloading or repeating setup. If native and WASM setup overlap, they install in verification-completion order; a pending cold setup can replace an intervening warm selection when it finishes. Await both setups and then call the desired enabler to make the final choice deterministic. Hash calls remain synchronous, and initialization state belongs to each Cetane module instance.
An optional signer uses a minimal build of Bitcoin Core's libsecp256k1 0.7.1. It needs no extra package and keeps the same independent signer/account interface:
import { privateKeyToSigner } from 'cetane/accounts/wasm'
const signer = await privateKeyToSigner(PRIVATE_KEY)The factory initializes WASM asynchronously once; its sign and signMessage methods are synchronous. It can be combined with enableWasmHashing() above. Importing this entry alone does not compile WASM, and ordinary JavaScript signers remain independent. Initialization requires WebAssembly compilation and crypto.getRandomValues, randomizes the shared context, and validates known public-key and signature vectors before use. Failures reject explicitly. The wrapper clears its input/output scratch after each operation; the signer retains its private key in its JavaScript closure, as the default signer does. This is not a guarantee that all engine or library memory is erased. Pinned source, build instructions and licensing make the embedded binary reproducible.
In the real Kernel signer comparison, standard WASM signing improved throughput 7–10% and reduced CPU per completion 14–16% versus JavaScript signing. Native signing was faster still; the larger WASM table showed small mixed gains. These are local Bun measurements with JavaScript hashing held constant, excluding initialization and download costs.
Combining that signer with await enableWasmHashing() improved completion
throughput another 11–14% and reduced CPU per completion 7–8% versus WASM signing
with JavaScript hashing in the paired Kernel test.
Both median and p99 improved. Hashing also applies to the benchmark's local relay;
these results do not isolate SDK-only savings or establish browser performance.
The Node comparison also improved with combined WASM backends: 7–9% higher throughput, 4–6% less CPU per completion, and lower median/p99 latency versus WASM signing with JS hashing.
An experimental larger-table signer has the same API:
import { privateKeyToSigner } from 'cetane/accounts/wasm/large'
const signer = await privateKeyToSigner(PRIVATE_KEY)This entry uses an 86 KiB generator table and 192 KiB of fixed WASM memory, compared with the default entry's 22 KiB table and 128 KiB memory. Each entry imports only its own binary and initializes its own shared context lazily; using both keeps both contexts. They share validation, secure initialization, known-answer checks and scratch cleanup. The integrated comparison measured 0.4–0.6 microsecond improvements for empty and 24 KiB public EOA sends in all three Node 25.2.1 rounds on an M4 Pro. Node 4 KiB results were mixed, and Bun 1.4.2 gains varied by round. The packed browser fixture adds 65,455 gzip bytes for the larger entry; startup was not measured. Applications can select the entry using measurements for their workload.
An experimental native signer is available as a separate server entry for Apple Silicon macOS:
import { privateKeyToSigner } from 'cetane/accounts/nativeSigner'
const signer = await privateKeyToSigner(PRIVATE_KEY)The factory loads the packaged native addon and checks known-answer vectors once; sign and signMessage are synchronous afterward. Each signer owns an independent randomized libsecp256k1 context. Importing the entry does not load the binary, and selecting it does not change other signers. Unsupported platforms reject at factory creation. No compiler, install script, network download or additional runtime dependency is required. Keep this entry external when bundling a server application so its relative native asset remains available. The default and browser entry points do not import this addon. Build inputs and licensing describe explicit local rebuilding.
The native signer retains its key in its owned native context until garbage collection finalizes it. Temporary decoded keys, seeds and signing outputs are cleared; this does not guarantee erasure of caller strings, engine copies or all process memory. The integrated comparison measured empty serial EOA sends 1.83–1.88× faster on Bun 1.4.2 and 1.49× faster on Node 25.2.1 than the default WASM signer. For 4 KiB calldata, gains were 1.41–1.43× and 1.27×. These are local M4 Pro measurements with controlled acknowledgments, not live-provider throughput. The 122,216-byte addon reproduced exactly across three builds; verification also covers actual Node 22.23.1, packed consumers and real local EOA/Kernel execution. The first lazy factory took 0.71–0.91 ms after importing the leaf and initializing shared hashing; that is not a cold-machine startup comparison.
With eight concurrent Kernel accounts, a local contract benchmark measured 629–633 verified operations/s with native signing versus 601–606 with JavaScript signing, using about 16–18% less client CPU per operation. Both Kernel 3.3 / EP 0.7 and Kernel 4 BETA / EP 0.9 were checked. Hashing and Node batching settings were held constant. This M4 Pro result includes local relay validation/mining and does not establish production-provider throughput; p95 latency did not improve in every scenario.
This is an early implementation, not a complete viem replacement. See development status for the remaining work.
Use
import { createClient, createWalletClient, http } from 'cetane'
import { createAccount, privateKeyToSigner } from 'cetane/accounts'
import { mainnet } from 'cetane/chains/mainnet'
import { waitForTransactionReceipt } from 'cetane/actions'
const transport = http(RPC_URL)
const relay = http(SUBMISSION_URL)
const reader = createClient({ chain: mainnet, transport })
const account = await createAccount({
client: reader,
address: ACCOUNT_ADDRESS,
})
const signer = privateKeyToSigner(PRIVATE_KEY)
const client = createWalletClient({
chain: mainnet,
transport,
relay,
account,
signer,
})
const hash = await client.sendTransaction({ to: recipient, value: 1n })
const receipt = await waitForTransactionReceipt(client, { hash })
const { id } = await client.sendCalls({
calls: [
{ to: token, data: approveData },
{ to: exchange, data: swapData },
],
})hash / id is an opaque submission identifier. On a relayed account it identifies the operation; on a native account it identifies the transaction. Receipt lookup works after recreating a client with the same chain configuration. receipt.transactionHash is the actual enclosing transaction. receipt.status reports the account operation's result, including a reverted call inside a successful bundle.
The Ethereum preset describes a deployed Kernel v3.3 with EntryPoint 0.7. An address alone does not make an arbitrary wallet compatible. Integrators select account and execution modules together in chain configuration. Applications do not pass implementation names or versions to actions. withKernel(chain, { version: '4' }) selects released Kernel v4 (0.4.0) with EntryPoint 0.9, pinned to tag v4.0, commit c960b42d2ed4adb0d5328f6e762962debdf8e57a. releaseReference exposes this pin. For counterfactual accounts, configure factory and a public root authorization in that profile.
The account owns its address, encoding, and authorization routing. The signer only signs hashes; it can omit an address entirely. Loading an existing Kernel account requires no owner or key, and preparing calls requires no signer. Set a default signer on the client or pass signer to an individual action. Switching a local signer does not change onchain ownership; use updateAuthorization for that.
For a smaller action surface:
import { createClient, http } from 'cetane'
import { sendTransaction, readContract } from 'cetane/actions'
const client = createClient({
chain,
account,
signer,
transport: http(RPC_URL),
relay,
})
const hash = await sendTransaction(client, { to, data })
const balance = await readContract(client, {
address: token,
abi: tokenAbi, // `as const` preserves argument and return inference
functionName: 'balanceOf',
args: [account.address],
})Only selected actions are bundled. client.extend(publicActions).extend(walletActions) is available when method-style access is preferable. http(url) and custom(provider) return small request objects. custom accepts an EIP-1193 provider; it does not turn a provider into an account signer.
For providers that support JSON-RPC batches, select the separate read transport:
import { httpBatch } from 'cetane/transports/httpBatch'
const transport = httpBatch(RPC_URL, { maxSize: 32, maxPending: 1024 })Known read methods share an HTTP request when issued in the same microtask window; a full batch dispatches immediately. Writes and unknown methods use individual HTTP requests. Each reader retains its own timeout and cancellation, and responses are matched by ID even when reordered. Batching is explicit and does not retry unsupported batches. A slow member can delay the other results in that response, so use ordinary http where independent response latency matters. The default http import does not include batching code.
The optional maxPending bounds retained logical calls across queued batches, dispatched batches and direct writes. Excess calls reject before serialization. Queued cancellation releases its slot immediately; a cancelled dispatched member keeps its slot until its shared Fetch request finishes, including response parsing. This prevents cancellation from bypassing the admission bound. The limit counts calls, not response bytes. Omitting it preserves the existing uncapped path without the admission wrapper. Choose the limit for your application's concurrency and provider capacity; writes are never retried by this transport.
Node applications can opt into the experimental HTTP/1.1 transport:
import { httpNode } from 'cetane/transports/httpNode'
const transport = httpNode(RPC_URL, {
maxSockets: 32,
maxPending: 1024,
maxResponseBytes: 16 * 1024 * 1024,
timeout: 10_000,
headers: { authorization: `Bearer ${RPC_TOKEN}` },
})
// Pass transport to createClient/createWalletClient.
// When its clients have finished, close the owned connection pool:
transport.close()This separate Node 22+ export uses a keep-alive pool and rejects excess requests
before serialization. maxPending includes requests Node still retains after
cancellation; a slot becomes available when those resources are released. The
deadline covers socket queueing, connection setup and the response body. Closing
rejects outstanding work; writes are never retried automatically. Headers are
snapshotted, and framing/connection headers are managed by the transport.
HTTPS verifies certificates and hostnames; an optional ca applies only to this
transport. Responses must use identity encoding (requested automatically), and
redirects are errors. Compression, automatic proxy discovery and Fetch-specific
options are not provided. On Bun, the Fetch-based http and httpBatch entries
performed better in the concurrent Kernel comparison:
individual Fetch had the highest completion throughput, while batching used
roughly one-third less client CPU per operation for a 2–3% throughput tradeoff.
Those are local M4 Pro measurements; p99 varied. This transport is opt-in and does not enter the portable
default bundle. See benchmark evidence and limits.
For the sixteen-lane Bun Kernel workload, httpBatch(rpcUrl, { maxSize: 8 })
improved completion throughput about 7% over the default size of 20, with
0.5–0.6 ms lower median latency and similar CPU per operation across reversed
run orders. See the batch-size measurements.
Tune against your provider; the transport default remains 20.
Batch size is workload-specific: the plain EOA receipt comparison favored size 64 over size 8 for throughput and CPU at 128 concurrent receipt waiters. The Kernel setting above should not be applied to every transport.
The Node batching comparison also favors size 8 for this workload: 7–10% higher throughput and 5–7% less CPU per completion, with sampled peak RSS increasing from 220–235 MiB to 249–252 MiB.
Bound application concurrency as well as transport capacity. In the local Bun Kernel sweep, 32 independent nonce lanes reached about 3,000 verified completions/s; 64 lanes added latency without more throughput. Sixteen lanes had better p99 latency. These limits are workload-specific, and production bundlers may impose different sender limits.
At 32 lanes, adding coalesceCanonicalReads reduced canonical RPC invocations
about 88% but lowered throughput 7–10% and increased median latency to 13 ms.
For that configuration, leave it disabled; see the
sharing comparison.
For providers that support JSON-RPC arrays, Node read batching is also opt-in:
import { httpBatchNode } from 'cetane/transports/httpBatchNode'
const transport = httpBatchNode(RPC_URL, {
maxSize: 64, // calls per HTTP batch; default 20, range 2–1024
maxPending: 1024, // retained logical calls, including direct calls
maxSockets: 32,
maxResponseBytes: 16 * 1024 * 1024,
})
// Pass transport to the client. Close after all consumers finish:
transport.close()Reads on the explicit allowlist group within a microtask; there is no added timer window. Writes, unknown methods, and receipt calls with provider-specific extra parameters stay direct. No call is retried. The complete response envelope is validated before any batch member succeeds; per-call RPC errors stay independent.
Admission is reserved before parameter serialization. A cancelled queued call
releases its slot immediately. A cancelled member of a dispatched batch keeps its
slot until that physical batch finishes, bounding retained cancellation state;
remaining members continue unless all cancel. close() rejects queued and active
calls and closes the shared HTTP agent. TLS, headers, timeouts, UTF-8/BOM decoding,
response byte limits, identity encoding and redirect rejection follow httpNode.
The byte limit applies to the whole batch response. Each logical call retains its
own timeout from enqueue. Node-only code stays outside the portable default bundle.
Batching can trade latency for HTTP/CPU savings and can cause a slow read to delay its siblings. Measure your provider and workload; this is not the default transport.
Hedged submission
To cut submission tail latency, broadcast the same signed bytes to several independent endpoints and resolve on the first acknowledgement:
import { hedged } from 'cetane/transports/hedged'
const relay = hedged([http(RELAY_A), http(RELAY_B), http(RELAY_C)], {
stagger: 50, // ms before each backup while nothing has replied; default 0
})
const client = createWalletClient({ chain, transport, relay, account, signer })Only eth_sendRawTransaction and eth_sendUserOperation are hedged by default
(hedgedSubmissionMethods; override with submissions). Their parameters are
already signed, so identical bytes cannot create a second transaction or
operation. Node-signed methods such as eth_sendTransaction are refused. Every
other method, including reads, goes to the first transport only unless listed in
reads; hedged reads resolve on the first success and cancel the rest.
- Each endpoint receives a request at most once; nothing is resent. A failed endpoint triggers the next one immediately instead of waiting out the stagger. Settlement cancels backups that have not been sent.
- The first identifier is returned unverified; the execution module still checks it against the signed payload. A wrong first reply therefore rejects the send and leaves its nonce uncertain.
- Late writes are not cancelled, so the payload still reaches the slower
endpoints. Their replies, including
already known/nonce too lowerrors and different identifiers, go to the optionalonLateobserver and never change the result. No error is ever converted to success: a nonce error cannot prove the conflicting transaction is ours, and the transport does not hash payloads. - If no endpoint acknowledges, the request rejects with
HedgedError(kind: 'failed') holding every endpoint's error by index. The payload may still have reached a node, so the send is treated as uncertain as usual. quorum: nwaits fornmatching identifiers; a disagreement rejects withkind: 'mismatch'. Caller cancellation aborts in-flight requests.
Hedging multiplies provider load (every endpoint at stagger 0) and, for UserOperations, can make several bundlers attempt the same operation; the EntryPoint nonce allows only one execution. In a synthetic benchmark with an injected 3% stall tail, three endpoints reduced p99 acknowledgement latency from about 1,100 ms to 48 ms (stagger 0) or 100 ms (stagger 50, 1.06 requests per submission). Real endpoint tails are often correlated; measure yours.
For concurrent receipt waits on Node, opt into canonical-read coalescing:
import { coalesceCanonicalReads } from 'cetane/transports/coalesceCanonicalReads'
const base = httpNode(RPC_URL)
const transport = coalesceCanonicalReads(base, { maxPending: 1024 })
// Pass transport to createClient. After waits finish:
console.log(transport.stats())
// Close the underlying transport when all of its clients finish.
base.close()This experimental wrapper shares eth_getBlockByNumber(quantity, false) calls
queued before the next setImmediate dispatch. Calls arriving after dispatch
always get a fresh request; completed results are never cached. It adds one
scheduling turn, so enable it for concurrent same-block reads and measure your
workload. Other RPC methods and block forms pass through unchanged. Each caller
can cancel independently; the physical request is aborted when all callers cancel. If dispatch has not
started, its scheduled callback is removed as well.
maxPending bounds logical canonical callers across queued and dispatched work.
Excess callers reject with TransportError. Underlying transports retain their
own physical request limits and lifecycle; the wrapper has no close method.
Use caller signals/deadlines with custom transports that may never settle.
stats() returns cumulative canonical-only counts (admitted, joined,
dispatched, rejected, cancelled) and the current pending gauge. Cancellation
counts cover admitted callers. dispatched counts calls into the base transport,
including failed attempts; a batching base may use fewer HTTP requests. Trace RPC
spans remain logical calls, so use these counters to distinguish shared dispatches.
Snapshots do not reset counters or include passthrough RPCs. Local tests and
benchmarks are linked in performance measurements.
The complete Node EOA connection example combines
separate submission and receipt pools, size-64 receipt batches and canonical-read
coalescing. It accepts your chain policy, address and signer independently,
awaits initialization, and returns wallet, reader, canonicalStats and
close. It connects only when called and never submits a transaction by itself.
For an EVM chain, configure its submission policy explicitly:
import { mainnet } from 'cetane/chains/mainnet'
import { connectEoa } from './examples/eoa-throughput.js'
const connection = await connectEoa({
rpcUrl: RPC_URL,
chain: {
...mainnet,
submission: { maxInFlight: 32, maxQueued: 120 },
},
address: accountAddress,
signer,
})
// Send through connection.wallet and wait through connection.reader.
// Bound application workers; drain all sends/waits before closing:
connection.close()Copy the example into your application and change its relative source imports
to the corresponding cetane package exports. The connection example uses
10 ms polling; the benchmark additionally supplied shared head notifications.
It does not enable native cryptography or provide a notification feed. Choose
those separately using the signer/hashing and receipt-subscription APIs.
The example check verifies concurrent
real transfers and pool shutdown, not a promised throughput level.
The one-minute local EOA run verified 747,034 completions at 12,449/s with 10.3 ms median and 20.1 ms p99; it used 128 bounded completion workers, native signing/hashing, explicit gas/fees and head notifications. Neither this worker count nor the queue/window settings are provider-independent defaults. Kernel uses independent nonce lanes instead of this EOA submission pipeline; the measured Kernel configuration favored batch size 8 and no canonical coalescing. See the workload-specific results above.
Preparation can happen before a signer is available:
const prepared = await client.prepareTransactionRequest({ to, data })
// Request-specific signer; account address and authorization stay bound to the preparation.
const hash = await client.sendPreparedTransaction(prepared, { signer })Submitting a prepared transaction does not repeat nonce, fee, or gas reads. It belongs to its original client and can be submitted once. A failed local signature leaves the nonce available for a new preparation; an uncertain submission cannot be replayed automatically. Ordinary send actions reject a missing signer before making preparation RPCs. Keystore configuration preparation is the exception: it needs the configuration signer to build its separately signed authorization payload.
estimateGas() and prepared.executionGas return the execution limit accepted by a request's gas field. prepared.gas is the total reserve, including validation and data gas, used to enforce chain and fee caps. Passing an estimate back to sendTransaction({ gas }) therefore adds AA overhead exactly once.
Client metadata
Client creation caches a configured chain ID immediately. If chain is omitted, it starts exactly one eth_chainId request, shared through client.ready. getChainId() uses that cache; { refresh: true } explicitly checks the endpoint and rejects a changed chain. IDs are scoped to the client, including its action decorators. The chain configuration and numeric fee/gas policies are snapshotted at construction so later edits to the original object cannot change the signing chain or relax its caps. Create a new client to select another profile.
Set chain.nativeAA to '8130', 'tempo', another supported capability tag, or false. An explicit value skips discovery. If absent, client initialization runs the injected discoverNativeAA hook once. Its current default is a mock returning 'unknown', with no capability RPC invented for the moving EIP-8130 draft. A native execution module requires a matching configured or discovered capability before preparing transactions. A capability tag alone does not install an execution adapter.
Concurrent preparations share outstanding deployment-code reads for the same client, account address, and block tag. Settled results are discarded, so later preparations observe deployments, reorgs, and local-chain resets. An established account profile without factory/creation configuration skips deployment probes entirely.
Chain policy
Following bundle_rs, each chain is a module with defaults and behavioral hooks. There is no central runtime switch or registry to edit. Use defineChain({ ...base, ...overrides }) to add a network or replace a policy.
import { defineChain } from 'cetane'
import { arbitrum } from 'cetane/chains/arbitrum'
const chain = defineChain({
...arbitrum,
limits: { ...arbitrum.limits, maxGas: 8_000_000n, maxFee: 10n ** 16n },
fees: { ...arbitrum.fees, maxPriorityFeePerGas: 10n ** 9n },
})
// The bid survives fee estimation. The computed fee cap includes base-fee headroom.
await sendTransaction(client, { to, data, maxPriorityFeePerGas: 500_000n })| Policy | Behavior |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| limits.maxGas | Reject excessive reserved gas before signing. |
| limits.maxTransactionSize | Check serialized native transactions or a single-operation handleOps calldata envelope before submission. The relay remains responsible for the full outer transaction/bundle size. |
| limits.maxFee, request maxFee | Cap gas fee liability: gas × maxFeePerGas. This excludes transferred value and any separate sponsor/token markup. |
| fees | Min/max priority fees, max fee per gas, base-fee multiplier, or a custom estimator. |
| validateCalls, validateTransaction | Chain-specific admission rules. |
| execution | Preparation, signing/submission, receipt lookup, and nonce semantics. |
| account | Account creation, deployment, call encoding, and signature semantics. |
| setup | Synchronous client initialization, once; no implicit network discovery. |
| receiptSubmission | Opt-in receipt-returning write for sendTransactionSync; the chain module names the method and its timeout semantics. |
| preconfirmation | Receipt reads may return sequencer previews; only waitForTransactionReceipt({ preconfirmed: true }) accepts them. |
Presets:
- Ethereum / Base: reserved gas caps, and Base's minimum priority fee.
withFlashblocks(base)opts in to Flashblocks preconfirmations and sync sends (see Receipt-returning sends). - MegaETH:
megaeth(4326) withrealtime_sendRawTransactionand mini-block preconfirmed receipt reads. Gas limits are left to estimation. - Arbitrum: priority bidding via ordinary fee fields. An unspecified bid defaults to zero; explicit bids are preserved, and the computed cap is twice base fee plus the bid. Operation fees cannot force a bundler to bid the same amount on its outer transaction. For direct control, use a native execution module; a relay must explicitly support your bidding policy. L1 data gas must come from the chain-aware estimator; it is not guessed from calldata size.
- HyperEVM: separate
hyperliquidandhyperliquidBigprofiles, gas caps, matching gas-price RPCs, and zero priority fees. Configure the outer sender or bundler's HyperCore block mode beforehand. Cetane does not toggle account-global HyperCore state as a side effect of sending. A requestedblockModethat differs from the configured profile is rejected. - Tempo: native atomic calls, fee-token selection, nonce keys, validity windows, and optional fee-payer signing. Native value transfers are rejected. Gas price and
maxFeeuse attodollars (10^-18 USD), not the TIP-20 token's six-decimal balance units. Expiring nonces, passkeys, and access keys are not implemented yet.
Arbitrum's effective priority is capped by fee headroom:
min(maxPriorityFeePerGas, maxFeePerGas - baseFeePerGas). An explicit fee cap
that is too close to the base fee can reduce the intended bid. The documented
PGA uses two ordering rounds per nominal 250 ms block; a later bid cannot
interrupt a round already in progress. Fast Feed is an optional authenticated
observation service, not a receipt or finality signal. Keep canonical receipt
checks even when using early notifications. The live read-only PGA check
verifies fee calculation and RPC sharing, not sequencer ordering or inclusion.
HyperEVM's documented mempool accepts only the next eight nonces per address.
Bound outstanding work through receipt completion, not just submission ACKs;
submission.maxInFlight alone does not enforce that protocol limit. Other
clients using the same address share that nonce budget. The mainnet EOA example's
32-submission setting is not suitable for this profile. Small blocks are configured
for 1 second / 3M gas and big blocks for 60 seconds / 30M gas. Switching the preset
does not switch the sender's HyperCore block mode. Priority fees are burned on
HyperEVM; the preset intentionally disables them rather than treating them as a
bidding mechanism. The official endpoint has no WebSocket RPC, so head-notification
benchmarks require a compatible alternative provider. See the
read-only preset verification.
Tempo's limits.maxGas is the 30M transaction cap in TIP-1010, not its
500M total block budget. General-purpose work also competes within a separate
30M gas pool per block; unused payment capacity does not imply unlimited
contract throughput. Cetane reads the fee quote unless fees are supplied,
instead of permanently caching a protocol-version-dependent price. See the
Tempo preset verification for the
source check and the live-RPC limitation.
Chain policy sources: Arbitrum PGA, HyperEVM dual blocks, Tempo gas parameters, and local ../bundle_rs/src/chains/. Presets are configurable snapshots; confirm the profile for the network/relay you deploy against.
For infrastructure configuration, cetane/execution/erc4337, cetane/execution/eip8130, cetane/execution/evm, and cetane/execution/tempo each export createExecution. cetane/accounts/kernel exports defineAccount and createAuthorization; cetane/chains/kernel exports withKernel to select the matching execution version. The relayed backend supports EntryPoint 0.7–0.9; 0.6 is unsupported. Kernel integration targets are v3.3 / 0.7 and released v4 (0.4.0) / 0.9. The older SimpleAccount adapter remains available as a leaf import.
For a bundle_rs relay, select its cached fee quotes and held receipt requests explicitly:
import { withKernel } from 'cetane/chains/kernel'
import { bundleRelay } from 'cetane/relays/bundle'
const chain = withKernel(arbitrum, {
version: '4', // Kernel 0.4.0 / EntryPoint 0.9
relay: bundleRelay({ receiptWait: 2_000 }),
})
const client = createWalletClient({
chain,
account,
signer,
transport: http(NODE_RPC_URL),
relay: http(BUNDLE_RS_URL),
})
const hash = await client.sendTransaction({ to, data })
await waitForTransactionReceipt(client, { hash })The fee policy reads pimlico_getUserOperationGasPrice once for concurrent requests, uses the standard quote, and preserves base-fee headroom when an explicit priority bid changes. Both explicit fee fields skip that read. Settled quotes are never cached by Cetane; bundle_rs owns its background refresh. Chain floors and caps still apply.
For a relay configured to pay gas itself (such as UltraRelay or bundle_rs fast mode), select bundleRelay({ relayPaysGas: true }). For other ERC-4337 relays, set fees: { ...chain.fees, relayPaysGas: true } in the chain profile. This policy sets both operation fee fields and the prepared maxFee to zero, skips node and relay fee reads, and supersedes the normal fee estimator and priority floor. Gas estimation still runs unless complete gas budgets are configured. Explicit fee fields must also be zero. Active paymaster sponsorship is mutually exclusive with relay-paid fees; sponsor: false disables a configured sponsor for that operation. This setting must match the relay's configuration; it does not enable relay-side gas payment.
waitForTransactionReceipt sends eth_getUserOperationReceipt(hash, waitMs), bounded by the remaining wait deadline. Set the relay's HTTP timeout longer than the hold duration plus response time; the default 2-second hold fits the default 10-second HTTP timeout. An unsupported extension falls back to ordinary polling once per client. Cancellation, confirmation depth, and canonical-block checks still apply; a preconfirmation cannot resolve the wait. getTransactionReceipt remains an immediate lookup. Other relays retain ordinary polling unless configured with createExecution({ receiptWait }).
On Arbitrum, relay-side sequencer routing, outer-transaction bidding, Nitro synchronous submission, and L1 fee accounting stay with the bundler. A UserOperation bid does not prescribe its outer transaction bid. Cetane avoids duplicating those RPCs; it consumes relay quotes and inclusion notifications through the policy above.
Native Keystore accounts — DRAFT
The EIP-8130 profile uses the same actions and independent signer/account model:
import { withKeystore } from 'cetane/chains/keystore'
import { createAuthorization } from 'cetane/accounts/keystore'
const chain = withKeystore(network, {
keystore: KEYSTORE_ADDRESS,
wireFormat: 'draft', // use 'base-zenith' for the pinned Base reference client
})
const reader = createClient({ chain, transport })
const account = await createAccount({
client: reader,
address: ACCOUNT_ADDRESS,
})
const client = createWalletClient({ chain, transport, account, signer })
await client.sendCalls({ calls })
const delegate = createAuthorization({
owner: DELEGATE_ADDRESS,
role: 'delegate',
})
const hash = await client.updateAuthorization({
changes: [{ type: 'grant', authorization: delegate }],
})
await waitForTransactionReceipt(client, { hash })The profile sets nativeAA: '8130' explicitly and is marked DRAFT. The codec and account adapter pin the specification, contracts, and Base client independently; their account-change tags and call tuples differ. See revision pins and test boundaries. Capability discovery remains mocked when no capability is configured.
The base-zenith profile is also exercised against the pinned native Base node: RPC admission, unsigned estimation, mining, native value, a separate payer, counterfactual creation, and owner/delegate updates. See the optional reference-node test. This validates that specific reference revision; the distinct draft wire format still has codec and mock-transport coverage.
Existing accounts load without RPC. A creation: { bytecode, actors } profile computes the CREATE2 address locally from runtime bytecode, public actor descriptors, and the account index; preparation probes pending code before including creation. Ordinary calls form one atomic phase. The draft sends native value through the canonical executeBatch ABI; set valueCalls: false for accounts that do not implement it. The pinned Base profile carries native value directly in each protocol call. Supplying nonce, fees, and gas skips their corresponding RPCs. Native nonce keys are cached independently; nonce-free mode is currently rejected because it needs a separate replay policy.
Keystore authorization descriptors support k1 owners/delegates and custom authenticators with explicit estimation signatures. Scope 0 identifies an owner; the default delegate scope permits execution, self-payment, and nonce management, without configuration authority. Policy-gated actors require an explicit manager and commitment. The Base estimator receives the selected descriptor's public actor ID, so policy simulation resolves the intended actor without signing. Private signers remain on the client or request.
updateAuthorization uses the Keystore's signed configuration batch. prepareAuthorization therefore requires a signer and creates a separately relayable configuration signature during preparation; fee/gas limits guard the enclosing transaction signature. Creation-aware preparation uses the post-creation configuration sequence. Batch related grants/revokes into one update. Before preparing another configuration update, wait for the prior update's receipt: configuration sequences are account-wide, and a pending-state RPC is not guaranteed to include an earlier accepted transaction. Concurrent manually prepared updates can become stale even when they use different transaction nonce keys.
For native sponsorship, configure sponsor: { account: payerAccount, signer: payerSigner }, optionally with a payer authorization descriptor. The payer address can differ from the signer address. Sender and payer signatures run concurrently over separate domains. Fee/gas limits reserve the additional payer authentication budget; the profile's maxAuthenticationGas defaults to 100,000. A local signer with an address can also sponsor through its implicit EOA account. Remote sponsor-service RPCs and open-payer envelopes are not implemented for this profile.
Authorizations and module gas
updateAuthorization({ changes }) is one atomic primitive for owner/signer replacement and delegate grant/revoke. Kernel's current descriptors configure ECDSA validators using public owner addresses. The actual signing capability remains separate:
import { createAuthorization } from 'cetane/accounts/kernel'
const delegate = createAuthorization({
validator: VALIDATOR_ADDRESS,
owner: DELEGATE_PUBLIC_ADDRESS,
role: 'delegate',
selectors: ['0xe9ae5c53'], // execute(bytes32,bytes)
gas: { enable: measuredEnableGas, disable: measuredDisableGas },
})
await client.updateAuthorization({
changes: [{ type: 'grant', authorization: delegate }],
})
await client.sendCalls({
calls,
authorization: delegate,
signer: delegateSigner,
})
await client.updateAuthorization({
changes: [{ type: 'revoke', authorization: delegate }],
})
// Owner rotation uses the same primitive, authorized by the current owner.
await client.updateAuthorization({
changes: [{ type: 'replace', current: root, next: nextRoot }],
})Kernel routes delegate validation through a separate nonce namespace. Applications pass an ordinary nonceKey and an optional sequence nonce; the account and execution adapters pack validator identifiers internally. Explicit nonce 0 works for a new delegate namespace, and prepared transactions expose the plain sequence. Kernel allows a 16-bit application nonce key. Same-validator replacement changes the ECDSA owner while preserving installed permissions. Replacing the root with a different validator also changes root selection and falls back to gas estimation unless the caller supplies an execution gas limit. Hooked validators and Kernel permission-policy modules need additional adapters.
Precomputed gas applies explicitly to Kernel authorization changes and ERC-7579 module enable/disable:
import { withKernel } from 'cetane/chains/kernel'
import { defineModule } from 'cetane/accounts/erc7579'
const chain = withKernel(mainnet, {
moduleGasOverhead: measuredBatchOverhead,
gas: {
verificationGasLimit: measuredValidationGas,
preVerificationGas: conservativeDataGas, // bigint or operation => bigint
// Optional separate budget for counterfactual deployment:
deploymentVerificationGasLimit: measuredDeploymentValidationGas,
},
})
const module = defineModule({
type: 1n,
address: MODULE_ADDRESS,
initData: accountSpecificInitData,
deInitData: accountSpecificDeInitData,
gas: { enable: measuredEnableGas, disable: measuredDisableGas },
})
// Use a client/account created from the configured chain above.
await client.updateModules({ changes: [{ type: 'enable', module }] })
await client.updateModules({ changes: [{ type: 'disable', module }] })Module budgets must cover the module's account-side install/uninstall work; Kernel authorization budgets also cover configured selector grants. Cetane adds the configured shared overhead once per batch. An explicit request gas overrides that computed execution limit. When execution, validation, data-gas, and any sponsor budgets are complete, preparation skips eth_estimateUserOperationGas entirely. Unknown module costs, missing overhead/validation configuration, or unbudgeted deployment use normal estimation once. Fee and gas caps still run before signing. These are caller-supplied conservative budgets for the exact account/module configuration; the library does not guess universal limits.
ERC-7579 initData and deInitData are opaque account-specific bytes. For a Kernel validator, use the versioned wrapper helper with the validator's own callback data:
import { defineValidatorModule } from 'cetane/accounts/kernel'
const module = defineValidatorModule({
version: '4', // also supports '3.3'
address: VALIDATOR_ADDRESS,
initData: validatorInstallData,
deInitData: validatorUninstallData,
selector: '0xe9ae5c53', // execute(bytes32,bytes)
gas: { enable: measuredEnableGas, disable: measuredDisableGas },
})
await client.updateModules({ changes: [{ type: 'enable', module }] })The helper encodes the matching Kernel wrapper and grants one optional selector, without a hook. Use defineModule for other module types or custom wrappers. prepareAuthorization and prepareModuleUpdate expose the same preparation path when submission should happen later.
Sponsorship
Configure a sponsor once; the application keeps using the same send actions:
const client = createWalletClient({
chain,
account,
signer,
transport: http(RPC_URL),
relay: http(SUBMISSION_URL),
sponsor: http(SPONSOR_URL),
sponsorContext: { policyId: 'app' },
})
await client.sendCalls({ calls, sponsor: true }) // require sponsorship
await client.sendTransaction({ to, data, sponsor: false }) // explicitly self-payAn omitted sponsor flag uses the configured sponsor. true fails if none is configured; sponsorship errors never silently fall back to charging the sender. Per-call sponsorContext replaces the client default. Tempo accepts a local signer in the same client sponsor field for fee-payer authorization. Native EVM execution rejects sponsorship unless explicitly disabled.
The relayed backend implements ERC-7677: request unsigned stub data, estimate once, and request final authorization. A final stub (isFinal: true) skips the second sponsor request. Responses cannot overwrite sender, calls, nonce, fees, or account gas limits. Final sponsor data must preserve the estimated paymaster, gas budgets, length, and calldata cost bounds. Infrastructure can also supply validated stub/finalize callbacks through the execution module.
EntryPoint 0.9 detached paymaster signatures permit sender signing and sponsor finalization to run concurrently. Cetane enables this path when the sponsor stub includes a detached signature slot, and rejects changes to sender-signed fields before submission. Other sponsor flows finalize before sender signing. A local contract integration test verifies sponsored Kernel v4 deployment with no account funds, paymaster deposit charging, postOp, and independent rejection of invalid sender/paymaster signatures. Its complete gas budgets skip estimation.
Sending and waiting
Independent nonce, fee, and deployment reads run concurrently. Identical fee reads share an in-flight request within a client, including across action decorators and the built-in chain estimators. Settled prices and failures are not cached: a later estimate fetches fresh data. Supplying fees and a nonce skips those corresponding reads. Known module budgets can skip gas estimation; otherwise estimation precedes signing. A prepared transaction exposes immutable common fields and keeps its wire payload private; it is bound to its client and can only submit once. The public API never exposes a prepared user operation or a protocol-specific transaction type.
By default, sends through one client serialize by account and nonce key, then advance a local nonce after acknowledgement. Different keys are independent. nonce, prepared nonce, and resetNonce({ nonce }) use a plain sequence within that key, including for delegated accounts. This prevents duplicate nonces and eliminates repeat nonce reads on the send path. Multiple clients/processes need an external nonce coordinator or explicitly assigned nonces. Preparation and signing failures release the nonce for retry. Once submission starts, an inconclusive failure leaves the result uncertain and blocks automatic nonce reuse; resolve the pending transaction before resetNonce, or deliberately supply an explicit nonce. Dropped operations also require reconciliation. Automatic fee replacement is not implemented.
ERC-4337 execution treats a validated JSON-RPC error response to eth_sendUserOperation as a conclusive rejection. It throws SubmissionRejectedError (exported from cetane) with the numeric code and fixed method, without the provider's message, data, or cause. A new preparation can reuse the rejected nonce; the failed prepared transaction remains single-use. Cetane does not resubmit or start receipt polling. HTTP, HTTP batch, Node HTTP, Node HTTP batch, and WebSocket transports capture this response evidence. Caller-created errors, custom transports without that evidence, aggregate hedged failures, timeouts, transport/HTTP failures (including 429/5xx), malformed responses, and invalid acknowledgements remain uncertain. A rejection never clears uncertainty from an earlier send.
Plain EOA clients can overlap submission acknowledgements when their endpoint accepts future nonces. Configure that capability explicitly on the chain profile:
The M1 live Nitro comparison verified this behavior against an existing private chain: a submission window of 32 reached about 310 confirmed transfers/s with roughly 100 ms median completion, versus about 10/s with a window of one. Matched viem runs reached the same throughput. These results establish the benefit of pipelining on that fixture, not support or capacity for every provider.
import { createExecution } from 'cetane/execution/evm'
const eoaChain = defineChain({
...network,
nativeAA: false,
execution: createExecution(),
submission: { maxInFlight: 8, maxQueued: 64 },
})maxInFlight bounds unacknowledged submissions per client/account/nonce key. maxQueued adds bounded backlog capacity (default 1,024); a full window plus backlog rejects new sends before preparation, signing or RPC. It does not count all accepted-but-unmined transactions or guarantee their inclusion. The endpoint must tolerate future nonces arriving before predecessors, since HTTP arrival order can differ from invocation order. Configure provider admission limits separately.
Tune the in-flight window and backlog together. In the equal-capacity local EOA screen, submission: { maxInFlight: 16, maxQueued: 120 } admitted all 8,000 offers/s in two 10-second runs, with p99 ACK latency of 8.74–9.88 ms. At the same total capacity, 8/128 rejected 26–124 offers and had p99 of 12.02–13.64 ms. Median latency rose about 0.3 ms with 16 in flight. This Node/native/httpNode result uses one-worker Anvil; measure your endpoint before adopting the setting. It does not establish sustained mined throughput. A 60-second follow-up verified 479,954 accepted transactions with 46 rejections and p99 ACK latency of 9.29 ms, so the short-run full-admission result is not a guarantee.
With the default maxSigning: 1, the pipeline prepares and signs in order, reserves each nonce only after local signing succeeds, and overlaps acknowledgements. Explicit nonce sends, prepared submissions and resetNonce wait for all earlier acknowledgements. An uncertain submission quarantines that sequence; later successful acknowledgements cannot clear it. Reconcile every affected submission before resetting or replacing a nonce. Public preparation remains a snapshot and can become stale while sends advance the sequence.
The built-in pipeline currently supports the EVM adapter with plain EOA accounts. Kernel, EIP-8130 and account authorization/module/deployment adapters reject concurrency above one; their existing independent nonce-key queues remain available. A policy with maxInFlight: 1 and maxQueued also bounds their per-key backlog while retaining serial acknowledgements. Multiple AA nonce keys do not imply that a bundler accepts multiple pending operations from one sender.
Experimental: parallel EOA signing. A signer that supports concurrent requests, such as the application-owned worker pool, can authorize multiple transactions on the same EOA sequence:
const parallelEoaChain = defineChain({
...eoaChain,
submission: { maxInFlight: 8, maxSigning: 4, maxQueued: 64 },
})maxSigning defaults to 1 and must be a positive safe integer no greater than maxInFlight. Values above one require execution.supportsParallelSigning; the built-in EVM adapter provides this capability for plain EOAs. The signer itself must support concurrent calls. This option does not parallelize synchronous cryptographic work on one JavaScript thread.
When enabled, maxInFlight bounds the combined work preparing, signing, waiting to submit, or awaiting an acknowledgement. maxSigning additionally bounds pending signer calls. Total admitted work remains bounded by maxInFlight + maxQueued. Signatures may finish out of order, but submission invocation follows nonce order. Explicit nonce sends, prepared submissions and resetNonce drain all earlier preparation, signing and acknowledgements before proceeding.
A local signing failure rejects that request and cancels later reserved transactions that have not been submitted. Cetane never automatically retries a signer or re-signs those canceled requests. Already running signer calls may finish after their callers receive cancellation errors; they retain their capacity until they settle. Only after those calls and earlier acknowledgements drain can queued unsigned requests reuse the unused nonces. Any ambiguous submitted acknowledgement quarantines the sequence, including an acknowledgement that fails during this drain; a later success cannot clear that uncertainty.
Receipt waits support cancellation, timeouts, confirmation depth, and canonical block-hash checks. An all-zero or all-ff placeholder block hash is treated as a preconfirmation, not confirmed inclusion. RPC failures propagate; they are not silently treated as pending receipts. Finality tags are still pending.
An application with an existing block or receipt notification feed can wake waits
early through subscribe, retaining ordinary polling as a fallback:
const notifications = new EventTarget()
// Your shared feed dispatches new Event('update') when a block/receipt changes.
const receipt = await waitForTransactionReceipt(client, {
hash,
subscribe(notify) {
notifications.addEventListener('update', notify)
return () => notifications.removeEventListener('update', notify)
},
})The callback is registered before the first read and unsubscribed when the wait finishes, fails, times out or is cancelled. Events during a read are retained; bursts coalesce into one subsequent check. Notifications carry no trusted receipt data and do not bypass confirmations or canonical-block checks. They wake polling sleeps without cancelling an in-flight RPC or held receipt request. The application owns the feed and should share its connection across waits. Subscription setup and cleanup errors propagate. This hook does not open a WebSocket connection itself.
In the local EOA completion comparison, a shared newHeads feed with the same 10 ms polling fallback increased verified completions from 5.0k/s to 7.4k/s and lowered p99 from 18.4–18.8 ms to 14.8 ms. CPU per completion fell about 9%, but total CPU and memory rose, and worst observed latency did not improve. Both modes used canonical-read coalescing and 64 concurrent lifecycles on one-worker Anvil. These are workload-specific results; the benchmark feed is application-owned, not a built-in reconnecting transport.
WebSocket transport
cetane/transports/webSocket sends JSON-RPC over one multiplexed connection, using the standard global WebSocket (Bun, Node 22+, browsers). It is a separate import and stays out of the default bundle.
import { createClient } from 'cetane'
import { waitForTransactionReceipt } from 'cetane/actions'
import { webSocket } from 'cetane/transports/webSocket'
const transport = webSocket('wss://rpc.example', { timeout: 10_000 })
const client = createClient({ chain, transport })
// Receipt waits now wake on newHeads; pollingInterval is only the fallback.
const receipt = await waitForTransactionReceipt(client, { hash })
await transport.close()The connection opens on the first request. Requests are matched by ID, bounded by maxPending (default 1024) and time out per request. transport.subscribe(params, { onData, onError, onReconnect }) issues eth_subscribe and resolves to an eth_unsubscribe function.
transport.heads shares one newHeads subscription across all receipt waits on the transport. It subscribes when the first wait starts and unsubscribes after headsLinger (default 5 s) without listeners. Notifications are hints only: each wait still reads the receipt, checks confirmations and the canonical block, and keeps polling at pollingInterval, so a missed notification costs at most one interval. An endpoint that rejects eth_subscribe is not asked again, and its waits keep polling. To use heads with another transport, pass heads: transport.heads to createClient. heads: false turns them off. http clients poll exactly as before.
If the socket closes unexpectedly while subscriptions or queued requests remain, the transport reconnects with exponential backoff (reconnect: { attempts: 10, delay: 100, maxDelay: 5000, stableAfter: 5000 }; false disables). The attempt count resets only after a connection stays open for stableAfter, so a server that accepts and immediately drops connections still exhausts the attempts and re-establishes subscriptions under their new server IDs. onReconnect fires so callers can catch up on missed events. Nothing is resent. Every request already written to the closed socket, including eth_sendRawTransaction and eth_sendUserOperation, rejects with a TransportError whose outcome is unknown. A write may still have been accepted. Only requests that were never written wait for the next connection. If reconnection gives up, queued requests reject and subscriptions call onError. The next request opens a fresh connection. A receipt wait whose read fails on disconnect rejects like any other RPC failure. close() rejects all outstanding work, and the transport cannot be reused.
On 1 s interval-mined Anvil, head-driven waits observed inclusion at 0.4 ms p50 and about 2 ms p99. 1000 ms HTTP polling took about 460/990 ms with the same three RPCs per wait (report). These are local loopback results.
Receipt-returning sends and preconfirmations
Some endpoints return the receipt from the send call itself, in the
EIP-7966 [raw, timeoutMs?] shape.
This is a chain capability, not an L1 default. A chain opts in with
receiptSubmission, and the chain module names the RPC method. sendTransactionSync
from cetane/actions uses it for plain EOA sends with the EVM execution:
import { sendTransactionSync, SubmissionTimeoutError } from 'cetane/actions'
import { megaeth } from 'cetane/chains/megaeth'
try {
const { hash, receipt } = await sendTransactionSync(client, { to, value })
if (receipt.preconfirmed) {
// A sequencer preview with no sealed block hash; not inclusion.
}
} catch (error) {
// Accepted without a receipt in the endpoint window. Wait; never resubmit.
if (error instanceof SubmissionTimeoutError)
await waitForTransactionReceipt(client, { hash: error.hash })
else throw error
}The write happens once and runs through the ordinary nonce queue. The returned
receipt's hash must equal the locally computed hash; a mismatch quarantines the
nonce, as with eth_sendRawTransaction. A per-call timeout (milliseconds) is
bounded by the chain's maxTimeout. A timeout the chain classifies as
accepted advances the nonce and raises SubmissionTimeoutError with the hash. A
-32601 "method not found" raises a clear error. There is no fallback write, and the nonce stays
uncertain until resetNonce. Other failures propagate. The sync send holds the
account's queue until the response, so sends from one account do not overlap.
sendTransactionSync is not on walletActions; it does not add size to the
ordinary send path.
- Base:
withFlashblocks(base)fromcetane/chains/baseenableseth_sendRawTransactionSync(at most 6 s;syncTimeoutsets a default). It also declares 200 ms preconfirmed receipt reads. Its receipts are always reported as preconfirmed, because the node returns either a Flashblock or a sealed receipt. It is opt-in: Base plans to retire Flashblocks at Denim. After that, the sync method returns "method not found". - MegaETH: the
megaethpreset (chain 4326) usesrealtime_sendRawTransaction(gateway cap 3 s;-32000 realtime transaction expiredis the timeout). Receipts with the all-ff placeholder block hash are preconfirmations. - Other endpoints, such as reth or Anvil: configure
rawTransactionSync({ method, receipts, maxTimeout, isTimeout })fromcetane/chains/sync. The default classifier recognizes EIP-7966 code 4. Anvil 1.7.1 rejects the timeout parameter and reports its timeout as-32603.
On a chain that declares preconfirmation, waitForTransactionReceipt(client, { hash, preconfirmed: true })
resolves at the first preview, polling at the chain's preconfirmation interval.
A receipt counts as preconfirmed when its block hash is a placeholder or does not
match the canonical block at that height. Such a result has preconfirmed: true
and no blockHash; it can still be dropped or reordered. A sealed match is
returned as an ordinary receipt. Without the flag, waits keep their canonical
behavior, and confirmations above one are rejected with it.
In the local Anvil comparison, a sync send used
