Skip to main content

freenet_stdlib/
delegate_host.rs

1//! Host function API for delegates.
2//!
3//! This module provides synchronous access to delegate context, secrets, and
4//! contract state via host functions, eliminating the need for message round-trips.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use freenet_stdlib::prelude::*;
10//!
11//! #[delegate]
12//! impl DelegateInterface for MyDelegate {
13//!     fn process(
14//!         ctx: &mut DelegateCtx,
15//!         _params: Parameters<'static>,
16//!         _attested: Option<&'static [u8]>,
17//!         message: InboundDelegateMsg,
18//!     ) -> Result<Vec<OutboundDelegateMsg>, DelegateError> {
19//!         // Read/write temporary context
20//!         let data = ctx.read();
21//!         ctx.write(b"new state");
22//!
23//!         // Access persistent secrets
24//!         if let Some(key) = ctx.get_secret(b"private_key") {
25//!             // use key...
26//!         }
27//!         ctx.set_secret(b"new_secret", b"value");
28//!
29//!         // V2: Direct contract access (no round-trips!)
30//!         let contract_id = [0u8; 32]; // your contract instance ID
31//!         if let Some(state) = ctx.get_contract_state(&contract_id) {
32//!             // process state...
33//!         }
34//!         ctx.put_contract_state(&contract_id, b"new state");
35//!
36//!         Ok(vec![])
37//!     }
38//! }
39//! ```
40//!
41//! # Context vs Secrets vs Contracts
42//!
43//! - **Context** (`read`/`write`): Temporary state within a single message batch.
44//!   Reset between separate runtime calls. Use for intermediate processing state.
45//!
46//! - **Secrets** (`get_secret`/`set_secret`): Persistent encrypted storage.
47//!   Survives across all delegate invocations. Use for private keys, tokens, etc.
48//!
49//! - **Contracts** (`get_contract_state`/`put_contract_state`/`update_contract_state`/
50//!   `subscribe_contract`/`list_subscriptions`): V2 host functions for direct
51//!   contract state access. Synchronous local reads/writes — no
52//!   request/response round-trips.
53//!
54//! # Adding a host function is the additive way to extend this API
55//!
56//! Host functions are resolved **by name at module instantiation**. A delegate
57//! that imports one an older node does not provide fails to load, with a named
58//! missing-import error; a delegate that does not import it is unaffected. So
59//! adding a host function is additive for every existing delegate, and its
60//! failure mode for a too-old node is loud and diagnosable at load time.
61//!
62//! Contrast the message API (`OutboundDelegateMsg`): a new variant sent to an
63//! older host fails mid-protocol at bincode decode, with no way for the
64//! delegate to have detected the host's version first. Where a capability can
65//! be expressed either way, prefer the host function.
66//!
67//! # Error Codes
68//!
69//! Host functions return negative values to indicate errors:
70//!
71//! | Code | Meaning |
72//! |------|---------|
73//! | 0    | Success |
74//! | -1   | Called outside process() context |
75//! | -2   | Secret not found |
76//! | -3   | Storage operation failed |
77//! | -4   | Invalid parameter (e.g., negative length) |
78//! | -5   | Context too large (exceeds i32::MAX) |
79//! | -6   | Buffer too small |
80//! | -7   | Contract not found in local store |
81//! | -8   | Internal state store error |
82//! | -9   | WASM memory bounds violation |
83//! | -10  | Contract code not registered |
84//!
85//! The wrapper methods in [`DelegateCtx`] handle these error codes and present
86//! a more ergonomic API.
87
88/// Error codes returned by host functions.
89///
90/// Negative values indicate errors, non-negative values indicate success
91/// (usually the number of bytes read/written).
92pub mod error_codes {
93    /// Operation succeeded.
94    pub const SUCCESS: i32 = 0;
95    /// Called outside of a process() context.
96    pub const ERR_NOT_IN_PROCESS: i32 = -1;
97    /// Secret not found.
98    pub const ERR_SECRET_NOT_FOUND: i32 = -2;
99    /// Storage operation failed.
100    pub const ERR_STORAGE_FAILED: i32 = -3;
101    /// Invalid parameter (e.g., negative length).
102    pub const ERR_INVALID_PARAM: i32 = -4;
103    /// Context too large (exceeds i32::MAX).
104    pub const ERR_CONTEXT_TOO_LARGE: i32 = -5;
105    /// Buffer too small to hold the data.
106    pub const ERR_BUFFER_TOO_SMALL: i32 = -6;
107    /// Contract not found in local store.
108    pub const ERR_CONTRACT_NOT_FOUND: i32 = -7;
109    /// Internal state store error.
110    pub const ERR_STORE_ERROR: i32 = -8;
111    /// WASM memory bounds violation (pointer/length out of range).
112    pub const ERR_MEMORY_BOUNDS: i32 = -9;
113    /// Contract code not registered in the index.
114    pub const ERR_CONTRACT_CODE_NOT_REGISTERED: i32 = -10;
115    /// Delegate creation depth limit exceeded.
116    pub const ERR_DEPTH_EXCEEDED: i32 = -20;
117    /// Per-call delegate creation limit exceeded.
118    pub const ERR_CREATIONS_EXCEEDED: i32 = -21;
119    /// Invalid WASM module (failed to construct DelegateContainer).
120    pub const ERR_INVALID_WASM: i32 = -23;
121    /// Failed to register delegate in secret/delegate store.
122    pub const ERR_STORE_FAILED: i32 = -24;
123}
124
125/// Upper bound on the serialized subscription list a host may report from
126/// `__frnt__delegate__list_subscriptions_len`, in bytes — 32 KiB, i.e. 1024
127/// contract ids.
128///
129/// This exists because the delegate allocates on the strength of that number.
130/// An allocation failure inside a delegate is an abort, not a value we can
131/// return, so an implausible length has to be refused before `vec![0u8; len]`
132/// rather than survived afterwards. A delegate holding a thousand subscriptions
133/// is already well outside the intended shape.
134pub const MAX_SUBSCRIPTION_LIST_BYTES: i64 = 32 * 1024;
135
136// The cap is compared against a length that must also be a multiple of 32. If
137// it ever stops being one, the upper bound becomes unreachable and the "1024
138// contract ids" above becomes a lie, silently.
139const _: () = assert!(MAX_SUBSCRIPTION_LIST_BYTES % 32 == 0);
140
141/// What a delegate's subscribe request actually achieved.
142///
143/// Returned by
144/// [`DelegateCtx::subscribe_contract_checked`](DelegateCtx::subscribe_contract_checked).
145/// It exists because `Result<(), _>` has only two states and the subscribe path
146/// has three: it can register against state the node holds, it can register
147/// against nothing, or it can fail. Reusing `Err` for the middle case is wrong — delegates
148/// legitimately subscribe before the node has settled, and a usually-transient
149/// condition surfacing as a hard failure would break working delegates today.
150///
151/// This type is **not on the wire**. It is the decoded form of a non-negative
152/// `i64` returned by a host function, so adding a variant costs no bincode
153/// variant tag and cannot shift one.
154///
155/// See freenet-core#5565.
156#[non_exhaustive]
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum SubscribeOutcome {
159    /// **The node holds state for this contract, and has recorded the
160    /// delegate's interest in it.** Notifications have something to fire on.
161    ///
162    /// This is a statement about *now*, not a durability promise. Under
163    /// demand-driven hosting **no subscription of any kind is an absolute
164    /// pin**, and a delegate subscription is weaker still: it registers
165    /// notification interest only. On freenet-core as it stands (pre-#4669) it
166    /// contributes **no hosting demand**, so it does not affect eviction
167    /// ordering at all — unlike a client subscription, which is a ranking
168    /// dimension. freenet-core#5493 implements #4669 and is open now, so treat
169    /// the "no demand" half as current behaviour rather than a fixed property. A delegate must not read this as "the node
170    /// will keep this contract for me"; it means the subscription is not
171    /// vacuous today.
172    Pinned,
173    /// Registered, but the node holds **no state** for the contract.
174    ///
175    /// This is the case [`DelegateCtx::subscribe_contract`]'s doc describes at
176    /// length and cannot report. The subscription exists, and there is nothing
177    /// for it to fire on: notifications arrive only if some *other* route
178    /// causes this node to hold the contract. The common way to get here is
179    /// subscribing at startup, before the node has fetched the contract.
180    ///
181    /// Treat it as "retry later" — and unlike a pin promise, this one does
182    /// clear: it clears as soon as the node holds the state.
183    NotPinned,
184    /// The node reported an outcome this build of the stdlib does not know.
185    ///
186    /// A newer node may report an outcome added after this delegate was
187    /// compiled. Treating it as [`Self::Pinned`] would reintroduce exactly the
188    /// silent over-claim this type exists to remove, so it is surfaced.
189    Unrecognized(i64),
190}
191
192impl SubscribeOutcome {
193    /// Discriminant for [`Self::Pinned`] on the host-function return channel.
194    pub const CODE_PINNED: i64 = 0;
195    /// Discriminant for [`Self::NotPinned`] on the host-function return channel.
196    pub const CODE_NOT_PINNED: i64 = 1;
197
198    /// Decode a non-negative host return code.
199    ///
200    /// Negative codes are host **errors** and never reach here; the caller
201    /// separates them first. An unrecognized non-negative code becomes
202    /// [`Self::Unrecognized`] rather than being folded into a known outcome.
203    pub fn from_code(code: i64) -> Self {
204        match code {
205            Self::CODE_PINNED => Self::Pinned,
206            Self::CODE_NOT_PINNED => Self::NotPinned,
207            other => Self::Unrecognized(other),
208        }
209    }
210
211    /// Whether the node holds state for the contract and recorded the
212    /// delegate's interest — i.e. whether the subscription can fire.
213    ///
214    /// False for [`Self::NotPinned`] and for [`Self::Unrecognized`] — an
215    /// outcome this build cannot interpret is not evidence either way, and
216    /// treating it as affirmative is the over-claim this type removes.
217    ///
218    /// Not a durability check. See [`Self::Pinned`]: nothing here promises the
219    /// node keeps the contract.
220    pub fn is_pinned(self) -> bool {
221        matches!(self, Self::Pinned)
222    }
223}
224/// Largest `tag` [`DelegateCtx::schedule_wakeup`] will send, in bytes.
225///
226/// freenet-core#3972 is **expected to** bound how many wakeups a delegate may
227/// hold pending; **no host does today**, so that count cap is an obligation and
228/// not a premise. Even once it exists, a count cap alone does not bound memory:
229/// `tag` would be an unbounded byte channel sitting behind a limit that looks
230/// bounded, which is the same defect as a cache capped by entry count while
231/// holding caller-controlled values. Hence a size cap as well as a count cap.
232///
233/// Applied by [`DelegateCtx::schedule_wakeup`] before it calls the host. That
234/// is fail-fast convenience for well-behaved callers, **not** a bound: a
235/// delegate can declare the import itself and bypass the wrapper, so only the
236/// host can enforce this.
237///
238/// 128 bytes comfortably holds a purpose string, a UUID, or a hash. A tag is an
239/// identifier the delegate chose; it is not a place to carry state, which is
240/// what secrets are for.
241pub const MAX_WAKEUP_TAG_BYTES: usize = 128;
242
243/// Shortest delay [`DelegateCtx::schedule_wakeup`] will request.
244/// Anything below it is clamped **up** to it, by that function.
245///
246/// A delegate that re-arms inside its own `WakeupFired` handler with a zero or
247/// near-zero delay would otherwise spin the node in a tight wake loop — the
248/// same unbounded-work hazard a deadline in the past would have created, which
249/// is why moving to a relative delay did not remove the need for this floor.
250///
251/// **The host must clamp too, and does not yet.** A delegate can declare the
252/// import itself and bypass this wrapper, so the clamp here bounds only
253/// well-behaved callers; only the host can make this a limit. That host-side
254/// floor is an obligation on freenet-core#3972, not a property of anything
255/// shipped.
256///
257/// A second obligation in the same place: for a long delay to be useful the
258/// host must persist pending wakeups across a node restart. **No host does this
259/// today.** The precedent runs the wrong way — `DELEGATE_SUBSCRIPTIONS`, the
260/// one comparable piece of per-delegate host state, is in-memory and a restart
261/// discards it entirely.
262pub const MIN_WAKEUP_DELAY: std::time::Duration = std::time::Duration::from_secs(1);
263
264/// Raise `after` to [`MIN_WAKEUP_DELAY`] if it is below it.
265///
266/// Pure, so host-side `cargo test` can exercise it. Used by
267/// [`prepare_wakeup`], which is where the boundary actually sits: everything
268/// up to and including the conversion to milliseconds runs on **both** targets,
269/// and only the `extern "C"` call beneath it is `cfg(target_family = "wasm")`
270/// and therefore never executed by CI — the wasm32 jobs build and lint but run
271/// nothing.
272pub fn clamp_wakeup_delay(after: std::time::Duration) -> std::time::Duration {
273    if after < MIN_WAKEUP_DELAY {
274        MIN_WAKEUP_DELAY
275    } else {
276        after
277    }
278}
279
280/// Validate and normalise the arguments to [`DelegateCtx::schedule_wakeup`],
281/// returning the delay in milliseconds for the host call.
282///
283/// This is the whole of that function except the FFI call itself, split out so
284/// the parts that CI can execute are executed. It covers the tag-size refusal,
285/// the [`MIN_WAKEUP_DELAY`] clamp, **the order of the two**, and the saturating
286/// conversion to milliseconds — the last of which otherwise lives inside the
287/// `cfg(target_family = "wasm")` block and would never run under test.
288///
289/// `schedule_wakeup` is a thin wrapper over this, so the wiring cannot be
290/// deleted without breaking compilation. That matters: an earlier arrangement
291/// clamped in the wrapper and discarded the result on the host target, so
292/// removing the clamp entirely left the suite green.
293fn prepare_wakeup(after: std::time::Duration, tag: &[u8]) -> Result<i64, i64> {
294    if tag.len() > MAX_WAKEUP_TAG_BYTES {
295        return Err(error_codes::ERR_INVALID_PARAM as i64);
296    }
297    let after = clamp_wakeup_delay(after);
298    // Saturate rather than wrap: `as_millis` is u128, and a delay past
299    // i64::MAX milliseconds is ~292 million years, so clamping loses nothing a
300    // caller could have meant.
301    Ok(i64::try_from(after.as_millis()).unwrap_or(i64::MAX))
302}
303
304// ============================================================================
305// Host function declarations (WASM only)
306// ============================================================================
307
308#[cfg(target_family = "wasm")]
309#[link(wasm_import_module = "freenet_delegate_ctx")]
310extern "C" {
311    /// Returns the current context length in bytes, or negative error code.
312    fn __frnt__delegate__ctx_len() -> i32;
313    /// Reads context into the buffer at `ptr` (max `len` bytes). Returns bytes written, or negative error code.
314    fn __frnt__delegate__ctx_read(ptr: i64, len: i32) -> i32;
315    /// Writes `len` bytes from `ptr` into the context, replacing existing content. Returns 0 on success, or negative error code.
316    fn __frnt__delegate__ctx_write(ptr: i64, len: i32) -> i32;
317}
318
319#[cfg(target_family = "wasm")]
320#[link(wasm_import_module = "freenet_delegate_secrets")]
321extern "C" {
322    /// Get a secret. Returns bytes written to `out_ptr`, or negative error code.
323    fn __frnt__delegate__get_secret(key_ptr: i64, key_len: i32, out_ptr: i64, out_len: i32) -> i32;
324    /// Get secret length without fetching value. Returns length, or negative error code.
325    fn __frnt__delegate__get_secret_len(key_ptr: i64, key_len: i32) -> i32;
326    /// Store a secret. Returns 0 on success, or negative error code.
327    fn __frnt__delegate__set_secret(key_ptr: i64, key_len: i32, val_ptr: i64, val_len: i32) -> i32;
328    /// Check if a secret exists. Returns 1 if yes, 0 if no, or negative error code.
329    fn __frnt__delegate__has_secret(key_ptr: i64, key_len: i32) -> i32;
330    /// Remove a secret. Returns 0 on success, or negative error code.
331    fn __frnt__delegate__remove_secret(key_ptr: i64, key_len: i32) -> i32;
332    /// Length (in bytes) of the serialized key list for all stored secret keys
333    /// whose raw key starts with the `prefix_len`-byte prefix at `prefix_ptr`
334    /// (an empty prefix matches every key). Returns the byte count to allocate
335    /// before calling `__frnt__delegate__list_secrets`, or a negative error code.
336    fn __frnt__delegate__list_secrets_len(prefix_ptr: i64, prefix_len: i32) -> i32;
337    /// Enumerate stored secret keys matching the prefix. Writes a length-prefixed
338    /// list to `out_ptr` (max `out_len` bytes): each record is a 4-byte
339    /// little-endian length followed by that many key bytes. Returns the number
340    /// of bytes written, or a negative error code.
341    fn __frnt__delegate__list_secrets(
342        prefix_ptr: i64,
343        prefix_len: i32,
344        out_ptr: i64,
345        out_len: i32,
346    ) -> i32;
347}
348
349#[cfg(target_family = "wasm")]
350#[link(wasm_import_module = "freenet_delegate_contracts")]
351extern "C" {
352    /// Get contract state length. Returns byte count, or negative error code (i64).
353    fn __frnt__delegate__get_contract_state_len(id_ptr: i64, id_len: i32) -> i64;
354    /// Get contract state. Returns byte count written, or negative error code (i64).
355    fn __frnt__delegate__get_contract_state(
356        id_ptr: i64,
357        id_len: i32,
358        out_ptr: i64,
359        out_len: i64,
360    ) -> i64;
361    /// Put (store) contract state. Returns 0 on success, or negative error code (i64).
362    fn __frnt__delegate__put_contract_state(
363        id_ptr: i64,
364        id_len: i32,
365        state_ptr: i64,
366        state_len: i64,
367    ) -> i64;
368    /// Update contract state (requires existing state). Returns 0 on success, or negative error code (i64).
369    fn __frnt__delegate__update_contract_state(
370        id_ptr: i64,
371        id_len: i32,
372        state_ptr: i64,
373        state_len: i64,
374    ) -> i64;
375    /// Subscribe to contract updates. Returns 0 on success, or negative error code (i64).
376    fn __frnt__delegate__subscribe_contract(id_ptr: i64, id_len: i32) -> i64;
377
378    /// Subscribe and report the *outcome*, not just success/failure.
379    ///
380    /// Returns a non-negative [`SubscribeOutcome`] discriminant, or a negative
381    /// error code. Distinct from `__frnt__delegate__subscribe_contract`, whose
382    /// `i64` is collapsed to a `bool` by its wrapper and so cannot express an
383    /// outcome that is neither success nor failure. See freenet-core#5565.
384    fn __frnt__delegate__subscribe_contract_checked(id_ptr: i64, id_len: i32) -> i64;
385    /// Byte length of this delegate's serialized subscription list — always a
386    /// multiple of 32, and never more than [`MAX_SUBSCRIPTION_LIST_BYTES`].
387    /// Returns the count to allocate, or a negative error code (i64). Zero
388    /// means "subscribed to nothing", which is distinct from an error and must
389    /// stay so.
390    fn __frnt__delegate__list_subscriptions_len() -> i64;
391    /// Enumerate this delegate's current contract subscriptions: writes the raw
392    /// 32-byte instance ids back to back into `out_ptr` (at most `out_len`
393    /// bytes) and returns the number of bytes written, or a negative error code
394    /// (i64).
395    ///
396    /// **The host MUST return `ERR_BUFFER_TOO_SMALL` (-6) rather than a partial
397    /// list if the set no longer fits in `out_len`.** The set can change
398    /// between the length call and this one, and a buffer filled exactly to
399    /// `out_len` is indistinguishable from one the host wanted to overflow — so
400    /// a silently truncated list would look complete to the delegate, which is
401    /// the failure this API exists to remove. Writing more than `out_len` bytes
402    /// is a contract violation in either direction.
403    fn __frnt__delegate__list_subscriptions(out_ptr: i64, out_len: i64) -> i64;
404}
405
406#[cfg(target_family = "wasm")]
407#[link(wasm_import_module = "freenet_delegate_management")]
408extern "C" {
409    /// Ask the host to deliver an `InboundDelegateMsg::WakeupFired` once
410    /// `after_millis` have elapsed, measured by the host from this call.
411    ///
412    /// `tag` is opaque to the host and echoed back on fire. Re-scheduling with
413    /// the same `tag` replaces any prior pending wakeup for this
414    /// `(delegate, tag)` pair. Returns 0 on success, negative on error.
415    fn __frnt__delegate__schedule_wakeup(after_millis: i64, tag_ptr: i64, tag_len: i32) -> i64;
416
417    /// Create a new delegate from WASM code + parameters.
418    /// Returns 0 on success, negative error code on failure.
419    /// On success, writes 32 bytes to out_key_ptr and 32 bytes to out_hash_ptr.
420    fn __frnt__delegate__create_delegate(
421        wasm_ptr: i64,
422        wasm_len: i64,
423        params_ptr: i64,
424        params_len: i64,
425        cipher_ptr: i64,
426        nonce_ptr: i64,
427        out_key_ptr: i64,
428        out_hash_ptr: i64,
429    ) -> i32;
430}
431
432// ============================================================================
433// DelegateCtx - Unified handle to context, secrets, and contracts
434// ============================================================================
435
436/// Opaque handle to the delegate's execution environment.
437///
438/// Provides access to:
439/// - **Temporary context**: State shared within a single message batch (reset between calls)
440/// - **Persistent secrets**: Encrypted storage that survives across all invocations
441/// - **Contract state** (V2): Direct synchronous access to local contract state
442///
443/// # Context Methods
444/// - [`read`](Self::read), [`write`](Self::write), [`len`](Self::len), [`clear`](Self::clear)
445///
446/// # Secret Methods
447/// - [`get_secret`](Self::get_secret), [`set_secret`](Self::set_secret),
448///   [`has_secret`](Self::has_secret), [`remove_secret`](Self::remove_secret)
449///
450/// # Contract Methods (V2)
451/// - [`get_contract_state`](Self::get_contract_state),
452///   [`put_contract_state`](Self::put_contract_state),
453///   [`update_contract_state`](Self::update_contract_state),
454///   [`subscribe_contract`](Self::subscribe_contract),
455///   [`list_subscriptions`](Self::list_subscriptions)
456///
457/// # Delegate Management Methods (V2)
458/// - [`create_delegate`](Self::create_delegate)
459#[derive(Default)]
460#[repr(transparent)]
461pub struct DelegateCtx {
462    _private: (),
463}
464
465impl DelegateCtx {
466    /// Creates the context handle.
467    ///
468    /// # Safety
469    ///
470    /// This should only be called by macro-generated code when the runtime
471    /// has set up the delegate execution environment.
472    #[doc(hidden)]
473    pub unsafe fn __new() -> Self {
474        Self { _private: () }
475    }
476
477    // ========================================================================
478    // Context methods (temporary state within a batch)
479    // ========================================================================
480
481    /// Returns the current context length in bytes.
482    #[inline]
483    pub fn len(&self) -> usize {
484        #[cfg(target_family = "wasm")]
485        {
486            let len = unsafe { __frnt__delegate__ctx_len() };
487            if len < 0 {
488                0
489            } else {
490                len as usize
491            }
492        }
493        #[cfg(not(target_family = "wasm"))]
494        {
495            0
496        }
497    }
498
499    /// Returns `true` if the context is empty.
500    #[inline]
501    pub fn is_empty(&self) -> bool {
502        self.len() == 0
503    }
504
505    /// Read the current context bytes.
506    ///
507    /// Returns an empty `Vec` if no context has been written.
508    pub fn read(&self) -> Vec<u8> {
509        #[cfg(target_family = "wasm")]
510        {
511            let len = unsafe { __frnt__delegate__ctx_len() };
512            if len <= 0 {
513                return Vec::new();
514            }
515            let mut buf = vec![0u8; len as usize];
516            let read = unsafe { __frnt__delegate__ctx_read(buf.as_mut_ptr() as i64, len) };
517            buf.truncate(read.max(0) as usize);
518            buf
519        }
520        #[cfg(not(target_family = "wasm"))]
521        {
522            Vec::new()
523        }
524    }
525
526    /// Read context into a provided buffer.
527    ///
528    /// Returns the number of bytes actually read.
529    pub fn read_into(&self, buf: &mut [u8]) -> usize {
530        #[cfg(target_family = "wasm")]
531        {
532            let read =
533                unsafe { __frnt__delegate__ctx_read(buf.as_mut_ptr() as i64, buf.len() as i32) };
534            read.max(0) as usize
535        }
536        #[cfg(not(target_family = "wasm"))]
537        {
538            let _ = buf;
539            0
540        }
541    }
542
543    /// Write new context bytes, replacing any existing content.
544    ///
545    /// Returns `true` on success, `false` on error.
546    pub fn write(&mut self, data: &[u8]) -> bool {
547        #[cfg(target_family = "wasm")]
548        {
549            let result =
550                unsafe { __frnt__delegate__ctx_write(data.as_ptr() as i64, data.len() as i32) };
551            result == 0
552        }
553        #[cfg(not(target_family = "wasm"))]
554        {
555            let _ = data;
556            false
557        }
558    }
559
560    /// Clear the context.
561    #[inline]
562    pub fn clear(&mut self) {
563        self.write(&[]);
564    }
565
566    // ========================================================================
567    // Secret methods (persistent encrypted storage)
568    // ========================================================================
569
570    /// Get the length of a secret without retrieving its value.
571    ///
572    /// Returns `None` if the secret does not exist.
573    pub fn get_secret_len(&self, key: &[u8]) -> Option<usize> {
574        #[cfg(target_family = "wasm")]
575        {
576            let result =
577                unsafe { __frnt__delegate__get_secret_len(key.as_ptr() as i64, key.len() as i32) };
578            if result < 0 {
579                None
580            } else {
581                Some(result as usize)
582            }
583        }
584        #[cfg(not(target_family = "wasm"))]
585        {
586            let _ = key;
587            None
588        }
589    }
590
591    /// Get a secret by key.
592    ///
593    /// Returns `None` if the secret does not exist.
594    pub fn get_secret(&self, key: &[u8]) -> Option<Vec<u8>> {
595        #[cfg(target_family = "wasm")]
596        {
597            // First get the length to allocate the right buffer size
598            let len = self.get_secret_len(key)?;
599
600            if len == 0 {
601                return Some(Vec::new());
602            }
603
604            let mut out = vec![0u8; len];
605            let result = unsafe {
606                __frnt__delegate__get_secret(
607                    key.as_ptr() as i64,
608                    key.len() as i32,
609                    out.as_mut_ptr() as i64,
610                    out.len() as i32,
611                )
612            };
613            if result < 0 {
614                None
615            } else {
616                out.truncate(result as usize);
617                Some(out)
618            }
619        }
620        #[cfg(not(target_family = "wasm"))]
621        {
622            let _ = key;
623            None
624        }
625    }
626
627    /// Store a secret.
628    ///
629    /// Returns `true` on success, `false` on error.
630    pub fn set_secret(&mut self, key: &[u8], value: &[u8]) -> bool {
631        #[cfg(target_family = "wasm")]
632        {
633            let result = unsafe {
634                __frnt__delegate__set_secret(
635                    key.as_ptr() as i64,
636                    key.len() as i32,
637                    value.as_ptr() as i64,
638                    value.len() as i32,
639                )
640            };
641            result == 0
642        }
643        #[cfg(not(target_family = "wasm"))]
644        {
645            let _ = (key, value);
646            false
647        }
648    }
649
650    /// Check if a secret exists.
651    pub fn has_secret(&self, key: &[u8]) -> bool {
652        #[cfg(target_family = "wasm")]
653        {
654            let result =
655                unsafe { __frnt__delegate__has_secret(key.as_ptr() as i64, key.len() as i32) };
656            result == 1
657        }
658        #[cfg(not(target_family = "wasm"))]
659        {
660            let _ = key;
661            false
662        }
663    }
664
665    /// Remove a secret.
666    ///
667    /// Returns `true` if the secret was removed, `false` if it didn't exist.
668    pub fn remove_secret(&mut self, key: &[u8]) -> bool {
669        #[cfg(target_family = "wasm")]
670        {
671            let result =
672                unsafe { __frnt__delegate__remove_secret(key.as_ptr() as i64, key.len() as i32) };
673            result == 0
674        }
675        #[cfg(not(target_family = "wasm"))]
676        {
677            let _ = key;
678            false
679        }
680    }
681
682    /// Enumerate the keys of every secret this delegate has stored whose raw
683    /// key begins with `prefix` (pass an empty slice to list all keys).
684    ///
685    /// Returns the matching raw keys (the same byte strings originally passed
686    /// to [`set_secret`](Self::set_secret)). Order is unspecified. The host
687    /// caps the number of keys returned; if storage holds more matching keys
688    /// than the cap, the list is truncated (callers needing exhaustive
689    /// enumeration should narrow the prefix).
690    ///
691    /// This closes the gap that previously forced apps storing an open-ended
692    /// key family (e.g. `room:<owner_vk>`) to maintain their own key registry:
693    /// after a delegate-WASM rebuild the delegate can now rediscover what it
694    /// has stored instead of probing a hardcoded key set.
695    pub fn list_secrets(&self, prefix: &[u8]) -> Vec<Vec<u8>> {
696        #[cfg(target_family = "wasm")]
697        {
698            let len = unsafe {
699                __frnt__delegate__list_secrets_len(prefix.as_ptr() as i64, prefix.len() as i32)
700            };
701            if len <= 0 {
702                // Negative => error; zero => no matching keys. Either way the
703                // caller gets an empty list (errors are non-fatal: enumeration
704                // is advisory).
705                return Vec::new();
706            }
707            let mut out = vec![0u8; len as usize];
708            let written = unsafe {
709                __frnt__delegate__list_secrets(
710                    prefix.as_ptr() as i64,
711                    prefix.len() as i32,
712                    out.as_mut_ptr() as i64,
713                    out.len() as i32,
714                )
715            };
716            if written < 0 {
717                return Vec::new();
718            }
719            out.truncate(written as usize);
720            decode_secret_key_list(&out)
721        }
722        #[cfg(not(target_family = "wasm"))]
723        {
724            let _ = prefix;
725            Vec::new()
726        }
727    }
728
729    // ========================================================================
730    // Contract methods (V2 — direct synchronous access)
731    // ========================================================================
732
733    /// Get contract state by instance ID.
734    ///
735    /// Returns `Some(state_bytes)` if the contract exists locally,
736    /// `None` if not found or on error.
737    ///
738    /// Uses a two-step protocol: first queries the state length, then reads
739    /// the state bytes into an allocated buffer.
740    pub fn get_contract_state(&self, instance_id: &[u8; 32]) -> Option<Vec<u8>> {
741        #[cfg(target_family = "wasm")]
742        {
743            // Step 1: Get the state length
744            let len = unsafe {
745                __frnt__delegate__get_contract_state_len(instance_id.as_ptr() as i64, 32)
746            };
747            if len < 0 {
748                return None;
749            }
750            let len = len as usize;
751            if len == 0 {
752                return Some(Vec::new());
753            }
754
755            // Step 2: Read the state bytes
756            let mut buf = vec![0u8; len];
757            let read = unsafe {
758                __frnt__delegate__get_contract_state(
759                    instance_id.as_ptr() as i64,
760                    32,
761                    buf.as_mut_ptr() as i64,
762                    buf.len() as i64,
763                )
764            };
765            if read < 0 {
766                None
767            } else {
768                buf.truncate(read as usize);
769                Some(buf)
770            }
771        }
772        #[cfg(not(target_family = "wasm"))]
773        {
774            let _ = instance_id;
775            None
776        }
777    }
778
779    /// Store (PUT) contract state by instance ID.
780    ///
781    /// The contract's code must already be registered in the runtime's contract
782    /// store. Returns `true` on success, `false` on error.
783    pub fn put_contract_state(&mut self, instance_id: &[u8; 32], state: &[u8]) -> bool {
784        #[cfg(target_family = "wasm")]
785        {
786            let result = unsafe {
787                __frnt__delegate__put_contract_state(
788                    instance_id.as_ptr() as i64,
789                    32,
790                    state.as_ptr() as i64,
791                    state.len() as i64,
792                )
793            };
794            result == 0
795        }
796        #[cfg(not(target_family = "wasm"))]
797        {
798            let _ = (instance_id, state);
799            false
800        }
801    }
802
803    /// Update contract state by instance ID.
804    ///
805    /// Like `put_contract_state`, but only succeeds if the contract already has
806    /// stored state. This performs a full state replacement (not a delta-based
807    /// update through the contract's `update_state` logic). Returns `true` on
808    /// success, `false` if no prior state exists or on other errors.
809    pub fn update_contract_state(&mut self, instance_id: &[u8; 32], state: &[u8]) -> bool {
810        #[cfg(target_family = "wasm")]
811        {
812            let result = unsafe {
813                __frnt__delegate__update_contract_state(
814                    instance_id.as_ptr() as i64,
815                    32,
816                    state.as_ptr() as i64,
817                    state.len() as i64,
818                )
819            };
820            result == 0
821        }
822        #[cfg(not(target_family = "wasm"))]
823        {
824            let _ = (instance_id, state);
825            false
826        }
827    }
828
829    /// Subscribe to contract updates by instance ID.
830    ///
831    /// Registers interest in receiving `ContractNotification` when the
832    /// contract's state changes, covering state committed locally as well as
833    /// state arriving from the network.
834    ///
835    /// **Delivery is best-effort and lossy.** The node drops notifications
836    /// rather than blocking a state commit when the delivery channel is full,
837    /// and if that channel is closed it removes the contract's subscription
838    /// entry outright — silently, for every delegate subscribed to it. A
839    /// delegate that needs to be sure should poll contract state as a fallback
840    /// rather than treat a notification as guaranteed.
841    ///
842    /// # Whether this registers demand is a property of the NODE, not of this library
843    ///
844    /// Subscribing always installs a local notification hook. Whether it *also*
845    /// registers demand — keeping the contract in the update mesh and
846    /// protecting it from eviction — depends on the freenet-core the delegate
847    /// happens to be running on, and **a delegate cannot detect which it has**.
848    /// The call reports success either way.
849    ///
850    /// - **Nodes predating freenet-core#4669** register no demand at all:
851    ///   `contract_in_use` has no delegate term, so the contract does not enter
852    ///   the renewal set and is not exempt from eviction. Such a delegate sees
853    ///   remote updates only while something else keeps the node subscribed to
854    ///   that contract — typically an open UI client. Close the tab and the
855    ///   notifications stop, with no error reported anywhere.
856    /// - **Once #4669 lands**, a subscribe registers demand *when the node is
857    ///   hosting the contract*. If the node can resolve the contract but is not
858    ///   hosting it, the subscribe still succeeds and notifications still work,
859    ///   but no demand is registered — registering demand for a contract the
860    ///   node does not hold would create a pin that can be neither renewed nor
861    ///   reclaimed. Closing that remaining gap needs a subscribe that can
862    ///   bootstrap an unheld contract over the network.
863    ///
864    /// So do not write a delegate that assumes its subscription pins anything.
865    /// This is documented at the call site rather than left in an issue
866    /// precisely because nothing in the return value, the logs, or the
867    /// delegate's own view distinguishes the cases. Tracked in
868    /// freenet-core#4669, phase 1 of the freenet-core#5467 epic.
869    ///
870    /// Returns `true` on success, `false` if the contract is unknown or on
871    /// error. Note that the contract must already be in the node's local store;
872    /// subscribing does not fetch it.
873    ///
874    /// # The `bool` cannot express the case above
875    ///
876    /// `true` means "the node accepted the registration", and says nothing
877    /// about whether there is anything for the subscription to fire on. A node
878    /// that knows the contract's code but holds no state for it registers the
879    /// interest and returns `true`, identically to one that holds the state —
880    /// which is the ordinary situation at startup, before the node has fetched
881    /// the contract. The `bool` also collapses every negative error code into
882    /// `false`, so a transient failure is indistinguishable from an unknown
883    /// contract.
884    ///
885    /// [`subscribe_contract_checked`](Self::subscribe_contract_checked) reports
886    /// the outcome instead, and is what a delegate should use when its
887    /// correctness depends on continuing to receive notifications. This method
888    /// is deliberately left behaviourally unchanged, because altering what it
889    /// returns would change the behaviour of already-deployed delegate WASM.
890    pub fn subscribe_contract(&mut self, instance_id: &[u8; 32]) -> bool {
891        #[cfg(target_family = "wasm")]
892        {
893            let result =
894                unsafe { __frnt__delegate__subscribe_contract(instance_id.as_ptr() as i64, 32) };
895            result == 0
896        }
897        #[cfg(not(target_family = "wasm"))]
898        {
899            let _ = instance_id;
900            false
901        }
902    }
903
904    /// Subscribe to contract updates, and learn whether the node actually
905    /// holds the contract the subscription is against.
906    ///
907    /// This is [`subscribe_contract`](Self::subscribe_contract) with the
908    /// outcome preserved instead of collapsed into a `bool`. Use it whenever
909    /// the delegate's correctness depends on the subscription being live — a
910    /// missed notification on a payment address is money, and the failure is
911    /// otherwise indistinguishable from "nothing has happened yet".
912    ///
913    /// [`SubscribeOutcome::NotPinned`] is a *retryable* condition, and it is
914    /// the ordinary one at startup: it clears once the node holds the state.
915    /// It is not a statement about eviction — see [`SubscribeOutcome::Pinned`],
916    /// which is deliberately not a durability promise.
917    ///
918    /// # Compatibility
919    ///
920    /// This is a **host function**, not a wire-format variant, so it costs no
921    /// bincode variant tag and is additive in both directions:
922    ///
923    /// - A delegate that does not call it is completely unaffected; host
924    ///   imports resolve by name at module instantiation, so an unimported
925    ///   function costs nothing.
926    /// - A delegate that *does* call it, on a node too old to provide it, fails
927    ///   to **instantiate** with a named missing-import error — loudly, at load
928    ///   time, before the delegate has touched any state. A wire variant
929    ///   instead fails mid-protocol at bincode decode, with no way for the
930    ///   delegate to have checked first.
931    ///
932    /// Requires a node providing `__frnt__delegate__subscribe_contract_checked`
933    /// in the `freenet_delegate_contracts` namespace. No released node does
934    /// yet; the host half is freenet-core#5565.
935    ///
936    /// # Errors
937    ///
938    /// `Err(code)` carries the negative host error code and means the
939    /// subscription did not happen at all. It is an `i64`, matching both the
940    /// host function's own return type and
941    /// [`list_subscriptions`](Self::list_subscriptions), rather than narrowing
942    /// to `i32`: the codes in [`error_codes`] all fit in `i32`, but a narrowing
943    /// conversion has to decide what to do with one that does not, and every
944    /// available answer invents a code the host never sent. A call that succeeded but did not
945    /// pin is `Ok(SubscribeOutcome::NotPinned)`, **not** an error — collapsing
946    /// those two is the defect this method exists to fix.
947    ///
948    /// Off-WASM this always returns `Err(ERR_NOT_IN_PROCESS)` rather than a
949    /// plausible-looking success, so a host-side test cannot read an outcome
950    /// out of a stub that never subscribed to anything.
951    pub fn subscribe_contract_checked(
952        &mut self,
953        instance_id: &[u8; 32],
954    ) -> Result<SubscribeOutcome, i64> {
955        #[cfg(target_family = "wasm")]
956        {
957            let code = unsafe {
958                __frnt__delegate__subscribe_contract_checked(instance_id.as_ptr() as i64, 32)
959            };
960            if code < 0 {
961                return Err(code);
962            }
963            Ok(SubscribeOutcome::from_code(code))
964        }
965        #[cfg(not(target_family = "wasm"))]
966        {
967            let _ = instance_id;
968            Err(error_codes::ERR_NOT_IN_PROCESS as i64)
969        }
970    }
971
972    /// List the contract instance ids this delegate is currently subscribed to.
973    ///
974    /// A delegate's subscription set lives in the node, not in the delegate:
975    /// the WASM is instantiated per invocation and dropped immediately after,
976    /// so between invocations the delegate has no view of it at all. Without
977    /// this call it can only keep a parallel record in its own secrets, which
978    /// drifts from the node's exactly in the cases that matter, or re-subscribe
979    /// to everything on every wake.
980    ///
981    /// # What this does not yet solve
982    ///
983    /// **Today the node does not survive a restart with its delegate
984    /// subscriptions intact — it loses them.** `DELEGATE_SUBSCRIPTIONS` is an
985    /// in-memory map (freenet-core `wasm_runtime/native_api.rs`), so after a
986    /// restart this call correctly returns `Ok(vec![])`, and a delegate should
987    /// read that as "the node is holding nothing for me", not as "my
988    /// subscriptions are gone but recoverable from somewhere else".
989    ///
990    /// So this call does **not**, on its own, deliver the restart-replay that
991    /// freenet-core#5467 asks for. It is the read side of that capability, and
992    /// it becomes load-bearing when #4669 part 3's durable
993    /// delegate-subscription store lands and there is finally something
994    /// persistent to read back. Until then its value is within a single node
995    /// lifetime: learning what the node currently holds, without guessing.
996    ///
997    /// Order is unspecified; do not depend on it.
998    ///
999    /// # What this list means
1000    ///
1001    /// It answers **"which contracts will notify me"** — not "which contracts
1002    /// am I keeping alive". Those coincide today, and coincide in the common
1003    /// case once freenet-core#4669 lands, but they are not the same thing by
1004    /// construction.
1005    ///
1006    /// A delegate subscription is two records on the node: the notification
1007    /// hook, and (after #4669) the demand registration that actually pins the
1008    /// contract. They are written and torn down together on the ordinary paths,
1009    /// but not on all of them — an eviction that sheds a still-in-use contract
1010    /// clears the demand and leaves the hook standing, and the delegate is told
1011    /// nothing. A list sourced from the hook alone would therefore report a
1012    /// contract the delegate is no longer pinning.
1013    ///
1014    /// That "looks subscribed, is not pinned" state is exactly what
1015    /// freenet-core#5467 exists to make visible, so this call must not
1016    /// reproduce it in the API meant to reveal it. The host is expected to
1017    /// answer from records it can cross-check rather than from the hook alone.
1018    /// This documentation deliberately promises the narrower meaning, so
1019    /// tightening the host's answer later is a bug fix and not a breaking
1020    /// change.
1021    ///
1022    /// Both divergences, and why the two obvious fixes are wrong, are tracked
1023    /// in freenet-core#5487. They close together at #4669 part 3's durable
1024    /// delegate-subscription store, where the two records become one with one
1025    /// owner — so introspection built against the two-record shape will want
1026    /// rewriting when that lands.
1027    ///
1028    /// # Cost
1029    ///
1030    /// The node holds delegate subscriptions keyed contract → delegates, so
1031    /// answering this is a scan across every contract carrying any delegate
1032    /// subscription, filtered to the caller — **O(all such contracts), not
1033    /// O(this delegate's subscriptions)**. Call it on wake or after a restart,
1034    /// which is what it is for; do not call it in a loop or per message.
1035    ///
1036    /// # Why this returns a `Result`
1037    ///
1038    /// An empty list and a failed enumeration mean opposite things to the
1039    /// caller — "you hold no subscriptions, take them out again" versus "I
1040    /// could not tell you" — so they must not be represented by the same
1041    /// value. Collapsing them into an empty `Vec` is how a delegate ends up
1042    /// concluding its user's content is unpinned because a host call failed.
1043    /// The error is the raw host code (see [`error_codes`]).
1044    ///
1045    /// Off-WASM this is `Err(ERR_NOT_IN_PROCESS)` rather than an empty list,
1046    /// for the same reason: a host-side unit test must not be able to read
1047    /// "no subscriptions" out of a stub that never had any.
1048    ///
1049    /// **Host requirement:** the node must register these imports in its
1050    /// wasmtime linker. That is a freenet-core change, not a stdlib one — host
1051    /// functions are registered by name and reference no stdlib type, so the
1052    /// stdlib version a node was built against guarantees nothing here. **No
1053    /// released node provides them yet.** A delegate that calls this against a
1054    /// node that does not fails to instantiate, with a named missing-import
1055    /// error — loud and diagnosable at load time rather than silently
1056    /// mid-protocol, which is the reason for choosing a host function over a
1057    /// message variant.
1058    pub fn list_subscriptions(&self) -> Result<Vec<[u8; 32]>, i64> {
1059        #[cfg(target_family = "wasm")]
1060        {
1061            // Step 1: how many bytes to allocate. Zero is a valid answer and
1062            // means "subscribed to nothing".
1063            //
1064            // The decisions live in `validate_list_len` and `resolve_written`,
1065            // which are pure and compiled on every target, so they have
1066            // host-side tests. Only the two `unsafe` host calls are wasm-only —
1067            // otherwise these branches would be reachable by nothing but a
1068            // wasm32 runtime CI does not run.
1069            let len = unsafe { __frnt__delegate__list_subscriptions_len() };
1070            let capacity = validate_list_len(len)?;
1071            if capacity == 0 {
1072                return Ok(Vec::new());
1073            }
1074
1075            let mut buf = vec![0u8; capacity];
1076            let written = unsafe {
1077                __frnt__delegate__list_subscriptions(buf.as_mut_ptr() as i64, buf.len() as i64)
1078            };
1079            buf.truncate(resolve_written(len, written)?);
1080            decode_contract_id_list(&buf).ok_or(error_codes::ERR_STORE_ERROR as i64)
1081        }
1082        #[cfg(not(target_family = "wasm"))]
1083        {
1084            Err(error_codes::ERR_NOT_IN_PROCESS as i64)
1085        }
1086    }
1087
1088    /// Ask the host to wake this delegate once `after` has elapsed.
1089    ///
1090    /// The host delivers an `InboundDelegateMsg::WakeupFired` carrying `tag`
1091    /// verbatim. `tag` is opaque to the host and is how a delegate tells its
1092    /// own wakeups apart. Re-scheduling with the same `tag` **replaces** any
1093    /// prior pending wakeup for this `(delegate, tag)` pair, which is also how
1094    /// a wakeup is cancelled early — re-arm it far enough out.
1095    ///
1096    /// Lets an always-on delegate run periodic background work (key rotation,
1097    /// TTL pruning, scheduled publication) with no UI attached, instead of
1098    /// pushing it into a client sync loop that stops when the tab closes.
1099    /// Driving use case: freenet/river#228.
1100    ///
1101    /// # Why this is a host function and not an outbound message
1102    ///
1103    /// A delegate's outbound messages are serialized as **one batch**
1104    /// (`delegate_interface.rs`, `Result<Vec<OutboundDelegateMsg>, _>` in a
1105    /// single `bincode::serialize`) and decoded whole by the host. An outbound
1106    /// variant the host does not know therefore fails the **entire batch**, so
1107    /// a delegate built against a newer stdlib, returning
1108    /// `[ApplicationMessage(reply), ScheduleWakeup{..}]` to a current-release
1109    /// node, would have **the reply discarded along with the wakeup** — the
1110    /// user's action silently doing nothing.
1111    ///
1112    /// That is the direction ordinary rollout produces every time, because
1113    /// stdlib ships before core by policy. A host function fails the other way:
1114    /// an unimported function fails at **instantiation**, with a named missing
1115    /// import, loudly and once, rather than silently and per message.
1116    ///
1117    /// `WakeupFired` remains an inbound wire variant, which has no equivalent
1118    /// hazard: a delegate that cannot schedule never receives one.
1119    ///
1120    /// # Minimum delay
1121    ///
1122    /// `after` is clamped up to [`MIN_WAKEUP_DELAY`] **by this function**, so
1123    /// `Duration::ZERO` is a one-second delay rather than a tight wake loop.
1124    /// A delegate that re-arms inside its own `WakeupFired` handler would
1125    /// otherwise spin the node — the same unbounded-work hazard a deadline in
1126    /// the past would have created.
1127    ///
1128    /// The host is expected to clamp as well, and must, since a delegate can
1129    /// bypass this wrapper entirely (see the note on [`MAX_WAKEUP_TAG_BYTES`]).
1130    /// That host-side floor is **not implemented yet** — freenet-core#3972.
1131    /// The clamp here is what makes the guarantee true of *this* API today.
1132    ///
1133    /// Nothing promises precision in the other direction: the guarantee is
1134    /// "not before".
1135    ///
1136    /// # Errors
1137    ///
1138    /// `Err(code)` carries the negative host error code.
1139    /// [`error_codes::ERR_INVALID_PARAM`] is returned without calling the host
1140    /// if `tag` exceeds [`MAX_WAKEUP_TAG_BYTES`].
1141    ///
1142    /// Requires a node providing `__frnt__delegate__schedule_wakeup` in the
1143    /// `freenet_delegate_management` namespace. No released node does yet; the
1144    /// host half is freenet-core#3972, which must also persist pending wakeups
1145    /// across a restart — see [`MIN_WAKEUP_DELAY`] for why that is stated as an
1146    /// obligation rather than a guarantee.
1147    pub fn schedule_wakeup(&mut self, after: std::time::Duration, tag: &[u8]) -> Result<(), i64> {
1148        // Everything except the FFI call lives in `prepare_wakeup`, which runs
1149        // on both targets and is unit-tested. Binding its result here is what
1150        // wires it in: delete this line and the code does not compile.
1151        let after_millis = prepare_wakeup(after, tag)?;
1152        #[cfg(target_family = "wasm")]
1153        {
1154            let code = unsafe {
1155                __frnt__delegate__schedule_wakeup(
1156                    after_millis,
1157                    tag.as_ptr() as i64,
1158                    tag.len() as i32,
1159                )
1160            };
1161            if code < 0 {
1162                return Err(code);
1163            }
1164            Ok(())
1165        }
1166        #[cfg(not(target_family = "wasm"))]
1167        {
1168            let _ = after_millis;
1169            Err(error_codes::ERR_NOT_IN_PROCESS as i64)
1170        }
1171    }
1172
1173    /// Create a new child delegate from WASM bytecode and parameters.
1174    ///
1175    /// This V2 host function allows a delegate to spawn new delegates at runtime.
1176    /// The child delegate is registered in the node's delegate store and secret store
1177    /// with the provided cipher and nonce.
1178    ///
1179    /// Returns `Ok((key_hash, code_hash))` where both are 32-byte arrays identifying
1180    /// the newly created delegate. Returns `Err(error_code)` on failure.
1181    ///
1182    /// # Resource Limits
1183    /// - Maximum creation depth: 4 (prevents fork bombs)
1184    /// - Maximum creations per process() call: 8
1185    ///
1186    /// # Error Codes
1187    /// - `-1`: Called outside process() context
1188    /// - `-4`: Invalid parameter
1189    /// - `-9`: WASM memory bounds violation
1190    /// - `-20`: Depth limit exceeded
1191    /// - `-21`: Per-call creation limit exceeded
1192    /// - `-23`: Invalid WASM module
1193    /// - `-24`: Store registration failed
1194    pub fn create_delegate(
1195        &mut self,
1196        wasm_code: &[u8],
1197        params: &[u8],
1198        cipher: &[u8; 32],
1199        nonce: &[u8; 24],
1200    ) -> Result<([u8; 32], [u8; 32]), i32> {
1201        #[cfg(target_family = "wasm")]
1202        {
1203            let mut key_buf = [0u8; 32];
1204            let mut hash_buf = [0u8; 32];
1205            let result = unsafe {
1206                __frnt__delegate__create_delegate(
1207                    wasm_code.as_ptr() as i64,
1208                    wasm_code.len() as i64,
1209                    params.as_ptr() as i64,
1210                    params.len() as i64,
1211                    cipher.as_ptr() as i64,
1212                    nonce.as_ptr() as i64,
1213                    key_buf.as_mut_ptr() as i64,
1214                    hash_buf.as_mut_ptr() as i64,
1215                )
1216            };
1217            if result == 0 {
1218                Ok((key_buf, hash_buf))
1219            } else {
1220                Err(result)
1221            }
1222        }
1223        #[cfg(not(target_family = "wasm"))]
1224        {
1225            let _ = (wasm_code, params, cipher, nonce);
1226            Err(error_codes::ERR_NOT_IN_PROCESS)
1227        }
1228    }
1229}
1230
1231impl std::fmt::Debug for DelegateCtx {
1232    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1233        f.debug_struct("DelegateCtx")
1234            .field("context_len", &self.len())
1235            .finish_non_exhaustive()
1236    }
1237}
1238
1239// ============================================================================
1240// list_subscriptions host-return validation
1241//
1242// Split out from the wasm-only body on purpose. CI runs `cargo test` on the
1243// host target only — the wasm32 matrix entries build and lint but execute
1244// nothing — so logic left inside `#[cfg(target_family = "wasm")]` is
1245// type-checked and never run. These are the branches most worth running.
1246// ============================================================================
1247
1248/// Validate the byte length the host reports before allocating on it.
1249///
1250/// Returns the capacity to allocate, or the error to hand back.
1251///
1252/// `usize` is 32 bits on wasm32, so an `as usize` cast on an unvalidated `i64`
1253/// truncates silently: a bogus `2^32` becomes `0` and surfaces as `Ok(vec![])`,
1254/// the "you hold no subscriptions" answer that this API's whole return type
1255/// exists to keep distinguishable from a failure. So the value is refused
1256/// rather than cast. The cap additionally keeps an implausible length away from
1257/// `vec![0u8; len]`, since an allocation failure inside a delegate is an abort
1258/// rather than an error anyone can return.
1259#[cfg_attr(not(target_family = "wasm"), allow(dead_code))]
1260fn validate_list_len(len: i64) -> Result<usize, i64> {
1261    if len < 0 {
1262        return Err(len);
1263    }
1264    if len % 32 != 0 || len > MAX_SUBSCRIPTION_LIST_BYTES {
1265        return Err(error_codes::ERR_STORE_ERROR as i64);
1266    }
1267    Ok(len as usize)
1268}
1269
1270/// Decide how much of the buffer to keep, given what the host reports writing.
1271///
1272/// `written < len` is accepted as a complete list: the import contract requires
1273/// the host to return `ERR_BUFFER_TOO_SMALL` rather than truncate, so a short
1274/// write means the set shrank between the two calls, not that it was cut off.
1275/// Completeness therefore rests on the host honouring that contract, which is
1276/// why the contract is stated on the import rather than left implied.
1277///
1278/// An earlier version re-called the length function whenever the buffer came
1279/// back exactly full, meaning to catch a set that had grown. That was wrong
1280/// twice over: an exactly-full buffer is the *normal* result, not an edge case,
1281/// so it doubled a scan documented as expensive on every non-empty call — and
1282/// it could fail a correct read, by reporting `ERR_BUFFER_TOO_SMALL` for a
1283/// complete list when a subscription happened to arrive in between.
1284#[cfg_attr(not(target_family = "wasm"), allow(dead_code))]
1285fn resolve_written(len: i64, written: i64) -> Result<usize, i64> {
1286    if written < 0 {
1287        return Err(written);
1288    }
1289    if written > len {
1290        // Writing past the buffer we advertised is a host bug. Left unchecked,
1291        // `truncate` is a no-op and the zero-filled tail decodes as
1292        // valid-looking all-zero contract ids.
1293        return Err(error_codes::ERR_STORE_ERROR as i64);
1294    }
1295    if written % 32 != 0 {
1296        return Err(error_codes::ERR_STORE_ERROR as i64);
1297    }
1298    Ok(written as usize)
1299}
1300
1301// ============================================================================
1302// Contract-id-list wire codec (shared host↔delegate contract for
1303// list_subscriptions)
1304// ============================================================================
1305
1306/// Serialize contract instance ids for [`DelegateCtx::list_subscriptions`]: the
1307/// raw 32 bytes of each id, back to back, with no framing.
1308///
1309/// Ids are fixed width, so unlike the secret-key list below they need no length
1310/// prefix. The encoding lives here rather than only in the host so that both
1311/// sides and the round-trip tests share one authoritative definition — a codec
1312/// written twice is a codec that will disagree with itself eventually.
1313pub fn encode_contract_id_list<'a, I>(ids: I) -> Vec<u8>
1314where
1315    I: IntoIterator<Item = &'a [u8; 32]>,
1316{
1317    let mut out = Vec::new();
1318    for id in ids {
1319        out.extend_from_slice(id);
1320    }
1321    out
1322}
1323
1324/// Decode the format written by [`encode_contract_id_list`], or `None` if the
1325/// buffer is not a whole number of ids.
1326///
1327/// This is deliberately stricter than [`decode_secret_key_list`], which
1328/// tolerates a truncated trailing record. Ids are fixed width, so a length that
1329/// is not a multiple of 32 cannot be a short read of a valid list — it is a
1330/// host-side bug or a corrupted buffer, and the only honest answer is that the
1331/// enumeration failed. Silently dropping a partial id would hand the delegate
1332/// a list that looks complete and is not, which is precisely the class of
1333/// failure this API exists to remove.
1334pub fn decode_contract_id_list(buf: &[u8]) -> Option<Vec<[u8; 32]>> {
1335    let chunks = buf.chunks_exact(32);
1336    if !chunks.remainder().is_empty() {
1337        return None;
1338    }
1339    Some(
1340        chunks
1341            .map(|chunk| {
1342                let mut id = [0u8; 32];
1343                id.copy_from_slice(chunk);
1344                id
1345            })
1346            .collect(),
1347    )
1348}
1349
1350// ============================================================================
1351// Secret-key-list wire codec (shared host↔delegate contract for list_secrets)
1352// ============================================================================
1353
1354/// Serialize a list of raw secret keys into the wire format read back by
1355/// [`decode_secret_key_list`]: for each key, a 4-byte little-endian length
1356/// followed by that many key bytes. This is the encoding the host
1357/// (`__frnt__delegate__list_secrets`) writes into the delegate's output buffer.
1358///
1359/// Kept in stdlib (rather than only in the host) so the format has exactly one
1360/// authoritative definition that both sides — and the round-trip tests — share.
1361pub fn encode_secret_key_list<'a, I>(keys: I) -> Vec<u8>
1362where
1363    I: IntoIterator<Item = &'a [u8]>,
1364{
1365    let mut buf = Vec::new();
1366    for key in keys {
1367        buf.extend_from_slice(&(key.len() as u32).to_le_bytes());
1368        buf.extend_from_slice(key);
1369    }
1370    buf
1371}
1372
1373/// Inverse of [`encode_secret_key_list`]. A truncated trailing record (which can
1374/// only happen if the buffer was clipped mid-record) is dropped rather than
1375/// panicking, so a short read degrades to "fewer keys" instead of a trap.
1376pub fn decode_secret_key_list(buf: &[u8]) -> Vec<Vec<u8>> {
1377    let mut keys = Vec::new();
1378    let mut pos = 0usize;
1379    while pos + 4 <= buf.len() {
1380        let len = u32::from_le_bytes([buf[pos], buf[pos + 1], buf[pos + 2], buf[pos + 3]]) as usize;
1381        pos += 4;
1382        if pos + len > buf.len() {
1383            // Truncated record: stop here rather than over-read.
1384            break;
1385        }
1386        keys.push(buf[pos..pos + len].to_vec());
1387        pos += len;
1388    }
1389    keys
1390}
1391
1392#[cfg(test)]
1393mod secret_key_list_codec_tests {
1394    use super::{decode_secret_key_list, encode_secret_key_list};
1395
1396    #[test]
1397    fn round_trip_multiple_keys() {
1398        let keys: Vec<&[u8]> = vec![b"room:alice", b"room:bob", b"private_key"];
1399        let encoded = encode_secret_key_list(keys.iter().copied());
1400        let decoded = decode_secret_key_list(&encoded);
1401        assert_eq!(
1402            decoded,
1403            vec![
1404                b"room:alice".to_vec(),
1405                b"room:bob".to_vec(),
1406                b"private_key".to_vec()
1407            ]
1408        );
1409    }
1410
1411    #[test]
1412    fn round_trip_empty_list() {
1413        let encoded = encode_secret_key_list(std::iter::empty::<&[u8]>());
1414        assert!(encoded.is_empty());
1415        assert!(decode_secret_key_list(&encoded).is_empty());
1416    }
1417
1418    #[test]
1419    fn round_trip_empty_key() {
1420        // A zero-length key is a legal (if unusual) record.
1421        let encoded = encode_secret_key_list([b"".as_slice()]);
1422        assert_eq!(encoded, vec![0, 0, 0, 0]);
1423        assert_eq!(decode_secret_key_list(&encoded), vec![Vec::<u8>::new()]);
1424    }
1425
1426    #[test]
1427    fn truncated_trailing_record_is_dropped() {
1428        let mut encoded = encode_secret_key_list([b"abc".as_slice(), b"defgh".as_slice()]);
1429        // Clip mid-way through the second record's payload.
1430        encoded.truncate(encoded.len() - 2);
1431        assert_eq!(decode_secret_key_list(&encoded), vec![b"abc".to_vec()]);
1432    }
1433}
1434
1435#[cfg(test)]
1436mod contract_id_list_codec_tests {
1437    use super::{decode_contract_id_list, encode_contract_id_list};
1438
1439    /// Off-WASM, `list_subscriptions` must report an error rather than an
1440    /// empty list.
1441    ///
1442    /// The whole argument for its `Result` return type is that "you hold no
1443    /// subscriptions" and "I could not tell you" must not share a
1444    /// representation. A stub that returned `Ok(vec![])` would let a host-side
1445    /// test read "no subscriptions" out of something that never had any — the
1446    /// exact conflation the type exists to prevent, reintroduced at the one
1447    /// place nobody looks.
1448    #[test]
1449    #[cfg(not(target_family = "wasm"))]
1450    fn list_subscriptions_off_wasm_is_an_error_not_an_empty_list() {
1451        // SAFETY: `__new` builds a zero-sized handle; off-WASM every method on
1452        // it takes the non-WASM branch and touches no host state.
1453        let ctx = unsafe { super::DelegateCtx::__new() };
1454        assert_eq!(
1455            ctx.list_subscriptions(),
1456            Err(super::error_codes::ERR_NOT_IN_PROCESS as i64),
1457            "off-WASM must be distinguishable from a successful empty enumeration"
1458        );
1459    }
1460
1461    #[test]
1462    fn round_trips_multiple_ids() {
1463        let ids = [[0x01u8; 32], [0xFEu8; 32], [0x00u8; 32]];
1464        let encoded = encode_contract_id_list(ids.iter());
1465        assert_eq!(
1466            encoded.len(),
1467            96,
1468            "ids are fixed width, so the encoding carries no framing"
1469        );
1470        assert_eq!(decode_contract_id_list(&encoded), Some(ids.to_vec()));
1471    }
1472
1473    #[test]
1474    fn round_trips_the_empty_list() {
1475        let encoded = encode_contract_id_list(std::iter::empty::<&[u8; 32]>());
1476        assert!(encoded.is_empty());
1477        assert_eq!(
1478            decode_contract_id_list(&encoded),
1479            Some(vec![]),
1480            "an empty buffer is a successful enumeration of nothing, NOT an error — \
1481             a delegate must be able to tell those apart"
1482        );
1483    }
1484
1485    #[test]
1486    fn rejects_a_truncated_trailing_id() {
1487        let mut encoded = encode_contract_id_list([&[0xAAu8; 32], &[0xBBu8; 32]]);
1488        encoded.truncate(encoded.len() - 1);
1489        assert_eq!(
1490            decode_contract_id_list(&encoded),
1491            None,
1492            "a partial id means the buffer is wrong; returning the ids that did parse \
1493             would hand the caller a list that looks complete and is not"
1494        );
1495    }
1496}
1497
1498#[cfg(test)]
1499mod list_subscriptions_guard_tests {
1500    use super::{error_codes, resolve_written, validate_list_len, MAX_SUBSCRIPTION_LIST_BYTES};
1501
1502    const STORE_ERR: i64 = error_codes::ERR_STORE_ERROR as i64;
1503
1504    #[test]
1505    fn a_negative_length_is_passed_through_as_the_host_error() {
1506        assert_eq!(
1507            validate_list_len(error_codes::ERR_NOT_IN_PROCESS as i64),
1508            Err(error_codes::ERR_NOT_IN_PROCESS as i64),
1509            "a host error code must reach the caller unchanged, not be reshaped"
1510        );
1511    }
1512
1513    #[test]
1514    fn zero_is_a_successful_empty_enumeration() {
1515        assert_eq!(
1516            validate_list_len(0),
1517            Ok(0),
1518            "zero means 'subscribed to nothing' and must NOT be an error"
1519        );
1520    }
1521
1522    #[test]
1523    fn a_length_that_is_not_a_whole_number_of_ids_is_refused() {
1524        assert_eq!(validate_list_len(33), Err(STORE_ERR));
1525        assert_eq!(validate_list_len(31), Err(STORE_ERR));
1526    }
1527
1528    #[test]
1529    fn an_implausible_length_is_refused_before_it_reaches_an_allocation() {
1530        assert_eq!(
1531            validate_list_len(MAX_SUBSCRIPTION_LIST_BYTES + 32),
1532            Err(STORE_ERR)
1533        );
1534        assert_eq!(
1535            validate_list_len(MAX_SUBSCRIPTION_LIST_BYTES),
1536            Ok(MAX_SUBSCRIPTION_LIST_BYTES as usize),
1537            "the cap itself is allowed"
1538        );
1539    }
1540
1541    /// The wasm32 truncation this guard exists for: `usize` is 32 bits there,
1542    /// so `2^32 as usize` is `0` and would have surfaced as an empty list.
1543    #[test]
1544    fn a_length_that_would_truncate_to_zero_on_wasm32_is_refused() {
1545        assert_eq!(validate_list_len(1i64 << 32), Err(STORE_ERR));
1546    }
1547
1548    #[test]
1549    fn a_negative_write_is_passed_through_as_the_host_error() {
1550        assert_eq!(
1551            resolve_written(320, error_codes::ERR_STORE_ERROR as i64),
1552            Err(STORE_ERR)
1553        );
1554    }
1555
1556    #[test]
1557    fn writing_past_the_advertised_buffer_is_refused() {
1558        assert_eq!(
1559            resolve_written(320, 352),
1560            Err(STORE_ERR),
1561            "truncate would be a no-op, leaving a zero-filled tail to decode as \
1562             valid-looking all-zero contract ids"
1563        );
1564    }
1565
1566    #[test]
1567    fn a_partial_id_written_is_refused() {
1568        assert_eq!(resolve_written(320, 300), Err(STORE_ERR));
1569    }
1570
1571    #[test]
1572    fn an_exactly_full_buffer_is_the_normal_case_and_is_accepted() {
1573        assert_eq!(
1574            resolve_written(320, 320),
1575            Ok(320),
1576            "len comes from the same set the read serialises, so exactly-full is \
1577             the ordinary outcome — treating it as suspicious cost a second scan \
1578             on every call and could fail a correct read"
1579        );
1580    }
1581
1582    #[test]
1583    fn a_short_write_is_accepted_as_a_set_that_shrank() {
1584        assert_eq!(
1585            resolve_written(320, 288),
1586            Ok(288),
1587            "the import contract requires ERR_BUFFER_TOO_SMALL rather than \
1588             truncation, so a short write means the set shrank"
1589        );
1590    }
1591}
1592
1593#[cfg(test)]
1594mod subscribe_outcome_tests {
1595    use super::*;
1596
1597    #[test]
1598    fn known_codes_decode_to_their_outcomes() {
1599        assert_eq!(
1600            SubscribeOutcome::from_code(SubscribeOutcome::CODE_PINNED),
1601            SubscribeOutcome::Pinned
1602        );
1603        assert_eq!(
1604            SubscribeOutcome::from_code(SubscribeOutcome::CODE_NOT_PINNED),
1605            SubscribeOutcome::NotPinned
1606        );
1607    }
1608
1609    /// The two discriminants are part of the host ABI. Changing either
1610    /// reassigns the meaning of a value already returned by deployed nodes, so
1611    /// they are pinned rather than left to whatever order the enum happens to
1612    /// be written in.
1613    #[test]
1614    fn outcome_codes_are_pinned() {
1615        assert_eq!(SubscribeOutcome::CODE_PINNED, 0);
1616        assert_eq!(SubscribeOutcome::CODE_NOT_PINNED, 1);
1617    }
1618
1619    /// An outcome added by a newer node must not be read as a pin. This is the
1620    /// whole point of the type: the failure being removed is a delegate
1621    /// concluding it holds durable interest when it does not.
1622    #[test]
1623    fn an_unknown_outcome_is_not_read_as_pinned() {
1624        let future = SubscribeOutcome::from_code(7);
1625        assert_eq!(future, SubscribeOutcome::Unrecognized(7));
1626        assert!(
1627            !future.is_pinned(),
1628            "an outcome this build cannot interpret must never report as pinned"
1629        );
1630    }
1631
1632    /// The type must be re-exported from the prelude. `use
1633    /// freenet_stdlib::prelude::*` is what a delegate writes, and the whole
1634    /// point of this type is to be matched on — needing a second, differently
1635    /// shaped import for the match arms would be a papercut on every consumer.
1636    ///
1637    /// The path is named in full, deliberately. A `use crate::prelude::*` here
1638    /// would prove nothing: this module already has `use super::*` in scope, so
1639    /// the name resolves whether or not the prelude re-exports it, and the test
1640    /// passes with the re-export deleted. Verified — the first version of this
1641    /// test did exactly that.
1642    #[test]
1643    fn the_type_is_re_exported_from_the_prelude() {
1644        let outcome = crate::prelude::SubscribeOutcome::from_code(
1645            crate::prelude::SubscribeOutcome::CODE_NOT_PINNED,
1646        );
1647        assert!(matches!(
1648            outcome,
1649            crate::prelude::SubscribeOutcome::NotPinned
1650        ));
1651    }
1652
1653    #[test]
1654    fn only_pinned_reports_pinned() {
1655        assert!(SubscribeOutcome::Pinned.is_pinned());
1656        assert!(!SubscribeOutcome::NotPinned.is_pinned());
1657        assert!(!SubscribeOutcome::Unrecognized(1_000).is_pinned());
1658    }
1659
1660    /// Off-WASM the host function does not exist, so the stub must report an
1661    /// error and never a plausible-looking outcome. A stub returning
1662    /// `Ok(Pinned)` would let a host-side test read a pin out of a call that
1663    /// subscribed to nothing — the same "absence reported as fact" defect the
1664    /// method exists to remove, reintroduced in the test harness. This mirrors
1665    /// `list_subscriptions_off_wasm_is_an_error_not_an_empty_list`.
1666    #[test]
1667    fn the_off_wasm_stub_reports_an_error_not_an_outcome() {
1668        // SAFETY: off-WASM every method on this handle is a stub that touches
1669        // no runtime state; the safety contract concerns the WASM execution
1670        // environment, which does not exist in a host-side test.
1671        let mut ctx = unsafe { DelegateCtx::__new() };
1672        let result = ctx.subscribe_contract_checked(&[0u8; 32]);
1673        assert_eq!(
1674            result,
1675            Err(error_codes::ERR_NOT_IN_PROCESS as i64),
1676            "the off-WASM stub must not fabricate a subscribe outcome"
1677        );
1678    }
1679}
1680
1681#[cfg(test)]
1682mod schedule_wakeup_guard_tests {
1683    use super::*;
1684
1685    /// The cap is applied before the host call so a well-behaved caller fails
1686    /// fast with a clear error rather than a remote one.
1687    ///
1688    /// **This is convenience, not a bound, and the host must check too.**
1689    /// `__frnt__delegate__schedule_wakeup` is an ordinary WASM import: any
1690    /// delegate can declare its own `extern "C"` block for
1691    /// `freenet_delegate_management` and pass a 10 MB tag without ever
1692    /// constructing a `DelegateCtx`. The guest controls its own imports, so no
1693    /// guest-side check can ever bound what the host receives. Only the host
1694    /// can make 128 bytes a limit — freenet-core#3972.
1695    ///
1696    /// That holds for every bound this crate documents, not just this one.
1697    #[test]
1698    fn an_oversized_tag_is_refused_without_calling_the_host() {
1699        // SAFETY: off-WASM every method on this handle is a stub touching no
1700        // runtime state; the safety contract concerns the WASM environment.
1701        let mut ctx = unsafe { DelegateCtx::__new() };
1702        let too_big = vec![0u8; MAX_WAKEUP_TAG_BYTES + 1];
1703        assert_eq!(
1704            ctx.schedule_wakeup(std::time::Duration::from_secs(60), &too_big),
1705            Err(error_codes::ERR_INVALID_PARAM as i64),
1706            "a tag over the cap must be refused as an invalid parameter"
1707        );
1708    }
1709
1710    /// A tag exactly at the cap is legal. Pinned because an off-by-one here
1711    /// silently narrows the usable tag space rather than failing loudly.
1712    #[test]
1713    fn a_tag_exactly_at_the_cap_is_not_refused_for_being_too_big() {
1714        let mut ctx = unsafe { DelegateCtx::__new() };
1715        let exactly = vec![0u8; MAX_WAKEUP_TAG_BYTES];
1716        // Off-WASM this reaches the stub, so the error must be the stub's
1717        // "not in process" and *not* the size rejection above.
1718        assert_eq!(
1719            ctx.schedule_wakeup(std::time::Duration::from_secs(60), &exactly),
1720            Err(error_codes::ERR_NOT_IN_PROCESS as i64),
1721            "a tag exactly at the cap must pass the size check"
1722        );
1723    }
1724
1725    /// Off-WASM the host function does not exist, so the stub must report an
1726    /// error and never a plausible success — a stub returning `Ok(())` would
1727    /// let a host-side test read "wakeup scheduled" out of a call that
1728    /// scheduled nothing.
1729    #[test]
1730    fn the_off_wasm_stub_reports_an_error_not_success() {
1731        let mut ctx = unsafe { DelegateCtx::__new() };
1732        assert_eq!(
1733            ctx.schedule_wakeup(std::time::Duration::from_secs(60), b"rotate"),
1734            Err(error_codes::ERR_NOT_IN_PROCESS as i64),
1735            "the off-WASM stub must not report a scheduled wakeup"
1736        );
1737    }
1738
1739    /// The prologue as one unit: cap, clamp, ordering, and the millisecond
1740    /// conversion. `schedule_wakeup` is a thin wrapper over `prepare_wakeup`,
1741    /// so these cover everything in that function except the `extern "C"` call.
1742    #[test]
1743    fn prepare_returns_the_clamped_delay_in_milliseconds() {
1744        assert_eq!(
1745            prepare_wakeup(std::time::Duration::ZERO, b"t"),
1746            Ok(MIN_WAKEUP_DELAY.as_millis() as i64),
1747            "a zero delay must reach the host as the floor, not as zero"
1748        );
1749        assert_eq!(
1750            prepare_wakeup(std::time::Duration::from_secs(604_800), b"t"),
1751            Ok(604_800_000),
1752            "a week must survive the conversion unchanged"
1753        );
1754    }
1755
1756    /// An oversized tag is refused even when the delay *also* needs correcting,
1757    /// so the two checks cannot mask each other.
1758    ///
1759    /// This deliberately does **not** claim to pin their order. Swapping them
1760    /// is an equivalent mutant — verified: moving the tag check below the clamp
1761    /// leaves all seven tests passing — because `clamp_wakeup_delay` is pure,
1762    /// infallible, and its result is discarded on the error path. There is no
1763    /// observation that distinguishes the two orders, so no test can, and an
1764    /// ordering test here would be a name asserting coverage it does not have.
1765    #[test]
1766    fn an_oversized_tag_is_refused_even_when_the_delay_also_needs_clamping() {
1767        let too_big = vec![0u8; MAX_WAKEUP_TAG_BYTES + 1];
1768        assert_eq!(
1769            prepare_wakeup(std::time::Duration::ZERO, &too_big),
1770            Err(error_codes::ERR_INVALID_PARAM as i64),
1771            "an oversized tag must be refused even when the delay also needs correcting"
1772        );
1773    }
1774
1775    /// `as_millis` is `u128`. Saturating rather than wrapping is what stops an
1776    /// absurd delay becoming a small or negative one at the FFI boundary — a
1777    /// wrap here would turn "never" into "immediately". This conversion sat
1778    /// inside the `cfg(target_family = "wasm")` block before `prepare_wakeup`
1779    /// existed, so nothing executed it.
1780    #[test]
1781    fn an_absurd_delay_saturates_rather_than_wrapping() {
1782        assert_eq!(
1783            prepare_wakeup(std::time::Duration::MAX, b"t"),
1784            Ok(i64::MAX),
1785            "a delay past i64::MAX ms must saturate, never wrap to a small value"
1786        );
1787    }
1788
1789    /// The floor is a documented host contract, so the constant is pinned:
1790    /// a delegate author relies on it, and silently lowering it would let the
1791    /// tight-wake-loop hazard back in without any test noticing.
1792    #[test]
1793    fn the_documented_bounds_are_pinned() {
1794        assert_eq!(MAX_WAKEUP_TAG_BYTES, 128);
1795        assert_eq!(MIN_WAKEUP_DELAY, std::time::Duration::from_secs(1));
1796    }
1797}
1798
1799#[cfg(test)]
1800mod wakeup_delay_clamp_tests {
1801    use super::*;
1802    use std::time::Duration;
1803
1804    /// `Duration::ZERO` is the case the floor exists for: a delegate re-arming
1805    /// inside its own `WakeupFired` handler with no delay spins the node.
1806    #[test]
1807    fn zero_is_raised_to_the_floor() {
1808        assert_eq!(clamp_wakeup_delay(Duration::ZERO), MIN_WAKEUP_DELAY);
1809    }
1810
1811    #[test]
1812    fn anything_below_the_floor_is_raised() {
1813        assert_eq!(
1814            clamp_wakeup_delay(Duration::from_millis(1)),
1815            MIN_WAKEUP_DELAY
1816        );
1817        assert_eq!(
1818            clamp_wakeup_delay(MIN_WAKEUP_DELAY - Duration::from_nanos(1)),
1819            MIN_WAKEUP_DELAY
1820        );
1821    }
1822
1823    /// The floor must not become a rounding-up of ordinary delays. A week is a
1824    /// week, and exactly-the-floor is already legal — clamping is `<`, not
1825    /// `<=`, so a caller asking for precisely the minimum is not perturbed.
1826    #[test]
1827    fn the_floor_and_anything_above_it_pass_through_unchanged() {
1828        assert_eq!(clamp_wakeup_delay(MIN_WAKEUP_DELAY), MIN_WAKEUP_DELAY);
1829        let week = Duration::from_secs(604_800);
1830        assert_eq!(clamp_wakeup_delay(week), week);
1831        assert_eq!(clamp_wakeup_delay(Duration::MAX), Duration::MAX);
1832    }
1833}