//! Storage and bookkeeping for pending request fan-out. //! //! Each pending-request map stores a `Vec` so that duplicate //! submissions of the same request key queue up and all receive the same MPC //! response. This module owns: //! //! * the cap on how many yields may be queued for a single key, //! * the queue mutations (`push`, FIFO pop, drain), //! * the read/write policy on the fan-out map: `push_pending_yield` appends, //! `resolve_yields_for` drains the full queue on a response, and //! `pop_oldest_pending_yield` removes the head entry on a timeout. //! //! Callers in `lib.rs` go through these helpers rather than touching the maps //! directly, so the queue policy lives in one place. use borsh::{BorshDeserialize, BorshSerialize}; use near_sdk::{CryptoHash, env, store::LookupMap}; use crate::{ errors::{Error, InvalidParameters, RequestError}, primitives::signature::YieldIndex, }; /// Maximum number of concurrent yield-resume promises that can be queued for a single /// request key (i.e. the number of duplicate submissions whose responses fan out from /// one MPC reply). /// /// The ceiling is needed because `respond*` drains the entire queue in one call: every /// queued yield triggers a host-side `promise_yield_resume`, paid for out of the /// responder's 300 TGas budget. Without a cap, an attacker could enqueue enough /// duplicates to make `respond*` run out of gas and strand every queued caller. /// /// 128 is validated empirically by the sandbox test /// `test_contract_request_duplicate_requests_fan_out`, which fills the queue to this /// cap across all four signature schemes and confirms `respond*` drains it inside its /// 300 TGas budget. pub const MAX_PENDING_REQUEST_FAN_OUT: u8 = 128; /// Append a yield index to the pending-request fan-out queue for `request`. /// /// Panics with `RequestError::PendingRequestQueueFull` if the resulting queue would /// exceed `MAX_PENDING_REQUEST_FAN_OUT`. pub(crate) fn push_pending_yield( requests: &mut LookupMap>, request: K, data_id: CryptoHash, ) where K: BorshSerialize + BorshDeserialize + Clone + Ord, { let queue = requests.entry(request).or_default(); if queue.len() >= usize::from(MAX_PENDING_REQUEST_FAN_OUT) { env::panic_str( &RequestError::PendingRequestQueueFull { limit: MAX_PENDING_REQUEST_FAN_OUT, } .to_string(), ); } queue.push(YieldIndex { data_id }); } /// Resume every yield queued for `request` with `response_bytes`, draining the /// fan-out map in one pass. Returns `Err(RequestNotFound)` if the map held no entry. /// /// Resuming a yield that has already timed out is a no-op at the SDK level. pub(crate) fn resolve_yields_for( requests: &mut LookupMap>, request: &K, response_bytes: Vec, ) -> Result<(), Error> where K: BorshSerialize + BorshDeserialize + Clone + Ord, { let resumed = requests .remove(request) .unwrap_or_default() .into_iter() .map(|YieldIndex { data_id }| { env::promise_yield_resume(&data_id, response_bytes.clone()); }) .count(); if resumed > 0 { Ok(()) } else { Err(InvalidParameters::RequestNotFound.into()) } } /// Account for one timed-out yield against `request`: pop the oldest queued yield /// from the fan-out for `request`. A no-op if the request is absent (e.g. `respond*` /// already drained it) or the stored queue had no entries to pop. /// /// Yields are removed in FIFO order because they were appended in submission order /// and time out in that same order — so the timing-out yield is always the head. /// If the queue empties (or was already empty), the map entry itself is removed. pub(crate) fn pop_oldest_pending_yield(requests: &mut LookupMap>, request: &K) where K: BorshSerialize + BorshDeserialize + Clone + Ord, { let Some(queue) = requests.get_mut(request) else { return; }; if queue.is_empty() { requests.remove(request); return; } queue.remove(0); if queue.is_empty() { requests.remove(request); } }