Skip to content

fix(settlement): reuse the prepared proof when retrying a failed submit - #639

Merged
kariy merged 5 commits into
mainfrom
fix/settlement-proof-reuse
Jul 20, 2026
Merged

fix(settlement): reuse the prepared proof when retrying a failed submit#639
kariy merged 5 commits into
mainfrom
fix/settlement-proof-reuse

Conversation

@kariy

@kariy kariy commented Jul 17, 2026

Copy link
Copy Markdown
Member

Why

When update_state submission fails after proving succeeded (fees, nonce, transient RPC), the settle loop retried the whole pipeline — including a fresh, paid SP1 proving round — every ~60s for the identical block range. Production incident (mainnet enclave, 2026-07-17): the settlement account was short on STRK for the submission fee, and the node burned ~0.4 PROVE per retry (~24 PROVE/hour) re-proving range 96–105 in a loop that could never succeed. A node restart had the same cost: the in-flight proof died with the process even though the prover network still held it.

What

Prove once, retry the submission (Worker::settle_batch): the payload is obtained at most once per attempt and lives as a local scoped to it; an inner loop retries only the update_state submission (own backoff, failure metrics, cursor-idempotency re-read, shutdown-responsive via the run loop's oneshot receiver). Reuse-while-unchanged, spend-on-success, drop-on-cursor-move, and drop-on-execution-revert are all control flow rather than cache state. Terminal conditions (prove failure, execution revert, on-chain cursor moved) return Err to the run loop, which backs off and recomputes the range. PiltoverClient::update_state takes &PiltoverInput (the generated bindings don't derive Clone).

