Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
218 changes: 188 additions & 30 deletions crates/solver-order/src/implementations/standards/_7683.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,20 @@ impl OrderInterface for Eip7683OrderImpl {
OrderError::ValidationFailed("No cross-chain output found".to_string())
})?;

// Defense in depth: cross-chain outputs must have a canonical non-zero oracle.
if output.oracle == [0u8; 32] {
return Err(OrderError::ValidationFailed(format!(
"Zero oracle not allowed for cross-chain output on chain {}",
output.chain_id.to::<u64>()
)));
}
if output.oracle[..12].iter().any(|&byte| byte != 0) {
return Err(OrderError::ValidationFailed(format!(
"Output oracle has dirty upper bytes for cross-chain output on chain {}",
output.chain_id.to::<u64>()
)));
}
Comment on lines +321 to +333

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add route-membership validation in fill/claim (not just zero/dirty-byte checks).

These blocks validate canonical encoding, but they still accept a canonical unsupported output oracle for a destination chain. That leaves a defense-in-depth gap in transaction generation paths.

Proposed patch direction
@@ async fn generate_fill_transaction(...)
-        if output.oracle[..12].iter().any(|&byte| byte != 0) {
+        if output.oracle[..12].iter().any(|&byte| byte != 0) {
             return Err(OrderError::ValidationFailed(format!(
                 "Output oracle has dirty upper bytes for cross-chain output on chain {}",
                 output.chain_id.to::<u64>()
             )));
         }
+        let dest_chain_id = output.chain_id.to::<u64>();
+        let output_oracle_address = &output.oracle[12..32];
+        let has_supported_output_oracle = self.oracle_routes.supported_routes.values().any(|outs| {
+            outs.iter().any(|supported| {
+                supported.chain_id == dest_chain_id
+                    && supported.oracle.0.as_slice() == output_oracle_address
+            })
+        });
+        if !has_supported_output_oracle {
+            return Err(OrderError::ValidationFailed(format!(
+                "Output oracle is not supported for destination chain {}",
+                dest_chain_id
+            )));
+        }

@@ async fn generate_claim_transaction(...)
                 if output.oracle[..12].iter().any(|&byte| byte != 0) {
                     return Err(OrderError::ValidationFailed(format!(
                         "Output oracle has dirty upper bytes for cross-chain output on chain {}",
                         output.chain_id.to::<u64>()
                     )));
                 }
+                let dest_chain_id = output.chain_id.to::<u64>();
+                let output_oracle_address = &output.oracle[12..32];
+                let has_supported_output_oracle = self.oracle_routes.supported_routes.values().any(|outs| {
+                    outs.iter().any(|supported| {
+                        supported.chain_id == dest_chain_id
+                            && supported.oracle.0.as_slice() == output_oracle_address
+                    })
+                });
+                if !has_supported_output_oracle {
+                    return Err(OrderError::ValidationFailed(format!(
+                        "Output oracle is not supported for destination chain {}",
+                        dest_chain_id
+                    )));
+                }
             }

