Skip to content

Feat/evm confirmation and reorg - #230

Open
ALLEN-AYODEJI wants to merge 2 commits into
Octo-Protocol-org:mainfrom
ALLEN-AYODEJI:feat/evm-confirmation-and-reorg
Open

Feat/evm confirmation and reorg#230
ALLEN-AYODEJI wants to merge 2 commits into
Octo-Protocol-org:mainfrom
ALLEN-AYODEJI:feat/evm-confirmation-and-reorg

Conversation

@ALLEN-AYODEJI

Copy link
Copy Markdown
Contributor

Description
This is the highest-risk issue in the epic. Octo currently credits a deposit the moment it sees
it, because on Stellar that is correct: a transaction_successful payment is final and the ledger
does not reorganise. That assumption is wired into the design — see the guarantees at the top of
crates/ingest/src/lib.rs and
crates/store/src/lib.rs.

On EVM, blocks reorg. A deposit seen at block N can vanish. If Octo credits on sight, an attacker
can deposit, get credited, force or exploit a reorg, and withdraw against a balance that no longer
exists. Even absent an attacker, ordinary 1–2 block reorgs happen routinely on L1 and L2s.

Introduce a confirmation state machine and a reorg detector that can reverse a credit.

Requirements and context
Deposits move through explicit states: detected → confirming → confirmed → creditable.
Only confirmed funds are spendable. A separate orphaned terminal state records reversals for
audit — never delete the row; the ledger is append-only and reversals must be visible.
Confirmation depth is per chain, configured in #216. Ethereum L1 and an L2 with a centralised
sequencer have very different risk profiles, and L2s can have deep reorgs on sequencer
failover. Do not hard-code a number.
Reorg detection: store the block hash alongside the block number for each processed block, and
on each poll verify the parent hash still matches. A number-only cursor cannot detect a reorg —
the same height with a different hash looks identical.
On reorg: rewind the cursor to the last common ancestor, mark affected deposits orphaned, emit a
reversal webhook, and re-scan. Rewinding must be bounded — an unbounded rewind on a malicious RPC
is a DoS.
Security: the window between crediting and finality is the exploitable window. State clearly
in the threat model what depth is used per chain and what that implies. Consider whether an
orphaned deposit that was already withdrawn against is possible, and what the system does about
it — an honest "this is prevented by requiring N confirmations before spendability" is the
expected answer.
Consider finalized / safe block tags (post-Merge) as a stronger signal than depth counting
where the provider supports them.
Suggested execution
Branch: feat/evm-confirmation-and-reorg

