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