newton-core 0.7.1

newton protocol core sdk
/// Policy set resolution.
///
/// A policy client owns one ordered, non-empty list of independently deployed policies.
/// One aggregate policyId identifies that exact ordered list at a revision, and
/// authorization is the AND over every policy in it.
///
/// Oracle output cache key is (chain_id, policy_client, policy_address, keccak256(oracle_input)).
/// Secrets scope is (chain_id, policy_client, policy_data_address) — the oracle child consumes them.
use alloy::primitives::{Address, B256, U256};
use alloy::sol_types::SolValue;
use eyre::Result;
use serde::{Deserialize, Serialize};

use crate::{
    common::policy_runtime::PolicyRuntime,
    generated::newton_policy_client::{INewtonPolicy::PolicyConfig, INewtonPolicyClient::PolicySpec},
};

/// Upper bound on policies in one client's set. Re-exported from newton_rego_kernel
/// so there is exactly one definition. Mirrors MAX_POLICIES in contracts/src/libraries/PolicyConstants.sol.
pub use newton_rego_kernel::{MAX_POLICIES, MAX_POLICY_FIELD_BYTES, MAX_RESPONSE_POLICY_BYTES, MAX_TASK_POLICY_BYTES};

/// Domain separator for policy set ID hash — source of truth is contracts/src/libraries/PolicyConstants.sol.
/// keccak256("newton.policy.set") = 0x671cdd5663cea1dd5f0b42278ce65570194bc54e20449731d2ae86713689de91
pub const POLICY_SET_DOMAIN: B256 = B256::new([
    0x67, 0x1c, 0xdd, 0x56, 0x63, 0xce, 0xa1, 0xdd, 0x5f, 0x0b, 0x42, 0x27, 0x8c, 0xe6, 0x55, 0x70, 0x19, 0x4b, 0xc5,
    0x4e, 0x20, 0x44, 0x97, 0x31, 0xd2, 0xae, 0x86, 0x71, 0x36, 0x89, 0xde, 0x91,
]);

/// Resolved policy set for a single client.
///
/// Each entry is a full `PolicyRuntime` snapshot (chain reads + fetched Rego + resolved
/// oracle metadata) rather than a re-derived subset of it — one struct, one source of
/// truth, whether the caller holds one policy or a composed set of them.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolvedPolicySet {
    /// Policy client address.
    pub policy_client: Address,
    /// Policy ID computed from (POLICY_SET_DOMAIN, chainId, address(this), revision, policies).
    pub policy_id: B256,
    /// Policy revision number.
    pub revision: u64,
    /// Ordered list of resolved policies (order is semantic).
    pub policies: Vec<PolicyRuntime>,
}

/// Reject a task whose dynamically sized policy data exceeds the protocol bounds.
///
/// Mirrors `PolicyValidationLib.requirePolicyDataBounds` and the response-side checks in
/// `PolicyValidationLib.validateResponse`. The policy count is bounded but a policy's params,
/// Rego bytes, oracle input and oracle output are not, so without this one task mints arbitrary
/// calldata, event data, operator allocations and proof input. Callers with a complete evaluation
/// must pass all four fields into one invocation so the aggregate spans the task-response pair.
///
/// `fields` yields `(policy index, field name, byte length)`; each call site supplies the fields
/// it actually holds — the gateway has params and oracle inputs, resolution has params and Rego.
pub fn check_policy_data_bounds<'a>(fields: impl IntoIterator<Item = (usize, &'a str, usize)>) -> Result<()> {
    check_policy_data_bounds_with_cap(fields, MAX_TASK_POLICY_BYTES)
}

