Railguard

Policy Engine

Deep dive into how Railguard inspects transactions and enforces your security policy.

The Policy Engine (rg-policy crate) is the heart of Railguard. It takes a transaction and returns a Verdict: either Allowed or Blocked { reason }.

Inspection Flow

Transaction → Global Limits → Destination Check → Selector Match → Argument Validation → Verdict

Every check that fails results in an immediate Blocked verdict with a descriptive reason.

1. Global Limits

First, the engine checks transaction-level limits:

// Value check
if tx.value > policy.global_limits.max_value {
    return Verdict::blocked("Value exceeds global limit");
}
 
// Gas price check (legacy/EIP-2930)
if tx.gas_price > policy.global_limits.max_gas_price {
    return Verdict::blocked("Gas price exceeds global limit");
}
 
// Max fee per gas (EIP-1559/4844)
if tx.max_fee_per_gas > policy.global_limits.max_fee_per_gas {
    return Verdict::blocked("Max fee per gas exceeds global limit");
}

2. Destination Allowlist

If the transaction has no calldata (simple ETH transfer), the to address must be in the allowlist:

if tx.input.is_empty() {
    if !policy.routes.contains_key(&tx.to) {
        return Verdict::blocked("Destination not in allowlist");
    }
    return Verdict::Allowed;
}

For contract calls, the destination must match a rule's contract field.

3. Selector Matching

Railguard extracts the 4-byte function selector from the calldata and matches it against allowed methods.

Selector Pre-computation

At startup, Railguard parses your method signatures and computes selectors:

// "transfer(address,uint256)" → 0xa9059cbb
let selector = keccak256("transfer(address,uint256)".as_bytes())[0..4];

This is done once at config load, not per-request, ensuring < 1ms latency.

Matching Logic

let selector: [u8; 4] = tx.input[0..4].try_into().unwrap();
 
let Some(contract_rules) = policy.routes.get(&tx.to) else {
    return Verdict::blocked("Contract not in allowlist");
};
 
let Some(rule) = contract_rules.get(&selector) else {
    return Verdict::blocked(format!(
        "Method 0x{} not allowed on {}",
        hex::encode(selector), tx.to
    ));
};

4. Argument Validation

If the matched rule has arg_constraints, Railguard decodes the calldata arguments and validates them.

ABI Decoding

Railguard uses alloy-dyn-abi to decode arguments:

// Skip 4-byte selector
let args_data = &tx.input[4..];
 
// Decode based on method signature's types
let decoded: Vec<DynSolValue> = dyn_abi_decode(&rule.inputs, args_data)?;

Constraint Checking

for constraint in &rule.arg_constraints {
    let value = &decoded[constraint.index];
 
    if let DynSolValue::Uint(v, _) = value {
        if v > constraint.max {
            return Verdict::blocked(format!(
                "Argument {} value {} exceeds limit {}",
                constraint.index, v, constraint.max
            ));
        }
    }
}

Raw Transaction Handling

For eth_sendRawTransaction, Railguard must first decode the RLP-encoded signed transaction:

pub fn decode_raw_transaction(hex_data: &str) -> Result<TransactionRequest, DecodeError> {
    let bytes = hex::decode(hex_data)?;
    let tx_envelope = TxEnvelope::decode(&mut bytes.as_slice())?;
 
    // Extract fields based on tx type (Legacy, EIP-2930, EIP-1559, EIP-4844, EIP-7702)
    let (to, value, input, gas_price, max_fee_per_gas, max_priority_fee_per_gas) =
        match &tx_envelope {
            TxEnvelope::Legacy(signed) => { /* ... */ },
            TxEnvelope::Eip1559(signed) => { /* ... */ },
            // etc.
        };
 
    Ok(TransactionRequest { to, value, input, /* ... */ })
}

This extracts the same fields as eth_sendTransaction, allowing unified inspection.

Fail Closed Architecture

The Policy Engine wraps all inspection in a panic catcher:

pub fn inspect(tx: &TransactionRequest, policy: &RuntimePolicy) -> (Verdict, u64) {
    let start = Instant::now();
 
    let verdict = panic::catch_unwind(panic::AssertUnwindSafe(|| {
        inspect_inner(tx, policy)
    }))
    .unwrap_or_else(|_| Verdict::blocked("Internal error - fail closed"));
 
    (verdict, start.elapsed().as_micros() as u64)
}

Any panic becomes a Blocked verdict. This is intentional—security-critical code must never silently succeed when something goes wrong.

Decode Error Handling

When fail_on_decode_error is true (default), any ABI decoding failure results in Blocked:

let decoded = match dyn_abi_decode(&rule.inputs, args) {
    Ok(values) => values,
    Err(err) => {
        if policy.fail_on_decode_error {
            return Verdict::blocked(format!("ABI decode failed: {}", err));
        } else {
            return Verdict::Allowed;  // Permissive mode
        }
    }
};

Performance

The Policy Engine is optimized for minimal overhead:

OperationTargetImplementation
Selector lookupO(1)HashMap<Address, HashMap<[u8;4], Rule>>
Selector computationOnce at startupPre-hashed in RuntimePolicy::from_config()
ABI decodingPer-requestalloy-dyn-abi (zero-copy where possible)
Total inspection< 1ms p99Measured via latency_us in Receipt

Benchmarks

#[bench]
fn bench_policy_inspection_simple_transfer(b: &mut Bencher) {
    let policy = RuntimePolicy::from_config(&test_config()).unwrap();
    let tx = mock_erc20_transfer();
 
    b.iter(|| {
        let (verdict, latency) = rg_policy::inspect(&tx, &policy);
        assert!(latency < 1000); // < 1ms
    });
}

Runtime Policy Structure

The RuntimePolicy struct optimizes for fast lookups:

pub struct RuntimePolicy {
    pub global_limits: GlobalLimits,
    pub fail_on_decode_error: bool,
    pub mode: FirewallMode,
    /// Map: Contract Address → Map: Selector → CompiledRule
    routes: HashMap<Address, HashMap<[u8; 4], CompiledRule>>,
}
 
pub struct CompiledRule {
    pub name: String,
    pub arg_constraints: Vec<ArgConstraint>,
    pub inputs: Vec<DynSolType>,  // Pre-parsed argument types
}

Verdict Types

#[derive(Debug, Clone, PartialEq)]
pub enum Verdict {
    Allowed,
    Blocked { reason: String },
}
 
impl Verdict {
    pub fn blocked(reason: impl Into<String>) -> Self {
        Verdict::Blocked { reason: reason.into() }
    }
 
    pub fn is_blocked(&self) -> bool {
        matches!(self, Verdict::Blocked { .. })
    }
}

The verdict is attached to a Receipt and:

  1. Displayed in the TUI
  2. Used to generate the JSON-RPC response
  3. Optionally uploaded to Railguard Cloud

Next Steps