diff --git a/Cargo.lock b/Cargo.lock index 66cd371..9ea703b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -354,8 +354,11 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" name = "core" version = "0.1.0" dependencies = [ + "dlopen2", "hex", "hex-literal", + "jet_plugin_abi", + "script_jets", "simplicity-lang", "thiserror", ] @@ -422,6 +425,29 @@ dependencies = [ "syn", ] +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "elements" version = "0.27.0" @@ -927,6 +953,13 @@ version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010" +[[package]] +name = "jet_plugin_abi" +version = "0.1.0" +dependencies = [ + "simplicity-sys", +] + [[package]] name = "js-sys" version = "0.3.83" @@ -1318,6 +1351,13 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "script_jets" +version = "0.1.0" +dependencies = [ + "jet_plugin_abi", +] + [[package]] name = "secp256k1" version = "0.29.1" @@ -2016,6 +2056,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 01edd17..0e97841 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,8 @@ resolver = "2" members = [ "cli", "core", + "jet_plugin_abi", + "plugin_tests/script_jets", "service", ] diff --git a/core/Cargo.toml b/core/Cargo.toml index acf6851..84cee35 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -13,3 +13,8 @@ hex-literal = "1.1.0" simplicity-lang = { workspace = true } thiserror = { workspace = true } hex = { workspace = true } +dlopen2 = "0.8.2" +jet_plugin_abi = { path = "../jet_plugin_abi" } + +[dev-dependencies] +script_jets = { path = "../plugin_tests/script_jets" } diff --git a/core/src/jets/base.rs b/core/src/jets/base.rs index 8539f6b..4e3eb56 100644 --- a/core/src/jets/base.rs +++ b/core/src/jets/base.rs @@ -1,15 +1,17 @@ -//! Runs base jets (`Core`, `Elements`) on an [`UnchainedEnv`]. +//! Runs base jets on an environment that wraps their own. //! //! `JetEnvironment::c_jet_ptr` must return a plain `fn` pointer, so a base jet can't be wrapped in -//! a closure that swaps the unchained env for the base C env. Instead, every base jet gets its own +//! a closure that swaps the outer env for the base C env. Instead, every base jet gets its own //! trampoline, a `fn` monomorphized over the jet's index in `ALL`. use simplicity::ffi::CFrameItem; use simplicity::jet::{Core, CoreEnv, Elements, ElementsTxEnv, Jet, JetEnvironment}; -use super::environments::UnchainedEnv; +use super::bitcoin::CoreExtension; +use super::elements::ElementsExtension; +use super::environments::{BitcoinUnchainedEnv, ElementsUnchainedEnv, UnchainedEnv}; -/// C jet function running on an [`UnchainedEnv`]. -pub type UnchainedJetFn = fn(&mut CFrameItem, CFrameItem, &UnchainedEnv) -> bool; +/// C jet function running on an environment of type `W`. +pub type JetFn = fn(&mut CFrameItem, CFrameItem, &W) -> bool; /// Upper bound on the number of jets in a base jet set, see [`fn_table`]. const MAX_BASE_JETS: usize = 512; @@ -25,6 +27,17 @@ pub trait BaseJet: Jet + Copy { fn index(&self) -> usize; } +/// An environment that holds the environment of a base jet set. +pub trait AsBaseEnv { + fn base_env(&self) -> &E; +} + +impl AsBaseEnv for UnchainedEnv { + fn base_env(&self) -> &E { + &self.env + } +} + impl BaseJet for Core { type Env = CoreEnv; const ALL: &'static [Self] = &Core::ALL; @@ -43,23 +56,49 @@ impl BaseJet for Elements { } } -/// Returns the C implementation of a base jet, adapted to run on an [`UnchainedEnv`]. -pub fn base_c_jet_ptr(jet: &J) -> UnchainedJetFn { +impl BaseJet for CoreExtension { + type Env = BitcoinUnchainedEnv; + const ALL: &'static [Self] = &CoreExtension::ALL; + + fn index(&self) -> usize { + match self { + CoreExtension::Core(inner) => inner.index(), + CoreExtension::GetOpcodeFromScript => Core::ALL.len(), + CoreExtension::GetPubkeyFromScript => Core::ALL.len() + 1, + } + } +} + +impl BaseJet for ElementsExtension { + type Env = ElementsUnchainedEnv; + const ALL: &'static [Self] = &ElementsExtension::ALL; + + fn index(&self) -> usize { + match self { + ElementsExtension::Elements(inner) => inner.index(), + ElementsExtension::GetOpcodeFromScript => Elements::ALL.len(), + ElementsExtension::GetPubkeyFromScript => Elements::ALL.len() + 1, + } + } +} + +/// Returns the C implementation of a base jet, adapted to run on `W`. +pub fn base_c_jet_ptr>(jet: &J) -> JetFn { const { assert!(J::ALL.len() <= MAX_BASE_JETS) }; - Trampolines::::TABLE.as_flattened().as_flattened()[jet.index()] + Trampolines::::TABLE.as_flattened().as_flattened()[jet.index()] } -fn trampoline( +fn trampoline, const I: usize>( dst: &mut CFrameItem, src: CFrameItem, - env: &UnchainedEnv, + env: &W, ) -> bool { let c_jet = ::c_jet_ptr(&J::ALL[I]); - c_jet(dst, src, env.env.c_jet_env()) + c_jet(dst, src, env.base_env().c_jet_env()) } -/// Builds a `[[[_; 8]; 8]; 8]` table of `$f::` for every `I` below [`MAX_BASE_JETS`]. +/// Builds a `[[[_; 8]; 8]; 8]` table of `$f::` for every `I` below [`MAX_BASE_JETS`]. // TODO: refactor into something less ugly, e.g. a flat table generated by a proc macro. macro_rules! fn_table { ($f:ident) => { @@ -72,28 +111,32 @@ macro_rules! fn_table { [$(fn_table!(@c $f; $a; $b; 0 1 2 3 4 5 6 7)),*] }; (@c $f:ident; $a:literal; $b:literal; $($c:literal)*) => { - [$($f:: as UnchainedJetFn),*] + [$($f:: as JetFn),*] }; } -struct Trampolines(J); +struct Trampolines(J, W); -impl Trampolines { +impl> Trampolines { #[allow(clippy::type_complexity)] - const TABLE: [[[UnchainedJetFn; 8]; 8]; 8] = fn_table!(trampoline); + const TABLE: [[[JetFn; 8]; 8]; 8] = fn_table!(trampoline); } #[cfg(test)] mod tests { use super::*; - #[test] - fn index_matches_position_in_all() { - for (i, jet) in Core::ALL.iter().enumerate() { - assert_eq!(jet.index(), i, "{jet}"); - } - for (i, jet) in Elements::ALL.iter().enumerate() { + fn assert_indices() { + for (i, jet) in J::ALL.iter().enumerate() { assert_eq!(jet.index(), i, "{jet}"); } } + + #[test] + fn index_matches_position_in_all() { + assert_indices::(); + assert_indices::(); + assert_indices::(); + assert_indices::(); + } } diff --git a/core/src/jets/mod.rs b/core/src/jets/mod.rs index 1bc69a0..c0794c0 100644 --- a/core/src/jets/mod.rs +++ b/core/src/jets/mod.rs @@ -5,7 +5,7 @@ pub mod environments; pub mod exec; #[cfg(test)] -mod tests { +pub(crate) mod tests { use std::ptr; use std::sync::Arc; @@ -141,7 +141,7 @@ mod tests { } } - fn dummy_elements_env() -> ElementsEnv> { + pub(crate) fn dummy_elements_env() -> ElementsEnv> { let ctrl_blk: [u8; 33] = [ 0xc0, 0xeb, 0x04, 0xb6, 0x8e, 0x9a, 0x26, 0xd1, 0x16, 0x04, 0x6c, 0x76, 0xe8, 0xff, 0x47, 0x33, 0x2f, 0xb7, 0x1d, 0xda, 0x90, 0xff, 0x4b, 0xef, 0x53, 0x70, 0xf2, 0x52, diff --git a/core/src/lib.rs b/core/src/lib.rs index 7347029..f2271fc 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -4,6 +4,7 @@ use simplicity::bitcoin::NetworkKind; use simplicity::elements::BlockHash; pub mod jets; +pub mod plugin; pub mod program; pub mod runner; pub mod utils; diff --git a/core/src/plugin/README.md b/core/src/plugin/README.md new file mode 100644 index 0000000..91cdc4a --- /dev/null +++ b/core/src/plugin/README.md @@ -0,0 +1,15 @@ +# Custom jet plugins + +## General plugin loading flow + +1. The plugin provider writes its jets as `unsafe extern "C"` functions and lists them in + `jet_plugin_abi::register_jets!`. The macro creates a static, plain-data descriptor + (ABI version, `simplicity-sys` version, base jet set) with an array of jets, each holding + its name, types, cost, encoding, CMR and function pointer, and exports a function + returning it from the dynamic library. +2. The host uses `declare_plugin!` to declare a handle type and generate its `PluginTrait` + impl. `Handle::load(path, sha256)` then loads the library, reads its descriptor, validates + it and stores the result in the handle for the rest of the process. +3. Programs are parsed and run with `PluginJet` and `PluginEnv`. Base jets run + through the host's own implementation; custom jets are called through the function + pointers from the descriptor. diff --git a/core/src/plugin/decode.rs b/core/src/plugin/decode.rs new file mode 100644 index 0000000..cd0ad86 --- /dev/null +++ b/core/src/plugin/decode.rs @@ -0,0 +1,145 @@ +//! Prefix tree over jet bit encodings. +//! +//! Built once at load from the base jets' encodings and the plugin's codes, so decoding never +//! needs to rewind the bit iterator and encoding conflicts are found while inserting. +use simplicity::{BitIter, decode}; + +/// Position of a node in `DecodeTrie::nodes`. +#[derive(Clone, Copy)] +struct NodeIdx(usize); + +const ROOT: NodeIdx = NodeIdx(0); + +#[derive(Default)] +struct Node { + /// Next node after reading bit 0. + zero: Option, + /// Next node after reading bit 1. + one: Option, + jet: Option, +} + +impl Node { + fn child(&self, bit: bool) -> Option { + if bit { self.one } else { self.zero } + } + + fn child_mut(&mut self, bit: bool) -> &mut Option { + if bit { &mut self.one } else { &mut self.zero } + } +} + +pub(crate) struct DecodeTrie { + nodes: Vec, +} + +impl DecodeTrie { + pub(crate) fn new() -> Self { + Self { + nodes: vec![Node::default()], + } + } + + fn node(&self, idx: NodeIdx) -> &Node { + &self.nodes[idx.0] + } + + fn node_mut(&mut self, idx: NodeIdx) -> &mut Node { + &mut self.nodes[idx.0] + } + + /// Inserts `jet` under `bits`. On conflict returns a jet whose encoding is a prefix of + /// `bits` or has `bits` as a prefix. + pub(crate) fn insert( + &mut self, + bits: impl IntoIterator, + jet: usize, + ) -> Result<(), usize> { + let mut node = ROOT; + for bit in bits { + if let Some(other) = self.node(node).jet { + return Err(other); + } + node = match self.node(node).child(bit) { + Some(child) => child, + None => { + let child = NodeIdx(self.nodes.len()); + self.nodes.push(Node::default()); + *self.node_mut(node).child_mut(bit) = Some(child); + child + } + }; + } + if let Some(other) = self.node(node).jet { + return Err(other); + } + if let Some(other) = self.any_jet_below(node) { + return Err(other); + } + self.node_mut(node).jet = Some(jet); + Ok(()) + } + + fn any_jet_below(&self, node: NodeIdx) -> Option { + let mut stack = vec![node]; + while let Some(node) = stack.pop() { + if let Some(jet) = self.node(node).jet { + return Some(jet); + } + let node = self.node(node); + stack.extend(node.zero); + stack.extend(node.one); + } + None + } + + /// Reads one jet encoding from `bits`. + pub(crate) fn decode>( + &self, + bits: &mut BitIter, + ) -> Result { + let mut node = ROOT; + loop { + if let Some(jet) = self.node(node).jet { + return Ok(jet); + } + let bit = bits.next().ok_or(decode::Error::EndOfStream)?; + node = self + .node(node) + .child(bit) + .ok_or(decode::Error::InvalidJet)?; + } + } +} + +/// The low `len` bits of `code`, most significant first. +pub(crate) fn code_bits(code: u64, len: u8) -> impl Iterator { + (0..len).rev().map(move |i| (code >> i) & 1 == 1) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_prefix_conflicts() { + let mut trie = DecodeTrie::new(); + trie.insert(code_bits(0b10, 2), 0).unwrap(); + assert_eq!(trie.insert(code_bits(0b101, 3), 1), Err(0)); + assert_eq!(trie.insert(code_bits(0b1, 1), 2), Err(0)); + assert_eq!(trie.insert(code_bits(0b10, 2), 3), Err(0)); + trie.insert(code_bits(0b11, 2), 4).unwrap(); + } + + #[test] + fn decodes_inserted_codes() { + let mut trie = DecodeTrie::new(); + trie.insert(code_bits(0b0, 1), 0).unwrap(); + trie.insert(code_bits(0b10, 2), 1).unwrap(); + + let bytes = [0b1000_0000]; + let mut bits = BitIter::from(&bytes[..]); + assert_eq!(trie.decode(&mut bits).unwrap(), 1); + assert_eq!(trie.decode(&mut bits).unwrap(), 0); + } +} diff --git a/core/src/plugin/env.rs b/core/src/plugin/env.rs new file mode 100644 index 0000000..a4698d8 --- /dev/null +++ b/core/src/plugin/env.rs @@ -0,0 +1,119 @@ +use jet_plugin_abi::utils::Bytes; +use jet_plugin_abi::{BaseSet, JetEnv}; +use simplicity::elements::encode::serialize; +use simplicity::ffi::CFrameItem; +use simplicity::jet::JetEnvironment; + +use super::PluginTrait; +use super::jet::PluginJet; +use super::loaded::CustomJet; +use super::trampolines::CustomJetSlots; +use crate::jets::base::{AsBaseEnv, BaseJet, base_c_jet_ptr}; +use crate::jets::bitcoin::CoreExtension; +use crate::jets::elements::ElementsExtension; + +/// A base jet set plugins can extend, with the data custom jets get to see. +pub trait PluginBase: BaseJet { + /// The ABI identifier of this set. + const ABI_BASE_SET: BaseSet; + + fn redeem_script(env: &Self::Env) -> &[u8]; + + /// Consensus-serialized spending transaction, empty if the environment has none. + fn tx(env: &Self::Env) -> Vec; + + fn input_index(env: &Self::Env) -> u32; +} + +impl PluginBase for CoreExtension { + const ABI_BASE_SET: BaseSet = BaseSet::Bitcoin; + + fn redeem_script(env: &Self::Env) -> &[u8] { + env.redeem_script.as_bytes() + } + + fn tx(_env: &Self::Env) -> Vec { + Vec::new() + } + + fn input_index(_env: &Self::Env) -> u32 { + 0 + } +} + +impl PluginBase for ElementsExtension { + const ABI_BASE_SET: BaseSet = BaseSet::Elements; + + fn redeem_script(env: &Self::Env) -> &[u8] { + env.redeem_script.as_bytes() + } + + fn tx(env: &Self::Env) -> Vec { + serialize(env.env.tx()) + } + + fn input_index(env: &Self::Env) -> u32 { + env.env.ix() + } +} + +/// Environment for programs using the jets of plugin `S`. +pub struct PluginEnv { + inner: ::Env, + tx: Vec, +} + +impl PluginEnv { + pub fn new(inner: ::Env) -> Self { + let tx = S::Base::tx(&inner); + Self { inner, tx } + } + + pub fn inner(&self) -> &::Env { + &self.inner + } + + pub(super) fn call(&self, jet: &CustomJet, dst: &mut CFrameItem, src: CFrameItem) -> bool { + let env = JetEnv { + redeem_script: bytes(S::Base::redeem_script(&self.inner)), + tx: bytes(&self.tx), + input_index: S::Base::input_index(&self.inner), + }; + + // SAFETY: `env` outlives the call; plugins are operator-trusted to follow the ABI, + // including staying within their frames. + unsafe { (jet.func)(dst, src, &env) } + } +} + +impl AsBaseEnv<::Env> for PluginEnv { + fn base_env(&self) -> &::Env { + &self.inner + } +} + +impl JetEnvironment for PluginEnv { + type Jet = PluginJet; + type CJetEnvironment = Self; + + fn c_jet_env(&self) -> &Self::CJetEnvironment { + self + } + + fn c_jet_ptr( + jet: &Self::Jet, + ) -> fn(&mut CFrameItem, CFrameItem, &Self::CJetEnvironment) -> bool { + let base = ::ALL; + match jet.index().checked_sub(base.len()) { + None => base_c_jet_ptr::(&base[jet.index()]), + Some(k) => CustomJetSlots::::TABLE[k], + } + } +} + +fn bytes(slice: &[u8]) -> Bytes { + Bytes { + ptr: slice.as_ptr(), + len: slice.len(), + } +} diff --git a/core/src/plugin/jet.rs b/core/src/plugin/jet.rs new file mode 100644 index 0000000..51ec89a --- /dev/null +++ b/core/src/plugin/jet.rs @@ -0,0 +1,162 @@ +use std::cmp::Ordering; +use std::fmt; +use std::hash::{Hash, Hasher}; +use std::io::Write; +use std::marker::PhantomData; + +use simplicity::jet::Jet; +use simplicity::jet::type_name::TypeName; +use simplicity::{BitIter, BitWriter, Cmr, Cost, decode}; + +use super::PluginTrait; +use super::loaded::{CustomJet, LoadedPlugin}; +use crate::jets::base::BaseJet; + +/// A jet of plugin `S`: a base jet of `S::Base` or one of the plugin's custom jets. +/// +/// Values only exist once `S` is loaded: they are created by decoding, parsing or [`all`]. +/// +/// [`all`]: PluginJet::all +pub struct PluginJet { + /// Position in `S::Base::ALL`, followed by the custom jets in descriptor order. + index: usize, + _plugin: PhantomData S>, +} + +enum Kind { + Base(&'static B), + Custom(&'static CustomJet), +} + +impl PluginJet { + fn new(index: usize) -> Self { + Self { + index, + _plugin: PhantomData, + } + } + + fn loaded() -> &'static LoadedPlugin { + S::get().expect("a PluginJet only exists once its plugin is loaded") + } + + fn kind(&self) -> Kind { + let base = ::ALL; + match self.index.checked_sub(base.len()) { + None => Kind::Base(&base[self.index]), + Some(k) => Kind::Custom(&Self::loaded().custom[k]), + } + } + + pub(crate) fn index(&self) -> usize { + self.index + } + + /// All base and custom jets of `S`, or nothing if `S` is not loaded. + pub fn all() -> Vec { + let Some(plugin) = S::get() else { + return vec![]; + }; + (0..plugin.base_len() + plugin.custom.len()) + .map(Self::new) + .collect() + } +} + +impl Jet for PluginJet { + fn cmr(&self) -> Cmr { + match self.kind() { + Kind::Base(jet) => jet.cmr(), + Kind::Custom(jet) => jet.cmr, + } + } + + fn source_ty(&self) -> TypeName { + match self.kind() { + Kind::Base(jet) => jet.source_ty(), + Kind::Custom(jet) => jet.source_ty.clone(), + } + } + + fn target_ty(&self) -> TypeName { + match self.kind() { + Kind::Base(jet) => jet.target_ty(), + Kind::Custom(jet) => jet.target_ty.clone(), + } + } + + fn encode(&self, w: &mut BitWriter<&mut dyn Write>) -> std::io::Result { + match self.kind() { + Kind::Base(jet) => jet.encode(w), + Kind::Custom(jet) => w.write_bits_be(jet.code, usize::from(jet.code_len)), + } + } + + fn decode>(bits: &mut BitIter) -> Result { + let plugin = S::get().ok_or(decode::Error::InvalidJet)?; + plugin.trie.decode(bits).map(Self::new) + } + + fn cost(&self) -> Cost { + match self.kind() { + Kind::Base(jet) => jet.cost(), + Kind::Custom(jet) => jet.cost, + } + } + + fn parse(s: &str) -> Result { + S::get() + .and_then(|plugin| plugin.names.get(s)) + .map(|&index| Self::new(index)) + .ok_or_else(|| simplicity::Error::InvalidJetName(s.into())) + } +} + +impl fmt::Display for PluginJet { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.kind() { + Kind::Base(jet) => fmt::Display::fmt(jet, f), + Kind::Custom(jet) => f.write_str(jet.name), + } + } +} + +impl fmt::Debug for PluginJet { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "PluginJet({self})") + } +} + +impl Clone for PluginJet { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for PluginJet {} + +impl PartialEq for PluginJet { + fn eq(&self, other: &Self) -> bool { + self.index == other.index + } +} + +impl Eq for PluginJet {} + +impl PartialOrd for PluginJet { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for PluginJet { + fn cmp(&self, other: &Self) -> Ordering { + self.index.cmp(&other.index) + } +} + +impl Hash for PluginJet { + fn hash(&self, state: &mut H) { + self.index.hash(state) + } +} diff --git a/core/src/plugin/loaded.rs b/core/src/plugin/loaded.rs new file mode 100644 index 0000000..d671e14 --- /dev/null +++ b/core/src/plugin/loaded.rs @@ -0,0 +1,295 @@ +//! Validation of plugin descriptors into owned, host-side jet tables. +use std::collections::{HashMap, HashSet}; + +use jet_plugin_abi::{ + ABI_VERSION, BaseSet, CustomJetDesc, JetFn, PluginDescriptor, SIMPLICITY_SYS_VERSION, +}; +use simplicity::jet::Jet; +use simplicity::jet::type_name::TypeName; +use simplicity::{BitWriter, Cmr, Cost}; + +use super::decode::{DecodeTrie, code_bits}; +use super::env::PluginBase; + +/// Maximum number of custom jets in one plugin, see `trampolines::CustomTrampolines`. +pub const MAX_CUSTOM_JETS: usize = 32; + +const MAX_NAME_LEN: usize = 64; +const MAX_TYPE_NAME_LEN: usize = 256; +const MAX_CODE_LEN: u8 = 64; + +#[derive(Debug, thiserror::Error)] +pub enum LoadError { + #[error("failed to read plugin file: {0}")] + Io(#[from] std::io::Error), + + #[error("plugin file hash {found} does not match pinned hash {expected}")] + HashMismatch { expected: String, found: String }, + + #[error("failed to open plugin: {0}")] + Open(#[from] dlopen2::Error), + + #[error("plugin returned a null descriptor")] + NullDescriptor, + + #[error("plugin ABI version {found}, host expects {expected}")] + AbiVersion { expected: u32, found: u32 }, + + #[error("plugin links simplicity-sys {found}, host links {expected}")] + SimplicitySysVersion { + expected: &'static str, + found: String, + }, + + #[error("plugin extends unknown base set {0}")] + UnknownBaseSet(u32), + + #[error("plugin extends base set {found:?}, host expects {expected:?}")] + BaseSet { expected: BaseSet, found: BaseSet }, + + #[error("plugin declares {found} jets, at most {max} are supported")] + TooManyJets { max: usize, found: usize }, + + #[error("plugin declares jets through a null pointer")] + NullJets, + + #[error("custom jet #{index} has an invalid name")] + InvalidName { index: usize }, + + #[error("custom jet `{name}` is already defined")] + DuplicateName { name: String }, + + #[error("custom jet `{name}` has an invalid type `{ty}`")] + InvalidType { name: String, ty: String }, + + #[error("custom jet `{name}` has an invalid cost {cost}")] + InvalidCost { name: String, cost: u64 }, + + #[error("custom jet `{name}` has an invalid encoding")] + InvalidCode { name: String }, + + #[error("custom jet `{name}` encoding conflicts with `{other}`")] + EncodingConflict { name: String, other: String }, + + #[error("custom jet `{name}` CMR is already used by `{other}`")] + DuplicateCmr { name: String, other: String }, + + #[error("custom jet `{name}` has no implementation")] + NullFunction { name: String }, + + #[error("a plugin is already loaded into this handle")] + AlreadyLoaded, +} + +pub(crate) struct CustomJet { + pub(crate) name: &'static str, + pub(crate) cmr: Cmr, + pub(crate) source_ty: TypeName, + pub(crate) target_ty: TypeName, + pub(crate) cost: Cost, + pub(crate) code: u64, + pub(crate) code_len: u8, + pub(crate) func: JetFn, +} + +/// A validated plugin: its custom jets plus lookup tables over the base and custom jets. +pub struct LoadedPlugin { + base_set: BaseSet, + base_len: usize, + pub(crate) custom: Vec, + pub(crate) names: HashMap, + pub(crate) trie: DecodeTrie, +} + +impl LoadedPlugin { + /// Validates `desc` as an extension of the base jet set `B`. + /// + /// Strings are copied and leaked, as plugins are never unloaded; `func` pointers stay + /// pointing into the plugin. + /// + /// # Safety + /// + /// `desc` must be null or point to a descriptor whose pointers are valid for the lengths + /// they are paired with, and whose functions follow the ABI for as long as the returned + /// value lives. + pub unsafe fn from_descriptor( + desc: *const PluginDescriptor, + ) -> Result { + let desc = unsafe { desc.as_ref() }.ok_or(LoadError::NullDescriptor)?; + + if desc.abi_version != ABI_VERSION { + return Err(LoadError::AbiVersion { + expected: ABI_VERSION, + found: desc.abi_version, + }); + } + let sys_version = unsafe { desc.simplicity_sys_version.as_slice() }; + if sys_version != SIMPLICITY_SYS_VERSION.as_bytes() { + return Err(LoadError::SimplicitySysVersion { + expected: SIMPLICITY_SYS_VERSION, + found: String::from_utf8_lossy(sys_version).into_owned(), + }); + } + let base_set = BaseSet::try_from(desc.base_set).map_err(LoadError::UnknownBaseSet)?; + if base_set != B::ABI_BASE_SET { + return Err(LoadError::BaseSet { + expected: B::ABI_BASE_SET, + found: base_set, + }); + } + if desc.jets_len > MAX_CUSTOM_JETS { + return Err(LoadError::TooManyJets { + max: MAX_CUSTOM_JETS, + found: desc.jets_len, + }); + } + let descs: &[CustomJetDesc] = if desc.jets_len == 0 { + &[] + } else if desc.jets.is_null() { + return Err(LoadError::NullJets); + } else { + unsafe { std::slice::from_raw_parts(desc.jets, desc.jets_len) } + }; + + let base_len = B::ALL.len(); + let mut names = HashMap::new(); + let mut cmrs = HashMap::new(); + let mut trie = DecodeTrie::new(); + for (index, jet) in B::ALL.iter().enumerate() { + names.insert(jet.to_string(), index); + cmrs.insert(jet.cmr(), jet.to_string()); + trie.insert(encoding(jet), index) + .expect("base jet encodings are prefix-free"); + } + + let mut custom: Vec = Vec::with_capacity(descs.len()); + let mut seen = HashSet::new(); + for (k, jet) in descs.iter().enumerate() { + let name = validate_name(unsafe { jet.name.as_slice() }) + .ok_or(LoadError::InvalidName { index: k })?; + if !seen.insert(name) || names.contains_key(name) { + return Err(LoadError::DuplicateName { name: name.into() }); + } + + let source_ty = validate_type(name, unsafe { jet.source_ty.as_slice() })?; + let target_ty = validate_type(name, unsafe { jet.target_ty.as_slice() })?; + + let cost = u32::try_from(jet.cost_milliweight) + .ok() + .filter(|&cost| cost > 0) + .map(Cost::from_milliweight) + .ok_or(LoadError::InvalidCost { + name: name.into(), + cost: jet.cost_milliweight, + })?; + + if jet.code_len == 0 + || jet.code_len > MAX_CODE_LEN + || (jet.code_len < 64 && jet.code >> jet.code_len != 0) + { + return Err(LoadError::InvalidCode { name: name.into() }); + } + + let func = jet + .func + .ok_or(LoadError::NullFunction { name: name.into() })?; + + // TODO: once the host derives CMRs (see `CustomJetDesc::cmr`), compute it here + // instead of trusting the plugin. + let cmr = Cmr::from_byte_array(jet.cmr); + if let Some(other) = cmrs.insert(cmr, name.to_string()) { + return Err(LoadError::DuplicateCmr { + name: name.into(), + other, + }); + } + + let index = base_len + k; + trie.insert(code_bits(jet.code, jet.code_len), index) + .map_err(|other| LoadError::EncodingConflict { + name: name.into(), + other: match other.checked_sub(base_len) { + None => B::ALL[other].to_string(), + Some(k) => custom[k].name.to_string(), + }, + })?; + + let name: &'static str = Box::leak(name.into()); + names.insert(name.to_string(), index); + custom.push(CustomJet { + name, + cmr, + source_ty, + target_ty, + cost, + code: jet.code, + code_len: jet.code_len, + func, + }); + } + + Ok(Self { + base_set, + base_len, + custom, + names, + trie, + }) + } + + /// The base jet set this plugin extends. + pub fn base_set(&self) -> BaseSet { + self.base_set + } + + pub(crate) fn base_len(&self) -> usize { + self.base_len + } + + /// Names of the custom jets, in descriptor order. + pub fn custom_names(&self) -> impl Iterator + '_ { + self.custom.iter().map(|jet| jet.name) + } +} + +fn encoding(jet: &J) -> Vec { + let mut bytes: Vec = vec![]; + let mut writer = BitWriter::new(&mut bytes as &mut dyn std::io::Write); + let len = jet + .encode(&mut writer) + .expect("writing to a Vec never fails"); + writer.flush_all().expect("writing to a Vec never fails"); + (0..len) + .map(|i| (bytes[i / 8] & (0x80 >> (i % 8))) != 0) + .collect() +} + +fn validate_name(name: &[u8]) -> Option<&str> { + let valid = !name.is_empty() + && name.len() <= MAX_NAME_LEN + && name + .iter() + .all(|&c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'_'); + valid.then(|| std::str::from_utf8(name).expect("checked to be ASCII")) +} + +/// Checks `ty` against the `TypeName` grammar, whose parser panics on bad input. +fn validate_type(name: &str, ty: &[u8]) -> Result { + let mut depth = 0usize; + let mut valid = !ty.is_empty() && ty.len() <= MAX_TYPE_NAME_LEN; + for &c in ty.iter().rev() { + match c { + b'1' | b'2' | b'c' | b's' | b'i' | b'l' | b'h' => depth += 1, + b'+' | b'*' if depth >= 2 => depth -= 1, + _ => valid = false, + } + } + if !valid || depth != 1 { + return Err(LoadError::InvalidType { + name: name.into(), + ty: String::from_utf8_lossy(ty).into_owned(), + }); + } + + Ok(TypeName(Box::leak(ty.into()))) +} diff --git a/core/src/plugin/loader.rs b/core/src/plugin/loader.rs new file mode 100644 index 0000000..e13aac5 --- /dev/null +++ b/core/src/plugin/loader.rs @@ -0,0 +1,35 @@ +use std::path::Path; + +use dlopen2::raw::Library; +use jet_plugin_abi::{DESCRIPTOR_SYMBOL, DescriptorFn}; +use simplicity::hashes::sha256; + +use super::env::PluginBase; +use super::loaded::{LoadError, LoadedPlugin}; + +/// Opens the plugin at `path` and validates it as an extension of `B`. +/// +/// The library is never closed: custom jet functions point into it. +pub(crate) fn load( + path: &Path, + sha256: Option<[u8; 32]>, +) -> Result { + if let Some(expected) = sha256 { + // TODO: the file can still change between hashing and opening; open from the hashed + // bytes (e.g. a memfd) if plugins ever stop being operator-trusted. + let found = sha256::Hash::hash(&std::fs::read(path)?).to_byte_array(); + if found != expected { + return Err(LoadError::HashMismatch { + expected: hex::encode(expected), + found: hex::encode(found), + }); + } + } + + let library = Library::open(path)?; + let describe: DescriptorFn = unsafe { library.symbol(DESCRIPTOR_SYMBOL) }?; + // SAFETY: plugins are operator-trusted to follow the ABI. + let plugin = unsafe { LoadedPlugin::from_descriptor::(describe()) }?; + std::mem::forget(library); + Ok(plugin) +} diff --git a/core/src/plugin/mod.rs b/core/src/plugin/mod.rs new file mode 100644 index 0000000..c763cb3 --- /dev/null +++ b/core/src/plugin/mod.rs @@ -0,0 +1,95 @@ +//! Custom jets loaded at runtime from dynamic libraries. +//! +//! A plugin extends one base jet set ([`CoreExtension`] or [`ElementsExtension`]) with custom +//! jets described by a [`jet_plugin_abi::PluginDescriptor`]. The host owns all Simplicity-facing +//! types: [`PluginJet`] and [`PluginEnv`] implement `Jet` and `JetEnvironment` for a plugin +//! declared with [`declare_plugin!`], base jets keep running through the host's own +//! implementation, and custom jets are called through the C ABI. +//! +//! [`CoreExtension`]: crate::jets::bitcoin::CoreExtension +//! [`ElementsExtension`]: crate::jets::elements::ElementsExtension +mod decode; +mod env; +mod jet; +mod loaded; +mod loader; +#[cfg(test)] +mod tests; +mod trampolines; + +use std::fmt::Debug; +use std::hash::Hash; +use std::path::Path; +use std::sync::OnceLock; + +pub use env::{PluginBase, PluginEnv}; +pub use jet::PluginJet; +pub use loaded::{LoadError, LoadedPlugin, MAX_CUSTOM_JETS}; + +/// A plugin handle declared with [`declare_plugin!`]. +/// +/// Each handle holds at most one plugin for the lifetime of the process; plugins are never +/// unloaded. +pub trait PluginTrait: Copy + Ord + Hash + Debug + Send + Sync + 'static { + /// The base jet set the plugin extends. + type Base: PluginBase; + + #[doc(hidden)] + fn cell() -> &'static OnceLock; + + /// The loaded plugin, if any. + fn get() -> Option<&'static LoadedPlugin> { + Self::cell().get() + } + + /// Loads the dynamic library at `path` into this handle. + /// + /// When `sha256` is given, the file must hash to it; `None` skips the check and is meant for + /// development only. + fn load(path: impl AsRef, sha256: Option<[u8; 32]>) -> Result<(), LoadError> { + if Self::get().is_some() { + return Err(LoadError::AlreadyLoaded); + } + let plugin = loader::load::(path.as_ref(), sha256)?; + Self::install(plugin) + } + + /// Installs an already validated plugin into this handle. + fn install(plugin: LoadedPlugin) -> Result<(), LoadError> { + let expected = ::ABI_BASE_SET; + if plugin.base_set() != expected { + return Err(LoadError::BaseSet { + expected, + found: plugin.base_set(), + }); + } + Self::cell() + .set(plugin) + .map_err(|_| LoadError::AlreadyLoaded) + } +} + +/// Declares a plugin handle type extending the given base jet set. +/// +/// ```ignore +/// declare_plugin!(pub Foo: ElementsExtension); +/// Foo::load("libfoo.so", Some(sha256))?; +/// let program = Program::>::from_str(program, witness)?; +/// ``` +#[macro_export] +macro_rules! declare_plugin { + ($vis:vis $name:ident : $base:ty) => { + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] + $vis struct $name; + + impl $crate::plugin::PluginTrait for $name { + type Base = $base; + + fn cell() -> &'static ::std::sync::OnceLock<$crate::plugin::LoadedPlugin> { + static CELL: ::std::sync::OnceLock<$crate::plugin::LoadedPlugin> = + ::std::sync::OnceLock::new(); + &CELL + } + } + }; +} diff --git a/core/src/plugin/tests.rs b/core/src/plugin/tests.rs new file mode 100644 index 0000000..49e3810 --- /dev/null +++ b/core/src/plugin/tests.rs @@ -0,0 +1,70 @@ +use std::sync::Arc; + +use simplicity::elements::Script; +use simplicity::jet::Jet; +use simplicity::node::CoreConstructible; +use simplicity::{BitMachine, ConstructNode, Word, types}; + +use super::{LoadError, PluginEnv, PluginJet, PluginTrait}; +use crate::jets::elements::ElementsExtension; +use crate::jets::environments::UnchainedEnv; +use crate::jets::tests::dummy_elements_env; + +/// `OP_2 <33-byte key> <33-byte key> OP_2 OP_CHECKMULTISIG`, 71 bytes. +const SCRIPT: &str = "5221033523982d58e94be3b735731593f8225043880d53727235b566c515d24a0f7baf21025eb4655feae15a304653e27441ca8e8ced2bef89c22ab6b20424b4c07b3d14cc52ae"; + +/// `plugin_tests/script_jets`, built next to this test binary as a dev-dependency. +fn script_jets_path() -> std::path::PathBuf { + let exe = std::env::current_exe().unwrap(); + exe.with_file_name(format!( + "{}script_jets{}", + std::env::consts::DLL_PREFIX, + std::env::consts::DLL_SUFFIX + )) +} + +#[test] +fn loads_dynamic_library() { + crate::declare_plugin!(ScriptJets: ElementsExtension); + crate::declare_plugin!(Unused: ElementsExtension); + + let path = script_jets_path(); + let sha256 = + simplicity::hashes::sha256::Hash::hash(&std::fs::read(&path).unwrap()).to_byte_array(); + + assert!(matches!( + Unused::load(&path, Some([0; 32])), + Err(LoadError::HashMismatch { .. }) + )); + assert!(matches!( + Unused::load(path.with_file_name("missing.so"), None), + Err(LoadError::Io(_)) | Err(LoadError::Open(_)) + )); + assert!(Unused::get().is_none()); + + ScriptJets::load(&path, Some(sha256)).unwrap(); + let plugin = ScriptJets::get().unwrap(); + assert_eq!( + plugin.custom_names().collect::>(), + ["script_len", "opcode_at"] + ); + + let redeem = types::Context::with_context(|ctx| { + let jet = + |name| Arc::::jet(&ctx, &PluginJet::::parse(name).unwrap()); + let actual = jet("script_len"); + let expected = Arc::::const_word(&ctx, Word::u16(71)); + let pair = Arc::::pair(&actual, &expected).unwrap(); + let check = Arc::::comp(&pair, &jet("eq_16")).unwrap(); + Arc::::comp(&check, &jet("verify")) + .unwrap() + .finalize_unpruned() + .unwrap() + }); + let env = PluginEnv::::new(UnchainedEnv::new( + Script::from_hex_no_prefix(SCRIPT).unwrap(), + dummy_elements_env(), + )); + let mut mac = BitMachine::for_program(&redeem).unwrap(); + mac.exec(&redeem, &env).unwrap(); +} diff --git a/core/src/plugin/trampolines.rs b/core/src/plugin/trampolines.rs new file mode 100644 index 0000000..bdccc6a --- /dev/null +++ b/core/src/plugin/trampolines.rs @@ -0,0 +1,58 @@ +//! One trampoline per custom jet position, see `PluginEnv::c_jet_ptr`. +// +// TODO: generate the table with a proc macro if plugins need more than 32 custom jets. +use super::MAX_CUSTOM_JETS; +use super::env::PluginEnv; +use crate::jets::base::JetFn; +use simplicity::ffi::CFrameItem; + +/// Runs custom jet `IDX` of `Plugin`. +fn custom_jet_slot( + dst: &mut CFrameItem, + src: CFrameItem, + env: &PluginEnv, +) -> bool { + match Plugin::get().and_then(|plugin| plugin.custom.get(IDX)) { + Some(jet) => env.call(jet, dst, src), + None => false, + } +} + +pub(super) struct CustomJetSlots(Plugin); + +impl CustomJetSlots { + pub(super) const TABLE: [JetFn>; MAX_CUSTOM_JETS] = [ + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + custom_jet_slot::, + ]; +} diff --git a/jet_plugin_abi/Cargo.toml b/jet_plugin_abi/Cargo.toml new file mode 100644 index 0000000..067bc07 --- /dev/null +++ b/jet_plugin_abi/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "jet_plugin_abi" +version = "0.1.0" +edition = "2024" +rust-version = "1.92.0" + +[dependencies] +# Pinned exactly: plugins and host must link the same version, see `SIMPLICITY_SYS_VERSION`. +simplicity-sys = "=0.8.0" diff --git a/jet_plugin_abi/src/lib.rs b/jet_plugin_abi/src/lib.rs new file mode 100644 index 0000000..6f3365e --- /dev/null +++ b/jet_plugin_abi/src/lib.rs @@ -0,0 +1,136 @@ +//! C ABI between the simplicity-unchained host and custom jet plugins. +//! +//! A plugin is a dynamic library holding a static [`PluginDescriptor`], returned by the function it +//! exports as [`DESCRIPTOR_SYMBOL`] (see [`DescriptorFn`]). The descriptor is plain data: the host +//! owns every Simplicity type and only calls back into the plugin through [`JetFn`]. +//! +//! Jets receive the bit machine's frames as [`CFrameItem`]s, so host and plugin must link the +//! same `simplicity-sys`; the host checks [`SIMPLICITY_SYS_VERSION`] at load time. +// +// TODO: drop the `simplicity-sys` dependency by passing opaque frames plus host read/write +// callbacks. That decouples plugins from the host's C library version and lets the host +// bounds-check frame access. +mod macros; +pub mod utils; + +use simplicity_sys::c_jets::frame_ffi::{c_readBit, c_writeBit}; + +pub use simplicity_sys::c_jets::frame_ffi::CFrameItem; + +use utils::Bytes; + +/// Version of this ABI. The host refuses plugins built against a different one. +pub const ABI_VERSION: u32 = 1; + +/// Version of `simplicity-sys` this crate is pinned to; keep in sync with `Cargo.toml`. +pub const SIMPLICITY_SYS_VERSION: &str = "0.8.0"; + +/// Name of the symbol every plugin exports. +pub const DESCRIPTOR_SYMBOL: &str = "unchained_jet_plugin"; + +/// Base jet set extended by a plugin. +/// +/// Crosses the ABI as a raw `u32` (see [`PluginDescriptor::base_set`]). +#[repr(u32)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BaseSet { + /// The host's Bitcoin jets. + Bitcoin = 0, + /// The host's Elements jets. + Elements = 1, +} + +impl TryFrom for BaseSet { + /// The unknown value. + type Error = u32; + + fn try_from(value: u32) -> Result { + match value { + 0 => Ok(Self::Bitcoin), + 1 => Ok(Self::Elements), + _ => Err(value), + } + } +} + +/// Signature of [`DESCRIPTOR_SYMBOL`]. +pub type DescriptorFn = unsafe extern "C" fn() -> *const PluginDescriptor; + +/// Implementation of a custom jet: reads its input from `src`, writes its output to `dst`. +/// Returns `false` when the jet fails. +/// +/// The frames are not bounds-checked: a jet must read and write exactly as many bits as its +/// source and target types have. +pub type JetFn = + unsafe extern "C" fn(dst: *mut CFrameItem, src: CFrameItem, env: *const JetEnv) -> bool; + +/// Everything the host needs to know about a plugin. +#[repr(C)] +#[derive(Debug)] +pub struct PluginDescriptor { + pub abi_version: u32, + /// Must be [`SIMPLICITY_SYS_VERSION`]. + pub simplicity_sys_version: Bytes, + /// A [`BaseSet`] discriminant. + pub base_set: u32, + pub jets: *const CustomJetDesc, + pub jets_len: usize, +} + +// SAFETY: descriptors are immutable data meant to live in plugin statics. +unsafe impl Sync for PluginDescriptor {} + +/// A single custom jet. +#[repr(C)] +#[derive(Clone, Copy, Debug)] +pub struct CustomJetDesc { + /// Jet name, `[a-z0-9_]+`. + pub name: Bytes, + // TODO: derive CMRs on the host from declared data instead of trusting the plugin + pub cmr: [u8; 32], + /// Source type in `TypeName` notation, e.g. `*ii`. + pub source_ty: Bytes, + /// Target type in `TypeName` notation. + pub target_ty: Bytes, + pub cost_milliweight: u64, + /// Bit encoding of the jet, the low `code_len` bits of `code`, most significant first. + pub code: u64, + pub code_len: u8, + pub func: Option, +} + +// SAFETY: descriptors are immutable data meant to live in plugin statics. +unsafe impl Sync for CustomJetDesc {} + +/// Data a custom jet may read about the spending transaction, valid during the jet call. +#[repr(C)] +#[derive(Debug)] +pub struct JetEnv { + pub redeem_script: Bytes, + pub tx: Bytes, + pub input_index: u32, +} + +/// Reads a big-endian `n`-bit value from a read frame. +/// +/// # Safety +/// +/// `src` must be a read frame with at least `n` bits left. +pub unsafe fn read_bits(src: &mut CFrameItem, n: u32) -> u64 { + let mut value = 0; + for _ in 0..n { + value = (value << 1) | u64::from(unsafe { c_readBit(src) }); + } + value +} + +/// Writes the low `n` bits of `value`, big-endian, to a write frame. +/// +/// # Safety +/// +/// `dst` must be a write frame with room for at least `n` bits. +pub unsafe fn write_bits(dst: &mut CFrameItem, value: u64, n: u32) { + for i in (0..n).rev() { + unsafe { c_writeBit(dst, (value >> i) & 1 == 1) }; + } +} diff --git a/jet_plugin_abi/src/macros.rs b/jet_plugin_abi/src/macros.rs new file mode 100644 index 0000000..5674fb3 --- /dev/null +++ b/jet_plugin_abi/src/macros.rs @@ -0,0 +1,71 @@ +/// Exports a plugin descriptor declaring the given jets. +/// +/// Each jet is named after the function implementing it, which must be a [`JetFn`] in scope. +/// Types use `TypeName` notation; the remaining fields are those of [`CustomJetDesc`]. +/// +/// ```ignore +/// jet_plugin_abi::register_jets! { +/// base: Elements, +/// jets: { +/// script_len: "1" -> "s" { +/// cost_milliweight: 100, +/// code: 0b1111_1110, +/// code_len: 8, +/// cmr: [0x11; 32], +/// }, +/// }, +/// } +/// ``` +/// +/// A crate can invoke it at most once, as it defines the [`DESCRIPTOR_SYMBOL`] export. +/// +/// [`JetFn`]: crate::JetFn +/// [`CustomJetDesc`]: crate::CustomJetDesc +/// [`DESCRIPTOR_SYMBOL`]: crate::DESCRIPTOR_SYMBOL +#[macro_export] +macro_rules! register_jets { + ( + base: $base:ident, + jets: { + $( + $name:ident : $source:literal -> $target:literal { + cost_milliweight: $cost:expr, + code: $code:expr, + code_len: $code_len:expr, + cmr: $cmr:expr $(,)? + } + ),* $(,)? + } $(,)? + ) => { + const _: () = { + const JETS: &[$crate::CustomJetDesc] = &[$( + $crate::CustomJetDesc { + name: $crate::utils::Bytes::new(stringify!($name).as_bytes()), + cmr: $cmr, + source_ty: $crate::utils::Bytes::new($source.as_bytes()), + target_ty: $crate::utils::Bytes::new($target.as_bytes()), + cost_milliweight: $cost, + code: $code, + code_len: $code_len, + func: Some($name as $crate::JetFn), + } + ),*]; + + static DESCRIPTOR: $crate::PluginDescriptor = $crate::PluginDescriptor { + abi_version: $crate::ABI_VERSION, + simplicity_sys_version: $crate::utils::Bytes::new( + $crate::SIMPLICITY_SYS_VERSION.as_bytes(), + ), + base_set: $crate::BaseSet::$base as u32, + jets: JETS.as_ptr(), + jets_len: JETS.len(), + }; + + // Must match `DESCRIPTOR_SYMBOL`. + #[unsafe(no_mangle)] + extern "C" fn unchained_jet_plugin() -> *const $crate::PluginDescriptor { + &DESCRIPTOR + } + }; + }; +} diff --git a/jet_plugin_abi/src/utils.rs b/jet_plugin_abi/src/utils.rs new file mode 100644 index 0000000..3d0ec0c --- /dev/null +++ b/jet_plugin_abi/src/utils.rs @@ -0,0 +1,27 @@ +/// Borrowed byte string. +#[repr(C)] +#[derive(Clone, Copy, Debug)] +pub struct Bytes { + pub ptr: *const u8, + pub len: usize, +} + +impl Bytes { + pub const fn new(bytes: &'static [u8]) -> Self { + Self { + ptr: bytes.as_ptr(), + len: bytes.len(), + } + } + + /// # Safety + /// + /// `ptr` must be valid for reads of `len` bytes for the whole of `'a`. + pub unsafe fn as_slice<'a>(&self) -> &'a [u8] { + if self.len == 0 { + &[] + } else { + unsafe { std::slice::from_raw_parts(self.ptr, self.len) } + } + } +} diff --git a/plugin_tests/script_jets/Cargo.toml b/plugin_tests/script_jets/Cargo.toml new file mode 100644 index 0000000..fbb8611 --- /dev/null +++ b/plugin_tests/script_jets/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "script_jets" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +jet_plugin_abi = { path = "../../jet_plugin_abi" } diff --git a/plugin_tests/script_jets/src/lib.rs b/plugin_tests/script_jets/src/lib.rs new file mode 100644 index 0000000..68af463 --- /dev/null +++ b/plugin_tests/script_jets/src/lib.rs @@ -0,0 +1,49 @@ +//! Test plugin extending the Elements jets with jets reading the redeem script. +use jet_plugin_abi::{CFrameItem, JetEnv, read_bits, write_bits}; + +/// 1 |- 2^16: length of the redeem script. +unsafe extern "C" fn script_len( + dst: *mut CFrameItem, + _src: CFrameItem, + env: *const JetEnv, +) -> bool { + let len = unsafe { &*env }.redeem_script.len; + let Ok(len) = u16::try_from(len) else { + return false; + }; + unsafe { write_bits(&mut *dst, len.into(), 16) }; + true +} + +/// 2^8 |- 2^8: byte of the redeem script at the given index. +unsafe extern "C" fn opcode_at( + dst: *mut CFrameItem, + mut src: CFrameItem, + env: *const JetEnv, +) -> bool { + let script = unsafe { (*env).redeem_script.as_slice() }; + let index = unsafe { read_bits(&mut src, 8) } as usize; + let Some(&opcode) = script.get(index) else { + return false; + }; + unsafe { write_bits(&mut *dst, opcode.into(), 8) }; + true +} + +jet_plugin_abi::register_jets! { + base: Elements, + jets: { + script_len: "1" -> "s" { + cost_milliweight: 100, + code: 0b1111_1110, + code_len: 8, + cmr: [0x11; 32], + }, + opcode_at: "c" -> "c" { + cost_milliweight: 100, + code: 0b1_1111_1110, + code_len: 9, + cmr: [0x22; 32], + }, + }, +}