/// As [`check_policy_data_bounds`], against an explicit aggregate cap.
///
/// A complete task-response pair is bounded by `MAX_RESPONSE_POLICY_BYTES`, not the admission
/// budget: the response carries the resolved Rego and oracle output on top of what admission saw.
/// Checking a response against the admission cap would refuse, pre-signing, tasks the chain
/// accepts — and since every operator runs this code, such a task never reaches quorum.
pub fn check_policy_data_bounds_with_cap<'a>(
    fields: impl IntoIterator<Item = (usize, &'a str, usize)>,
    total_cap: usize,
) -> Result<()> {
    let mut total = 0usize;
    for (index, field, len) in fields {
        eyre::ensure!(
            len <= MAX_POLICY_FIELD_BYTES,
            "policy {index} {field} is {len} bytes, over the {MAX_POLICY_FIELD_BYTES} byte limit"
        );
        total += len;
    }

    eyre::ensure!(
        total <= total_cap,
        "task policy data is {total} bytes, over the {total_cap} byte limit"
    );

    Ok(())
}

impl ResolvedPolicySet {
    /// Recompute policy ID from the stored fields and verify it matches.
    ///
    /// policyId = keccak256(abi.encode(POLICY_SET_DOMAIN, chainId, address(this), revision, policies))
    /// where policies is the ordered PolicySpec[] array.
    ///
    /// `abi_encode_params`, never `abi_encode`: the argument list is dynamic (PolicySpec carries
    /// `bytes policyParams`), and `abi_encode` would treat the tuple as one struct value and prepend
    /// a 32-byte offset word that `abi.encode(a, b, c, ...)` does not emit.
    pub fn compute_policy_id(&self, chain_id: u64) -> B256 {
        let specs: Vec<PolicySpec> = self
            .policies
            .iter()
            .map(|p| PolicySpec {
                policy: p.policy_address,
                config: PolicyConfig {
                    policyParams: p.policy_config.policyParams.clone(),
                    expireAfter: p.policy_config.expireAfter,
                },
            })
            .collect();

        // abi.encode(POLICY_SET_DOMAIN, chainId, address(this), revision, policies)
        // chain_id widens to uint256 and revision to uint64 identically: both are static words.
        let encoded = (
            POLICY_SET_DOMAIN,
            U256::from(chain_id),
            self.policy_client,
            self.revision,
            specs,
        )
            .abi_encode_params();
        alloy::primitives::keccak256(&encoded)
    }

    /// Recompute the policy ID and verify the stored value matches.
    pub fn verify_policy_id(&self, chain_id: u64) -> Result<()> {
        let computed = self.compute_policy_id(chain_id);
        eyre::ensure!(
            computed == self.policy_id,
            "policy ID mismatch: stored={}, computed={}",
            self.policy_id,
            computed
        );
        Ok(())
    }

