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}