Recovery across restarts (persisted network reference): the moment proving yields a ProofId, it is persisted as a PendingBatchProof (range + id) in a new singleton PendingSettlementProofs table — before submission — and cleared when the batch settles. On startup of an attempt, a matching reference is resolved through the new ProvingBackend::recover, which fetches the fulfilled proof from the prover network (SDK-side: new Program::recover_proofsp1_sdk wait_proof by request id, the same plumbing as gen_raw_proof minus the paid request; SDK rev bump to cartridge-gg/amd-sev-snp-attestation-sdk@2685854) and rebuilds the payload from a freshly built attestation. Only range-derived fields enter the payload, so a rebuilt attestation + recovered proof is equivalent in everything on-chain validation checks. Mock proving reports recovery as unsupported (it's free anyway); recovery failure (expired/unknown id) falls back to fresh proving.

Escape hatch: an update_state that reverts in execution (e.g. a TEE-registry trust-root rotation invalidated the proof between proving and submission) drops the payload and its persisted reference, so the next attempt proves fresh — retry-with-same-proof only applies to pre-execution failures (fees, nonce, transport), the incident class where it's wanted.

Reuse is sound because the payload is validity-stable for a fixed historical range — report_data is a pure function of the settled range, and Piltover validates the proof against the range-derived commitment, not any timestamp embedded at proving time. The PendingSettlementProofs table addition is version-neutral (same as SettlementProofs in #631). proof_generation_seconds now records only actual proving rounds.

Operator-visible log change: transient submission failures emit Failed to submit state update; will retry with the same proof.; Failed to settle block range; will retry. now marks terminal attempt failures only. Docs updated accordingly.

Tests

The proof_reuse suite drives settle_batch's real submission-retry loop under a paused tokio clock against an unreachable Piltover endpoint (the incident shape), shut down after bounded virtual time: one prove across many submission retries with the persisted reference surviving, restart recovery from the reference, unsupported/failed recovery fallback to fresh proving, stale-range reference skip, prove-failure early return, and reference clearing.

🤖 Generated with Claude Code

The settle loop re-ran the whole proving pipeline on every retry of a
failed attempt, even when proving had succeeded and only the
update_state submission failed. On the SP1 prover network every retry
is a paid proving round: the mainnet enclave burned ~0.4 PROVE per
minute (~24/hour) re-proving the identical range while its settlement
account was short on STRK for the submission fee (2026-07-17 incident).

Hold the proving result in the worker as a PreparedBatch keyed by the
settle range: retries of the unchanged range skip straight to
submission, a successful submission spends the payload, and a range
change (the on-chain cursor moved) drops it before proving fresh.
Reuse is sound because the payload is validity-stable for a fixed
historical range — on-chain validation checks the proof against the
range-derived commitment, not any timestamp embedded at proving time.
The cache is deliberately in-memory: a restart clears it, which is
also the escape hatch if a held proof is ever invalidated externally
(e.g. a TEE-registry trust-root rotation).

PiltoverClient::update_state now takes the input by reference so the
worker keeps ownership across a failed submit (the generated
PiltoverInput bindings don't derive Clone).

The new proof_reuse tests drive prepare_batch/settle_batch with a
counting mock backend, including the incident shape: submission fails
against an unreachable Piltover endpoint after proving succeeded, and
the retry must not prove again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown

Codec benchmark diff vs main

Benchmark Baseline (ns) Current (ns) Δ
CompiledClass(fixture)/compress 2698471 2637056 -2.28%
CompiledClass(fixture)/decompress 2819218 2779860 -1.40%
ExecutionCheckpoint/compress 30 33 +10.00%
ExecutionCheckpoint/decompress 23 25 +8.70%
PruningCheckpoint/compress 30 33 +10.00%
PruningCheckpoint/decompress 23 25 +8.70%
VersionedHeader/compress 654 653 -0.15%
VersionedHeader/decompress 835 890 +6.59%
StoredBlockBodyIndices/compress 74 79 +6.76%
StoredBlockBodyIndices/decompress 34 38 +11.76%
StorageEntry/compress 144 149 +3.47%
StorageEntry/decompress 141 157 +11.35%
ContractNonceChange/compress 146 149 +2.05%
ContractNonceChange/decompress 235 260 +10.64%
ContractClassChange/compress 189 192 +1.59%
ContractClassChange/decompress 254 281 +10.63%
ContractStorageEntry/compress 163 157 -3.68%
ContractStorageEntry/decompress 337 350 +3.86%
GenericContractInfo/compress 134 135 +0.75%
GenericContractInfo/decompress 104 110 +5.77%
Felt/compress 82 73 -10.98%
Felt/decompress 56 61 +8.93%
BlockHash/compress 82 72 -12.20%
BlockHash/decompress 56 60 +7.14%
TxHash/compress 81 71 -12.35%
TxHash/decompress 56 61 +8.93%
ClassHash/compress 82 73 -10.98%
ClassHash/decompress 56 60 +7.14%
CompiledClassHash/compress 83 71 -14.46%
CompiledClassHash/decompress 56 61 +8.93%
BlockNumber/compress 46 33 -28.26%
BlockNumber/decompress 24 24 +0.00%
TxNumber/compress 46 33 -28.26%
TxNumber/decompress 24 25 +4.17%
FinalityStatus/compress 1 0 -100.00%
FinalityStatus/decompress 10 10 +0.00%
TypedTransactionExecutionInfo/compress 18030 14848 -17.65%
TypedTransactionExecutionInfo/decompress 3709 3717 +0.22%
VersionedContractClass/compress 380 364 -4.21%
VersionedContractClass/decompress 802 832 +3.74%
MigratedCompiledClassHash/compress 152 154 +1.32%
MigratedCompiledClassHash/decompress 138 159 +15.22%
ContractInfoChangeList/compress 1406 1408 +0.14%
ContractInfoChangeList/decompress 2171 2278 +4.93%
BlockChangeList/compress 614 605 -1.47%
BlockChangeList/decompress 846 922 +8.98%
ReceiptEnvelope/compress 31533 26585 -15.69%
ReceiptEnvelope/decompress 6165 6504 +5.50%
TrieDatabaseValue/compress 169 171 +1.18%
TrieDatabaseValue/decompress 211 263 +24.64%
TrieHistoryEntry/compress 287 335 +16.72%
TrieHistoryEntry/decompress 245 307 +25.31%

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown

Runner: AMD EPYC 9V74 80-Core Processor (4 cores) · 15Gi RAM

@codecov

codecov Bot commented Jul 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 69.49686% with 97 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.19%. Comparing base (9bde0ae) to head (98eda1d).
⚠️ Report is 500 commits behind head on main.

Files with missing lines Patch % Lines
crates/settlement/src/service.rs 82.47% 44 Missing ⚠️
crates/settlement/src/backend/tee/prover.rs 0.00% 40 Missing ⚠️
...torage/provider/provider/src/providers/fork/mod.rs 0.00% 9 Missing ⚠️
crates/settlement/src/backend/mod.rs 0.00% 2 Missing ⚠️
crates/settlement/src/backend/tee/mod.rs 0.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #639      +/-   ##
==========================================
- Coverage   73.32%   68.19%   -5.14%     
==========================================
  Files         209      335     +126     
  Lines       23132    47004   +23872     
==========================================
+ Hits        16961    32053   +15092     
- Misses       6171    14951    +8780     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

kariy and others added 4 commits July 20, 2026 17:34
…ter restart

A crash or restart between proving and settling discarded the prepared
payload, forcing a fresh (paid) SP1 proving round for the identical
range. The proof itself still exists on the prover network though — so
persist just its network reference and recover it instead.

- The moment proving yields a ProofId, record it as a PendingBatchProof
  (range + id) in a new singleton PendingSettlementProofs table —
  before update_state submission. Cleared when the batch settles.
- On an in-memory miss, prepare_batch resolves a matching persisted
  reference via the new ProvingBackend::recover, which fetches the
  fulfilled proof from the network (SDK-side: the new
  Program::recover_proof — sp1_sdk wait_proof by request id, same
  plumbing as gen_raw_proof minus the paid request; SDK rev bump) and
  rebuilds the payload from a freshly built attestation. Only
  range-derived fields enter the payload, so a rebuilt attestation +
  recovered proof is byte-equivalent in everything on-chain validation
  checks. Mock proving reports recovery as unsupported (it is free
  anyway); any recovery failure falls back to fresh proving.
- Escape hatch for externally invalidated proofs: an update_state that
  REVERTS in execution (e.g. a TEE-registry trust-root rotation between
  proving and submission) drops the payload and its persisted
  reference, so the retry proves fresh. Pre-execution failures (fees,
  nonce, transport) keep both — that's the incident case where reuse is
  the whole point.

Table addition is version-neutral (same as SettlementProofs in #631).
New proof_reuse tests cover restart recovery, unsupported/failed
recovery fallback, stale-range references, and reference lifetime
across failed submissions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the PreparedBatch cache field with structure: settle_batch now
proves at most once and then loops on the update_state submission
itself, holding the payload as a local scoped to the attempt. Every
invalidation rule the cache needed becomes plain control flow — reuse
while the range is unchanged is staying in the loop, spend-on-success
is returning, drop-on-cursor-move is bailing out for the run loop to
recompute, and drop-on-execution-revert is the one explicit early
return. The persisted PendingBatchProof reference remains solely for
crash recovery.

The submission loop owns its own backoff, failure accounting, and the
cursor-idempotency re-read, and stays shutdown-responsive by selecting
on the (Unpin) oneshot receiver borrowed from the run loop, returning
SettleOutcome::ShuttingDown. The outer loop's error branch now only
handles terminal attempt failures (prove failure, execution revert,
cursor moved).

Log-line change for operators: transient submission failures now emit
"Failed to submit state update; will retry with the same proof."
instead of re-entering "Failed to settle block range" each cycle (that
message now marks terminal attempt failures only). Docs updated.

Tests now drive settle_batch's real submission-retry loop under a
paused tokio clock (tokio test-util) against an unreachable Piltover
endpoint, shutting it down after a bounded virtual time: one prove
across many submission retries, restart recovery, recovery fallbacks,
stale-reference skip, and reference lifetime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Option<ProofId> in recover's return was structurally dead: recovery
happens BY a proof id, so a recovered payload can never lack one — the
inner Option only existed because the signature mirrored prove's shape,
where None legitimately means off-network proving. And returning the id
at all was redundant: the caller reads it out of the persisted
PendingBatchProof before calling recover. The backend now returns just
the payload and settle_batch pairs it with the reference it resolved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kariy
kariy merged commit f3a5405 into main Jul 20, 2026
17 checks passed
@kariy
kariy deleted the fix/settlement-proof-reuse branch July 20, 2026 12:07
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.

1 participant