webb-tools / relayer

🕸️ The Webb Relayer Network

Home Page:https://webb-tools.github.io/relayer/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

[TASK] Queue Proposals and implement Policy-based validation

shekohex opened this issue · comments

Note: Part of Smart Anchor Updates v2

Overview of the Current Approach of handling the Proposals

For any proposal, that implements the following trait:

/// The `Proposal` trait is used to abstract over the different proposals for
/// all the different chains.
pub trait ProposalTrait {
    /// Get the proposal header.
    fn header(&self) -> crate::ProposalHeader;
    /// Convert the proposal into bytes.
    ///
    /// Note: This also includes the proposal header.
    fn to_vec(&self) -> Vec<u8>;
}

We have a ProposalSigningBackend defined like the following:

/// A Proposal Signing Backend is responsible for signing proposal `P` where `P` is anything really that implements `ProposalTrait`.
///
/// As of now, we have two implementations of this trait:
///
/// - `DkgSigningBackend`: This is using the `DKG` protocol to sign the proposal.
/// - `MockedSigningBackend`: This is using the Governor's `PrivateKey` to sign the proposal directly.
#[async_trait::async_trait]
pub trait ProposalSigningBackend {
    /// A method to be called first to check if this backend can handle this proposal or not.
    async fn can_handle_proposal(
        &self,
        proposal: &(impl ProposalTrait + Sync + Send + 'static),
    ) -> webb_relayer_utils::Result<bool>;
    /// Send the Unsigned Proposal to the backend to start handling the signing process.
    async fn handle_proposal(
        &self,
        proposal: &(impl ProposalTrait + Sync + Send + 'static),
        metrics: Arc<Mutex<metric::Metrics>>,
    ) -> webb_relayer_utils::Result<()>;
}

When we want to execute some proposal P we send it to the Proposal Signing backend to be signed then sent to the target system to be executed. In our example we have a Proposal Type called AnchorUpdate Proposal, and is defined as following (For EVM Chains):

/// Anchor Update Proposal.
///
/// The [`AnchorUpdateProposal`] updates the target Anchor's knowledge of the
/// source Anchor's Merkle roots. This knowledge is necessary to prove
/// membership in the source Anchor's Merkle tree on the target chain.
pub struct AnchorUpdateProposal {
    header: ProposalHeader,
    merkle_root: [u8; 32],
    src_resource_id: ResourceId,
}

And for convince, here is how any ProposalHeader looks like:

/// Proposal Header (40 bytes).
pub struct ProposalHeader {
    /// Resource ID of the execution context
    pub resource_id: ResourceId,
    /// Function signature / identifier
    pub function_signature: FunctionSignature,
    /// Nonce for proposal execution
    pub nonce: Nonce,
}

/// Proposal Target Function Signature (4 bytes).
pub struct FunctionSignature(pub [u8; 4]);

/// Proposal Target `ResourceId` (32 bytes).
/// Composed from TargetSystem + TypedChainId
pub struct ResourceId(pub [u8; 32]);
/// Proposal Target Chain and its type (6 bytes).
pub enum TypedChainId {
    /// None chain type.
    ///
    /// This is used when the chain type is not known.
    None,
    /// EVM Based Chain (Mainnet, Polygon, ...etc)
    Evm(u32),
    /// Standalone Substrate Based Chain (Webb, Edgeware, ...etc)
    Substrate(u32),
}
/// Proposal Nonce (4 bytes).
pub struct Nonce(pub u32);

/// TargetSystem (26 Bytes)
pub enum TargetSystem {
    /// Ethereum Contract address (20 bytes).
    ContractAddress([u8; 20]),
    /// Webb Protocol-Substrate 6 bytes (pallet_index, call_index, tree_id ).
    Substrate(SubstrateTargetSystem),
}

So, Imagine we have 3 Chains here (Goerli, Mumbai, and Rinkeby), each have a different resource Id composed of their Anchor Smart contract address + Typed Chain Id. When some user updates the MerkleTree on Goerli, the relayer have to create 2 Anchor Update Proposals one that targets Mumbai, and another one that targets Rinkeby, sends both of these two proposals to the be signed by the proposals signing backend, and once signed they will be sent as transactions to another smart contract called SignatureBridge that only accepts signed proposals. This all good if we have one update every now and then, but imagine we have a lot of updates that happens more frequently? this means we are going to create a lot of Anchor Update proposals and hence a lot of txs. However, Note that we only need to relay the last merkle root, so that means we do not need send each proposal to be signed and to be executed, we could make a better job here to optimize this and save up the resources and gas fees.

To optimize the handling of Anchor Update Proposals and reduce gas fees and resource usage, we can introduce a queuing mechanism and implement a policy-based approach for determining when to sign and execute proposals. Here's an outline of the proposed solution:

  1. Proposal Queue: Create a proposal queue to store incoming Anchor Update Proposals. The queue should keep track of the most recent proposal for each resource ID.

  2. Policy-based Approach: Introduce a policy-based architecture to determine whether a proposal should be signed and executed based on various conditions. We'll define a ProposalPolicy trait with methods for checking if a proposal should be signed and executed. We can chain multiple policies together to create a flexible decision-making process.

    pub trait ProposalPolicy {
        fn should_sign_proposal(&self, proposal: &AnchorUpdateProposal) -> bool;
        fn should_execute_proposal(&self, proposal: &AnchorUpdateProposal) -> bool;
    }

    Example policies:

    • NoncePolicy: Checks if the proposal has a higher nonce than the most recent proposal for the same resource ID.
    • TimeDelayPolicy: Considers the time elapsed since the last proposal for a resource ID and determines whether it's appropriate to send a new proposal based on a delay threshold.
    • Additional policies can be added based on specific requirements.
  3. SignatureBridge Integration: Modify the ProposalSigningBackend to work with the queuing mechanism and policies. The handle_proposal method will enqueue the proposal if it satisfies the policies. The queuing mechanism will ensure that only the most recent proposal for each resource ID is stored.

    #[async_trait::async_trait]
    pub trait ProposalSigningBackend {
        // ...
        async fn handle_proposal(
            &self,
            proposal: &AnchorUpdateProposal,
            metrics: Arc<Mutex<metric::Metrics>>,
            queue: Arc<Mutex<ProposalQueue>>,
            policies: Vec<Box<dyn ProposalPolicy>>,
        ) -> webb_relayer_utils::Result<()>;
    }
  4. Background Worker: Run a background worker that periodically checks the proposal queue and processes proposals that satisfy the policies. This worker will handle signing and executing proposals based on the policies and interact with the SignatureBridge smart contract.

    The worker can utilize the policies to determine the order and timing of proposal execution. For example, it can prioritize proposals based on nonce, waiting for the appropriate time delay, and execute them accordingly.

    The worker can be implemented using async tasks and timers, ensuring efficient resource utilization.

By implementing the above solution, we ensure that only the most recent proposals are processed, and redundant proposals with old nonces are discarded. The policies allow for flexible decision-making based on various constraints, and the background worker ensures efficient processing and utilization of resources.

To implement the TimeDelayPolicy that adjusts the delay dynamically based on the number of incoming proposals, you can use a sliding window algorithm. Here's an example of how you can implement it in Rust:

use std::time::{Duration, Instant};

const INITIAL_DELAY: u64 = 60; // Initial delay in seconds
const MIN_DELAY: u64 = 10; // Minimum delay in seconds
const MAX_DELAY: u64 = 300; // Maximum delay in seconds
const WINDOW_SIZE: usize = 5; // Number of recent proposals to consider

struct TimeDelayPolicy {
    delays: Vec<u64>, // Sliding window of recent proposal delays
    current_delay: u64, // Current delay value
}

impl TimeDelayPolicy {
    pub fn new() -> Self {
        Self {
            delays: vec![INITIAL_DELAY; WINDOW_SIZE],
            current_delay: INITIAL_DELAY,
        }
    }

    pub fn update_delay(&mut self, num_proposals: usize) {
        // Add the current delay to the sliding window
        self.delays.push(self.current_delay);
        if self.delays.len() > WINDOW_SIZE {
            self.delays.remove(0);
        }

        // Calculate the average delay from the sliding window
        let sum: u64 = self.delays.iter().sum();
        let avg_delay = sum / self.delays.len() as u64;

        // Adjust the current delay based on the average and the number of proposals
        let adjusted_delay = if num_proposals > 0 {
            let scaling_factor = num_proposals as u64;
            let adjusted = avg_delay * scaling_factor;
            adjusted.min(MAX_DELAY).max(MIN_DELAY)
        } else {
            INITIAL_DELAY
        };

        self.current_delay = adjusted_delay;
    }

    pub fn get_delay(&self) -> Duration {
        Duration::from_secs(self.current_delay)
    }
}

In this implementation, we maintain a sliding window of the delays vector, which keeps track of the delays of the most recent proposals. We initialize it with the INITIAL_DELAY value.

The update_delay method is responsible for updating the delay value based on the number of incoming proposals. It calculates the average delay from the sliding window and adjusts the current delay based on the average and the number of proposals. The adjusted delay is capped between MIN_DELAY and MAX_DELAY.

The get_delay method returns the current delay as a Duration type, which can be used for waiting or scheduling tasks.

To use the TimeDelayPolicy in your code, you can call the update_delay method whenever a new proposal arrives and pass the number of proposals received so far. Then, you can retrieve the current delay by calling get_delay.