Skip to main content

qcoin_script/
lib.rs

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}