Skip to content

Add transaction manager - #138

Open
jannikluhn wants to merge 6 commits into
mainfrom
feat/transaction-manager
Open

Add transaction manager#138
jannikluhn wants to merge 6 commits into
mainfrom
feat/transaction-manager

Conversation

@jannikluhn

Copy link
Copy Markdown
Contributor

Closes #133

Adds a transaction manager that assigns nonces, watches transactions, reports on their inclusion, and if necessary resends transactions if they get stuck or lost.

jannikluhn and others added 6 commits August 12, 2026 19:04
TotalSuccessfulIdentityRegistration, TotalDecryptionKeysReceived and
TotalFailedRPCCalls are only ever incremented, so Counter is the correct
type. Series names are unchanged, so existing queries keep working, and
rate() over them is now legitimate rather than an accident.
Prometheus counters carry _total as a suffix, not a prefix, and the
plural belongs on the thing being counted:

  shutter_api_total_successful_identities_registration
    -> shutter_api_successful_identity_registrations_total
  shutter_api_total_decryption_keys_received
    -> shutter_api_decryption_keys_received_total
  shutter_api_total_failed_rpc_calls
    -> shutter_api_failed_rpc_calls_total

Go identifiers drop their now-redundant Total prefix to match. Existing
series keep their history under the old names but stop being written to,
so any dashboard or alert rule querying them needs updating.
The signer pays gas for every identity registration, but the service
never queried its balance, so an account draining to empty was only
visible once registrations started failing. At that point it surfaced as
failed_rpc_calls_total from the transaction send sites, indistinguishable
from an RPC outage.

shutter_api_signer_balance_ether is published by a new BalancePoller
running as a service alongside the metrics server, so it is gated on
METRICS_ENABLED. It reads once at startup and then every 60s, each read
bounded by a 10s timeout so a hung endpoint cannot stall the loop. A
failed read logs, increments failed_rpc_calls_total and leaves the gauge
alone; it never returns an error, because a transient RPC failure must
not bring the service down through the error group.

The metric is a GaugeVec with no labels rather than a plain Gauge. A
plain Gauge is registered holding 0, so a restart while the RPC endpoint
was down would publish 0 ether and fire the low-balance alert this metric
exists to raise. With no value set the series is simply absent, and the
exposed series is otherwise identical.

Balance is reported in ether rather than wei so that alert thresholds are
readable; the conversion goes through big.Float, as an integer quotient
would truncate everything below 1 ether to zero.

Known gap: a reading that stops being refreshed keeps its last value
indefinitely, so a dead poller looks healthy. Detection relies on
failed_rpc_calls_total, which now also moves for background polls and can
therefore rise with no traffic.

Co-Authored-By: Claude <noreply@anthropic.com>
successful_identity_registrations_total counts submissions, which says
nothing about whether a transaction was mined. These three cover what
happens after the response goes out.

transactions_resolved_total is labelled by terminal state, and is finer
grained than what the transaction manager reports to its callers. A
revert is the contract's verdict rather than the manager's, so callers
read it off the receipt, but a revert rate is still worth alerting on.
Rejected and abandoned are one error to a caller that only wants to know
nothing will be mined, and two series here. Every status series is
created at registration so that rate() over a state that has not
happened yet reads as zero instead of returning no data.

submission_timeouts_total counts requests answered with an error while
their transaction was still on its way to the node. Giving up waiting
does not stop the transaction, so each one is a client told its
registration failed that may have landed on chain anyway, and a
candidate for reconciliation rather than a plain failure.

pending_transactions is a gauge because it is not monotonic. A value
that climbs and does not fall means registrations are being accepted but
not mined, and that every later registration is queued behind them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every registration is signed by the same key, so all of them compete for
one nonce sequence. Left to the generated bindings, a nil
TransactOpts.Nonce makes each call resolve its own nonce, so two
concurrent requests sign two transactions with the same one and only one
of them can be mined. Under any real load the API was losing
registrations to that race.

The Manager closes the window structurally rather than with locks: Send
only queues a request, and a single goroutine owns nonce assignment,
submission and everything in flight. That goroutine also polls for
receipts, so the package holds no mutexes. Polling is only observation,
so a submission delaying a receipt check by a few hundred milliseconds
costs nothing next to what sharing the nonce sequence would.

A request is watched until it is mined, and if something else mines its
nonce first it is carried over to a free one rather than failed. A
transaction that goes RebroadcastAfter without being mined is sent
again, priced to outbid the version it replaces, which doubles as gas
bumping. Recomputing from the node's suggestion alone would not do: geth
demands a bump on the tip as well as the fee cap, and the tip oracle
does not move when the base fee does, so a transaction stranded below a
risen base fee could never be replaced and would wedge the whole queue
behind it. MaxFeeCapPercent bounds how far that escalation goes.

Only the lowest-nonce transaction is resubmitted, and only one per poll.
Transactions are mined in nonce order, so repricing the others cannot
make them move until it does.

Known limits: pending state is in memory and lost on restart, so
transactions still in the pool are adopted by nonce rather than
resubmitted. An account with no gas money is watched indefinitely and
wedges registration, visible in pending_transactions and
signer_balance_ether, and clears once it is refilled. A transaction sent
from this key by anything other than this process will collide with
ours. Carrying a request over abandons a transaction that cannot be
proven dead, so if the old one is still included the request lands
twice: the time registry reverts the second with AlreadyRegistered, the
event registry has no such error and registers the identity twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both registration endpoints signed and sent their own transaction with a
nil nonce, so concurrent registrations raced for the same one. They now
hand a closure to a single process-wide manager, which owns the nonce
sequence of the signing key. One manager for the whole process is the
point: it only works if every registration goes through the same one.

Submission is no longer synchronous, so the endpoints wait for the first
event on the request's channel to get a hash to answer with.
awaitSubmission bounds that wait at five seconds, which has to cover
assigning a nonce, reading gas prices and broadcasting, plus the wait
behind any submissions already queued in front of it. It does not cover
waiting for a block, since the response carries a hash rather than a
receipt. The bound is generous rather than tight because giving up does
not cancel anything: the transaction is still sent, so a client told its
registration failed may find it on chain regardless, which costs far
more than waiting a little longer. A full queue is the one failure
reported to the client as such, with 503, because "overloaded, come back
later" is the only one a client can act on.

The event endpoint's receipt poller becomes recordEventRegistration,
following the same channel instead of polling TransactionReceipt on a
one-second ticker. It also now covers the case where the request gave up
waiting or its insert failed, so an identity registered on chain is no
longer one the API has no record of.

The signer address comes from the manager rather than being derived from
the config key at each call site, which also removes ChainID from the
per-request path: it is read once at startup instead. ChainID and
TransactionReceipt leave EthClientInterface, since nothing in the
usecase layer reads the chain directly any more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Serialize nonces in outgoing messages

1 participant