Skip to content

Commit 1b27a78

Browse files
committed
fix(validity): regenerate an aggregation proof the chain reverts
An aggregation proof the L2OO rejects was resubmitted unchanged on every loop. The request stays `Complete`, so it keeps counting toward fetch_active_agg_proofs_count and create_aggregation_proofs never builds a replacement; the L2OO stops advancing until someone edits the row by hand. Observed on QA3: agg request 946 sat Complete for two days while 486 range proofs piled up ~90k blocks ahead of a frozen contract head, every loop resubmitting bytes the verifier had already rejected with `InvalidExitCode()` (0x1fcf9177). Fail the request on the first revert instead. Resubmitting bytes the chain has rejected cannot succeed, so there is nothing to retry; failing it drops the row out of that count and the next loop builds a fresh aggregation over the same range. This covers the causes without having to tell them apart — a guest that halted abnormally, an L1 head reorged out from under the checkpoint, a config the contract no longer agrees with all present as a revert, and all are fixed by regenerating. The checkpoint case even self-heals: create_aggregation_proofs re-checkpoints when the stored L1 hash no longer matches the contract's, which is exactly what 946 needed. Delivery failures are excluded. A transport error, timeout or nonce problem says nothing about the proof, and regenerating on those would cost a full aggregation proof every time the RPC hiccups. is_execution_revert matches text, case-insensitively, because neither source has a stable typed form: a node rejecting the transaction up front returns a JSON-RPC error, and the on-chain case is the message relay_aggregation_proof builds from `receipt.status()`. EIP-1474 fixes the error CODE at 3, not the message text, so the code is matched too — an unrecognised revert is the stuck-forever bug, while over-matching costs one regenerated proof, and that asymmetry decides which way to err. The trailing colon on `error code 3:` is load-bearing: bare, it prefix-matches `error code 32000`, geth's generic server error, which is a delivery failure. Six tests pin both directions, including the ones that must NOT trigger regeneration. A DB error from the Failed transition is logged rather than propagated: the caller logs whatever this function returns, and a DB error there would appear in place of the revert that explains the failure. A failed transition only means the next loop resubmits and reverts again. Two unrelated fixes ride along, both protecting afaf9bc rather than this change: the HSM_API_NAME comment alignment that wrap_comments split into what reads as an unrelated paragraph, and a lint step asserting op-succinct-validity and sp1-sdk resolve the same tonic — three tonic versions already coexist in this tree, and if that pairing diverges the typed downcast silently returns None and the transport classifier degrades to the substring matching afaf9bc removed, with no compile error and no failing test. Supersedes an earlier approach (reverted before this commit) that read the guest exit code out of each proof before storage. It worked, but only addressed one cause, and the cost of being cause-specific was steep: two SP1-internal crates to reach a type with no stability guarantee, the same check duplicated across both prover paths, and a retry counter whose increment, clear and threshold were three more things to get wrong. A revert is the same signal, arrives at one place, and needs none of that.
1 parent c033da5 commit 1b27a78

3 files changed

Lines changed: 163 additions & 4 deletions

File tree

.github/workflows/lint.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,23 @@ jobs:
8989
command: check
9090
args: --all-targets --all-features --tests
9191

92+
- name: Check tonic version is unified with sp1-sdk
93+
run: |
94+
# validity classifies transport faults by downcasting the sp1-sdk's error to
95+
# `tonic::Status`. If the two crates resolve DIFFERENT tonic versions the downcast
96+
# silently returns None and the classifier degrades to substring matching — no
97+
# compile error, no test failure, just a quiet regression to the fragile behaviour
98+
# that typed matching was introduced to remove. Several tonic versions already
99+
# coexist in this tree, so guard the one pairing that must stay unified.
100+
VALIDITY=$(cargo tree -p op-succinct-validity --depth 1 -e normal | grep -oE 'tonic v[0-9.]+' | head -1)
101+
SDK=$(cargo tree -p sp1-sdk --depth 1 -e normal | grep -oE 'tonic v[0-9.]+' | head -1)
102+
echo "op-succinct-validity: ${VALIDITY:-<none>} / sp1-sdk: ${SDK:-<none>}"
103+
if [ -z "$VALIDITY" ] || [ "$VALIDITY" != "$SDK" ]; then
104+
echo "::error::tonic version drift: op-succinct-validity (${VALIDITY:-<none>}) and sp1-sdk (${SDK:-<none>}) must resolve the same tonic, otherwise is_transient_transport_error silently falls back to string matching. Update the tonic pin in validity/Cargo.toml."
105+
exit 1
106+
fi
107+
echo "tonic version check passed"
108+
92109
- name: Run cargo fmt
93110
uses: actions-rs/cargo@v1
94111
with:

utils/signer/src/lib.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,10 @@ impl Signer {
6262

6363
pub async fn from_env() -> Result<Self> {
6464
// [MANTLE compat] Existing Mantle deployments configure GCP KMS via two env vars:
65-
// HSM_API_NAME — full GCP resource path
66-
//
67-
// projects/<P>/locations/<L>/keyRings/<KR>/cryptoKeys/<K>[/cryptoKeyVersions/<V>]
68-
// HSM_CREDENTIALS — hex-encoded JSON service account key
65+
// HSM_API_NAME — full GCP resource path:
66+
// projects/<P>/locations/<L>/keyRings/<KR>/cryptoKeys/<K>
67+
// optionally followed by /cryptoKeyVersions/<V>
68+
// HSM_CREDENTIALS — hex-encoded JSON service account key
6969
// Production posture forbids writing service-account JSON to disk, so this path
7070
// pipes the decoded JSON straight into gcloud-sdk's TokenSourceType::Json — the
7171
// credential never touches the filesystem.

validity/src/proposer.rs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,36 @@ pub struct DriverConfig {
104104
/// Type alias for a map of task IDs to their join handles and associated requests
105105
pub type TaskMap = HashMap<i64, (tokio::task::JoinHandle<Result<()>>, OPSuccinctRequest)>;
106106

107+
/// Whether relaying failed because the chain rejected the proof, as opposed to the transaction
108+
/// never being delivered.
109+
///
110+
/// A revert is a verdict on the proof itself: the same bytes will be rejected on every
111+
/// resubmission, so the aggregation has to be regenerated rather than retried. Delivery failures
112+
/// (transport, timeout, nonce) say nothing about the proof and are left to the normal retry — a
113+
/// flaky RPC must not cost a full aggregation proof.
114+
///
115+
/// Matched on text because neither source has a stable typed form to match on: a node rejecting
116+
/// the transaction up front returns a JSON-RPC error, and the on-chain case is the message
117+
/// `relay_aggregation_proof` builds itself when `receipt.status()` is false.
118+
///
119+
/// EIP-1474 fixes the error CODE at 3 for an execution error; it does NOT fix the message text,
120+
/// which clients word differently (`execution reverted`, the same with an appended reason, or
121+
/// their own phrasing and capitalisation). Matching the code as well as the common wording, and
122+
/// matching case-insensitively, keeps a client we have not seen from going unrecognised — and
123+
/// failing to recognise a revert is what leaves the proof stuck forever, whereas over-matching
124+
/// only costs one regenerated proof.
125+
///
126+
/// Two details are load-bearing. The trailing colon on the code: a bare `error code 3` also
127+
/// prefix-matches `error code 32000`, geth's generic server error, which is a delivery failure and
128+
/// must not trigger regeneration. And the code match is deliberately broader than "revert" —
129+
/// EIP-1474 puts every execution error under 3 — which is the right side to err on here.
130+
fn is_execution_revert(e: &anyhow::Error) -> bool {
131+
let rendered = format!("{e:?}").to_lowercase();
132+
rendered.contains("execution reverted") ||
133+
rendered.contains("transaction reverted") ||
134+
rendered.contains("error code 3:")
135+
}
136+
107137
pub struct Proposer<P, H: OPSuccinctHost>
108138
where
109139
P: Provider + 'static,
@@ -1350,6 +1380,47 @@ where
13501380
Ok(transaction_hash) => transaction_hash,
13511381
Err(e) => {
13521382
ValidityGauge::RelayAggProofErrorCount.increment(1.0);
1383+
1384+
// A revert condemns the proof, so fail it immediately rather than resubmitting
1385+
// bytes the chain has already rejected. Without this the request stays `Complete`
1386+
// forever: `submit_agg_proofs` resubmits it every loop, every attempt reverts, and
1387+
// because it still counts toward `fetch_active_agg_proofs_count`,
1388+
// `create_aggregation_proofs` never builds a replacement — the L2OO stops
1389+
// advancing until someone edits the row by hand. Failing it drops it out of that
1390+
// count so the next loop generates a fresh aggregation over the same range.
1391+
//
1392+
// This covers the causes without having to tell them apart: a guest that halted
1393+
// abnormally (`InvalidExitCode()`), an L1 head reorged out from under the
1394+
// checkpoint, a config the contract no longer agrees with — all of them present
1395+
// as a revert, and all of them are fixed by regenerating rather than retrying.
1396+
if is_execution_revert(&e) {
1397+
warn!(
1398+
request_id = completed_agg_proof.id,
1399+
start_block = completed_agg_proof.start_block,
1400+
end_block = completed_agg_proof.end_block,
1401+
error = ?e,
1402+
"Aggregation proof was rejected on chain; failing it so a new one is generated"
1403+
);
1404+
// Do not propagate a DB error from here: the caller logs whatever this
1405+
// function returns, and returning the DB error would put that in front of the
1406+
// revert that actually explains the failure. A failed transition just means
1407+
// the next loop resubmits and reverts again, which is recoverable; losing the
1408+
// revert from the logs is what makes an incident take a day to diagnose.
1409+
if let Err(db_err) = self
1410+
.driver_config
1411+
.driver_db_client
1412+
.update_request_status(completed_agg_proof.id, RequestStatus::Failed)
1413+
.await
1414+
{
1415+
warn!(
1416+
request_id = completed_agg_proof.id,
1417+
error = ?db_err,
1418+
"Failed to mark the reverted aggregation proof as Failed; it will be \
1419+
resubmitted and revert again until this transition succeeds"
1420+
);
1421+
}
1422+
}
1423+
13531424
return Err(e);
13541425
}
13551426
};
@@ -1993,6 +2064,77 @@ mod contiguous_block_tests {
19932064
}
19942065
}
19952066

2067+
#[cfg(test)]
2068+
mod execution_revert_tests {
2069+
use super::is_execution_revert;
2070+
2071+
#[test]
2072+
fn detects_a_node_rejecting_the_transaction_up_front() {
2073+
// The shape observed on QA3: the node simulates the call, sees it revert, and returns a
2074+
// JSON-RPC error. `data` is the verifier's error selector — here InvalidExitCode().
2075+
let rejected = anyhow::anyhow!(
2076+
"server returned an error response: error code 3: execution reverted, data: \"0x1fcf9177\""
2077+
);
2078+
assert!(is_execution_revert(&rejected));
2079+
}
2080+
2081+
#[test]
2082+
fn detects_a_transaction_that_reverted_on_chain() {
2083+
// The message `relay_aggregation_proof` builds itself when `receipt.status()` is false.
2084+
let reverted = anyhow::anyhow!("Transaction reverted: {:?}", "<receipt>");
2085+
assert!(is_execution_revert(&reverted));
2086+
}
2087+
2088+
#[test]
2089+
fn detects_a_revert_through_context_wrapping() {
2090+
let wrapped = anyhow::anyhow!("error code 3: execution reverted")
2091+
.context("Failed to send transaction");
2092+
assert!(is_execution_revert(&wrapped));
2093+
}
2094+
2095+
#[test]
2096+
fn detects_a_revert_worded_by_another_client() {
2097+
// EIP-1474 fixes the code, not the text. A client we have not seen must still be
2098+
// recognised — an unrecognised revert is exactly the stuck-forever case this prevents.
2099+
let other_wording =
2100+
anyhow::anyhow!("server returned an error response: error code 3: VM execution error");
2101+
assert!(is_execution_revert(&other_wording));
2102+
}
2103+
2104+
#[test]
2105+
fn is_case_insensitive() {
2106+
// Capitalisation varies by client, and `relay_aggregation_proof` is not the only place in
2107+
// this file that builds a "... transaction reverted" message. Matching case-sensitively
2108+
// would make recognition depend on which component happened to phrase it.
2109+
assert!(is_execution_revert(&anyhow::anyhow!("Execution Reverted")));
2110+
assert!(is_execution_revert(&anyhow::anyhow!(
2111+
"Checkpoint block transaction reverted: <receipt>"
2112+
)));
2113+
}
2114+
2115+
#[test]
2116+
fn does_not_prefix_match_a_generic_server_error() {
2117+
// `error code 32000` starts with `error code 3`. It is geth's generic server error — a
2118+
// delivery failure — and regenerating on it would burn a proof for nothing. This is why
2119+
// the code match carries a trailing colon.
2120+
let generic = anyhow::anyhow!("server returned an error response: error code 32000: oops");
2121+
assert!(!is_execution_revert(&generic));
2122+
}
2123+
2124+
#[test]
2125+
fn leaves_delivery_failures_alone() {
2126+
// These say nothing about the proof — regenerating on them would burn an aggregation
2127+
// proof every time the RPC hiccups.
2128+
assert!(!is_execution_revert(&anyhow::anyhow!("tcp connect error")));
2129+
assert!(!is_execution_revert(&anyhow::anyhow!("error trying to connect: dns error")));
2130+
assert!(!is_execution_revert(&anyhow::anyhow!("nonce too low")));
2131+
assert!(!is_execution_revert(&anyhow::anyhow!(
2132+
"timed out waiting for transaction receipt"
2133+
)));
2134+
assert!(!is_execution_revert(&anyhow::anyhow!("insufficient funds for gas * price")));
2135+
}
2136+
}
2137+
19962138
#[cfg(test)]
19972139
mod admission_shed_tests {
19982140
use super::{is_admission_shed_error, is_transient_transport_error};

0 commit comments

Comments
 (0)