    /// Whether any policy in this resolved snapshot requires privacy (TEE attestation).
    ///
    /// Scans the already-fetched Rego bytes — no network read, so this reflects the exact
    /// snapshot a task committed to rather than the client's possibly-reconfigured live set.
    pub fn requires_privacy(&self) -> bool {
        self.policies
            .iter()
            .any(|p| crate::common::privacy_detection::requires_privacy(&String::from_utf8_lossy(&p.rego)))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use alloy::primitives::{b256, Bytes};

    const CONSTANTS_SOL: &str = include_str!("../../../../contracts/src/libraries/PolicyConstants.sol");

    /// Parse the bound out of Solidity rather than restating it, so drift actually fails here.
    #[test]
    fn max_policies_matches_solidity() {
        let declared = CONSTANTS_SOL
            .lines()
            .find_map(|l| l.split_once("uint256 constant MAX_POLICIES ="))
            .map(|(_, rhs)| rhs.trim().trim_end_matches(';').parse::<usize>().unwrap())
            .expect("MAX_POLICIES not found in PolicyConstants.sol");
        assert_eq!(MAX_POLICIES, declared);
    }

    #[test]
    fn policy_set_domain_matches_solidity_preimage() {
        let preimage = CONSTANTS_SOL
            .lines()
            .find_map(|l| l.split_once("POLICY_SET_DOMAIN = keccak256(\""))
            .and_then(|(_, rhs)| rhs.split_once('"'))
            .map(|(s, _)| s.to_string())
            .expect("POLICY_SET_DOMAIN not found in PolicyConstants.sol");
        assert_eq!(preimage, "newton.policy.set");
        assert_eq!(POLICY_SET_DOMAIN, alloy::primitives::keccak256(&preimage));
    }

    fn sample_policy(policy_address: Address, policy_params: &[u8], expire_after: u32) -> PolicyRuntime {
        PolicyRuntime {
            chain_id: 1,
            policy_client: Address::repeat_byte(0x11),
            policy_id: B256::ZERO,
            policy_address,
            policy_config: crate::newton_policy::INewtonPolicy::PolicyConfig {
                policyParams: Bytes::copy_from_slice(policy_params),
                expireAfter: expire_after,
            },
            entrypoint: String::new(),
            schema: serde_json::Value::Null,
            policy_cid: String::new(),
            policy_code_hash: B256::ZERO,
            current_block: 0,
            expire_block: 0,
            wasm_cid: String::new(),
            secrets_schema_cid: String::new(),
            rego: Bytes::new(),
        }
    }

    fn sample() -> ResolvedPolicySet {
        ResolvedPolicySet {
            policy_client: Address::repeat_byte(0x11),
            // Ground truth from Solidity's own encoder:
            //   cast abi-encode "f(bytes32,uint256,address,uint64,(address,(bytes,uint32))[])" \
            //     <POLICY_SET_DOMAIN> 1 0x1111..11 7 '[(0x2222..22,(0xdeadbeef,100))]'
            //   | cast keccak
            policy_id: b256!("6da916bc40a763e80c09dbfd714f41fe08003f62765cb6c2fc881510f6c0cf7c"),
            revision: 7,
            policies: vec![sample_policy(
                Address::repeat_byte(0x22),
                &[0xde, 0xad, 0xbe, 0xef],
                100,
            )],
        }
    }

    #[test]
    fn policy_data_bounds_reject_an_oversize_field() {
        let err = check_policy_data_bounds([(0, "params", MAX_POLICY_FIELD_BYTES + 1)]).unwrap_err();
        assert!(err.to_string().contains("policy 0 params"));
    }

    /// MAX_POLICIES fields each under the per-field cap can still blow the aggregate, which is
    /// what actually bounds a task.
    #[test]
    fn policy_data_bounds_reject_an_oversize_aggregate() {
        let fields: Vec<_> = (0..MAX_POLICIES).map(|i| (i, "rego", MAX_POLICY_FIELD_BYTES)).collect();
        let err = check_policy_data_bounds(fields).unwrap_err();
        assert!(err.to_string().contains("task policy data"));
    }

    #[test]
    fn policy_data_bounds_accept_a_set_at_the_aggregate_limit() {
        let count = MAX_TASK_POLICY_BYTES / MAX_POLICY_FIELD_BYTES;
        let fields: Vec<_> = (0..count).map(|i| (i, "rego", MAX_POLICY_FIELD_BYTES)).collect();
        check_policy_data_bounds(fields).unwrap();
    }

    /// Pins the encoding against Solidity. Catches `abi_encode` vs `abi_encode_params`, which
    /// differ by a leading offset word here because PolicySpec carries dynamic `bytes`.
    #[test]
    fn policy_id_matches_onchain_encoding() {
        sample().verify_policy_id(1).unwrap();
    }

    #[test]
    fn policy_id_is_bound_to_every_field() {
        // Each mutation must break the commitment; policyId commits the whole ordered set.
        let mut wrong_chain = sample();
        assert!(wrong_chain.verify_policy_id(2).is_err());

        wrong_chain.revision += 1;
        assert!(wrong_chain.verify_policy_id(1).is_err());

        let mut wrong_params = sample();
        wrong_params.policies[0].policy_config.policyParams = Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef, 0x00]);
        assert!(wrong_params.verify_policy_id(1).is_err());

        let mut wrong_expiry = sample();
        wrong_expiry.policies[0].policy_config.expireAfter = 101;
        assert!(wrong_expiry.verify_policy_id(1).is_err());

        let mut wrong_policy = sample();
        wrong_policy.policies[0].policy_address = Address::repeat_byte(0x23);
        assert!(wrong_policy.verify_policy_id(1).is_err());
    }
}