Skip to main content

labcoat_test/
lib.rs

1//! Native host-side integration testing for Labcoat contracts.
2//!
3//! `labcoat test` compiles WASIp1 WebAssembly modules and points this harness
4//! at the resulting artifact directory through `LABCOAT_TEST_ARTIFACT_DIR`.
5
6use serde::Deserialize;
7use std::path::{Path, PathBuf};
8use wasmtime::{Caller, Engine, Extern, Linker, Module, Store};
9use wasmtime_wasi::preview1::{self, WasiP1Ctx};
10use wasmtime_wasi::WasiCtxBuilder;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum Value {
14    U128(u128),
15    String(String),
16}
17
18impl From<u128> for Value {
19    fn from(value: u128) -> Self {
20        Self::U128(value)
21    }
22}
23
24impl From<&str> for Value {
25    fn from(value: &str) -> Self {
26        Self::String(value.to_owned())
27    }
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct AlkaneTransfer {
32    pub block: u128,
33    pub tx: u128,
34    pub value: u128,
35}
36
37#[derive(Debug, Clone, Default, PartialEq, Eq)]
38pub struct ChainContext {
39    pub myself: (u128, u128),
40    pub caller: (u128, u128),
41    pub vout: u128,
42    pub incoming: Vec<AlkaneTransfer>,
43}
44
45impl ChainContext {
46    pub fn deterministic() -> Self {
47        Self {
48            myself: (1, 1),
49            caller: (2, 2),
50            vout: 0,
51            incoming: Vec::new(),
52        }
53    }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct CallResult {
58    pub transfers: Vec<AlkaneTransfer>,
59    pub data: Vec<u8>,
60}
61
62impl CallResult {
63    pub fn data_text(&self) -> String {
64        let data = self.data.strip_prefix(&[0, 0, 0, 0]).unwrap_or(&self.data);
65        String::from_utf8_lossy(data)
66            .trim_end_matches('\0')
67            .to_owned()
68    }
69}
70
71#[derive(Debug, thiserror::Error)]
72pub enum HarnessError {
73    #[error("contract artifact not found: {0}")]
74    ArtifactMissing(PathBuf),
75    #[error("method `{0}` is not present in the contract ABI")]
76    UnknownMethod(String),
77    #[error("contract trapped or reverted: {0}")]
78    Revert(String),
79    #[error("invalid contract response: {0}")]
80    InvalidResponse(String),
81    #[error(transparent)]
82    Runtime(#[from] anyhow::Error),
83    #[error(transparent)]
84    Io(#[from] std::io::Error),
85    #[error(transparent)]
86    Json(#[from] serde_json::Error),
87}
88
89#[derive(Debug, Deserialize)]
90struct ContractAbi {
91    methods: Vec<AbiMethod>,
92}
93
94#[derive(Debug, Deserialize)]
95struct AbiMethod {
96    name: String,
97    opcode: u128,
98}
99
100struct HostState {
101    wasi: WasiP1Ctx,
102    context: Vec<u8>,
103}
104
105pub struct ContractHarness {
106    engine: Engine,
107    module: Module,
108    abi: ContractAbi,
109    context: ChainContext,
110}
111
112impl ContractHarness {
113    pub fn for_contract(name: &str) -> Result<Self, HarnessError> {
114        let root = std::env::var_os("LABCOAT_TEST_ARTIFACT_DIR")
115            .map(PathBuf::from)
116            .unwrap_or_else(|| PathBuf::from(".labcoat/test-artifacts"));
117        Self::from_files(
118            root.join(format!("{}.wasm", name)),
119            root.join(format!("{}.abi.json", name)),
120        )
121    }
122
123    pub fn from_files(
124        wasm_path: impl AsRef<Path>,
125        abi_path: impl AsRef<Path>,
126    ) -> Result<Self, HarnessError> {
127        let wasm_path = wasm_path.as_ref();
128        if !wasm_path.exists() {
129            return Err(HarnessError::ArtifactMissing(wasm_path.to_path_buf()));
130        }
131        let abi_path = abi_path.as_ref();
132        if !abi_path.exists() {
133            return Err(HarnessError::ArtifactMissing(abi_path.to_path_buf()));
134        }
135        let engine = Engine::default();
136        let module = Module::from_file(&engine, wasm_path).map_err(HarnessError::Runtime)?;
137        let abi = serde_json::from_slice(&std::fs::read(abi_path)?)?;
138        Ok(Self {
139            engine,
140            module,
141            abi,
142            context: ChainContext::deterministic(),
143        })
144    }
145
146    pub fn context(&self) -> &ChainContext {
147        &self.context
148    }
149
150    pub fn context_mut(&mut self) -> &mut ChainContext {
151        &mut self.context
152    }
153
154    pub fn set_context(&mut self, context: ChainContext) -> &mut Self {
155        self.context = context;
156        self
157    }
158
159    pub fn call_method(
160        &mut self,
161        method: &str,
162        args: &[Value],
163    ) -> Result<CallResult, HarnessError> {
164        let opcode = self
165            .abi
166            .methods
167            .iter()
168            .find(|entry| entry.name == method)
169            .map(|entry| entry.opcode)
170            .ok_or_else(|| HarnessError::UnknownMethod(method.to_owned()))?;
171        self.call_opcode(opcode, args)
172    }
173
174    pub fn call_opcode(
175        &mut self,
176        opcode: u128,
177        args: &[Value],
178    ) -> Result<CallResult, HarnessError> {
179        let context = serialize_context(&self.context, opcode, args)?;
180        let mut linker: Linker<HostState> = Linker::new(&self.engine);
181        preview1::add_to_linker_sync(&mut linker, |state| &mut state.wasi)
182            .map_err(HarnessError::Runtime)?;
183        add_alkanes_imports(&mut linker)?;
184
185        let state = HostState {
186            wasi: WasiCtxBuilder::new().build_p1(),
187            context,
188        };
189        let mut store = Store::new(&self.engine, state);
190        let instance = linker
191            .instantiate(&mut store, &self.module)
192            .map_err(HarnessError::Runtime)?;
193        if let Ok(initialize) = instance.get_typed_func::<(), ()>(&mut store, "_initialize") {
194            initialize
195                .call(&mut store, ())
196                .map_err(|e| HarnessError::Revert(format!("{e:#}")))?;
197        }
198        let execute = instance
199            .get_typed_func::<(), i32>(&mut store, "__execute")
200            .map_err(HarnessError::Runtime)?;
201        let pointer = execute
202            .call(&mut store, ())
203            .map_err(|e| HarnessError::Revert(format!("{e:#}")))?;
204        let memory = instance
205            .get_memory(&mut store, "memory")
206            .ok_or_else(|| HarnessError::InvalidResponse("missing memory export".into()))?;
207        decode_response(memory.data(&store), pointer)
208    }
209}
210
211fn add_alkanes_imports(linker: &mut Linker<HostState>) -> Result<(), HarnessError> {
212    linker
213        .func_wrap(
214            "env",
215            "println",
216            |mut caller: Caller<'_, HostState>, pointer: i32, length: i32| {
217                let memory = caller
218                    .get_export("memory")
219                    .and_then(Extern::into_memory)
220                    .ok_or_else(|| anyhow::anyhow!("missing memory export"))?;
221                let data = memory
222                    .data(&caller)
223                    .get(pointer as usize..pointer as usize + length as usize)
224                    .ok_or_else(|| anyhow::anyhow!("println outside memory"))?;
225                eprintln!("{}", String::from_utf8_lossy(data));
226                Ok(())
227            },
228        )
229        .map_err(HarnessError::Runtime)?;
230    linker
231        .func_wrap(
232            "env",
233            "abort",
234            |_message: i32, _file: i32, line: i32, column: i32| -> anyhow::Result<()> {
235                anyhow::bail!("WASM abort at {line}:{column}")
236            },
237        )
238        .map_err(HarnessError::Runtime)?;
239    linker
240        .func_wrap(
241            "env",
242            "__request_context",
243            |caller: Caller<'_, HostState>| caller.data().context.len() as i32,
244        )
245        .map_err(HarnessError::Runtime)?;
246    linker
247        .func_wrap(
248            "env",
249            "__load_context",
250            |mut caller: Caller<'_, HostState>, pointer: i32| -> anyhow::Result<i32> {
251                let context = caller.data().context.clone();
252                let memory = caller
253                    .get_export("memory")
254                    .and_then(Extern::into_memory)
255                    .ok_or_else(|| anyhow::anyhow!("missing memory export"))?;
256                memory.write(&mut caller, pointer as usize, &context)?;
257                // The contract ABI declares an i32 result. The legacy JS host
258                // returned `undefined`, which WebAssembly coerced to zero.
259                Ok(0)
260            },
261        )
262        .map_err(HarnessError::Runtime)?;
263    Ok(())
264}
265
266fn serialize_context(
267    context: &ChainContext,
268    opcode: u128,
269    args: &[Value],
270) -> Result<Vec<u8>, HarnessError> {
271    let mut output = Vec::new();
272    for value in [
273        context.myself.0,
274        context.myself.1,
275        context.caller.0,
276        context.caller.1,
277        context.vout,
278        context.incoming.len() as u128,
279    ] {
280        output.extend_from_slice(&value.to_le_bytes());
281    }
282    for incoming in &context.incoming {
283        output.extend_from_slice(&incoming.block.to_le_bytes());
284        output.extend_from_slice(&incoming.tx.to_le_bytes());
285        output.extend_from_slice(&incoming.value.to_le_bytes());
286    }
287    output.extend_from_slice(&opcode.to_le_bytes());
288    for arg in args {
289        match arg {
290            Value::U128(value) => output.extend_from_slice(&value.to_le_bytes()),
291            Value::String(value) => {
292                let bytes = value.as_bytes();
293                if bytes.is_empty() {
294                    return Err(HarnessError::InvalidResponse(
295                        "string arguments must not be empty".into(),
296                    ));
297                }
298                for chunk in bytes.chunks(16) {
299                    let mut word = [0u8; 16];
300                    word[..chunk.len()].copy_from_slice(chunk);
301                    output.extend_from_slice(&word);
302                }
303            }
304        }
305    }
306    Ok(output)
307}
308
309fn decode_response(memory: &[u8], pointer: i32) -> Result<CallResult, HarnessError> {
310    let pointer = usize::try_from(pointer)
311        .map_err(|_| HarnessError::InvalidResponse("negative response pointer".into()))?;
312    let length_offset = pointer
313        .checked_sub(4)
314        .ok_or_else(|| HarnessError::InvalidResponse("response pointer is below 4".into()))?;
315    let length_bytes: [u8; 4] = memory
316        .get(length_offset..pointer)
317        .ok_or_else(|| HarnessError::InvalidResponse("missing response length".into()))?
318        .try_into()
319        .unwrap();
320    let length = u32::from_le_bytes(length_bytes) as usize;
321    let response = memory
322        .get(pointer..pointer + length)
323        .ok_or_else(|| HarnessError::InvalidResponse("response exceeds memory".into()))?;
324    let (count, mut offset) = read_u128(response, 0)?;
325    let mut transfers = Vec::new();
326    for _ in 0..count {
327        let (block, next) = read_u128(response, offset)?;
328        let (tx, next2) = read_u128(response, next)?;
329        let (value, next3) = read_u128(response, next2)?;
330        offset = next3;
331        transfers.push(AlkaneTransfer { block, tx, value });
332    }
333    Ok(CallResult {
334        transfers,
335        data: response[offset..].to_vec(),
336    })
337}
338
339fn read_u128(bytes: &[u8], offset: usize) -> Result<(u128, usize), HarnessError> {
340    let end = offset + 16;
341    let word: [u8; 16] = bytes
342        .get(offset..end)
343        .ok_or_else(|| HarnessError::InvalidResponse("truncated u128 word".into()))?
344        .try_into()
345        .unwrap();
346    Ok((u128::from_le_bytes(word), end))
347}
348
349pub fn assert_revert<T: std::fmt::Debug>(result: Result<T, HarnessError>, message: &str) {
350    let error = result.expect_err("expected contract call to revert");
351    assert!(
352        error.to_string().contains(message),
353        "expected revert containing `{message}`, got `{error}`"
354    );
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    #[test]
362    fn context_encoding_includes_transfers_opcode_and_arguments() {
363        let context = ChainContext {
364            myself: (1, 2),
365            caller: (3, 4),
366            vout: 5,
367            incoming: vec![AlkaneTransfer {
368                block: 6,
369                tx: 7,
370                value: 8,
371            }],
372        };
373        let encoded =
374            serialize_context(&context, 9, &[Value::U128(10), Value::String("AB".into())]).unwrap();
375        let words: Vec<u128> = encoded
376            .chunks_exact(16)
377            .map(|word| u128::from_le_bytes(word.try_into().unwrap()))
378            .collect();
379        assert_eq!(words, vec![1, 2, 3, 4, 5, 1, 6, 7, 8, 9, 10, 0x4241]);
380    }
381
382    #[test]
383    fn response_decoding_returns_transfers_and_text() {
384        let mut response = Vec::new();
385        response.extend_from_slice(&1u128.to_le_bytes());
386        response.extend_from_slice(&2u128.to_le_bytes());
387        response.extend_from_slice(&3u128.to_le_bytes());
388        response.extend_from_slice(&4u128.to_le_bytes());
389        response.extend_from_slice(b"hello");
390        let mut memory = (response.len() as u32).to_le_bytes().to_vec();
391        memory.extend_from_slice(&response);
392        let result = decode_response(&memory, 4).unwrap();
393        assert_eq!(result.transfers.len(), 1);
394        assert_eq!(result.transfers[0].value, 4);
395        assert_eq!(result.data_text(), "hello");
396    }
397
398    #[test]
399    fn harness_looks_up_methods_and_reports_traps() {
400        let root = std::env::temp_dir().join(format!(
401            "labcoat-harness-{}-{}",
402            std::process::id(),
403            std::thread::current().name().unwrap_or("test")
404        ));
405        std::fs::create_dir_all(&root).unwrap();
406        let wasm = wat::parse_str(
407            r#"(module
408                (memory (export "memory") 1)
409                (data (i32.const 0)
410                    "\12\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\68\69")
411                (func (export "__execute") (result i32) i32.const 4)
412            )"#,
413        )
414        .unwrap();
415        let wasm_path = root.join("Example.wasm");
416        let abi_path = root.join("Example.abi.json");
417        std::fs::write(&wasm_path, wasm).unwrap();
418        std::fs::write(
419            &abi_path,
420            r#"{"contract":"Example","methods":[{"name":"greet","opcode":1,"params":[],"returns":"String"}]}"#,
421        )
422        .unwrap();
423
424        let mut harness = ContractHarness::from_files(&wasm_path, &abi_path).unwrap();
425        let result = harness.call_method("greet", &[]).unwrap();
426        assert_eq!(result.data_text(), "hi");
427        assert_eq!(harness.call_opcode(1, &[]).unwrap().data_text(), "hi");
428        assert!(matches!(
429            harness.call_method("Missing", &[]),
430            Err(HarnessError::UnknownMethod(_))
431        ));
432
433        let trap = wat::parse_str(
434            r#"(module
435                (memory (export "memory") 1)
436                (func (export "__execute") (result i32) unreachable)
437            )"#,
438        )
439        .unwrap();
440        std::fs::write(&wasm_path, trap).unwrap();
441        let mut harness = ContractHarness::from_files(&wasm_path, &abi_path).unwrap();
442        assert_revert(harness.call_method("greet", &[]), "contract trapped");
443
444        let expected_revert = wat::parse_str(
445            r#"(module
446                (import "env" "abort" (func $abort (param i32 i32 i32 i32)))
447                (memory (export "memory") 1)
448                (func (export "__execute") (result i32)
449                    i32.const 0
450                    i32.const 0
451                    i32.const 12
452                    i32.const 34
453                    call $abort
454                    i32.const 0)
455            )"#,
456        )
457        .unwrap();
458        std::fs::write(&wasm_path, expected_revert).unwrap();
459        let mut harness = ContractHarness::from_files(&wasm_path, &abi_path).unwrap();
460        assert_revert(harness.call_method("greet", &[]), "WASM abort at 12:34");
461        std::fs::remove_dir_all(root).ok();
462    }
463}