Skip to main content

chains_sdk/neo/
transaction.rs

1//! NEO N3 transaction building, NEP-17 token helpers, and contract invocation.
2//!
3//! Provides:
4//! - **NEP-17**: Standard token transfer/balanceOf encoding
5//! - **Transaction builder**: NEO N3 transaction construction
6//! - **Script builder**: NeoVM opcode encoding for contract calls
7
8use crate::error::SignerError;
9
10// ═══════════════════════════════════════════════════════════════════
11// NeoVM Script Builder
12// ═══════════════════════════════════════════════════════════════════
13
14/// NeoVM opcode constants.
15pub mod opcode {
16    /// Push zero onto the stack.
17    pub const PUSH0: u8 = 0x00;
18    /// Push data with 1-byte length prefix.
19    pub const PUSHDATA1: u8 = 0x0C;
20    /// Push integer 1.
21    pub const PUSH1: u8 = 0x11;
22    /// Push integer 2.
23    pub const PUSH2: u8 = 0x12;
24    /// Push integer 3.
25    pub const PUSH3: u8 = 0x13;
26    /// Push integer 4.
27    pub const PUSH4: u8 = 0x14;
28    /// Push integer 5.
29    pub const PUSH5: u8 = 0x15;
30    /// Push integer 8.
31    pub const PUSH8: u8 = 0x18;
32    /// Push integer 16.
33    pub const PUSH16: u8 = 0x20;
34    /// No operation.
35    pub const NOP: u8 = 0x21;
36    /// Create a new array.
37    pub const NEWARRAY: u8 = 0xC5;
38    /// Pack stack items into an array.
39    pub const PACK: u8 = 0xC1;
40    /// System call opcode.
41    pub const SYSCALL: u8 = 0x41;
42}
43
44/// Build a NeoVM invocation script.
45#[derive(Debug, Clone, Default)]
46pub struct ScriptBuilder {
47    data: Vec<u8>,
48}
49
50impl ScriptBuilder {
51    /// Create a new empty script builder.
52    #[must_use]
53    pub fn new() -> Self {
54        Self { data: Vec::new() }
55    }
56
57    /// Push a raw byte (opcode).
58    pub fn emit(&mut self, op: u8) -> &mut Self {
59        self.data.push(op);
60        self
61    }
62
63    /// Push an integer onto the stack.
64    pub fn emit_push_integer(&mut self, value: i64) -> &mut Self {
65        if value == -1 {
66            self.data.push(0x0F); // PUSHM1
67        } else if value == 0 {
68            self.data.push(opcode::PUSH0);
69        } else if (1..=16).contains(&value) {
70            self.data.push(opcode::PUSH1 + (value as u8 - 1));
71        } else {
72            // Encode as variable-length integer
73            let bytes = int_to_bytes(value);
74            self.emit_push_bytes(&bytes);
75        }
76        self
77    }
78
79    /// Push bytes onto the stack.
80    pub fn emit_push_bytes(&mut self, data: &[u8]) -> &mut Self {
81        let len = data.len();
82        if len <= 0xFF {
83            self.data.push(opcode::PUSHDATA1);
84            self.data.push(len as u8);
85        } else if len <= 0xFFFF {
86            self.data.push(0x0D); // PUSHDATA2
87            self.data.extend_from_slice(&(len as u16).to_le_bytes());
88        } else {
89            self.data.push(0x0E); // PUSHDATA4
90            self.data.extend_from_slice(&(len as u32).to_le_bytes());
91        }
92        self.data.extend_from_slice(data);
93        self
94    }
95
96    /// Push a 20-byte script hash.
97    pub fn emit_push_hash160(&mut self, hash: &[u8; 20]) -> &mut Self {
98        self.emit_push_bytes(hash)
99    }
100
101    /// Emit a syscall by its 4-byte hash.
102    pub fn emit_syscall(&mut self, method_hash: u32) -> &mut Self {
103        self.data.push(opcode::SYSCALL);
104        self.data.extend_from_slice(&method_hash.to_le_bytes());
105        self
106    }
107
108    /// Emit a contract call: `System.Contract.Call`.
109    ///
110    /// Hash of `System.Contract.Call` = `0x627d5b52`
111    pub fn emit_contract_call(
112        &mut self,
113        contract_hash: &[u8; 20],
114        method: &str,
115        args_count: usize,
116    ) -> &mut Self {
117        // Push args count onto stack for PACK
118        self.emit_push_integer(args_count as i64);
119        self.emit(opcode::PACK);
120        // Push method name
121        self.emit_push_bytes(method.as_bytes());
122        // Push contract hash (little-endian)
123        self.emit_push_hash160(contract_hash);
124        // Syscall System.Contract.Call
125        self.emit_syscall(0x627d5b52);
126        self
127    }
128
129    /// Get the built script bytes.
130    #[must_use]
131    pub fn to_bytes(&self) -> Vec<u8> {
132        self.data.clone()
133    }
134}
135
136fn int_to_bytes(value: i64) -> Vec<u8> {
137    if value == 0 {
138        return vec![0];
139    }
140    let mut val = value;
141    let mut bytes = Vec::new();
142    let negative = val < 0;
143    while val != 0 && val != -1 {
144        bytes.push(val as u8);
145        val >>= 8;
146    }
147    // Sign bit handling
148    if !negative && (bytes.last().is_some_and(|b| b & 0x80 != 0)) {
149        bytes.push(0);
150    }
151    if negative && (bytes.last().is_some_and(|b| b & 0x80 == 0)) {
152        bytes.push(0xFF);
153    }
154    bytes
155}
156
157// ═══════════════════════════════════════════════════════════════════
158// NEP-17 Token Helpers
159// ═══════════════════════════════════════════════════════════════════
160
161/// Well-known NEO N3 contract hashes (little-endian).
162pub mod contracts {
163    /// NEO native token script hash.
164    pub const NEO_TOKEN: [u8; 20] = [
165        0xf5, 0x63, 0xea, 0x40, 0xbc, 0x28, 0x3d, 0x4d, 0x0e, 0x05, 0xc4, 0x8e, 0xa3, 0x05, 0xb3,
166        0xf2, 0xa0, 0x73, 0x40, 0xef,
167    ];
168
169    /// GAS native token script hash.
170    pub const GAS_TOKEN: [u8; 20] = [
171        0xcf, 0x76, 0xe2, 0x8b, 0xd0, 0x06, 0x2c, 0x4a, 0x47, 0x8e, 0xe3, 0x55, 0x61, 0x01, 0x13,
172        0x19, 0xf3, 0xcf, 0xa4, 0xd2,
173    ];
174}
175
176/// Build a NEP-17 `transfer` invocation script.
177///
178/// # Arguments
179/// - `token_hash` — Contract script hash (20 bytes, little-endian)
180/// - `from` — Sender script hash
181/// - `to` — Recipient script hash
182/// - `amount` — Transfer amount (in token's smallest unit)
183pub fn nep17_transfer(
184    token_hash: &[u8; 20],
185    from: &[u8; 20],
186    to: &[u8; 20],
187    amount: i64,
188) -> Vec<u8> {
189    let mut sb = ScriptBuilder::new();
190    // Push arguments in reverse order for NeoVM
191    sb.emit(opcode::PUSH0); // data (null for simple transfer)
192    sb.emit_push_integer(amount);
193    sb.emit_push_hash160(to);
194    sb.emit_push_hash160(from);
195    sb.emit_contract_call(token_hash, "transfer", 4);
196    sb.to_bytes()
197}
198
199/// Build a NEP-17 `balanceOf` invocation script.
200pub fn nep17_balance_of(token_hash: &[u8; 20], account: &[u8; 20]) -> Vec<u8> {
201    let mut sb = ScriptBuilder::new();
202    sb.emit_push_hash160(account);
203    sb.emit_contract_call(token_hash, "balanceOf", 1);
204    sb.to_bytes()
205}
206
207/// Build a NEP-17 `symbol` invocation script.
208pub fn nep17_symbol(token_hash: &[u8; 20]) -> Vec<u8> {
209    let mut sb = ScriptBuilder::new();
210    sb.emit_contract_call(token_hash, "symbol", 0);
211    sb.to_bytes()
212}
213
214/// Build a NEP-17 `decimals` invocation script.
215pub fn nep17_decimals(token_hash: &[u8; 20]) -> Vec<u8> {
216    let mut sb = ScriptBuilder::new();
217    sb.emit_contract_call(token_hash, "decimals", 0);
218    sb.to_bytes()
219}
220
221/// Build a NEP-17 `totalSupply` invocation script.
222pub fn nep17_total_supply(token_hash: &[u8; 20]) -> Vec<u8> {
223    let mut sb = ScriptBuilder::new();
224    sb.emit_contract_call(token_hash, "totalSupply", 0);
225    sb.to_bytes()
226}
227
228// ═══════════════════════════════════════════════════════════════════
229// Transaction Builder
230// ═══════════════════════════════════════════════════════════════════
231
232/// NEO N3 transaction.
233#[derive(Debug, Clone)]
234pub struct NeoTransaction {
235    /// Transaction version (currently 0).
236    pub version: u8,
237    /// Nonce for uniqueness.
238    pub nonce: u32,
239    /// System fee in fractions of GAS.
240    pub system_fee: i64,
241    /// Network fee in fractions of GAS.
242    pub network_fee: i64,
243    /// Valid until this block height.
244    pub valid_until_block: u32,
245    /// Transaction signers.
246    pub signers: Vec<TransactionSigner>,
247    /// Transaction attributes.
248    pub attributes: Vec<TransactionAttribute>,
249    /// The invocation script.
250    pub script: Vec<u8>,
251}
252
253/// A transaction signer.
254#[derive(Debug, Clone)]
255pub struct TransactionSigner {
256    /// Account script hash.
257    pub account: [u8; 20],
258    /// Witness scope.
259    pub scope: WitnessScope,
260    /// Allowed contracts (for CustomContracts scope).
261    pub allowed_contracts: Vec<[u8; 20]>,
262}
263
264/// Witness scope for transaction signers.
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266#[repr(u8)]
267pub enum WitnessScope {
268    /// No restrictions.
269    None = 0x00,
270    /// Only the entry contract.
271    CalledByEntry = 0x01,
272    /// Custom contracts list.
273    CustomContracts = 0x10,
274    /// Global scope.
275    Global = 0x80,
276}
277
278/// Transaction attribute (extensible).
279#[derive(Debug, Clone)]
280pub struct TransactionAttribute {
281    /// Attribute type.
282    pub attr_type: u8,
283    /// Attribute data.
284    pub data: Vec<u8>,
285}
286
287impl NeoTransaction {
288    /// Create a new transaction with default values.
289    #[must_use]
290    pub fn new(script: Vec<u8>) -> Self {
291        Self {
292            version: 0,
293            nonce: 0,
294            system_fee: 0,
295            network_fee: 0,
296            valid_until_block: 0,
297            signers: vec![],
298            attributes: vec![],
299            script,
300        }
301    }
302
303    /// Serialize the transaction for signing (without witnesses).
304    #[must_use]
305    pub fn serialize_unsigned(&self) -> Vec<u8> {
306        let mut buf = Vec::new();
307        buf.push(self.version);
308        buf.extend_from_slice(&self.nonce.to_le_bytes());
309        buf.extend_from_slice(&self.system_fee.to_le_bytes());
310        buf.extend_from_slice(&self.network_fee.to_le_bytes());
311        buf.extend_from_slice(&self.valid_until_block.to_le_bytes());
312
313        // Signers
314        write_var_int(&mut buf, self.signers.len() as u64);
315        for signer in &self.signers {
316            buf.extend_from_slice(&signer.account);
317            buf.push(signer.scope as u8);
318            if signer.scope == WitnessScope::CustomContracts {
319                write_var_int(&mut buf, signer.allowed_contracts.len() as u64);
320                for c in &signer.allowed_contracts {
321                    buf.extend_from_slice(c);
322                }
323            }
324        }
325
326        // Attributes
327        write_var_int(&mut buf, self.attributes.len() as u64);
328        for attr in &self.attributes {
329            buf.push(attr.attr_type);
330            write_var_int(&mut buf, attr.data.len() as u64);
331            buf.extend_from_slice(&attr.data);
332        }
333
334        // Script
335        write_var_int(&mut buf, self.script.len() as u64);
336        buf.extend_from_slice(&self.script);
337
338        buf
339    }
340
341    /// Compute the transaction hash (SHA-256 of serialized unsigned tx).
342    #[must_use]
343    pub fn hash(&self) -> [u8; 32] {
344        use sha2::{Digest, Sha256};
345        let data = self.serialize_unsigned();
346        let mut out = [0u8; 32];
347        out.copy_from_slice(&Sha256::digest(data));
348        out
349    }
350
351    /// Sign the transaction with a NEO signer.
352    pub fn sign(&self, signer: &super::NeoSigner) -> Result<super::NeoSignature, SignerError> {
353        let hash = self.hash();
354        signer.sign_digest(&hash)
355    }
356}
357
358fn write_var_int(buf: &mut Vec<u8>, val: u64) {
359    if val < 0xFD {
360        buf.push(val as u8);
361    } else if val <= 0xFFFF {
362        buf.push(0xFD);
363        buf.extend_from_slice(&(val as u16).to_le_bytes());
364    } else if val <= 0xFFFF_FFFF {
365        buf.push(0xFE);
366        buf.extend_from_slice(&(val as u32).to_le_bytes());
367    } else {
368        buf.push(0xFF);
369        buf.extend_from_slice(&val.to_le_bytes());
370    }
371}
372
373// ═══════════════════════════════════════════════════════════════════
374// Transaction Deserialization
375// ═══════════════════════════════════════════════════════════════════
376
377/// Read a variable-length integer from a byte slice.
378///
379/// Returns `(value, bytes_consumed)`.
380pub fn read_var_int(data: &[u8]) -> Result<(u64, usize), SignerError> {
381    if data.is_empty() {
382        return Err(SignerError::ParseError("read_var_int: empty".into()));
383    }
384    match data[0] {
385        0..=0xFC => Ok((u64::from(data[0]), 1)),
386        0xFD => {
387            if data.len() < 3 {
388                return Err(SignerError::ParseError(
389                    "read_var_int: truncated u16".into(),
390                ));
391            }
392            let val = u16::from_le_bytes([data[1], data[2]]);
393            if val < 0xFD {
394                return Err(SignerError::ParseError(
395                    "read_var_int: non-canonical u16 encoding".into(),
396                ));
397            }
398            Ok((u64::from(val), 3))
399        }
400        0xFE => {
401            if data.len() < 5 {
402                return Err(SignerError::ParseError(
403                    "read_var_int: truncated u32".into(),
404                ));
405            }
406            let val = u32::from_le_bytes([data[1], data[2], data[3], data[4]]);
407            if val <= 0xFFFF {
408                return Err(SignerError::ParseError(
409                    "read_var_int: non-canonical u32 encoding".into(),
410                ));
411            }
412            Ok((u64::from(val), 5))
413        }
414        0xFF => {
415            if data.len() < 9 {
416                return Err(SignerError::ParseError(
417                    "read_var_int: truncated u64".into(),
418                ));
419            }
420            let val = u64::from_le_bytes([
421                data[1], data[2], data[3], data[4], data[5], data[6], data[7], data[8],
422            ]);
423            if val <= 0xFFFF_FFFF {
424                return Err(SignerError::ParseError(
425                    "read_var_int: non-canonical u64 encoding".into(),
426                ));
427            }
428            Ok((val, 9))
429        }
430    }
431}
432
433impl NeoTransaction {
434    /// Deserialize a NEO N3 transaction from its unsigned byte representation.
435    ///
436    /// Parses version, nonce, fees, signers, attributes, and script.
437    pub fn deserialize(data: &[u8]) -> Result<Self, SignerError> {
438        if data.len() < 25 {
439            return Err(SignerError::ParseError("neo tx: too short".into()));
440        }
441        let mut pos = 0;
442
443        let version = data[pos];
444        pos += 1;
445
446        let nonce = u32::from_le_bytes(
447            data[pos..pos + 4]
448                .try_into()
449                .map_err(|_| SignerError::ParseError("neo tx: nonce".into()))?,
450        );
451        pos += 4;
452
453        let system_fee = i64::from_le_bytes(
454            data[pos..pos + 8]
455                .try_into()
456                .map_err(|_| SignerError::ParseError("neo tx: system_fee".into()))?,
457        );
458        pos += 8;
459
460        let network_fee = i64::from_le_bytes(
461            data[pos..pos + 8]
462                .try_into()
463                .map_err(|_| SignerError::ParseError("neo tx: network_fee".into()))?,
464        );
465        pos += 8;
466
467        let valid_until_block = u32::from_le_bytes(
468            data[pos..pos + 4]
469                .try_into()
470                .map_err(|_| SignerError::ParseError("neo tx: valid_until".into()))?,
471        );
472        pos += 4;
473
474        // Signers
475        let (num_signers, consumed) = read_var_int(&data[pos..])?;
476        pos += consumed;
477        let mut signers = Vec::new();
478        for _ in 0..num_signers {
479            if pos + 21 > data.len() {
480                return Err(SignerError::ParseError("neo tx: truncated signer".into()));
481            }
482            let mut account = [0u8; 20];
483            account.copy_from_slice(&data[pos..pos + 20]);
484            pos += 20;
485            let scope_byte = data[pos];
486            pos += 1;
487            let scope = match scope_byte {
488                0x00 => WitnessScope::None,
489                0x01 => WitnessScope::CalledByEntry,
490                0x10 => WitnessScope::CustomContracts,
491                0x80 => WitnessScope::Global,
492                _ => {
493                    return Err(SignerError::ParseError(format!(
494                        "neo tx: unknown witness scope 0x{scope_byte:02x}"
495                    )))
496                }
497            };
498            let mut allowed_contracts = Vec::new();
499            if scope == WitnessScope::CustomContracts {
500                let (num_contracts, c) = read_var_int(&data[pos..])?;
501                pos += c;
502                for _ in 0..num_contracts {
503                    if pos + 20 > data.len() {
504                        return Err(SignerError::ParseError(
505                            "neo tx: truncated allowed contract".into(),
506                        ));
507                    }
508                    let mut contract = [0u8; 20];
509                    contract.copy_from_slice(&data[pos..pos + 20]);
510                    pos += 20;
511                    allowed_contracts.push(contract);
512                }
513            }
514            signers.push(TransactionSigner {
515                account,
516                scope,
517                allowed_contracts,
518            });
519        }
520
521        // Attributes
522        let (num_attrs, consumed) = read_var_int(&data[pos..])?;
523        pos += consumed;
524        let mut attributes = Vec::new();
525        for _ in 0..num_attrs {
526            if pos >= data.len() {
527                return Err(SignerError::ParseError("neo tx: truncated attr".into()));
528            }
529            let attr_type = data[pos];
530            pos += 1;
531            let (attr_len, c) = read_var_int(&data[pos..])?;
532            pos += c;
533            let attr_len_usize = usize::try_from(attr_len)
534                .map_err(|_| SignerError::ParseError("neo tx: attr length exceeds usize".into()))?;
535            let attr_end = pos
536                .checked_add(attr_len_usize)
537                .ok_or_else(|| SignerError::ParseError("neo tx: attr length overflow".into()))?;
538            if attr_end > data.len() {
539                return Err(SignerError::ParseError(
540                    "neo tx: truncated attr data".into(),
541                ));
542            }
543            let attr_data = data[pos..attr_end].to_vec();
544            pos = attr_end;
545            attributes.push(TransactionAttribute {
546                attr_type,
547                data: attr_data,
548            });
549        }
550
551        // Script
552        let (script_len, consumed) = read_var_int(&data[pos..])?;
553        pos += consumed;
554        let script_len_usize = usize::try_from(script_len)
555            .map_err(|_| SignerError::ParseError("neo tx: script length exceeds usize".into()))?;
556        let script_end = pos
557            .checked_add(script_len_usize)
558            .ok_or_else(|| SignerError::ParseError("neo tx: script length overflow".into()))?;
559        if script_end > data.len() {
560            return Err(SignerError::ParseError("neo tx: truncated script".into()));
561        }
562        let script = data[pos..script_end].to_vec();
563        pos = script_end;
564
565        // Reject trailing bytes
566        if pos != data.len() {
567            return Err(SignerError::ParseError(format!(
568                "neo tx: {} trailing bytes",
569                data.len() - pos
570            )));
571        }
572
573        Ok(Self {
574            version,
575            nonce,
576            system_fee,
577            network_fee,
578            valid_until_block,
579            signers,
580            attributes,
581            script,
582        })
583    }
584}
585
586// ═══════════════════════════════════════════════════════════════════
587// NEP-17 Extended Operations
588// ═══════════════════════════════════════════════════════════════════
589
590/// Build a NEP-17 `approve` invocation script.
591///
592/// Allows `spender` to transfer up to `amount` tokens on behalf of `owner`.
593/// Note: Not all NEP-17 tokens support approve — only extended implementations.
594pub fn nep17_approve(
595    token_hash: &[u8; 20],
596    owner: &[u8; 20],
597    spender: &[u8; 20],
598    amount: i64,
599) -> Vec<u8> {
600    let mut sb = ScriptBuilder::new();
601    sb.emit_push_integer(amount)
602        .emit_push_hash160(spender)
603        .emit_push_hash160(owner)
604        .emit_contract_call(token_hash, "approve", 3);
605    sb.to_bytes()
606}
607
608/// Build a NEP-17 `allowance` query script.
609///
610/// Returns the remaining amount that `spender` is allowed to transfer from `owner`.
611pub fn nep17_allowance(token_hash: &[u8; 20], owner: &[u8; 20], spender: &[u8; 20]) -> Vec<u8> {
612    let mut sb = ScriptBuilder::new();
613    sb.emit_push_hash160(spender)
614        .emit_push_hash160(owner)
615        .emit_contract_call(token_hash, "allowance", 2);
616    sb.to_bytes()
617}
618
619/// Build a NEP-17 `transferFrom` invocation script.
620///
621/// Transfers `amount` from `from` to `to` using an approved allowance.
622pub fn nep17_transfer_from(
623    token_hash: &[u8; 20],
624    spender: &[u8; 20],
625    from: &[u8; 20],
626    to: &[u8; 20],
627    amount: i64,
628) -> Vec<u8> {
629    let mut sb = ScriptBuilder::new();
630    sb.emit_push_integer(amount)
631        .emit_push_hash160(to)
632        .emit_push_hash160(from)
633        .emit_push_hash160(spender)
634        .emit_contract_call(token_hash, "transferFrom", 4);
635    sb.to_bytes()
636}
637
638// ═══════════════════════════════════════════════════════════════════
639// Contract Deployment
640// ═══════════════════════════════════════════════════════════════════
641
642/// ContractManagement native contract hash.
643pub const CONTRACT_MANAGEMENT_HASH: [u8; 20] = [
644    0xfd, 0xa3, 0xfa, 0x43, 0x34, 0x6b, 0x9d, 0x6b, 0x51, 0xd3, 0x3c, 0x64, 0xb2, 0x1c, 0x68, 0x24,
645    0x38, 0x97, 0x28, 0xe6,
646];
647
648/// Build a contract deployment invocation script.
649///
650/// # Arguments
651/// - `nef_bytes` — Compiled NEF (NEO Executable Format) bytes
652/// - `manifest_json` — Contract manifest JSON string
653pub fn contract_deploy(nef_bytes: &[u8], manifest_json: &str) -> Vec<u8> {
654    let mut sb = ScriptBuilder::new();
655    sb.emit_push_bytes(manifest_json.as_bytes())
656        .emit_push_bytes(nef_bytes)
657        .emit_contract_call(&CONTRACT_MANAGEMENT_HASH, "deploy", 2);
658    sb.to_bytes()
659}
660
661/// Build a contract update invocation script.
662pub fn contract_update(nef_bytes: &[u8], manifest_json: &str) -> Vec<u8> {
663    let mut sb = ScriptBuilder::new();
664    sb.emit_push_bytes(manifest_json.as_bytes())
665        .emit_push_bytes(nef_bytes)
666        .emit_contract_call(&CONTRACT_MANAGEMENT_HASH, "update", 2);
667    sb.to_bytes()
668}
669
670/// Build a contract destroy invocation script.
671pub fn contract_destroy() -> Vec<u8> {
672    let mut sb = ScriptBuilder::new();
673    sb.emit_contract_call(&CONTRACT_MANAGEMENT_HASH, "destroy", 0);
674    sb.to_bytes()
675}
676
677// ═══════════════════════════════════════════════════════════════════
678// Governance / Voting
679// ═══════════════════════════════════════════════════════════════════
680
681/// Build a governance `vote` script.
682///
683/// Votes for a consensus node with the given public key.
684///
685/// # Arguments
686/// - `voter` — Script hash of the voter
687/// - `candidate_pubkey` — 33-byte compressed public key of the candidate (or empty to cancel vote)
688pub fn neo_vote(voter: &[u8; 20], candidate_pubkey: Option<&[u8; 33]>) -> Vec<u8> {
689    let mut sb = ScriptBuilder::new();
690    match candidate_pubkey {
691        Some(pk) => sb.emit_push_bytes(pk),
692        None => sb.emit(opcode::PUSH0), // null = cancel vote
693    };
694    sb.emit_push_hash160(voter)
695        .emit_contract_call(&contracts::NEO_TOKEN, "vote", 2);
696    sb.to_bytes()
697}
698
699/// Build a script to query unclaimed GAS for an account.
700pub fn neo_unclaimed_gas(account: &[u8; 20], end_height: u32) -> Vec<u8> {
701    let mut sb = ScriptBuilder::new();
702    sb.emit_push_integer(i64::from(end_height))
703        .emit_push_hash160(account)
704        .emit_contract_call(&contracts::NEO_TOKEN, "unclaimedGas", 2);
705    sb.to_bytes()
706}
707
708/// Build a script to register as a consensus candidate.
709pub fn neo_register_candidate(pubkey: &[u8; 33]) -> Vec<u8> {
710    let mut sb = ScriptBuilder::new();
711    sb.emit_push_bytes(pubkey)
712        .emit_contract_call(&contracts::NEO_TOKEN, "registerCandidate", 1);
713    sb.to_bytes()
714}
715
716/// Build a script to query the list of registered candidates.
717pub fn neo_get_candidates() -> Vec<u8> {
718    let mut sb = ScriptBuilder::new();
719    sb.emit_contract_call(&contracts::NEO_TOKEN, "getCandidates", 0);
720    sb.to_bytes()
721}
722
723/// Build a script to get the current committee members.
724pub fn neo_get_committee() -> Vec<u8> {
725    let mut sb = ScriptBuilder::new();
726    sb.emit_contract_call(&contracts::NEO_TOKEN, "getCommittee", 0);
727    sb.to_bytes()
728}
729
730// ═══════════════════════════════════════════════════════════════════
731// Tests
732// ═══════════════════════════════════════════════════════════════════
733
734#[cfg(test)]
735#[allow(clippy::unwrap_used, clippy::expect_used)]
736mod tests {
737    use super::*;
738    use crate::traits::KeyPair;
739
740    // ─── Script Builder Tests ──────────────────────────────────────
741
742    #[test]
743    fn test_script_builder_push_integer() {
744        let mut sb = ScriptBuilder::new();
745        sb.emit_push_integer(0);
746        assert_eq!(sb.to_bytes(), vec![opcode::PUSH0]);
747    }
748
749    #[test]
750    fn test_script_builder_push_integer_range() {
751        for i in 1..=16 {
752            let mut sb = ScriptBuilder::new();
753            sb.emit_push_integer(i);
754            let bytes = sb.to_bytes();
755            assert_eq!(bytes.len(), 1);
756            assert_eq!(bytes[0], opcode::PUSH1 + (i as u8 - 1));
757        }
758    }
759
760    #[test]
761    fn test_script_builder_push_bytes() {
762        let mut sb = ScriptBuilder::new();
763        sb.emit_push_bytes(b"hello");
764        let bytes = sb.to_bytes();
765        assert_eq!(bytes[0], opcode::PUSHDATA1);
766        assert_eq!(bytes[1], 5);
767        assert_eq!(&bytes[2..], b"hello");
768    }
769
770    #[test]
771    fn test_script_builder_syscall() {
772        let mut sb = ScriptBuilder::new();
773        sb.emit_syscall(0x627d5b52);
774        let bytes = sb.to_bytes();
775        assert_eq!(bytes[0], opcode::SYSCALL);
776        assert_eq!(&bytes[1..5], &0x627d5b52u32.to_le_bytes());
777    }
778
779    // ─── NEP-17 Tests ──────────────────────────────────────────────
780
781    #[test]
782    fn test_nep17_transfer_script() {
783        let from = [0xAA; 20];
784        let to = [0xBB; 20];
785        let script = nep17_transfer(&contracts::NEO_TOKEN, &from, &to, 10);
786        assert!(!script.is_empty());
787        // Should contain "transfer" method name
788        let s = String::from_utf8_lossy(&script);
789        assert!(s.contains("transfer"));
790    }
791
792    #[test]
793    fn test_nep17_balance_of_script() {
794        let account = [0xCC; 20];
795        let script = nep17_balance_of(&contracts::GAS_TOKEN, &account);
796        assert!(!script.is_empty());
797        let s = String::from_utf8_lossy(&script);
798        assert!(s.contains("balanceOf"));
799    }
800
801    #[test]
802    fn test_nep17_symbol() {
803        let script = nep17_symbol(&contracts::NEO_TOKEN);
804        assert!(!script.is_empty());
805        let s = String::from_utf8_lossy(&script);
806        assert!(s.contains("symbol"));
807    }
808
809    #[test]
810    fn test_nep17_decimals() {
811        let script = nep17_decimals(&contracts::GAS_TOKEN);
812        let s = String::from_utf8_lossy(&script);
813        assert!(s.contains("decimals"));
814    }
815
816    #[test]
817    fn test_nep17_total_supply() {
818        let script = nep17_total_supply(&contracts::NEO_TOKEN);
819        let s = String::from_utf8_lossy(&script);
820        assert!(s.contains("totalSupply"));
821    }
822
823    // ─── Transaction Tests ─────────────────────────────────────────
824
825    #[test]
826    fn test_neo_transaction_serialization() {
827        let script = nep17_transfer(&contracts::NEO_TOKEN, &[0xAA; 20], &[0xBB; 20], 1);
828        let tx = NeoTransaction {
829            version: 0,
830            nonce: 12345,
831            system_fee: 100_000,
832            network_fee: 50_000,
833            valid_until_block: 1000,
834            signers: vec![TransactionSigner {
835                account: [0xAA; 20],
836                scope: WitnessScope::CalledByEntry,
837                allowed_contracts: vec![],
838            }],
839            attributes: vec![],
840            script,
841        };
842        let serialized = tx.serialize_unsigned();
843        assert!(!serialized.is_empty());
844        assert_eq!(serialized[0], 0); // version 0
845    }
846
847    #[test]
848    fn test_neo_transaction_hash_deterministic() {
849        let script = nep17_transfer(&contracts::GAS_TOKEN, &[0xAA; 20], &[0xBB; 20], 100);
850        let tx = NeoTransaction::new(script);
851        assert_eq!(tx.hash(), tx.hash());
852    }
853
854    #[test]
855    fn test_neo_transaction_sign() {
856        let signer = super::super::NeoSigner::generate().unwrap();
857        let script_hash = signer.script_hash();
858        let script = nep17_transfer(&contracts::NEO_TOKEN, &script_hash, &[0xBB; 20], 1);
859        let tx = NeoTransaction::new(script);
860        let sig = tx.sign(&signer).unwrap();
861        assert_eq!(sig.to_bytes().len(), 64);
862    }
863
864    #[test]
865    fn test_neo_transaction_different_nonce_different_hash() {
866        let script = vec![0x00];
867        let mut tx1 = NeoTransaction::new(script.clone());
868        tx1.nonce = 1;
869        let mut tx2 = NeoTransaction::new(script);
870        tx2.nonce = 2;
871        assert_ne!(tx1.hash(), tx2.hash());
872    }
873
874    // ─── Deserialization Tests ─────────────────────────────────────
875
876    #[test]
877    fn test_neo_tx_serialize_deserialize_roundtrip() {
878        let script = nep17_transfer(&contracts::NEO_TOKEN, &[0xAA; 20], &[0xBB; 20], 10);
879        let tx = NeoTransaction {
880            version: 0,
881            nonce: 42,
882            system_fee: 100_000,
883            network_fee: 50_000,
884            valid_until_block: 999,
885            signers: vec![TransactionSigner {
886                account: [0xAA; 20],
887                scope: WitnessScope::CalledByEntry,
888                allowed_contracts: vec![],
889            }],
890            attributes: vec![],
891            script,
892        };
893        let bytes = tx.serialize_unsigned();
894        let restored = NeoTransaction::deserialize(&bytes).unwrap();
895        assert_eq!(restored.version, tx.version);
896        assert_eq!(restored.nonce, tx.nonce);
897        assert_eq!(restored.system_fee, tx.system_fee);
898        assert_eq!(restored.network_fee, tx.network_fee);
899        assert_eq!(restored.valid_until_block, tx.valid_until_block);
900        assert_eq!(restored.signers.len(), 1);
901        assert_eq!(restored.signers[0].account, [0xAA; 20]);
902        assert_eq!(restored.script, tx.script);
903    }
904
905    #[test]
906    fn test_neo_tx_deserialize_empty_fails() {
907        assert!(NeoTransaction::deserialize(&[]).is_err());
908        assert!(NeoTransaction::deserialize(&[0u8; 10]).is_err());
909    }
910
911    #[test]
912    fn test_read_var_int() {
913        assert_eq!(read_var_int(&[0x00]).unwrap(), (0, 1));
914        assert_eq!(read_var_int(&[0xFC]).unwrap(), (252, 1));
915        assert_eq!(read_var_int(&[0xFD, 0xFD, 0x00]).unwrap(), (253, 3));
916        assert_eq!(
917            read_var_int(&[0xFE, 0x00, 0x00, 0x01, 0x00]).unwrap(),
918            (65_536, 5)
919        );
920        assert!(read_var_int(&[0xFD, 0x01, 0x00]).is_err()); // non-canonical
921        assert!(read_var_int(&[0xFE, 0x01, 0x00, 0x00, 0x00]).is_err()); // non-canonical
922        assert!(read_var_int(&[0xFF, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]).is_err());
923    }
924
925    // ─── NEP-17 Extended Tests ────────────────────────────────────
926
927    #[test]
928    fn test_nep17_approve_script() {
929        let script = nep17_approve(&contracts::GAS_TOKEN, &[0xAA; 20], &[0xBB; 20], 1000);
930        assert!(!script.is_empty());
931        let s = String::from_utf8_lossy(&script);
932        assert!(s.contains("approve"));
933    }
934
935    #[test]
936    fn test_nep17_allowance_script() {
937        let script = nep17_allowance(&contracts::GAS_TOKEN, &[0xAA; 20], &[0xBB; 20]);
938        assert!(!script.is_empty());
939        let s = String::from_utf8_lossy(&script);
940        assert!(s.contains("allowance"));
941    }
942
943    #[test]
944    fn test_nep17_transfer_from_script() {
945        let script = nep17_transfer_from(
946            &contracts::GAS_TOKEN,
947            &[0xAA; 20],
948            &[0xBB; 20],
949            &[0xCC; 20],
950            500,
951        );
952        let s = String::from_utf8_lossy(&script);
953        assert!(s.contains("transferFrom"));
954    }
955
956    // ─── Contract Deployment Tests ────────────────────────────────
957
958    #[test]
959    fn test_contract_deploy_script() {
960        let nef = b"\x4e\x45\x46\x33"; // NEF magic
961        let manifest = r#"{"name":"test"}"#;
962        let script = contract_deploy(nef, manifest);
963        assert!(!script.is_empty());
964        let s = String::from_utf8_lossy(&script);
965        assert!(s.contains("deploy"));
966    }
967
968    #[test]
969    fn test_contract_update_script() {
970        let script = contract_update(b"\x00", r#"{}"#);
971        let s = String::from_utf8_lossy(&script);
972        assert!(s.contains("update"));
973    }
974
975    #[test]
976    fn test_contract_destroy_script() {
977        let script = contract_destroy();
978        let s = String::from_utf8_lossy(&script);
979        assert!(s.contains("destroy"));
980    }
981
982    // ─── Governance Tests ─────────────────────────────────────────
983
984    #[test]
985    fn test_neo_vote_script() {
986        let voter = [0xAA; 20];
987        let pubkey = [0x02; 33];
988        let script = neo_vote(&voter, Some(&pubkey));
989        let s = String::from_utf8_lossy(&script);
990        assert!(s.contains("vote"));
991    }
992
993    #[test]
994    fn test_neo_vote_cancel() {
995        let voter = [0xAA; 20];
996        let script = neo_vote(&voter, None);
997        let s = String::from_utf8_lossy(&script);
998        assert!(s.contains("vote"));
999    }
1000
1001    #[test]
1002    fn test_neo_unclaimed_gas() {
1003        let script = neo_unclaimed_gas(&[0xAA; 20], 100_000);
1004        let s = String::from_utf8_lossy(&script);
1005        assert!(s.contains("unclaimedGas"));
1006    }
1007
1008    #[test]
1009    fn test_neo_register_candidate() {
1010        let script = neo_register_candidate(&[0x02; 33]);
1011        let s = String::from_utf8_lossy(&script);
1012        assert!(s.contains("registerCandidate"));
1013    }
1014
1015    #[test]
1016    fn test_neo_get_candidates() {
1017        let script = neo_get_candidates();
1018        let s = String::from_utf8_lossy(&script);
1019        assert!(s.contains("getCandidates"));
1020    }
1021
1022    #[test]
1023    fn test_neo_get_committee() {
1024        let script = neo_get_committee();
1025        let s = String::from_utf8_lossy(&script);
1026        assert!(s.contains("getCommittee"));
1027    }
1028}