Also applies to: 444-458

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/solver-order/src/implementations/standards/_7683.rs` around lines 317
- 329, The current validation only checks that cross-chain output oracles are
non-zero and have zero upper bytes but does not verify the oracle is actually
supported for the destination chain; add a route-membership check after the
existing canonical checks to ensure the oracle corresponds to a known/registered
route for output.chain_id (e.g. call a routing lookup like
is_oracle_valid_for_chain(output.oracle, output.chain_id) or consult the
RouteRegistry/AllowedOracles for that chain), and return
OrderError::ValidationFailed with a clear message ("unsupported oracle for
destination chain {chain_id}") if the oracle is not a member; apply the same
additional check in the other analogous block (the fill/claim validation at the
other location) so both places enforce route membership.


// Get the output settler address from the order
let dest_chain_id = output.chain_id.to::<u64>();
let output_chain = order
Expand Down Expand Up @@ -431,6 +445,22 @@ impl OrderInterface for Eip7683OrderImpl {
.outputs
.iter()
.map(|output| -> Result<SolMandateOutput, OrderError> {
if output.chain_id != order_data.origin_chain_id {
// Defense in depth for claim calldata construction.
if output.oracle == [0u8; 32] {
return Err(OrderError::ValidationFailed(format!(
"Zero oracle not allowed for cross-chain output on chain {}",
output.chain_id.to::<u64>()
)));
}
if output.oracle[..12].iter().any(|&byte| byte != 0) {
return Err(OrderError::ValidationFailed(format!(
"Output oracle has dirty upper bytes for cross-chain output on chain {}",
output.chain_id.to::<u64>()
)));
}
}

// Use the oracle value from the original order
let oracle_bytes32 = FixedBytes::<32>::from(output.oracle);

Expand Down Expand Up @@ -617,7 +647,7 @@ impl OrderInterface for Eip7683OrderImpl {
supported_outputs.iter().map(|info| info.chain_id).collect();

// Single pass validation for all outputs
for output in &standard_order.outputs {
for (output_index, output) in standard_order.outputs.iter().enumerate() {
let dest_chain = output.chainId.to::<u64>();

// Skip same-chain outputs as they don't need cross-chain routes
Expand All @@ -632,31 +662,34 @@ impl OrderInterface for Eip7683OrderImpl {
)));
}

// If a specific output oracle is specified, validate it
if output.oracle != [0u8; 32] {
// Check if this specific output oracle is compatible with input oracle
// Use address normalization to compare regardless of padding format
let mut found_compatible = false;
for supported in supported_outputs.iter() {
if supported.chain_id == dest_chain {
let addresses_match = solver_types::utils::conversion::addresses_equal(
&supported.oracle.0,
output.oracle.as_slice(),
);
if addresses_match {
found_compatible = true;
break;
}
}
}
let is_compatible = found_compatible;
let output_oracle = output.oracle.0;

if output_oracle == [0u8; 32] {
return Err(OrderError::ValidationFailed(format!(
"Zero oracle not allowed for cross-chain output at index {output_index} on chain {dest_chain}"
)));
}

if output_oracle[..12].iter().any(|&byte| byte != 0) {
return Err(OrderError::ValidationFailed(format!(
"Output oracle has dirty upper bytes for cross-chain output at index {output_index} on chain {dest_chain}"
)));
}

// Check if this specific output oracle is compatible with input oracle.
// Output oracle is a clean bytes32 address, so exact lower-20-byte match is required.
let output_oracle_address = &output_oracle[12..32];
let is_compatible = supported_outputs.iter().any(|supported| {
supported.chain_id == dest_chain
&& supported.oracle.0.len() == 20
&& supported.oracle.0.as_slice() == output_oracle_address
});

if !is_compatible {
return Err(OrderError::ValidationFailed(format!(
if !is_compatible {
return Err(OrderError::ValidationFailed(format!(
"Output oracle {:?} on chain {} is not compatible with input oracle {:?} on chain {}",
output.oracle, dest_chain, input_oracle, origin_chain
)));
}
}
}

Expand Down Expand Up @@ -888,7 +921,11 @@ mod tests {
}

fn create_test_order_data() -> Eip7683OrderData {
Eip7683OrderDataBuilder::new().build()
let mut order_data = Eip7683OrderDataBuilder::new().build();
let mut output_oracle = [0u8; 32];
output_oracle[12..32].copy_from_slice(&[11u8; 20]);
order_data.outputs[0].oracle = output_oracle;
order_data
}

fn create_test_intent(order_data: Eip7683OrderData, source: &str) -> Intent {
Expand All @@ -906,6 +943,8 @@ mod tests {
use solver_types::current_timestamp;

let current_time = current_timestamp() as u32;
let mut output_oracle = [0u8; 32];
output_oracle[12..32].copy_from_slice(&[11u8; 20]);
interfaces::StandardOrder {
user: AlloyAddress::from([0x11; 20]),
nonce: U256::from(123),
Expand All @@ -915,7 +954,7 @@ mod tests {
inputOracle: AlloyAddress::from([10u8; 20]), // Matches test oracle routes
inputs: vec![[U256::from(100), U256::from(200)]],
outputs: vec![interfaces::SolMandateOutput {
oracle: B256::from([11u8; 32]), // Matches test oracle routes
oracle: B256::from(output_oracle), // Canonical bytes32 form of supported output oracle
settler: B256::from([0x44; 32]),
chainId: U256::from(137), // Cross-chain output
token: B256::from([0x55; 32]),
Expand Down Expand Up @@ -1070,6 +1109,62 @@ mod tests {
assert!(!tx.data.is_empty());
}

#[tokio::test]
async fn test_generate_fill_transaction_rejects_zero_oracle_for_cross_chain_output() {
let networks = create_test_networks();
let oracle_routes = create_test_oracle_routes();
let order_impl = Eip7683OrderImpl::new(networks, oracle_routes).unwrap();

let mut order_data = create_test_order_data();
order_data.outputs[0].oracle = [0u8; 32];
let order = OrderBuilder::new()
.with_data(serde_json::to_value(&order_data).unwrap())
.with_solver_address(Address(vec![99u8; 20]))
.with_quote_id(Some("test-quote".to_string()))
.with_input_chain_ids(vec![1])
.with_output_chain_ids(vec![137])
.build();
let params = ExecutionParams {
gas_price: U256::ZERO,
priority_fee: None,
};

let result = order_impl.generate_fill_transaction(&order, &params).await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Zero oracle not allowed for cross-chain output"));
}

#[tokio::test]
async fn test_generate_fill_transaction_rejects_dirty_oracle_upper_bytes() {
let networks = create_test_networks();
let oracle_routes = create_test_oracle_routes();
let order_impl = Eip7683OrderImpl::new(networks, oracle_routes).unwrap();

let mut order_data = create_test_order_data();
order_data.outputs[0].oracle[0] = 0xAA;
let order = OrderBuilder::new()
.with_data(serde_json::to_value(&order_data).unwrap())
.with_solver_address(Address(vec![99u8; 20]))
.with_quote_id(Some("test-quote".to_string()))
.with_input_chain_ids(vec![1])
.with_output_chain_ids(vec![137])
.build();
let params = ExecutionParams {
gas_price: U256::ZERO,
priority_fee: None,
};

let result = order_impl.generate_fill_transaction(&order, &params).await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Output oracle has dirty upper bytes"));
}

#[tokio::test]
async fn test_generate_claim_transaction_escrow() {
let networks = create_test_networks();
Expand Down Expand Up @@ -1141,6 +1236,39 @@ mod tests {
assert!(!tx.data.is_empty());
}

#[tokio::test]
async fn test_generate_claim_transaction_rejects_dirty_oracle_upper_bytes() {
let networks = create_test_networks();
let oracle_routes = create_test_oracle_routes();
let order_impl = Eip7683OrderImpl::new(networks, oracle_routes).unwrap();

let mut order_data = create_test_order_data();
order_data.outputs[0].oracle[0] = 0xAA;
let order = OrderBuilder::new()
.with_data(serde_json::to_value(&order_data).unwrap())
.with_solver_address(Address(vec![99u8; 20]))
.with_quote_id(Some("test-quote".to_string()))
.with_input_chain_ids(vec![1])
.with_output_chain_ids(vec![137])
.build();
let fill_proof = FillProof {
tx_hash: TransactionHash(hex::decode("abcd").unwrap()),
block_number: 12345,
attestation_data: Some(b"proof".to_vec()),
filled_timestamp: 123456789,
oracle_address: "0x0B0B0B0B0B0B0B0B0B0B0B0B0B0B0B0B0B0B0B0B".to_string(),
};

let result = order_impl
.generate_claim_transaction(&order, &fill_proof)
.await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Output oracle has dirty upper bytes"));
}

#[test]
fn test_config_schema_validation() {
let schema = Eip7683OrderSchema;
Expand Down Expand Up @@ -1323,8 +1451,10 @@ mod tests {
let order_impl = Eip7683OrderImpl::new(networks, oracle_routes).unwrap();

let mut standard_order = create_valid_standard_order();
// Use incompatible output oracle (different from what's supported)
standard_order.outputs[0].oracle = alloy_primitives::B256::from([0x99; 32]);
// Use incompatible but clean output oracle (different from what's supported)
let mut incompatible_oracle = [0u8; 32];
incompatible_oracle[12..32].copy_from_slice(&[0x99; 20]);
standard_order.outputs[0].oracle = alloy_primitives::B256::from(incompatible_oracle);
let order_bytes = encode_standard_order(&standard_order);

let result = order_impl.validate_order(&order_bytes).await;
Expand All @@ -1335,18 +1465,44 @@ mod tests {
}

#[tokio::test]
async fn test_validate_order_zero_output_oracle_allowed() {
async fn test_validate_order_rejects_zero_output_oracle_for_cross_chain() {
let networks = create_test_networks();
let oracle_routes = create_test_oracle_routes();
let order_impl = Eip7683OrderImpl::new(networks, oracle_routes).unwrap();

let mut standard_order = create_valid_standard_order();
// Use zero oracle (should be allowed - means any compatible oracle can be used)
// Zero oracle is not allowed for cross-chain outputs.
standard_order.outputs[0].oracle = alloy_primitives::B256::from([0x00; 32]);
let order_bytes = encode_standard_order(&standard_order);

let result = order_impl.validate_order(&order_bytes).await;
assert!(result.is_ok());
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Zero oracle not allowed for cross-chain output"));
}

#[tokio::test]
async fn test_validate_order_rejects_dirty_output_oracle_upper_bytes() {
let networks = create_test_networks();
let oracle_routes = create_test_oracle_routes();
let order_impl = Eip7683OrderImpl::new(networks, oracle_routes).unwrap();

let mut standard_order = create_valid_standard_order();
// Dirty upper bytes, but lower 20 bytes still match the supported oracle.
let mut dirty_oracle = [0u8; 32];
dirty_oracle[0..12].copy_from_slice(&[0xAA; 12]);
dirty_oracle[12..32].copy_from_slice(&[11u8; 20]);
standard_order.outputs[0].oracle = alloy_primitives::B256::from(dirty_oracle);
let order_bytes = encode_standard_order(&standard_order);

let result = order_impl.validate_order(&order_bytes).await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Output oracle has dirty upper bytes"));
}

#[tokio::test]
Expand Down Expand Up @@ -1752,9 +1908,11 @@ mod tests {
let order_impl = Eip7683OrderImpl::new(networks, oracle_routes).unwrap();

let mut standard_order = create_valid_standard_order();
let mut arbitrum_oracle = [0u8; 32];
arbitrum_oracle[12..32].copy_from_slice(&[12u8; 20]);
// Add a second output on a different destination chain (Arbitrum)
standard_order.outputs.push(interfaces::SolMandateOutput {
oracle: alloy_primitives::B256::from([12u8; 32]), // Different oracle for Arbitrum
oracle: alloy_primitives::B256::from(arbitrum_oracle), // Different oracle for Arbitrum
settler: alloy_primitives::B256::from([0x44; 32]),
chainId: U256::from(42161), // Arbitrum chain ID (different from first output)
token: alloy_primitives::B256::from([0x77; 32]),
Expand Down
Loading