Skip to main content

dpp_plugin_sdk/
lib.rs

1//! Guest-side SDK for Odal Node Wasm sector plugins.
2//!
3//! A sector plugin author implements the [`DppSectorPlugin`] trait from
4//! `dpp-plugin-traits` and invokes [`export_plugin!`] once. The macro generates
5//! the full low-level Wasm ABI (`alloc`, `dealloc`, `metadata`, `describe`,
6//! `validate`, `calculate_metrics`, `generate_passport`) and wires each export
7//! to the corresponding trait method. Plugins no longer hand-roll the ABI shim
8//! or redefine their own output structs — they speak the shared contract.
9//!
10//! ## Why `describe()`
11//!
12//! The host calls `describe()` immediately after loading a plugin to read its
13//! [`PluginCapabilities`](dpp_plugin_traits::PluginCapabilities) (ABI version, supported schema versions, feature
14//! capabilities) and run `dpp_plugin_traits::check_compatibility` *before*
15//! dispatching any work. This is what makes the version registry enforceable at
16//! the Wasm boundary rather than aspirational.
17//!
18//! ## ABI summary
19//!
20//! | Export | Signature | Returns (JSON) |
21//! |--------|-----------|----------------|
22//! | `alloc` | `(len: u32) -> u32` | pointer to `len` bytes |
23//! | `dealloc` | `(ptr: u32, len: u32)` | — |
24//! | `metadata` | `() -> u64` | [`PluginMeta`](dpp_plugin_traits::PluginMeta) |
25//! | `describe` | `() -> u64` | [`PluginCapabilities`](dpp_plugin_traits::PluginCapabilities) |
26//! | `validate` | `(ptr: u32, len: u32) -> u64` | [`AbiResult`] (`ok: null` / `error`) |
27//! | `calculate_metrics` | `(ptr: u32, len: u32) -> u64` | [`AbiResult`] (`ok: PluginResult`) |
28//! | `generate_passport` | `(ptr: u32, len: u32) -> u64` | [`AbiResult`] (`ok: payload`) |
29//!
30//! Every `-> u64` return packs the output buffer as `(out_ptr << 32) | out_len`.
31//! The host reads the JSON, then frees the buffer via `dealloc`.
32
33/// Re-export of the shared host/guest contract so plugins need only one
34/// path dependency.
35pub use dpp_plugin_traits as traits;
36
37/// Re-export of the shared cross-field regulatory rules ([`dpp_rules`]), so a
38/// plugin uses the same rule implementation as `dpp-domain` rather than
39/// reimplementing it.
40pub use dpp_rules as rules;
41
42pub mod validate;
43
44use dpp_plugin_traits::{AbiResult, DppSectorPlugin, PluginError, PluginInput};
45pub use dpp_plugin_traits::{
46    METRIC_CO2E_SCORE, METRIC_RECYCLED_CONTENT_PCT, METRIC_REPAIRABILITY_INDEX,
47    PluginComplianceStatus,
48};
49use serde::Serialize;
50
51// ─── Low-level linear-memory ABI ──────────────────────────────────────────────
52
53pub mod abi {
54    use std::alloc::{Layout, alloc as mem_alloc, dealloc as mem_dealloc};
55
56    /// Allocate `len` bytes in the module's linear memory and return the
57    /// pointer as a `u32`. Returns `0` for a zero-length request.
58    #[must_use]
59    pub fn host_alloc(len: u32) -> u32 {
60        if len == 0 {
61            return 0;
62        }
63        let layout = Layout::from_size_align(len as usize, 1).expect("valid layout");
64        // SAFETY: `layout` has non-zero size; a null return is handled by the host.
65        unsafe { mem_alloc(layout) as u32 }
66    }
67
68    /// Free a buffer previously returned by [`host_alloc`] (or packed into a
69    /// `-> u64` ABI return). No-op for null pointers or zero length.
70    pub fn host_dealloc(ptr: u32, len: u32) {
71        if ptr == 0 || len == 0 {
72            return;
73        }
74        let layout = Layout::from_size_align(len as usize, 1).expect("valid layout");
75        // SAFETY: `ptr`/`len` must describe a buffer from `host_alloc`.
76        unsafe { mem_dealloc(ptr as *mut u8, layout) }
77    }
78
79    /// View the host-written input buffer as a byte slice.
80    ///
81    /// # Safety
82    ///
83    /// `ptr` and `len` must describe a single allocation written by the host
84    /// (via `alloc`) that lives for the duration of the returned borrow.
85    #[must_use]
86    pub unsafe fn read_input<'a>(ptr: u32, len: u32) -> &'a [u8] {
87        unsafe {
88            if len == 0 {
89                return &[];
90            }
91            std::slice::from_raw_parts(ptr as *const u8, len as usize)
92        }
93    }
94
95    /// Leak `bytes` into linear memory and return the packed
96    /// `(ptr << 32) | len` the host uses to read and later free it.
97    ///
98    /// The buffer is shrunk to an exact-size allocation (`capacity == len`,
99    /// align 1) so that the host's `dealloc(ptr, len)` frees precisely the
100    /// allocation it was given. Returning a `Vec` directly would leak its
101    /// (possibly larger) capacity and make `dealloc` a size-mismatched free.
102    #[must_use]
103    pub fn write_output(bytes: Vec<u8>) -> u64 {
104        let mut boxed = bytes.into_boxed_slice();
105        let out_len = boxed.len() as u32;
106        let out_ptr = boxed.as_mut_ptr() as usize as u32;
107        std::mem::forget(boxed);
108        ((out_ptr as u64) << 32) | (out_len as u64)
109    }
110}
111
112// ─── Pure glue (host-testable, no linear-memory side effects) ─────────────────
113
114fn to_bytes<T: Serialize>(value: &T) -> Vec<u8> {
115    serde_json::to_vec(value).unwrap_or_default()
116}
117
118fn parse_input(bytes: &[u8]) -> Result<PluginInput, PluginError> {
119    serde_json::from_slice(bytes).map_err(|e| PluginError::InvalidInput(e.to_string()))
120}
121
122/// Serialise the plugin's [`PluginMeta`](dpp_plugin_traits::PluginMeta) to JSON bytes.
123pub fn metadata_bytes<P: DppSectorPlugin>(plugin: &P) -> Vec<u8> {
124    to_bytes(&plugin.meta())
125}
126
127/// Serialise the plugin's [`PluginCapabilities`](dpp_plugin_traits::PluginCapabilities) to JSON bytes.
128pub fn describe_bytes<P: DppSectorPlugin>(plugin: &P) -> Vec<u8> {
129    to_bytes(&plugin.capabilities())
130}
131
132/// Run `validate_input` and serialise the [`AbiResult`] envelope.
133pub fn validate_bytes<P: DppSectorPlugin>(plugin: &P, input: &[u8]) -> Vec<u8> {
134    let outcome = match parse_input(input) {
135        Ok(value) => match plugin.validate_input(&value) {
136            Ok(()) => AbiResult::Ok(serde_json::Value::Null),
137            Err(e) => AbiResult::Error(e),
138        },
139        Err(e) => AbiResult::Error(e),
140    };
141    to_bytes(&outcome)
142}
143
144/// Run `calculate_metrics` and serialise the [`AbiResult`] envelope.
145pub fn calculate_metrics_bytes<P: DppSectorPlugin>(plugin: &P, input: &[u8]) -> Vec<u8> {
146    let outcome = match parse_input(input) {
147        Ok(value) => match plugin.calculate_metrics(&value) {
148            Ok(result) => AbiResult::ok(&result),
149            Err(e) => AbiResult::Error(e),
150        },
151        Err(e) => AbiResult::Error(e),
152    };
153    to_bytes(&outcome)
154}
155
156/// Run `generate_passport` and serialise the [`AbiResult`] envelope.
157pub fn generate_passport_bytes<P: DppSectorPlugin>(plugin: &P, input: &[u8]) -> Vec<u8> {
158    let outcome = match parse_input(input) {
159        Ok(value) => match plugin.generate_passport(&value) {
160            Ok(payload) => AbiResult::Ok(payload),
161            Err(e) => AbiResult::Error(e),
162        },
163        Err(e) => AbiResult::Error(e),
164    };
165    to_bytes(&outcome)
166}
167
168// ─── ABI entry-point wrappers (called by `export_plugin!`) ────────────────────
169
170pub fn run_metadata<P: DppSectorPlugin>(plugin: &P) -> u64 {
171    abi::write_output(metadata_bytes(plugin))
172}
173
174pub fn run_describe<P: DppSectorPlugin>(plugin: &P) -> u64 {
175    abi::write_output(describe_bytes(plugin))
176}
177
178/// # Safety
179/// `ptr`/`len` must describe a host-written input buffer (see [`abi::read_input`]).
180pub unsafe fn run_validate<P: DppSectorPlugin>(plugin: &P, ptr: u32, len: u32) -> u64 {
181    unsafe { abi::write_output(validate_bytes(plugin, abi::read_input(ptr, len))) }
182}
183
184/// # Safety
185/// `ptr`/`len` must describe a host-written input buffer (see [`abi::read_input`]).
186pub unsafe fn run_calculate_metrics<P: DppSectorPlugin>(plugin: &P, ptr: u32, len: u32) -> u64 {
187    unsafe { abi::write_output(calculate_metrics_bytes(plugin, abi::read_input(ptr, len))) }
188}
189
190/// # Safety
191/// `ptr`/`len` must describe a host-written input buffer (see [`abi::read_input`]).
192pub unsafe fn run_generate_passport<P: DppSectorPlugin>(plugin: &P, ptr: u32, len: u32) -> u64 {
193    unsafe { abi::write_output(generate_passport_bytes(plugin, abi::read_input(ptr, len))) }
194}
195
196// ─── Export macro ─────────────────────────────────────────────────────────────
197
198/// Generate the full Wasm ABI for a sector plugin.
199///
200/// `$plugin` must implement [`DppSectorPlugin`](dpp_plugin_traits::DppSectorPlugin)
201/// and [`Default`] (plugins are deterministic and stateless, so the instance is
202/// constructed per call). Invoke once at the crate root:
203///
204/// ```ignore
205/// use dpp_plugin_sdk::{export_plugin, traits::*};
206///
207/// #[derive(Default)]
208/// struct BatteryPlugin;
209/// impl DppSectorPlugin for BatteryPlugin { /* ... */ }
210///
211/// export_plugin!(BatteryPlugin);
212/// ```
213#[macro_export]
214macro_rules! export_plugin {
215    ($plugin:ty) => {
216        #[unsafe(no_mangle)]
217        pub extern "C" fn alloc(len: u32) -> u32 {
218            $crate::abi::host_alloc(len)
219        }
220
221        #[unsafe(no_mangle)]
222        pub extern "C" fn dealloc(ptr: u32, len: u32) {
223            $crate::abi::host_dealloc(ptr, len)
224        }
225
226        #[unsafe(no_mangle)]
227        pub extern "C" fn metadata() -> u64 {
228            $crate::run_metadata(&<$plugin as ::core::default::Default>::default())
229        }
230
231        #[unsafe(no_mangle)]
232        pub extern "C" fn describe() -> u64 {
233            $crate::run_describe(&<$plugin as ::core::default::Default>::default())
234        }
235
236        #[unsafe(no_mangle)]
237        pub extern "C" fn validate(ptr: u32, len: u32) -> u64 {
238            // SAFETY: the host guarantees `ptr`/`len` describe a buffer it wrote via `alloc`.
239            unsafe {
240                $crate::run_validate(&<$plugin as ::core::default::Default>::default(), ptr, len)
241            }
242        }
243
244        #[unsafe(no_mangle)]
245        pub extern "C" fn calculate_metrics(ptr: u32, len: u32) -> u64 {
246            // SAFETY: the host guarantees `ptr`/`len` describe a buffer it wrote via `alloc`.
247            unsafe {
248                $crate::run_calculate_metrics(
249                    &<$plugin as ::core::default::Default>::default(),
250                    ptr,
251                    len,
252                )
253            }
254        }
255
256        #[unsafe(no_mangle)]
257        pub extern "C" fn generate_passport(ptr: u32, len: u32) -> u64 {
258            // SAFETY: the host guarantees `ptr`/`len` describe a buffer it wrote via `alloc`.
259            unsafe {
260                $crate::run_generate_passport(
261                    &<$plugin as ::core::default::Default>::default(),
262                    ptr,
263                    len,
264                )
265            }
266        }
267    };
268}
269
270// ─── Tests ────────────────────────────────────────────────────────────────────
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use dpp_plugin_traits::{
276        AbiVersion, METRIC_CO2E_SCORE, PluginCapabilities, PluginCapability,
277        PluginComplianceStatus, PluginFieldError, PluginMeta, PluginResult, SchemaVersionRange,
278    };
279    use serde_json::{Value, json};
280
281    /// Minimal plugin exercising every glue path.
282    #[derive(Default)]
283    struct DummyPlugin;
284
285    impl DppSectorPlugin for DummyPlugin {
286        fn meta(&self) -> PluginMeta {
287            PluginMeta {
288                sector: "dummy".into(),
289                name: "Dummy".into(),
290                version: "0.1.0".into(),
291                license: "Apache-2.0".into(),
292                description: None,
293                author: None,
294                homepage: None,
295            }
296        }
297
298        fn capabilities(&self) -> PluginCapabilities {
299            PluginCapabilities {
300                abi_version: AbiVersion::current(),
301                supported_schemas: vec![SchemaVersionRange {
302                    min_version: "1.0.0".into(),
303                    max_version: "1.0.0".into(),
304                }],
305                capabilities: vec![PluginCapability::ComputeMetrics],
306                min_host_version: None,
307                max_fuel: None,
308                max_memory_bytes: None,
309            }
310        }
311
312        fn validate_input(&self, input: &PluginInput) -> Result<(), PluginError> {
313            if input.get("ok").is_some() {
314                Ok(())
315            } else {
316                Err(PluginError::ValidationErrors(vec![PluginFieldError {
317                    field: "/ok".into(),
318                    code: "missing".into(),
319                    message: "ok is required".into(),
320                }]))
321            }
322        }
323
324        fn calculate_metrics(&self, input: &PluginInput) -> Result<PluginResult, PluginError> {
325            self.validate_input(input)?;
326            Ok(PluginResult::new(PluginComplianceStatus::NotAssessed)
327                .maybe_metric(METRIC_CO2E_SCORE, input.get("co2e").and_then(Value::as_f64)))
328        }
329
330        fn generate_passport(&self, input: &PluginInput) -> Result<Value, PluginError> {
331            self.validate_input(input)?;
332            Ok(input.clone())
333        }
334    }
335
336    fn parse(bytes: &[u8]) -> Value {
337        serde_json::from_slice(bytes).expect("glue emits valid JSON")
338    }
339
340    #[test]
341    fn describe_emits_capabilities() {
342        let json = parse(&describe_bytes(&DummyPlugin));
343        assert_eq!(json["abiVersion"]["major"], 1);
344        assert!(json["supportedSchemas"].is_array());
345        // Round-trips back into the typed contract the host uses.
346        let back: PluginCapabilities = serde_json::from_value(json).unwrap();
347        assert_eq!(back.abi_version, AbiVersion::current());
348    }
349
350    #[test]
351    fn metadata_emits_meta() {
352        let json = parse(&metadata_bytes(&DummyPlugin));
353        assert_eq!(json["sector"], "dummy");
354    }
355
356    #[test]
357    fn calculate_metrics_ok_envelope() {
358        let input = json!({ "ok": true, "co2e": 42.0 });
359        let json = parse(&calculate_metrics_bytes(&DummyPlugin, &to_bytes(&input)));
360        assert_eq!(json["ok"]["metrics"]["co2e_score"], 42.0);
361        assert_eq!(json["ok"]["complianceStatus"], "NOT_ASSESSED");
362    }
363
364    #[test]
365    fn calculate_metrics_validation_error_envelope() {
366        let input = json!({ "co2e": 42.0 }); // missing "ok"
367        let json = parse(&calculate_metrics_bytes(&DummyPlugin, &to_bytes(&input)));
368        assert!(json.get("error").is_some());
369        assert!(json.get("ok").is_none());
370    }
371
372    #[test]
373    fn validate_error_on_malformed_json() {
374        let json = parse(&validate_bytes(&DummyPlugin, b"not json {{{"));
375        let back: AbiResult = serde_json::from_value(json).unwrap();
376        assert!(!back.is_ok());
377    }
378
379    #[test]
380    fn validate_ok_envelope_is_null() {
381        let input = json!({ "ok": true });
382        let json = parse(&validate_bytes(&DummyPlugin, &to_bytes(&input)));
383        assert!(json["ok"].is_null());
384    }
385
386    #[test]
387    fn generate_passport_passthrough() {
388        let input = json!({ "ok": true, "gtin": "12345678901231" });
389        let json = parse(&generate_passport_bytes(&DummyPlugin, &to_bytes(&input)));
390        assert_eq!(json["ok"]["gtin"], "12345678901231");
391    }
392
393    #[test]
394    fn validate_error_when_input_parses_but_is_rejected() {
395        // Valid JSON, but DummyPlugin rejects it (missing "ok") — exercises the
396        // parse-ok-but-validation-error arm, distinct from malformed JSON.
397        let input = json!({ "missing": "ok" });
398        let json = parse(&validate_bytes(&DummyPlugin, &to_bytes(&input)));
399        assert!(json.get("error").is_some());
400        assert!(json.get("ok").is_none());
401    }
402
403    #[test]
404    fn generate_passport_error_when_input_parses_but_is_rejected() {
405        let input = json!({ "missing": "ok" });
406        let json = parse(&generate_passport_bytes(&DummyPlugin, &to_bytes(&input)));
407        assert!(json.get("error").is_some());
408        assert!(json.get("ok").is_none());
409    }
410
411    // Note: the `write_output`/`read_input` packing uses 32-bit pointers and is
412    // only valid on `wasm32` (host pointers are 64-bit and would truncate). The
413    // host-testable surface is the pure `*_bytes` glue exercised above.
414
415    // ── `export_plugin!` macro expansion (host-target coverage) ──────────────
416    //
417    // The macro generates `extern "C"` wrappers that the host calls across the
418    // Wasm boundary. We can exercise the *expansion itself* on the host without
419    // a Wasm runtime: every wrapper delegates to host-testable glue, and the
420    // input-taking exports are driven with `len == 0`, which `read_input`
421    // short-circuits to an empty slice — so no host pointer is ever
422    // dereferenced. The 32-bit pointer truncation only affects the packed
423    // `out_ptr` high bits; the `out_len` low 32 bits are exact, so we assert the
424    // wrapper packs the same buffer length the glue produces.
425    export_plugin!(DummyPlugin);
426
427    /// Low 32 bits of a packed `(out_ptr << 32) | out_len` ABI return.
428    fn out_len(packed: u64) -> usize {
429        (packed & 0xFFFF_FFFF) as usize
430    }
431
432    #[test]
433    fn macro_alloc_dealloc_are_callable() {
434        // Zero-length alloc returns a null pointer without allocating.
435        assert_eq!(alloc(0), 0);
436        // Non-zero alloc returns a (truncated-on-host) pointer; the allocation
437        // is intentionally leaked — the truncated u32 cannot be safely freed on
438        // a 64-bit host, and dealloc's no-op path is covered below.
439        let _ = alloc(8);
440        // dealloc's null/zero guard is the only branch safe to drive on host.
441        dealloc(0, 0);
442    }
443
444    #[test]
445    fn macro_metadata_and_describe_pack_glue_output() {
446        assert_eq!(out_len(metadata()), metadata_bytes(&DummyPlugin).len());
447        assert_eq!(out_len(describe()), describe_bytes(&DummyPlugin).len());
448    }
449
450    #[test]
451    fn macro_input_exports_pack_error_envelope_for_empty_input() {
452        // `(ptr, 0)` → read_input yields `&[]` (it short-circuits on len == 0
453        // and never dereferences the pointer) → parse error → Error envelope.
454        assert_eq!(
455            out_len(validate(0, 0)),
456            validate_bytes(&DummyPlugin, &[]).len()
457        );
458        assert_eq!(
459            out_len(calculate_metrics(0, 0)),
460            calculate_metrics_bytes(&DummyPlugin, &[]).len()
461        );
462        assert_eq!(
463            out_len(generate_passport(0, 0)),
464            generate_passport_bytes(&DummyPlugin, &[]).len()
465        );
466    }
467}