1use 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 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}