Implement changes
Migration 00NN_deposit_confirmations.sql: add confirmation_state, block_number,
block_hash, confirmations, and orphaned_at to transactions; add a partial index over
rows still confirming, since that is the hot query.
Add a confirmation tracker that re-checks confirming deposits each tick, promotes them at depth,
and emits deposit.confirmed.
Add reorg detection via parent-hash chaining, with a bounded rewind depth (configurable, default
≥ 2× confirmation depth) and a loud alert if the bound is hit.
Add reversal: mark orphaned, adjust balances, emit deposit.orphaned via
octo-webhooks.
Ensure balance queries only sum confirmed rows. Audit every existing balance/aggregate query
for this — one missed query makes unconfirmed funds spendable and defeats the whole issue.
Test and commit
Reorg integration test using Anvil snapshot/revert (#219): deposit at block N, confirm it, force
a reorg, assert the deposit is marked orphaned, the balance is reduced, and the webhook fires.
This is the acceptance test for the issue.
Test that a deposit below confirmation depth is not spendable — attempt a withdrawal against
it and assert rejection.
Test progressive confirmation counting and promotion at exactly the configured depth.
Test deep-reorg bounding: a reorg deeper than the rewind bound alerts rather than looping.
Test that re-scanning after a reorg re-detects a transaction that survived, without
double-crediting.
Test that Stellar deposits are unaffected and still credit immediately.
Update docs/threat-model.md and docs/deposit-model.md
with the per-chain depths and the reasoning behind each.
Example commit message
feat(ingest): confirmation depth and reorg handling for EVM

Stellar finality is instant, so Octo credits on sight. EVM blocks
reorg, so crediting on sight would let an attacker deposit, withdraw,
and reorg away the deposit.

Deposits now progress detected → confirming → confirmed, with only
confirmed rows spendable, and reorgs are detected by parent-hash
chaining (a number-only cursor cannot see a same-height different-hash
reorg). Affected deposits are marked orphaned and never deleted.

Closes #222

Adds octo-evm-core (BIP-32/BIP-44 derivation, EIP-55 checksums) and a
minimal octo-chain ChainAdapter, a migration adding chain_kind/derivation
columns to wallets/addresses, Store::allocate_evm_address (same
row-lock atomicity as the Stellar path), and chain-aware API responses.
Docs updated for the EVM deposit model and the xpub/sibling-key risk.

Refs Octo-Protocol-org#220
Stellar deposits credit on sight because Stellar finality is instant.
EVM blocks reorg, so crediting on sight would let an attacker deposit,
get credited, force/exploit a reorg, and withdraw against a balance
that no longer exists. This closes that window.

Store layer (migration 0022, already on this branch):
- transactions gain confirmation_state (detected/confirming/confirmed/
  orphaned), evm_tx_hash/log_index/block_number/block_hash/
  confirmations/orphaned_at; wallets gain confirmation_depth/
  reorg_rewind_bound (per-wallet, not hard-coded).
- record_evm_deposit, confirming_evm_transactions,
  progress_evm_confirmation, orphan_evm_deposits_from_block, and
  evm_block_headers/ingest_cursor helpers for hash-chained reorg
  detection.
- Fixed wallets_due_for_poll to filter chain_kind = 'stellar' (it had
  no chain_kind filter at all, so an EVM wallet sharing the same
  `network` value would already have been handed to the Horizon
  poller); added the EVM analogue, evm_wallets_due_for_poll.

Ingest layer (new this commit):
- crates/ingest/src/evm_rpc.rs: a minimal eth_getBlockByNumber-only
  JSON-RPC client (no alloy/ethers, per the workspace's narrow-
  primitives ADR), with the same retry/circuit-breaker treatment as
  the existing Horizon client.
- crates/ingest/src/confirmation.rs: ConfirmationTracker (one per EVM
  wallet) and EvmSupervisor (fans it out, mirroring Supervisor). Each
  tick: (1) extends the wallet's verified block-hash chain from its
  cursor to the current tip, detecting a reorg via parent-hash
  continuity rather than a number-only comparison, with a bounded
  backward search for the last common ancestor (reorg_rewind_bound) —
  hitting the bound alerts instead of guessing or looping; (2)
  recomputes confirmations for every still-accumulating deposit and
  promotes it to confirmed (spendable) at confirmation_depth, firing
  deposit.confirmed; a reorg instead marks affected rows orphaned
  (never deleted) and fires deposit.orphaned.
- Wired into bin/server behind an optional EVM_RPC_URL (no default —
  unlike Horizon there's no sane public default to fall back to; the
  tracker simply doesn't start if unset).

Audited every existing balance/aggregate query: sum_deposits_for_address(es)
already filter status = 'confirmed', so an EVM deposit is invisible to
them for free until this tracker promotes it — no query changes needed.

Tests:
- crates/ingest/tests/confirmation_tests.rs runs against a real Anvil
  node (evm_snapshot/evm_revert to force genuine reorgs, not mocked
  ones): progressive confirmation counting + promotion at exactly the
  configured depth + not spendable below it; the acceptance scenario
  (deposit reaches confirmed, a reorg reverts its block, it's marked
  orphaned, balance drops, deposit.orphaned fires); a reorg deeper than
  reorg_rewind_bound alerts without touching state.
- crates/store/tests/store_tests.rs: record_evm_deposit dedups on
  (evm_tx_hash, log_index), so a post-reorg rescan re-detecting a
  survived transaction can't double-credit it.

Docs: docs/deposit-model.md gets a "Confirmation depth and reorg
handling" section (per-chain depth guidance, the state machine, and
why an already-withdrawn deposit can't be reorged away); docs/threat-
model.md gets the corresponding threat rows and known-limitations
entries (depth is an operator trade-off, the header window is finite).

Refs Octo-Protocol-org#222
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.

feat(ingest): Confirmation depth and reorg handling for EVM deposits

1 participant