Skip to main content

tenzro_types/
transaction.rs

1//! Transaction types for Tenzro Network
2//!
3//! This module defines the transaction structure and related types used
4//! to represent state transitions on the Tenzro Network blockchain.
5
6use crate::asset::AssetId;
7use crate::primitives::{Address, Hash, Nonce, Signature, Timestamp, ChainId};
8use crate::settlement::{ReleaseConditions, ServiceProof};
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11
12/// Maximum transaction data size in bytes (128 KB)
13pub const MAX_TX_DATA_SIZE: usize = 131_072;
14
15/// A transaction on Tenzro Network
16///
17/// Transactions represent actions taken by accounts, including transfers,
18/// smart contract calls, agent operations, and model inference requests.
19///
20/// # Note
21/// The `Default` implementation creates a transaction with zero values
22/// and is intended for testing purposes only. Production transactions
23/// should be created with `Transaction::new()` and properly signed.
24///
25/// # Post-quantum migration
26///
27/// The `pq_public_key` field carries the ML-DSA-65 verifying key bytes
28/// (FIPS 204, exactly 1952 bytes) and is **mandatory**. Tenzro Network does
29/// not support classical-only transactions — the field has no `Option`
30/// wrapper and no `serde(default)`. Decoders reject any payload that omits
31/// or mis-sizes this field. There is no legacy fallback: a classical-only
32/// transaction cannot be constructed in this codebase and cannot be parsed
33/// from any external source.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct Transaction {
36    /// The chain ID this transaction is valid for
37    pub chain_id: ChainId,
38    /// The sender's address
39    pub from: Address,
40    /// The recipient's address (or contract address)
41    pub to: Address,
42    /// The nonce for replay protection
43    pub nonce: Nonce,
44    /// The transaction type and payload
45    pub tx_type: TransactionType,
46    /// Maximum gas to spend
47    pub gas_limit: u64,
48    /// Gas price in the smallest unit of TNZO
49    pub gas_price: u64,
50    /// Transaction timestamp
51    pub timestamp: Timestamp,
52    /// Optional memo or metadata
53    pub memo: Option<String>,
54    /// ML-DSA-65 verifying key bytes (FIPS 204, exactly 1952 bytes).
55    /// Bound into the `hash()` preimage so a hybrid signer commits to the PQ
56    /// key before signing and any key-substitution attempt invalidates the
57    /// transaction.
58    #[serde(deserialize_with = "crate::validation::bounded_pq_public_key_bytes")]
59    pub pq_public_key: Vec<u8>,
60}
61
62impl Transaction {
63    /// Creates a new transaction. The `pq_public_key` is the ML-DSA-65
64    /// verifying key bytes (FIPS 204, exactly 1952 bytes) and is mandatory —
65    /// classical-only transactions are not constructible.
66    pub fn new(
67        chain_id: ChainId,
68        from: Address,
69        to: Address,
70        nonce: Nonce,
71        tx_type: TransactionType,
72        gas_limit: u64,
73        gas_price: u64,
74        pq_public_key: Vec<u8>,
75    ) -> Self {
76        Self {
77            chain_id,
78            from,
79            to,
80            nonce,
81            tx_type,
82            gas_limit,
83            gas_price,
84            timestamp: Timestamp::now(),
85            memo: None,
86            pq_public_key,
87        }
88    }
89
90    /// Adds a memo to the transaction
91    pub fn with_memo(mut self, memo: String) -> Self {
92        self.memo = Some(memo);
93        self
94    }
95
96    /// Computes the SHA-256 hash of the transaction.
97    ///
98    /// The preimage layout is the canonical signing surface for the network.
99    /// The mandatory ML-DSA-65 verifying key (`pq_public_key`) is included
100    /// with an explicit `u32` little-endian length prefix so the preimage
101    /// is unambiguous and a key-substitution attempt invalidates the hash.
102    pub fn hash(&self) -> Hash {
103        let mut hasher = Sha256::new();
104        hasher.update(self.chain_id.0.to_le_bytes());
105        hasher.update(self.from.as_bytes());
106        hasher.update(self.to.as_bytes());
107        hasher.update(self.nonce.0.to_le_bytes());
108        hasher.update(self.gas_limit.to_le_bytes());
109        hasher.update(self.gas_price.to_le_bytes());
110        hasher.update(self.timestamp.0.to_le_bytes());
111        // Include tx_type via JSON serialization for deterministic encoding
112        if let Ok(tx_type_json) = serde_json::to_vec(&self.tx_type) {
113            hasher.update(&tx_type_json);
114        }
115        if let Some(ref memo) = self.memo {
116            hasher.update(memo.as_bytes());
117        }
118        // PQ-binding: explicit length prefix is canonical even though the
119        // field is fixed-size — keeps the preimage shape stable if FIPS 204
120        // ever moves to a variant with a different key length.
121        hasher.update((self.pq_public_key.len() as u32).to_le_bytes());
122        hasher.update(&self.pq_public_key);
123        let result = hasher.finalize();
124        Hash::new(result.into())
125    }
126}
127
128/// The type and payload of a transaction.
129///
130/// Different transaction types enable different operations on Tenzro Network.
131///
132/// Uses serde's default externally-tagged enum representation
133/// (`{"Transfer": {...}}` in JSON, `u32` discriminant + payload in bincode).
134/// Adjacently-tagged form (`#[serde(tag = "type", content = "data")]`) is
135/// incompatible with bincode 1.x — see `tenzro_network::message::MessagePayload`.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137pub enum TransactionType {
138    /// Transfer TNZO tokens
139    Transfer {
140        /// Amount to transfer in the smallest unit
141        amount: u128,
142    },
143    /// Deploy a smart contract
144    ContractDeploy {
145        /// Contract bytecode
146        code: Vec<u8>,
147        /// Constructor arguments
148        args: Vec<u8>,
149    },
150    /// Call a smart contract
151    ContractCall {
152        /// Function signature
153        function: String,
154        /// Function arguments
155        args: Vec<u8>,
156    },
157    /// Register an AI agent
158    AgentRegister {
159        /// Agent configuration
160        config: Vec<u8>,
161    },
162    /// Execute an agent task
163    AgentExecute {
164        /// Task specification
165        task: Vec<u8>,
166    },
167    /// Request model inference
168    ModelInference {
169        /// Model identifier
170        model_id: String,
171        /// Inference input
172        input: Vec<u8>,
173    },
174    /// Register as a TEE provider
175    TeeProviderRegister {
176        /// Attestation data
177        attestation: Vec<u8>,
178        /// Provider info
179        info: Vec<u8>,
180    },
181    /// Stake tokens for provider operations
182    ProviderStake {
183        /// Amount to stake
184        amount: u128,
185        /// Provider type
186        provider_type: String,
187    },
188    /// Unstake tokens
189    ProviderUnstake {
190        /// Amount to unstake
191        amount: u128,
192    },
193    /// Submit a governance proposal
194    GovernancePropose {
195        /// Proposal data
196        proposal: Vec<u8>,
197    },
198    /// Vote on a governance proposal
199    GovernanceVote {
200        /// Proposal ID
201        proposal_id: String,
202        /// Vote value
203        vote: bool,
204    },
205    /// Initiate a bridge transfer
206    BridgeTransfer {
207        /// Target chain
208        target_chain: String,
209        /// Target address
210        target_address: String,
211        /// Amount to bridge
212        amount: u128,
213    },
214    /// Create a new escrow account, locking funds from the payer
215    ///
216    /// The escrow_id is derived deterministically by the VM as
217    /// `SHA-256("tenzro/escrow/id" || payer || nonce_le)` and the funds are
218    /// transferred to a vault address derived as
219    /// `Address(SHA-256("tenzro/escrow/vault" || escrow_id)[..20])`.
220    ///
221    /// Authorization: `tx.from` must equal the payer (enforced by signature verification).
222    CreateEscrow {
223        /// Recipient of funds upon successful release
224        payee: Address,
225        /// Amount to lock in escrow
226        amount: u128,
227        /// Asset being escrowed
228        asset_id: AssetId,
229        /// Unix timestamp (millis) at which the escrow expires
230        expires_at: u64,
231        /// Conditions for releasing funds to the payee
232        release_conditions: ReleaseConditions,
233    },
234    /// Release escrowed funds to the payee with a proof of service
235    ///
236    /// Authorization: `tx.from` must equal the original payer.
237    ReleaseEscrow {
238        /// 32-byte deterministic escrow identifier
239        escrow_id: [u8; 32],
240        /// Proof satisfying the escrow's release conditions
241        proof: ServiceProof,
242    },
243    /// Refund escrowed funds to the payer
244    ///
245    /// Authorization: `tx.from` must equal the original payer AND the escrow must
246    /// either be expired or use Timeout/Custom release conditions.
247    RefundEscrow {
248        /// 32-byte deterministic escrow identifier
249        escrow_id: [u8; 32],
250    },
251    /// Pause an agent (Agent-Swarm Spec 1, tier 1).
252    ///
253    /// Reversible state. Agent stops accepting new tasks; existing
254    /// obligations are honored under the pause-bypass allow-list. Stake
255    /// untouched. Inbound payments still permitted; outbound payments
256    /// blocked.
257    ///
258    /// Authorization: `tx.from` MUST equal the agent's controller DID
259    /// (or hold a `DelegationScope.allowed_operations` entry of
260    /// `pause_agent`). Network-initiated pauses are not allowed — escalate
261    /// to Quarantine instead.
262    PauseAgent {
263        /// DID of the agent to pause (`did:tenzro:machine:...`)
264        agent_did: String,
265        /// DID of the authorising controller. Must match the identity
266        /// bound to `tx.from`; echoed on the wire so the receipt is
267        /// self-describing and the VM does not need an identity lookup.
268        controller_did: String,
269        /// Canonical reason code (see kill-switch spec §"Reason codes")
270        reason_code: u16,
271        /// Optional human reason text, capped at 256 bytes
272        reason_text: Option<String>,
273        /// Optional pause expiry; `None` means indefinite (capped by
274        /// governance `pause_max_duration`).
275        until: Option<Timestamp>,
276    },
277    /// Quarantine an agent (Agent-Swarm Spec 1, tier 2).
278    ///
279    /// Reversible only after evidence review. Inbound + outbound payments
280    /// blocked. Stake frozen — cannot withdraw, cannot earn rewards.
281    /// Existing tasks halt.
282    ///
283    /// Authorization: controller (as PauseAgent), OR slashing-committee
284    /// quorum via `StakingSlashingCallback`, OR governance proposal.
285    QuarantineAgent {
286        /// DID of the agent to quarantine
287        agent_did: String,
288        /// DID of the authorising controller. Must match the identity
289        /// bound to `tx.from`.
290        controller_did: String,
291        /// Canonical reason code
292        reason_code: u16,
293        /// Optional human reason text, capped at 256 bytes
294        reason_text: Option<String>,
295        /// Optional commitment hash to off-chain evidence (32-byte SHA-256)
296        evidence_hash: Option<[u8; 32]>,
297    },
298    /// Terminate an agent (Agent-Swarm Spec 1, tier 3).
299    ///
300    /// **Irreversible.** Identity revoked via the underlying
301    /// `revoke_did` primitive. Stake/bond slashed by `slash_bps` capped
302    /// at the governance `slash_bps_cap`. With `cascade=true`, all
303    /// descendants under the agent's `children:` index are terminated
304    /// recursively (depth-bounded by `cascade_max_depth`, default 32).
305    ///
306    /// Authorization: controller, OR governance proposal with timelock,
307    /// OR cascade=true descended from a parent's TerminateAgent.
308    TerminateAgent {
309        /// DID of the agent to terminate
310        agent_did: String,
311        /// DID of the authorising controller. Must match the identity
312        /// bound to `tx.from`.
313        controller_did: String,
314        /// Canonical reason code
315        reason_code: u16,
316        /// Basis points of stake/bond to slash (0..=10000), capped per
317        /// governance `slash_bps_cap`.
318        slash_bps: u16,
319        /// If true, recursively terminate descendants under `children:`.
320        cascade: bool,
321    },
322    /// Post a fresh AgentBond for `agent_did` (Agent-Swarm Spec 9).
323    ///
324    /// Locks `amount` TNZO from `tx.from` (the controller wallet) into
325    /// the bond vault derived as
326    /// `Address(SHA-256("tenzro/agent-bond/vault" || agent_did))`. The
327    /// bond enters `BondLifecycle::Active` and promotes the agent to the
328    /// Delegated admission lane while ≥ `bond_min_for_promotion`.
329    ///
330    /// Authorization: `tx.from` is the controller; the VM enforces that
331    /// the agent_did either has no prior bond or its prior bond is in a
332    /// terminal state (`Returned` / `Slashed`).
333    PostAgentBond {
334        /// DID of the agent being bonded (`did:tenzro:machine:...`)
335        agent_did: String,
336        /// DID of the controller posting the bond. Echoed on the wire so
337        /// the receipt is self-describing without an identity lookup.
338        controller_did: String,
339        /// Amount of TNZO to lock in the bond vault
340        amount: u128,
341    },
342    /// Top up an existing Active AgentBond by `amount`.
343    ///
344    /// Authorization: `tx.from` MUST equal the original poster (the
345    /// bond's `controller` field).
346    IncreaseAgentBond {
347        /// DID of the agent whose bond is being increased
348        agent_did: String,
349        /// Additional TNZO to lock
350        amount: u128,
351    },
352    /// Initiate the cooldown timer on an Active AgentBond. Funds are
353    /// **not** released by the VM — finalisation happens off-VM via the
354    /// node-side `BondManager` once `cooldown_ms` has elapsed.
355    ///
356    /// Authorization: `tx.from` MUST equal the bond's controller.
357    WithdrawAgentBond {
358        /// DID of the agent whose bond is being withdrawn
359        agent_did: String,
360    },
361    /// Pay out an `Approved` insurance claim from the insurance pool
362    /// vault to the claimant. The off-chain `BondManager` has already
363    /// validated the claim and reserved funds; this transaction performs
364    /// the on-chain transfer and persists a `paid_claim:<claim_id>`
365    /// marker so the same claim cannot be paid twice.
366    ///
367    /// Authorization: governance committee (or `tx.from` matching the
368    /// configured insurance-pool admin DID).
369    PayInsuranceClaim {
370        /// 32-byte deterministic claim identifier (lowercase hex)
371        claim_id_hex: String,
372        /// Recipient of the payout
373        claimant: Address,
374        /// Amount to pay from the pool vault, in TNZO base units
375        amount: u128,
376    },
377    /// Register the signing wallet as a Candidate validator (Dynamic Validator
378    /// Set, 2026-SOTA permissionless join).
379    ///
380    /// On execution the VM emits a `ValidatorRegister` typed log carrying
381    /// `from || stake_le || consensus_pubkey || pq_pubkey || withdrawal_address ||
382    /// metadata_uri`. The node-side `ValidatorRegistry` consumes the log and
383    /// inserts a `Candidate` entry that becomes `PendingActive` at the next
384    /// epoch boundary if `self_stake >= min_self_stake` and the activation
385    /// churn budget admits it; the `EpochManager` then promotes it to `Active`
386    /// `ACTIVATION_EFFECTIVE_DELAY_BLOCKS` after the boundary block.
387    ///
388    /// Authorization: `tx.from` is the validator's stake-owning wallet. The
389    /// classical Ed25519 signature in `SignedTransaction::signature` proves
390    /// control of `consensus_pubkey`, the ML-DSA-65 leg proves control of
391    /// `pq_pubkey`, and the BLS leg proves control of `bls_pubkey`.
392    RegisterValidator {
393        /// 32-byte Ed25519 BFT signing key.
394        consensus_pubkey: Vec<u8>,
395        /// 1952-byte ML-DSA-65 verifying key (FIPS 204). Mandatory hybrid PQ.
396        pq_pubkey: Vec<u8>,
397        /// 48-byte BLS12-381 G1-compressed verifying key (`min_pk` scheme).
398        /// Mandatory third leg, used by HotStuff-2 to aggregate per-vote
399        /// signatures into a single QC-level aggregate.
400        bls_pubkey: Vec<u8>,
401        /// Address rewards / unbonded principal settle to.
402        withdrawal_address: Address,
403        /// Self-stake committed to the candidate. Must be ≥ the registry's
404        /// `min_self_stake` (default 10,000 TNZO).
405        self_stake: u128,
406        /// Optional ≤256-byte off-chain pointer (moniker / website / contact).
407        metadata_uri: String,
408    },
409    /// Voluntarily exit the active set (Dynamic Validator Set).
410    ///
411    /// The VM emits a `ValidatorExit` log; the registry transitions the entry
412    /// to `PendingExit` and the next epoch boundary stages it for removal —
413    /// effective `ACTIVATION_EFFECTIVE_DELAY_BLOCKS` after that boundary
414    /// block. Re-registration is blocked for `reentry_cooldown_epochs` (default
415    /// 4) following voluntary exit.
416    ///
417    /// Authorization: `tx.from` MUST equal the validator's registry address.
418    ExitValidator,
419    /// Update validator metadata (moniker / TEE attestation commitment).
420    ///
421    /// At least one of `metadata_uri` or `tee_attestation_hash` should be
422    /// `Some`; the registry treats `None` as "no change". `tee_attestation_hash`
423    /// is the 32-byte SHA-256 commitment to a fresh attestation document — the
424    /// active-set boundary applies the TEE multiplier from this commitment.
425    ///
426    /// Authorization: `tx.from` MUST equal the validator's registry address.
427    UpdateValidatorMetadata {
428        /// New off-chain pointer; `None` = no change. ≤256 bytes when present.
429        metadata_uri: Option<String>,
430        /// New 32-byte SHA-256 commitment to the attestation document.
431        tee_attestation_hash: Option<[u8; 32]>,
432    },
433}
434
435/// A signed transaction ready for submission
436///
437/// Contains the transaction and the **composite** signature (classical +
438/// post-quantum) proving authorization. Both legs are mandatory — there is
439/// no classical-only path. Verifiers reject a `SignedTransaction` whose
440/// `pq_signature` is absent or wrong-sized.
441///
442/// # Wire layout
443///
444/// - `signature` — classical Ed25519 / Secp256k1 leg (existing field, raw bytes
445///   in `signature.bytes` and the classical pubkey in `signature.public_key`).
446/// - `pq_signature` — ML-DSA-65 signature (FIPS 204, exactly 3309 bytes).
447/// - The ML-DSA-65 verifying key lives in `transaction.pq_public_key` so that
448///   the hash preimage commits to the PQ identity.
449#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
450pub struct SignedTransaction {
451    /// The underlying transaction
452    pub transaction: Transaction,
453    /// The classical signature authorizing the transaction
454    pub signature: Signature,
455    /// ML-DSA-65 signature over `transaction.hash()` (FIPS 204, exactly 3309
456    /// bytes). Mandatory; mis-sized payloads are rejected at deserialization.
457    #[serde(deserialize_with = "crate::validation::bounded_pq_signature_bytes")]
458    pub pq_signature: Vec<u8>,
459    /// Cached transaction hash
460    #[serde(skip)]
461    hash: Option<Hash>,
462}
463
464impl SignedTransaction {
465    /// Creates a new signed transaction. Both the classical signature and the
466    /// ML-DSA-65 signature are mandatory.
467    pub fn new(transaction: Transaction, signature: Signature, pq_signature: Vec<u8>) -> Self {
468        Self {
469            transaction,
470            signature,
471            pq_signature,
472            hash: None,
473        }
474    }
475
476    /// Returns the hash of the transaction
477    pub fn hash(&mut self) -> Hash {
478        if let Some(hash) = self.hash {
479            hash
480        } else {
481            let hash = self.transaction.hash();
482            self.hash = Some(hash);
483            hash
484        }
485    }
486
487    /// Validates the signed transaction
488    ///
489    /// Performs basic validation checks:
490    /// - Classical signature bytes are non-empty
491    /// - Classical public key is non-empty
492    /// - PQ signature is exactly ML_DSA_65_SIG_LEN bytes (3309)
493    /// - PQ public key (carried in `transaction.pq_public_key`) is exactly
494    ///   ML_DSA_65_VK_LEN bytes (1952)
495    /// - Gas limit is non-zero
496    /// - Addresses are non-zero
497    /// - Transaction data size is within limits
498    ///
499    /// Note: This does NOT verify the cryptographic signature.
500    /// Signature verification requires the crypto crate.
501    pub fn validate(&self) -> Result<(), &'static str> {
502        // Check classical signature is non-empty
503        if self.signature.bytes.is_empty() {
504            return Err("Transaction signature is empty");
505        }
506
507        // Check classical public key is non-empty
508        if self.signature.public_key.is_empty() {
509            return Err("Transaction public key is empty");
510        }
511
512        // PQ leg is mandatory — defense-in-depth in case in-memory construction
513        // bypassed the deserializer-level length checks.
514        if self.pq_signature.len() != 3309 {
515            return Err("PQ signature has wrong length (expected ML-DSA-65, 3309 bytes)");
516        }
517        if self.transaction.pq_public_key.len() != 1952 {
518            return Err("PQ public key has wrong length (expected ML-DSA-65 vk, 1952 bytes)");
519        }
520
521        // Check gas limit is non-zero
522        if self.transaction.gas_limit == 0 {
523            return Err("Gas limit cannot be zero");
524        }
525
526        // Check sender is not zero address
527        if self.transaction.from == Address::zero() {
528            return Err("Sender address cannot be zero");
529        }
530
531        // Validate transaction data size based on type
532        match &self.transaction.tx_type {
533            TransactionType::ContractDeploy { code, args } => {
534                if code.len() > MAX_TX_DATA_SIZE {
535                    return Err("Contract code exceeds maximum size");
536                }
537                if args.len() > MAX_TX_DATA_SIZE {
538                    return Err("Contract args exceed maximum size");
539                }
540            }
541            TransactionType::ContractCall { args, .. } => {
542                if args.len() > MAX_TX_DATA_SIZE {
543                    return Err("Contract call args exceed maximum size");
544                }
545            }
546            TransactionType::AgentRegister { config } => {
547                if config.len() > MAX_TX_DATA_SIZE {
548                    return Err("Agent config exceeds maximum size");
549                }
550            }
551            TransactionType::AgentExecute { task } => {
552                if task.len() > MAX_TX_DATA_SIZE {
553                    return Err("Agent task exceeds maximum size");
554                }
555            }
556            TransactionType::ModelInference { input, .. } => {
557                if input.len() > MAX_TX_DATA_SIZE {
558                    return Err("Inference input exceeds maximum size");
559                }
560            }
561            TransactionType::TeeProviderRegister { attestation, info } => {
562                if attestation.len() > MAX_TX_DATA_SIZE {
563                    return Err("TEE attestation exceeds maximum size");
564                }
565                if info.len() > MAX_TX_DATA_SIZE {
566                    return Err("Provider info exceeds maximum size");
567                }
568            }
569            TransactionType::GovernancePropose { proposal } => {
570                if proposal.len() > MAX_TX_DATA_SIZE {
571                    return Err("Proposal data exceeds maximum size");
572                }
573            }
574            TransactionType::ReleaseEscrow { proof, .. } => {
575                if proof.proof_data.len() > MAX_TX_DATA_SIZE {
576                    return Err("Escrow proof data exceeds maximum size");
577                }
578                // Bound number of signatures to prevent DoS at verification time
579                if proof.signatures.len() > 16 {
580                    return Err("Too many signatures in escrow release proof");
581                }
582            }
583            TransactionType::PauseAgent { agent_did, reason_text, .. }
584            | TransactionType::QuarantineAgent { agent_did, reason_text, .. } => {
585                if agent_did.len() > 256 {
586                    return Err("agent_did exceeds maximum length");
587                }
588                if let Some(t) = reason_text
589                    && t.len() > 256
590                {
591                    return Err("reason_text exceeds 256 bytes");
592                }
593            }
594            TransactionType::TerminateAgent { agent_did, slash_bps, .. } => {
595                if agent_did.len() > 256 {
596                    return Err("agent_did exceeds maximum length");
597                }
598                if *slash_bps > 10_000 {
599                    return Err("slash_bps exceeds 100%");
600                }
601            }
602            TransactionType::RegisterValidator {
603                consensus_pubkey,
604                pq_pubkey,
605                bls_pubkey,
606                metadata_uri,
607                ..
608            } => {
609                if consensus_pubkey.len() != 32 {
610                    return Err("consensus_pubkey must be 32 bytes (Ed25519)");
611                }
612                if pq_pubkey.len() != 1952 {
613                    return Err("pq_pubkey must be 1952 bytes (ML-DSA-65 vk)");
614                }
615                if bls_pubkey.len() != 48 {
616                    return Err("bls_pubkey must be 48 bytes (BLS12-381 G1-compressed, min_pk)");
617                }
618                if metadata_uri.len() > 256 {
619                    return Err("metadata_uri exceeds 256 bytes");
620                }
621            }
622            TransactionType::UpdateValidatorMetadata { metadata_uri, .. } => {
623                if let Some(uri) = metadata_uri
624                    && uri.len() > 256
625                {
626                    return Err("metadata_uri exceeds 256 bytes");
627                }
628            }
629            // Other transaction types have bounded data (strings, primitives)
630            _ => {}
631        }
632
633        // Recipient can be zero for contract creation
634
635        Ok(())
636    }
637
638    /// Returns the sender address
639    pub fn sender(&self) -> Address {
640        self.transaction.from
641    }
642
643    /// Returns the recipient address
644    pub fn recipient(&self) -> Address {
645        self.transaction.to
646    }
647
648    /// Returns the nonce
649    pub fn nonce(&self) -> Nonce {
650        self.transaction.nonce
651    }
652
653    /// Checks if the transaction has both classical and PQ signatures
654    /// populated (non-empty classical legs and exact-size PQ legs).
655    /// This does NOT verify cryptographic validity.
656    pub fn is_signed(&self) -> bool {
657        !self.signature.bytes.is_empty()
658            && !self.signature.public_key.is_empty()
659            && self.pq_signature.len() == 3309
660            && self.transaction.pq_public_key.len() == 1952
661    }
662}