|
| 1 | +# EVM transaction submission |
| 2 | + |
| 3 | +This page documents how Blockbook broadcasts a **Trezor Suite** EVM transaction through the |
| 4 | +private send-tx relay and tracks it in its own pending-transaction cache until the chain confirms |
| 5 | +or supersedes it. Fee estimation — a separate step of the same send flow — is covered in |
| 6 | +[fees.md](/docs/fees.md) and is not repeated here. |
| 7 | + |
| 8 | +The private-relay pieces (the alternative send-tx provider and its pending-tx cache) are active |
| 9 | +only when a coin is configured with the `*_ALTERNATIVE_SENDTX_URLS`, `*_ALTERNATIVE_SENDTX_ONLY`, |
| 10 | +and `*_ALTERNATIVE_FETCH_MEMPOOL_TX` environment variables. Without them Blockbook sends through |
| 11 | +the normal backend RPC and the cache path below is skipped. |
| 12 | + |
| 13 | +## Broadcast and the pending-transaction cache |
| 14 | + |
| 15 | +`SendRawTransaction` broadcasts and — when the relay accepts and the coin runs in |
| 16 | +`ALTERNATIVE_SENDTX_ONLY` + `ALTERNATIVE_FETCH_MEMPOOL_TX` mode — keeps its own pending-tx cache, |
| 17 | +because the relay exposes no mempool to reconcile against. A background loop and the read path |
| 18 | +reconcile that cache against the chain. |
| 19 | + |
| 20 | +```mermaid |
| 21 | +%%{init: {"theme": "base", "themeVariables": {"lineColor": "#6b7280", "primaryTextColor": "#111827"}}}%% |
| 22 | +flowchart TD |
| 23 | + send["SendRawTransaction(hex, disableAlternativeRPC)"] |
| 24 | + route{"provider configured<br/>and not disabled?"} |
| 25 | + primary["primary eth_sendRawTransaction"] |
| 26 | + relay["broadcast to relay URLs"] |
| 27 | + acc{"any URL accepted?"} |
| 28 | + reject["onlyAlternative: return error<br/>else: fall back to primary"] |
| 29 | + reg["registerSuccessfulSend<br/>(record sender + URL, assign gen)"] |
| 30 | + evict["evictReplacedByNonce<br/>retire same-(from,nonce) predecessor<br/>on ACK (RBF / cancel), gen-ordered"] |
| 31 | + handle["handleMempoolTransaction<br/>fetch-back → cache pending (gen-ordered)<br/>→ AddTransactionToMempool → notify"] |
| 32 | +
|
| 33 | + subgraph rec ["reconcileMempoolTxs (every minute, per cached tx)"] |
| 34 | + mined["mined → evict"] |
| 35 | + super["nonce_superseded<br/>(confirmed nonce > tx nonce) → evict"] |
| 36 | + miss["provider_missing<br/>(relay stopped surfacing) → keep to timeout"] |
| 37 | + to["past cache timeout → evict"] |
| 38 | + end |
| 39 | +
|
| 40 | + readpath["GetTransaction read path<br/>expired entry → evict"] |
| 41 | + remove[("removeMempoolTx<br/>clear cache + wrapped mempool<br/>+ release nonce routing<br/>metered once")] |
| 42 | +
|
| 43 | + send --> route |
| 44 | + route -- "no" --> primary |
| 45 | + route -- "yes" --> relay --> acc |
| 46 | + acc -- "no" --> reject |
| 47 | + acc -- "yes" --> reg --> evict --> handle |
| 48 | + evict -. removes predecessor .-> remove |
| 49 | + handle -. caches new tx .-> readpath |
| 50 | + mined --> remove |
| 51 | + super --> remove |
| 52 | + to --> remove |
| 53 | + readpath --> remove |
| 54 | +
|
| 55 | + classDef normal fill:#e7f0ff,stroke:#4078c0,color:#10243e; |
| 56 | + classDef store fill:#e8f7ed,stroke:#2e8b57,color:#0b2c19; |
| 57 | + classDef error fill:#ffecec,stroke:#c03535,color:#3b0a0a; |
| 58 | + class send,route,relay,acc,reg,evict,handle,mined,super,miss,to,readpath,primary normal; |
| 59 | + class remove store; |
| 60 | + class reject error; |
| 61 | +``` |
| 62 | + |
| 63 | +Key invariants: |
| 64 | + |
| 65 | +- **A same-`(from, nonce)` predecessor is retired the moment the relay ACKs its replacement**, from |
| 66 | + the raw hex — not by waiting for the relay to surface the replacement. A Blink drop-mode cancel |
| 67 | + is never surfaced and its nonce is never consumed on-chain, so without this the superseded tx |
| 68 | + lingered as "Unconfirmed" until the cache timeout. This is deliberately distinct from an *empty* |
| 69 | + `eth_getTransactionByHash` probe, which is **not** authoritative — a private relay stops |
| 70 | + surfacing a still-mineable tx while it stays broadcast, so `provider_missing` is kept until the |
| 71 | + timeout rather than evicted early. `mined` and `nonce_superseded` are the only deterministic |
| 72 | + early evictions the reconcile loop makes. |
| 73 | +- **Send generations order concurrent same-nonce sends.** Each accepted send gets a monotonic |
| 74 | + generation; an older submission's slow fetch-back neither caches itself over, nor evicts, a |
| 75 | + newer replacement that already holds the nonce slot. |
| 76 | +- **Every exit funnels through `removeMempoolTx`**, which clears the provider cache *and* the |
| 77 | + wrapped Blockbook mempool (address index), releases the sender's nonce routing once nothing |
| 78 | + private remains pending, and records the lifecycle metric exactly once (gated on the actual |
| 79 | + removal, so concurrent reconcile / read-path / RBF evictions of the same entry don't |
| 80 | + double-count). |
| 81 | +- **Removals are not pushed to the wallet.** Blockbook pushes only *added* txs; a wallet learns a |
| 82 | + pending tx is gone on its next account re-fetch (the initiating device also removes it |
| 83 | + optimistically). The cache timeout is the backstop for anything the deterministic evictions miss. |
| 84 | + |
| 85 | +## Observability |
| 86 | + |
| 87 | +Prometheus counters for the cache lifecycle: |
| 88 | + |
| 89 | +- `blockbook_eth_alternative_mempool_reconciliation_events_total{action}` — cache exits by reason |
| 90 | + (`mined`, `nonce_superseded`, `provider_missing`, `timeout`, `rbf_replaced`, plus the kept |
| 91 | + actions `skipped_fresh`, `provider_missing_pending`, `kept`, `provider_error`). |
| 92 | +- `blockbook_eth_alternative_mempool_tx_residence_seconds{action}` — how long an entry lived before |
| 93 | + each eviction reason fired (e.g. `provider_missing` clustering near the timeout rather than at |
| 94 | + ~1–2 min would flag a premature-eviction regression). |
| 95 | +- `blockbook_eth_alternative_mempool_cache_size` — current cache depth. |
0 commit comments