1use blake3::hash;
2use qcoin_crypto::{default_registry, PqSchemeRegistry, PublicKey, Signature};
3use qcoin_types::{Output, SighashFlags, Transaction, TransactionInput};
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7const DEFAULT_MAX_GAS: u64 = 50_000;
8const DEFAULT_MAX_STACK_ITEMS: usize = 1_024;
9const DEFAULT_MAX_PUSH_BYTES: usize = 4 * 1024;
10const DEFAULT_MAX_SCRIPT_LEN: usize = 2_048;
11
12#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
13pub enum OpCode {
14 CheckSig,
15 CheckMultiSig { threshold: u8, total: u8 },
16 CheckTimeLock,
17 CheckRelativeTimeLock,
18 CheckHashLock,
19 PushBytes(Vec<u8>),
20 Nop,
21}
22
23#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
24pub struct Script(pub Vec<OpCode>);
25
26#[derive(Clone, Debug)]
27pub struct ScriptContext {
28 pub tx: Transaction,
29 pub input_index: usize,
30 pub current_height: Option<u64>,
31 pub chain_id: u32,
32 pub script_hash: qcoin_types::Hash256,
33}
34
35#[derive(Debug, Error)]
36pub enum ScriptError {
37 #[error("script evaluation error: {0}")]
38 Evaluation(String),
39
40 #[error("script exceeded execution budget")]
41 OutOfGas,
42
43 #[error("script stack underflow")]
44 StackUnderflow,
45
46 #[error("script stack exceeded limit")]
47 StackOverflow,
48
49 #[error("script length exceeded limit")]
50 ScriptTooLarge,
51}
52
53pub trait ScriptEngine {
54 fn eval<H: ScriptHost>(
55 &self,
56 script: &Script,
57 ctx: &ScriptContext,
58 host: &H,
59 ) -> Result<ScriptResult, ScriptError>;
60}
61
62#[derive(Clone, Debug)]
63pub struct VmConfig {
64 pub max_gas: u64,
65 pub max_stack_items: usize,
66 pub max_push_bytes: usize,
67 pub max_script_len: usize,
68}
69
70impl Default for VmConfig {
71 fn default() -> Self {
72 Self {
73 max_gas: DEFAULT_MAX_GAS,
74 max_stack_items: DEFAULT_MAX_STACK_ITEMS,
75 max_push_bytes: DEFAULT_MAX_PUSH_BYTES,
76 max_script_len: DEFAULT_MAX_SCRIPT_LEN,
77 }
78 }
79}
80
81#[derive(Clone, Debug, Default)]
82pub struct ScriptResult {
83 pub gas_consumed: u64,
84}
85
86#[derive(Clone, Debug)]
87pub struct ResolvedInput {
88 pub output: Output,
89 pub created_height: Option<u64>,
90}
91
92pub trait ScriptHost {
93 fn current_height(&self) -> Option<u64>;
94 fn input_utxo(&self, input: &TransactionInput) -> Option<ResolvedInput>;
95}
96
97#[derive(Default)]
98pub struct DeterministicScriptEngine {
99 config: VmConfig,
100}
101
102impl DeterministicScriptEngine {
103 pub fn with_config(config: VmConfig) -> Self {
104 Self { config }
105 }
106}
107
108pub mod consensus_codec {
109 use super::{OpCode, Script};
110
111 fn encode_len(len: usize, out: &mut Vec<u8>) {
112 let len: u32 = len
113 .try_into()
114 .expect("script encoding length should fit into u32");
115 out.extend_from_slice(&len.to_le_bytes());
116 }
117
118 pub fn encode_script(script: &Script) -> Vec<u8> {
119 let mut out = Vec::new();
120 encode_len(script.0.len(), &mut out);
121
122 for op in &script.0 {
123 match op {
124 OpCode::CheckSig => out.push(0),
125 OpCode::CheckMultiSig { threshold, total } => {
126 out.push(1);
127 out.push(*threshold);
128 out.push(*total);
129 }
130 OpCode::CheckTimeLock => out.push(2),
131 OpCode::CheckRelativeTimeLock => out.push(3),
132 OpCode::CheckHashLock => out.push(4),
133 OpCode::PushBytes(data) => {
134 out.push(5);
135 encode_len(data.len(), &mut out);
136 out.extend_from_slice(data);
137 }
138 OpCode::Nop => out.push(6),
139 }
140 }
141
142 out
143 }
144}
145
146struct GasMeter {
147 remaining: u64,
148 limit: u64,
149}
150
151impl GasMeter {
152 fn new(limit: u64) -> Self {
153 Self {
154 remaining: limit,
155 limit,
156 }
157 }
158
159 fn consume(&mut self, amount: u64) -> Result<(), ScriptError> {
160 if amount > self.remaining {
161 return Err(ScriptError::OutOfGas);
162 }
163 self.remaining -= amount;
164 Ok(())
165 }
166
167 fn used(&self) -> u64 {
168 self.limit - self.remaining
169 }
170}
171
172struct Stack {
173 items: Vec<Vec<u8>>,
174 max_items: usize,
175}
176
177impl Stack {
178 fn new(max_items: usize) -> Self {
179 Self {
180 items: Vec::with_capacity(max_items.min(32)),
181 max_items,
182 }
183 }
184
185 fn push(&mut self, value: Vec<u8>) -> Result<(), ScriptError> {
186 if self.items.len() >= self.max_items {
187 return Err(ScriptError::StackOverflow);
188 }
189 self.items.push(value);
190 Ok(())
191 }
192
193 fn pop(&mut self) -> Result<Vec<u8>, ScriptError> {
194 self.items.pop().ok_or(ScriptError::StackUnderflow)
195 }
196}
197
198impl ScriptEngine for DeterministicScriptEngine {
199 fn eval<H: ScriptHost>(
200 &self,
201 script: &Script,
202 ctx: &ScriptContext,
203 host: &H,
204 ) -> Result<ScriptResult, ScriptError> {
205 if script.0.len() > self.config.max_script_len {
206 return Err(ScriptError::ScriptTooLarge);
207 }
208
209 let mut gas = GasMeter::new(self.config.max_gas);
210 let mut stack = Stack::new(self.config.max_stack_items);
211 let registry = default_registry();
212
213 for op in &script.0 {
214 let op_cost = gas_cost(op, self.config.max_push_bytes)?;
215 gas.consume(op_cost)?;
216
217 match op {
218 OpCode::PushBytes(data) => {
219 if data.len() > self.config.max_push_bytes {
220 return Err(ScriptError::Evaluation(
221 "push exceeds byte limit".to_string(),
222 ));
223 }
224 stack.push(data.clone())?;
225 }
226 OpCode::Nop => {}
227 OpCode::CheckSig => {
228 let signature_bytes = stack.pop()?;
229 let public_key_bytes = stack.pop()?;
230
231 let public_key = PublicKey::from_bytes(&public_key_bytes).map_err(|err| {
232 ScriptError::Evaluation(format!("invalid public key: {err}"))
233 })?;
234 let signature = Signature::from_bytes(&signature_bytes).map_err(|err| {
235 ScriptError::Evaluation(format!("invalid signature: {err}"))
236 })?;
237
238 let scheme = registry.get(&public_key.scheme).ok_or_else(|| {
239 ScriptError::Evaluation("signature scheme not registered".to_string())
240 })?;
241
242 let prev_output = host
243 .input_utxo(ctx.tx.core.inputs.get(ctx.input_index).ok_or_else(|| {
244 ScriptError::Evaluation("input index out of bounds".to_string())
245 })?)
246 .ok_or_else(|| {
247 ScriptError::Evaluation("host could not resolve input".to_string())
248 })?;
249
250 let sighash = ctx.tx.sighash(
251 ctx.input_index,
252 &prev_output.output,
253 ctx.script_hash,
254 ctx.chain_id,
255 SighashFlags::default(),
256 );
257
258 scheme
259 .verify(&public_key, &sighash, &signature)
260 .map_err(|err| {
261 ScriptError::Evaluation(format!("signature verification failed: {err}"))
262 })?;
263 }
264 OpCode::CheckMultiSig { threshold, total } => {
265 let threshold = *threshold as usize;
266 let total = *total as usize;
267
268 if threshold == 0 || total == 0 || threshold > total {
269 return Err(ScriptError::Evaluation(
270 "invalid multisig threshold".to_string(),
271 ));
272 }
273
274 let mut signatures = Vec::with_capacity(threshold);
275 for _ in 0..threshold {
276 let sig_bytes = stack.pop()?;
277 let signature = Signature::from_bytes(&sig_bytes).map_err(|err| {
278 ScriptError::Evaluation(format!("invalid signature: {err}"))
279 })?;
280 signatures.push(signature);
281 }
282
283 let mut pubkeys = Vec::with_capacity(total);
284 for _ in 0..total {
285 let pk_bytes = stack.pop()?;
286 let public_key = PublicKey::from_bytes(&pk_bytes).map_err(|err| {
287 ScriptError::Evaluation(format!("invalid public key: {err}"))
288 })?;
289 pubkeys.push(public_key);
290 }
291
292 for (idx, signature) in signatures.iter().enumerate() {
293 let public_key = pubkeys.get(idx).ok_or_else(|| {
294 ScriptError::Evaluation(
295 "multisig stack did not contain enough public keys".to_string(),
296 )
297 })?;
298
299 let scheme = registry.get(&public_key.scheme).ok_or_else(|| {
300 ScriptError::Evaluation("signature scheme not registered".to_string())
301 })?;
302
303 let prev_output = host
304 .input_utxo(ctx.tx.core.inputs.get(ctx.input_index).ok_or_else(
305 || ScriptError::Evaluation("input index out of bounds".to_string()),
306 )?)
307 .ok_or_else(|| {
308 ScriptError::Evaluation("host could not resolve input".to_string())
309 })?;
310
311 let sighash = ctx.tx.sighash(
312 ctx.input_index,
313 &prev_output.output,
314 ctx.script_hash,
315 ctx.chain_id,
316 SighashFlags::default(),
317 );
318
319 scheme
320 .verify(public_key, &sighash, signature)
321 .map_err(|err| {
322 ScriptError::Evaluation(format!(
323 "multisig verification failed: {err}"
324 ))
325 })?;
326 }
327 }
328 OpCode::CheckTimeLock => {
329 let required_height_bytes = stack.pop()?;
330 if required_height_bytes.len() != 8 {
331 return Err(ScriptError::Evaluation(
332 "timelock expects 8-byte height".to_string(),
333 ));
334 }
335
336 let required_height = u64::from_le_bytes(
337 required_height_bytes
338 .as_slice()
339 .try_into()
340 .expect("length already checked"),
341 );
342
343 let current_height =
344 host.current_height()
345 .or(ctx.current_height)
346 .ok_or_else(|| {
347 ScriptError::Evaluation(
348 "current height unavailable for timelock".to_string(),
349 )
350 })?;
351
352 if current_height < required_height {
353 return Err(ScriptError::Evaluation(
354 "absolute timelock not satisfied".to_string(),
355 ));
356 }
357 }
358 OpCode::CheckRelativeTimeLock => {
359 let relative_bytes = stack.pop()?;
360 if relative_bytes.len() != 8 {
361 return Err(ScriptError::Evaluation(
362 "relative timelock expects 8-byte height".to_string(),
363 ));
364 }
365
366 let relative_height = u64::from_le_bytes(
367 relative_bytes
368 .as_slice()
369 .try_into()
370 .expect("length already checked"),
371 );
372
373 let input = ctx.tx.core.inputs.get(ctx.input_index).ok_or_else(|| {
374 ScriptError::Evaluation("input index out of bounds".to_string())
375 })?;
376
377 let resolved = host.input_utxo(input).ok_or_else(|| {
378 ScriptError::Evaluation(
379 "host could not resolve input for relative timelock".to_string(),
380 )
381 })?;
382
383 let created_height = resolved.created_height.ok_or_else(|| {
384 ScriptError::Evaluation("input creation height unavailable".to_string())
385 })?;
386
387 let current_height =
388 host.current_height()
389 .or(ctx.current_height)
390 .ok_or_else(|| {
391 ScriptError::Evaluation(
392 "current height unavailable for timelock".to_string(),
393 )
394 })?;
395
396 if current_height < created_height + relative_height {
397 return Err(ScriptError::Evaluation(
398 "relative timelock not satisfied".to_string(),
399 ));
400 }
401 }
402 OpCode::CheckHashLock => {
403 let preimage = stack.pop()?;
404 let expected_hash = stack.pop()?;
405
406 if expected_hash.len() != 32 {
407 return Err(ScriptError::Evaluation(
408 "hashlock expects 32-byte hash".to_string(),
409 ));
410 }
411
412 let actual = hash(&preimage);
413 if expected_hash.as_slice() != actual.as_bytes() {
414 return Err(ScriptError::Evaluation(
415 "hashlock preimage mismatch".to_string(),
416 ));
417 }
418 }
419 }
420 }
421
422 Ok(ScriptResult {
423 gas_consumed: gas.used(),
424 })
425 }
426}
427
428fn gas_cost(op: &OpCode, max_push_bytes: usize) -> Result<u64, ScriptError> {
429 const BASE_COST: u64 = 10;
430 const SIG_COST: u64 = 5_000;
431 const HASH_COST: u64 = 250;
432
433 match op {
434 OpCode::Nop => Ok(1),
435 OpCode::PushBytes(data) => {
436 if data.len() > max_push_bytes {
437 return Err(ScriptError::Evaluation(
438 "push exceeds byte limit".to_string(),
439 ));
440 }
441 Ok(BASE_COST + data.len() as u64)
442 }
443 OpCode::CheckSig => Ok(SIG_COST),
444 OpCode::CheckMultiSig { threshold, .. } => Ok(SIG_COST * (*threshold as u64).max(1)),
445 OpCode::CheckTimeLock | OpCode::CheckRelativeTimeLock => Ok(BASE_COST),
446 OpCode::CheckHashLock => Ok(HASH_COST),
447 }
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453 use qcoin_crypto::SignatureSchemeId;
454 use qcoin_types::{
455 AssetAmount, AssetId, Hash256, Output, TransactionCore, TransactionInput, TransactionKind,
456 TransactionWitness,
457 };
458 use std::collections::HashMap;
459
460 #[derive(Default)]
461 struct StaticHost {
462 current_height: Option<u64>,
463 inputs: HashMap<(Hash256, u32), ResolvedInput>,
464 }
465
466 impl StaticHost {
467 fn new(current_height: Option<u64>) -> Self {
468 Self {
469 current_height,
470 inputs: HashMap::new(),
471 }
472 }
473
474 fn with_input(mut self, input: TransactionInput, resolved: ResolvedInput) -> Self {
475 self.inputs.insert((input.tx_id, input.index), resolved);
476 self
477 }
478 }
479
480 impl ScriptHost for StaticHost {
481 fn current_height(&self) -> Option<u64> {
482 self.current_height
483 }
484
485 fn input_utxo(&self, input: &TransactionInput) -> Option<ResolvedInput> {
486 self.inputs.get(&(input.tx_id, input.index)).cloned()
487 }
488 }
489
490 fn sample_tx() -> (Transaction, TransactionInput) {
491 let input = TransactionInput {
492 tx_id: [1u8; 32],
493 index: 0,
494 };
495
496 let tx = Transaction {
497 core: TransactionCore {
498 kind: TransactionKind::Transfer,
499 inputs: vec![input.clone()],
500 outputs: vec![Output {
501 owner_script_hash: [2u8; 32],
502 assets: vec![AssetAmount {
503 asset_id: AssetId([3u8; 32]),
504 amount: 10,
505 }],
506 metadata_hash: None,
507 }],
508 },
509 witness: TransactionWitness::default(),
510 };
511
512 (tx, input)
513 }
514
515 fn default_engine() -> DeterministicScriptEngine {
516 DeterministicScriptEngine::default()
517 }
518
519 fn u64_le_bytes(value: u64) -> Vec<u8> {
520 value.to_le_bytes().to_vec()
521 }
522
523 fn script_hash(script: &Script) -> qcoin_types::Hash256 {
524 *hash(&consensus_codec::encode_script(script)).as_bytes()
525 }
526
527 #[test]
528 fn checks_signature_successfully() {
529 let registry = default_registry();
530 let scheme = registry
531 .get(&SignatureSchemeId::Dilithium2)
532 .expect("scheme should exist");
533 let (pk, sk) = scheme.keygen().expect("keygen should work");
534
535 let (tx, input) = sample_tx();
536 let script = Script(vec![
537 OpCode::PushBytes(pk.to_bytes().expect("pk to bytes")),
538 OpCode::PushBytes(Vec::new()),
539 OpCode::CheckSig,
540 ]);
541
542 let script_hash = script_hash(&script);
543 let prev_output = tx.core.outputs[0].clone();
544 let sighash = tx.sighash(0, &prev_output, script_hash, 0, SighashFlags::default());
545 let signature = scheme.sign(&sk, &sighash).expect("signing should work");
546
547 let script = Script(vec![
548 OpCode::PushBytes(pk.to_bytes().expect("pk to bytes")),
549 OpCode::PushBytes(signature.to_bytes().expect("sig to bytes")),
550 OpCode::CheckSig,
551 ]);
552
553 let host = StaticHost::new(Some(10)).with_input(
554 input.clone(),
555 ResolvedInput {
556 output: tx.core.outputs[0].clone(),
557 created_height: Some(1),
558 },
559 );
560
561 let ctx = ScriptContext {
562 tx,
563 input_index: 0,
564 current_height: Some(10),
565 chain_id: 0,
566 script_hash,
567 };
568
569 let engine = default_engine();
570 let result = engine.eval(&script, &ctx, &host);
571
572 assert!(result.is_ok());
573 }
574
575 #[test]
576 fn rejects_invalid_signature() {
577 let registry = default_registry();
578 let scheme = registry
579 .get(&SignatureSchemeId::Dilithium2)
580 .expect("scheme should exist");
581 let (pk, sk) = scheme.keygen().expect("keygen should work");
582
583 let (tx, input) = sample_tx();
584 let bad_signature = scheme
585 .sign(&sk, b"wrong message")
586 .expect("signing should work");
587
588 let script = Script(vec![
589 OpCode::PushBytes(pk.to_bytes().expect("pk to bytes")),
590 OpCode::PushBytes(bad_signature.to_bytes().expect("sig to bytes")),
591 OpCode::CheckSig,
592 ]);
593
594 let script_hash = script_hash(&script);
595
596 let host = StaticHost::new(Some(5)).with_input(
597 input.clone(),
598 ResolvedInput {
599 output: tx.core.outputs[0].clone(),
600 created_height: Some(0),
601 },
602 );
603
604 let ctx = ScriptContext {
605 tx,
606 input_index: 0,
607 current_height: Some(5),
608 chain_id: 0,
609 script_hash,
610 };
611
612 let engine = default_engine();
613 let result = engine.eval(&script, &ctx, &host);
614
615 assert!(matches!(result, Err(ScriptError::Evaluation(_))));
616 }
617
618 #[test]
619 fn enforces_absolute_timelock() {
620 let (tx, input) = sample_tx();
621 let required_height = 11u64;
622 let script = Script(vec![
623 OpCode::PushBytes(u64_le_bytes(required_height)),
624 OpCode::CheckTimeLock,
625 ]);
626
627 let script_hash = script_hash(&script);
628
629 let host = StaticHost::new(Some(10)).with_input(
630 input.clone(),
631 ResolvedInput {
632 output: tx.core.outputs[0].clone(),
633 created_height: Some(0),
634 },
635 );
636
637 let ctx = ScriptContext {
638 tx: tx.clone(),
639 input_index: 0,
640 current_height: Some(10),
641 chain_id: 0,
642 script_hash,
643 };
644
645 let engine = default_engine();
646 let result = engine.eval(&script, &ctx, &host);
647 assert!(matches!(result, Err(ScriptError::Evaluation(_))));
648
649 let host = StaticHost::new(Some(12)).with_input(
650 input,
651 ResolvedInput {
652 output: tx.core.outputs[0].clone(),
653 created_height: Some(0),
654 },
655 );
656 let ctx = ScriptContext {
657 tx,
658 input_index: 0,
659 current_height: Some(12),
660 chain_id: 0,
661 script_hash,
662 };
663
664 let result = engine.eval(&script, &ctx, &host);
665 assert!(result.is_ok());
666 }
667
668 #[test]
669 fn enforces_relative_timelock_using_host() {
670 let (tx, input) = sample_tx();
671 let script = Script(vec![
672 OpCode::PushBytes(u64_le_bytes(3)),
673 OpCode::CheckRelativeTimeLock,
674 ]);
675
676 let resolved = ResolvedInput {
677 output: tx.core.outputs[0].clone(),
678 created_height: Some(5),
679 };
680
681 let script_hash = script_hash(&script);
682
683 let host = StaticHost::new(Some(7)).with_input(input.clone(), resolved.clone());
684 let ctx = ScriptContext {
685 tx: tx.clone(),
686 input_index: 0,
687 current_height: Some(7),
688 chain_id: 0,
689 script_hash,
690 };
691 let engine = default_engine();
692 let result = engine.eval(&script, &ctx, &host);
693 assert!(matches!(result, Err(ScriptError::Evaluation(_))));
694
695 let host = StaticHost::new(Some(9)).with_input(input, resolved);
696 let ctx = ScriptContext {
697 tx,
698 input_index: 0,
699 current_height: Some(9),
700 chain_id: 0,
701 script_hash,
702 };
703 let result = engine.eval(&script, &ctx, &host);
704 assert!(result.is_ok());
705 }
706
707 #[test]
708 fn validates_hashlock_preimage() {
709 let (tx, input) = sample_tx();
710 let preimage = b"super-secret".to_vec();
711 let expected_hash = hash(&preimage).as_bytes().to_vec();
712
713 let script = Script(vec![
714 OpCode::PushBytes(expected_hash.clone()),
715 OpCode::PushBytes(preimage.clone()),
716 OpCode::CheckHashLock,
717 ]);
718
719 let script_hash = script_hash(&script);
720
721 let host = StaticHost::new(Some(1)).with_input(
722 input,
723 ResolvedInput {
724 output: tx.core.outputs[0].clone(),
725 created_height: Some(0),
726 },
727 );
728 let ctx = ScriptContext {
729 tx,
730 input_index: 0,
731 current_height: Some(1),
732 chain_id: 0,
733 script_hash,
734 };
735 let engine = default_engine();
736 let result = engine.eval(&script, &ctx, &host);
737 assert!(result.is_ok());
738
739 let tampered_script = Script(vec![
740 OpCode::PushBytes(expected_hash),
741 OpCode::PushBytes(b"wrong".to_vec()),
742 OpCode::CheckHashLock,
743 ]);
744 let result = engine.eval(&tampered_script, &ctx, &host);
745 assert!(matches!(result, Err(ScriptError::Evaluation(_))));
746 }
747
748 #[test]
749 fn halts_when_out_of_gas() {
750 let (tx, input) = sample_tx();
751 let script = Script(vec![OpCode::Nop; 10]);
752
753 let script_hash = script_hash(&script);
754
755 let host = StaticHost::new(Some(0)).with_input(
756 input,
757 ResolvedInput {
758 output: tx.core.outputs[0].clone(),
759 created_height: Some(0),
760 },
761 );
762 let ctx = ScriptContext {
763 tx,
764 input_index: 0,
765 current_height: Some(0),
766 chain_id: 0,
767 script_hash,
768 };
769
770 let engine = DeterministicScriptEngine::with_config(VmConfig {
771 max_gas: 5,
772 ..VmConfig::default()
773 });
774
775 let result = engine.eval(&script, &ctx, &host);
776 assert!(matches!(result, Err(ScriptError::OutOfGas)));
777 }
778}