Skip to content

Commit 29ca56f

Browse files
authored
Merge pull request #46 from mantle-xyz/mantle/proposer-hardening
feat(validity): handle relay rejections, back off transport faults, and anchor checkpoints to safe
2 parents c7a6162 + fbf8a25 commit 29ca56f

15 files changed

Lines changed: 2144 additions & 121 deletions

File tree

Cargo.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,9 @@ alloy-signer-local = { version = "2.0.4" }
118118
alloy-provider = { version = "2.0.4" }
119119
alloy-transport = { version = "2.0.4" }
120120
alloy-transport-http = { version = "2.0.4" }
121+
# [MANTLE] Test-only: constructing `ErrorPayload` to pin that relay-rejection classification reads
122+
# revert data from the `data` field and never from the client's message wording.
123+
alloy-json-rpc = { version = "2.0.4" }
121124
alloy-contract = { version = "2.0.4" }
122125
alloy-network = { version = "2.0.4" }
123126

MANTLE_CHANGES.md

Lines changed: 125 additions & 1 deletion
Large diffs are not rendered by default.

utils/client/src/precompiles/factory.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,7 @@ impl Default for ZkvmOpEvmFactory {
3131
}
3232

3333
impl EvmFactory for ZkvmOpEvmFactory {
34-
type Evm<DB: Database, I: Inspector<OpEvmContext<DB>>> =
35-
OpEvm<DB, I, OpZkvmPrecompiles, OpTx>;
34+
type Evm<DB: Database, I: Inspector<OpEvmContext<DB>>> = OpEvm<DB, I, OpZkvmPrecompiles, OpTx>;
3635
// [MANTLE] alloy_op_evm::OpEvmContext<DB> is Context<BlockEnv, OpTx, CfgEnv<OpSpecId>, DB, ...>
3736
// — the OpTx newtype is what implements FromTxWithEncoded<OpTxEnvelope> +
3837
// FromRecoveredTx<OpTxEnvelope> + OpTxEnv that kona-proof's KonaExecutor requires.

utils/client/src/precompiles/mod.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,8 +216,7 @@ where
216216
mod tests {
217217
use super::*;
218218
use alloc::vec::Vec;
219-
use alloy_primitives::U256;
220-
use alloy_primitives::B256;
219+
use alloy_primitives::{B256, U256};
221220
use op_revm::{precompiles::bn254_pair, DefaultOp as _, OpContext};
222221
use revm::{
223222
bytecode::Bytecode,

utils/host/src/contract.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,28 @@ sol! {
2929
external
3030
payable
3131
whenNotOptimistic;
32+
33+
// [MANTLE] The oracle's custom errors. Declared so a rejected `proposeL2Output` can be
34+
// classified from TYPED revert data instead of by matching the rendered error string —
35+
// see `validity/src/relay_rejection.rs`. Without these declarations the generated
36+
// `OPSuccinctL2OutputOracleErrors` enum is empty and nothing can be decoded.
37+
error L1BlockHashNotCheckpointed();
38+
error L1BlockHashNotAvailable();
39+
}
40+
}
41+
42+
// [MANTLE] Errors raised by the SP1 verifier and bubbled up through `proposeL2Output`'s
43+
// `verifyProof` call, so a relay rejection can carry one of these rather than an oracle error.
44+
//
45+
// `InvalidProof()` matches the vendored verifier
46+
// (`contracts/lib/sp1-contracts/contracts/src/v3.0.0/SP1VerifierGroth16.sol:18`).
47+
// `InvalidExitCode()` is NOT in the vendored copy — the deployed verifier is newer — but its
48+
// selector `0x1fcf9177` was observed on QA3 when the guest halted abnormally, so it is declared
49+
// here from the selector. See MANTLE_CHANGES.md §3.9.
50+
sol! {
51+
interface SP1Verifier {
52+
error InvalidProof();
53+
error InvalidExitCode();
3254
}
3355
}
3456

utils/host/src/fetcher.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -590,9 +590,7 @@ impl OPSuccinctDataFetcher {
590590

591591
if !status.is_success() {
592592
let snippet: String = body_text.chars().take(256).collect();
593-
return Err(anyhow::anyhow!(
594-
"HTTP {status} calling {method}: {snippet}"
595-
));
593+
return Err(anyhow::anyhow!("HTTP {status} calling {method}: {snippet}"));
596594
}
597595

598596
let response: serde_json::Value = serde_json::from_str(&body_text).map_err(|e| {

utils/signer/src/lib.rs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@ use alloy_signer::Signer as AlloySigner;
1010
use alloy_signer_gcp::{
1111
// [MANTLE] Use the gcloud_sdk version re-exported by alloy_signer_gcp 2.x so the
1212
// GoogleApiClient type used to construct GcpSigner matches what GcpSigner::new expects.
13-
// The workspace's gcloud-sdk = "0.27" pulls a different version that produces incompatible types.
13+
// The workspace's gcloud-sdk = "0.27" pulls a different version that produces incompatible
14+
// types.
1415
gcloud_sdk::{
1516
google::cloud::kms::v1::key_management_service_client::KeyManagementServiceClient,
1617
GoogleApi, TokenSourceType, GCP_DEFAULT_SCOPES,
1718
},
18-
GcpKeyRingRef, GcpSigner, KeySpecifier,
19+
GcpKeyRingRef,
20+
GcpSigner,
21+
KeySpecifier,
1922
};
2023
use alloy_signer_local::PrivateKeySigner;
2124
use alloy_transport_http::reqwest::Url;
@@ -60,7 +63,8 @@ impl Signer {
6063
pub async fn from_env() -> Result<Self> {
6164
// [MANTLE compat] Existing Mantle deployments configure GCP KMS via two env vars:
6265
// HSM_API_NAME — full GCP resource path
63-
// projects/<P>/locations/<L>/keyRings/<KR>/cryptoKeys/<K>[/cryptoKeyVersions/<V>]
66+
//
67+
// projects/<P>/locations/<L>/keyRings/<KR>/cryptoKeys/<K>[/cryptoKeyVersions/<V>]
6468
// HSM_CREDENTIALS — hex-encoded JSON service account key
6569
// Production posture forbids writing service-account JSON to disk, so this path
6670
// pipes the decoded JSON straight into gcloud-sdk's TokenSourceType::Json — the
@@ -265,10 +269,10 @@ fn parse_gcp_key_resource_path(s: &str) -> Result<GcpKeyPath> {
265269
if parts.len() != 8 && parts.len() != 10 {
266270
return Err(invalid());
267271
}
268-
if parts[0] != "projects"
269-
|| parts[2] != "locations"
270-
|| parts[4] != "keyRings"
271-
|| parts[6] != "cryptoKeys"
272+
if parts[0] != "projects" ||
273+
parts[2] != "locations" ||
274+
parts[4] != "keyRings" ||
275+
parts[6] != "cryptoKeys"
272276
{
273277
return Err(invalid());
274278
}

validity/Cargo.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,21 @@ op-succinct-signer-utils.workspace = true
3131
# sp1
3232
sp1-sdk.workspace = true
3333
sp1-cluster-utils.workspace = true
34+
# [MANTLE] Pinned to the tonic the pinned sp1-sdk uses (0.12.x) so downcasting an
35+
# SDK RPC error to `tonic::Status` resolves the SAME type the SDK produced — a
36+
# version mismatch would silently make the downcast return None. Only the core
37+
# `Status`/`Code` types are needed, so no transport/codegen features.
38+
tonic = { version = "0.12", default-features = false }
3439

3540
# alloy
3641
alloy-eips.workspace = true
3742
alloy-provider = { workspace = true }
3843
alloy-signer-local.workspace = true
3944
alloy-sol-types.workspace = true
45+
alloy-rpc-types-eth.workspace = true
46+
# Needed to downcast a relay failure to `RpcError` and read its revert data as bytes, instead of
47+
# classifying the rejection by matching the rendered error string. See src/relay_rejection.rs.
48+
alloy-transport.workspace = true
4049

4150
anyhow.workspace = true
4251
dotenv.workspace = true
@@ -68,6 +77,7 @@ op-succinct-build-utils.workspace = true
6877

6978
[dev-dependencies]
7079
postgresql_embedded = { version = "0.20", features = ["bundled"] }
80+
alloy-json-rpc.workspace = true
7181

7282
[features]
7383
default = []

validity/src/db/client.rs

Lines changed: 185 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,45 @@ impl DriverDBClient {
294294
Ok(requests)
295295
}
296296

297+
/// Fetch the maximum `l1_head_block_number` across the consecutive complete range proofs that
298+
/// will be aggregated over `[start_block, end_block]`.
299+
///
300+
/// This is the L1 head the aggregation guest must anchor to: the guest walks headers back from
301+
/// the checkpointed head and requires every range proof's `l1Head` to appear in that chain, so
302+
/// the checkpoint must be at or after this block. Returns `None` if no matching range proof has
303+
/// an `l1_head_block_number` recorded (e.g. proofs predating that column).
304+
///
305+
/// The WHERE clause must stay in sync with [`Self::get_consecutive_complete_range_proofs`] so
306+
/// the MAX is taken over exactly the set of range proofs the aggregation will consume.
307+
///
308+
/// [UPSTREAM #923] Backported from succinctlabs/op-succinct#923 (in upstream v3.10.0), keeping
309+
/// upstream's name and body so a future sync can drop this copy cleanly. The one deviation:
310+
/// upstream's WHERE also carries `invalidated_at IS NULL`, a column added by #951 which this
311+
/// baseline does not have. Drop that deviation if #951 is ever backported.
312+
pub async fn get_max_l1_head_block_number_for_range(
313+
&self,
314+
start_block: i64,
315+
end_block: i64,
316+
commitment: &CommitmentConfig,
317+
l1_chain_id: i64,
318+
l2_chain_id: i64,
319+
) -> Result<Option<i64>, Error> {
320+
let result = sqlx::query_scalar::<_, Option<i64>>(
321+
"SELECT MAX(l1_head_block_number) FROM requests WHERE range_vkey_commitment = $1 AND rollup_config_hash = $2 AND status = $3 AND req_type = $4 AND start_block >= $5 AND end_block <= $6 AND l1_chain_id = $7 AND l2_chain_id = $8",
322+
)
323+
.bind(&commitment.range_vkey_commitment[..])
324+
.bind(&commitment.rollup_config_hash[..])
325+
.bind(RequestStatus::Complete as i16)
326+
.bind(RequestType::Range as i16)
327+
.bind(start_block)
328+
.bind(end_block)
329+
.bind(l1_chain_id)
330+
.bind(l2_chain_id)
331+
.fetch_one(&self.pool)
332+
.await?;
333+
Ok(result)
334+
}
335+
297336
/// Fetch the checkpointed block hash and number for an aggregation request with the same start
298337
/// block, end block, and commitment config.
299338
pub async fn fetch_failed_agg_request_with_checkpointed_block_hash(
@@ -833,6 +872,7 @@ mod tests {
833872
rollup_config_hash: B256,
834873
l1_chain_id: i64,
835874
l2_chain_id: i64,
875+
l1_head_block_number: Option<i64>,
836876
}
837877

838878
impl Default for RequestBuilder {
@@ -848,6 +888,7 @@ mod tests {
848888
rollup_config_hash: B256::ZERO,
849889
l1_chain_id: L1ID,
850890
l2_chain_id: L2ID,
891+
l1_head_block_number: None,
851892
}
852893
}
853894
}
@@ -890,6 +931,14 @@ mod tests {
890931
self
891932
}
892933

934+
/// The L1 head the range proof was generated against. Written during witness generation, so
935+
/// a request that never got that far leaves it NULL — which is why the default is
936+
/// `None`.
937+
fn l1_head(mut self, l1_head_block_number: i64) -> Self {
938+
self.l1_head_block_number = Some(l1_head_block_number);
939+
self
940+
}
941+
893942
fn build(self) -> OPSuccinctRequest {
894943
// [MANTLE] Use UTC, not Local — the DB column is TIMESTAMP WITHOUT TIME ZONE and
895944
// `update_request_status` sets it to PG `NOW()` (UTC). With Local::now().naive_local()
@@ -926,7 +975,7 @@ mod tests {
926975
l2_chain_id: self.l2_chain_id,
927976
contract_address: None,
928977
prover_address: None,
929-
l1_head_block_number: None,
978+
l1_head_block_number: self.l1_head_block_number,
930979
cluster_proof_handle: None,
931980
}
932981
}
@@ -1117,6 +1166,141 @@ mod tests {
11171166
assert_eq!(result[2].start_block, 300);
11181167
}
11191168

1169+
// Three DISTINCT byte patterns for the three `bytea` predicates. `default_commitment()` is
1170+
// all-zero, so with it a swapped `$1`/`$2`/`$3` binding passes every assertion silently — which
1171+
// matters most for queries built with the runtime API, where no compile-time SQL check exists.
1172+
const D_RANGE_VKEY: B256 = B256::repeat_byte(0x11);
1173+
const D_ROLLUP_CFG: B256 = B256::repeat_byte(0x22);
1174+
const D_AGG_VKEY: B256 = B256::repeat_byte(0x33);
1175+
1176+
fn distinct_commitment() -> CommitmentConfig {
1177+
CommitmentConfig {
1178+
range_vkey_commitment: D_RANGE_VKEY,
1179+
agg_vkey_hash: D_AGG_VKEY,
1180+
rollup_config_hash: D_ROLLUP_CFG,
1181+
}
1182+
}
1183+
1184+
/// A builder already carrying [`distinct_commitment`]'s three patterns.
1185+
fn distinct_builder() -> RequestBuilder {
1186+
RequestBuilder::new().commitment(D_RANGE_VKEY, D_ROLLUP_CFG).agg_vkey(D_AGG_VKEY)
1187+
}
1188+
1189+
/// [UPSTREAM #923] `get_max_l1_head_block_number_for_range` uses the runtime query API, so it
1190+
/// gets no compile-time SQL check — this test is what catches a malformed query, a swapped
1191+
/// binding, or a predicate drifting out of sync with
1192+
/// `get_consecutive_complete_range_proofs`.
1193+
#[tokio::test]
1194+
async fn test_get_max_l1_head_block_number_for_range() {
1195+
let db = TestDb::new().await;
1196+
let c = db.client();
1197+
1198+
let with_head = |start: i64, end: i64, status: RequestStatus, l1_head: i64| {
1199+
distinct_builder().range(start, end).status(status).l1_head(l1_head).build()
1200+
};
1201+
1202+
let requests = vec![
1203+
// Covered and Complete: exactly the rows the aggregation will consume. The highest
1204+
// head sits on the `end_block == $6` boundary row, so tightening `<=` to
1205+
// `<` changes the answer.
1206+
with_head(100, 200, RequestStatus::Complete, 1000),
1207+
with_head(200, 300, RequestStatus::Complete, 1005),
1208+
with_head(300, 400, RequestStatus::Complete, 1010),
1209+
// Beyond the end bound — must not raise the result.
1210+
with_head(400, 500, RequestStatus::Complete, 9999),
1211+
// Below the start bound. A re-proved older interval legitimately carries a NEWER head,
1212+
// so dropping `start_block >= $5` would raise the result and force a
1213+
// pointless re-checkpoint every loop.
1214+
with_head(50, 150, RequestStatus::Complete, 7777),
1215+
// Covered but not Complete: the aggregation will not consume it.
1216+
with_head(100, 200, RequestStatus::Failed, 8888),
1217+
// Complete but never reached witness generation, so its head is NULL.
1218+
distinct_builder().range(150, 250).status(RequestStatus::Complete).build(),
1219+
// An Aggregation row carrying a head: `req_type` is what must exclude it.
1220+
distinct_builder()
1221+
.range(100, 400)
1222+
.req_type(RequestType::Aggregation)
1223+
.status(RequestStatus::Complete)
1224+
.l1_head(6666)
1225+
.build(),
1226+
// Same shape as a covered row but on another chain / another commitment. Each exists
1227+
// so that deleting the corresponding predicate fails this test rather than
1228+
// passing silently.
1229+
distinct_builder()
1230+
.range(100, 200)
1231+
.status(RequestStatus::Complete)
1232+
.l1_head(5555)
1233+
.chains(999, L2ID)
1234+
.build(),
1235+
distinct_builder()
1236+
.range(100, 200)
1237+
.status(RequestStatus::Complete)
1238+
.l1_head(5554)
1239+
.chains(L1ID, 999)
1240+
.build(),
1241+
RequestBuilder::new()
1242+
.range(100, 200)
1243+
.status(RequestStatus::Complete)
1244+
.l1_head(5553)
1245+
.commitment(B256::repeat_byte(0xEE), D_ROLLUP_CFG)
1246+
.build(),
1247+
RequestBuilder::new()
1248+
.range(100, 200)
1249+
.status(RequestStatus::Complete)
1250+
.l1_head(5552)
1251+
.commitment(D_RANGE_VKEY, B256::repeat_byte(0xEE))
1252+
.build(),
1253+
];
1254+
insert_requests(c, &requests).await;
1255+
1256+
let max = c
1257+
.get_max_l1_head_block_number_for_range(100, 400, &distinct_commitment(), L1ID, L2ID)
1258+
.await
1259+
.unwrap();
1260+
assert_eq!(max, Some(1010), "only covered, Complete, same-chain Range rows count");
1261+
1262+
// A narrower window where the batch's FIRST segment is the only match. The wide query above
1263+
// cannot see `start_block >= $5` tightening to `>`, because its boundary row (100,200) does
1264+
// not carry the maximum — so without this assertion that single character can be changed
1265+
// freely, and the floor would then omit the earliest range proof whose l1Head can well be
1266+
// the largest (e.g. after that segment was re-proved).
1267+
let first_segment_only = c
1268+
.get_max_l1_head_block_number_for_range(100, 200, &distinct_commitment(), L1ID, L2ID)
1269+
.await
1270+
.unwrap();
1271+
assert_eq!(first_segment_only, Some(1000), "the start boundary row must be included");
1272+
}
1273+
1274+
#[tokio::test]
1275+
async fn test_get_max_l1_head_block_number_for_range_is_none_without_recorded_heads() {
1276+
let db = TestDb::new().await;
1277+
let c = db.client();
1278+
1279+
// `MAX` over zero rows yields a single NULL row, and so does a set of rows whose
1280+
// l1_head_block_number is entirely NULL. Both must surface as `None`: defaulting to 0 would
1281+
// make every checkpoint look new enough and silently disable the floor.
1282+
let max = c
1283+
.get_max_l1_head_block_number_for_range(100, 400, &distinct_commitment(), L1ID, L2ID)
1284+
.await
1285+
.unwrap();
1286+
assert_eq!(max, None, "no matching rows at all");
1287+
1288+
insert_requests(
1289+
c,
1290+
&[
1291+
distinct_builder().range(100, 200).status(RequestStatus::Complete).build(),
1292+
distinct_builder().range(200, 300).status(RequestStatus::Complete).build(),
1293+
],
1294+
)
1295+
.await;
1296+
1297+
let max = c
1298+
.get_max_l1_head_block_number_for_range(100, 400, &distinct_commitment(), L1ID, L2ID)
1299+
.await
1300+
.unwrap();
1301+
assert_eq!(max, None, "rows present but none has a head recorded");
1302+
}
1303+
11201304
#[tokio::test]
11211305
async fn test_fetch_active_agg_proofs_count_excludes_inactive_statuses() {
11221306
let db = TestDb::new().await;

0 commit comments

Comments
 (0)