Skip to main content

blockle_core/
transaction.rs

1//! Blockle's transaction model: a note/output system (UTXO-style) with a
2//! reserved shielded bundle so the privacy milestone extends the same wire
3//! format instead of forking it.
4
5use serde::{Deserialize, Serialize};
6
7use crate::encode::{put_bytes, put_fixed, put_u32, put_u64};
8use crate::hash::{blake2b_256, Hash32};
9use crate::keys::Address;
10
11/// Reference to a transaction output being spent.
12#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
13pub struct OutPoint {
14    pub txid: Hash32,
15    pub vout: u32,
16}
17
18#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
19pub struct TxInput {
20    pub prev: OutPoint,
21    /// ML-DSA-44 public key whose hash must match the spent output's address.
22    pub pubkey: Vec<u8>,
23    /// ML-DSA-44 signature over the transaction sighash. Empty while unsigned.
24    pub signature: Vec<u8>,
25}
26
27#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
28pub struct TxOutput {
29    pub recipient: Address,
30    pub amount: u64,
31}
32
33/// A shielded spend: consumes a note from the commitment tree.
34///
35/// Reveals the nullifier (double-spend tag) and the note's value; the STARK
36/// proof shows the note exists under `anchor` without revealing which one.
37/// The proof is excluded from the sighash (it *binds* the sighash via the
38/// proof transcript instead).
39#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
40pub struct ShieldedSpend {
41    /// A historical note-tree root this spend proves membership against.
42    pub anchor: Hash32,
43    pub nullifier: Hash32,
44    pub value: u64,
45    pub proof: Vec<u8>,
46}
47
48/// A shielded output: appends a note commitment to the tree.
49/// The value is public in protocol v1 (ownership privacy, not amount
50/// privacy); the note secrets travel to the recipient out of band.
51#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
52pub struct ShieldedOutput {
53    pub commitment: Hash32,
54    pub value: u64,
55}
56
57/// A fully-shielded transfer: spends one note into two new notes (payment +
58/// change) with **hidden values** — only the fee is public. The STARK proves
59/// value conservation. `memo` carries the recipient's encrypted note secrets
60/// (opaque to consensus, bound by the sighash).
61#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
62pub struct ShieldedTransfer {
63    pub anchor: Hash32,
64    pub nullifier: Hash32,
65    pub commitment1: Hash32,
66    pub commitment2: Hash32,
67    pub fee: u64,
68    pub memo: Vec<u8>,
69    pub proof: Vec<u8>,
70}
71
72#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
73pub struct ShieldedBundle {
74    pub spends: Vec<ShieldedSpend>,
75    pub outputs: Vec<ShieldedOutput>,
76    /// Hidden-amount z→z transfers.
77    #[serde(default)]
78    pub transfers: Vec<ShieldedTransfer>,
79}
80
81/// A Blockle VM action carried by a transaction. Gas is prepaid: consensus
82/// requires `fee >= gas_limit * gas_price`, whether or not execution
83/// succeeds or uses less.
84/// Blockle Intelligence actions (height-gated at `ChainParams::intel_activation_height`;
85/// see docs/BLOCKLE-INTELLIGENCE.md §3). Wrapped in `ContractAction::Intel` so they ride
86/// the existing contract-action rails (fees, gas, refunds) without touching base-tx rules.
87/// Additive: old nodes decode the unknown tag as malformed, so these are inherently a
88/// height-activated fork — rejected below H_INTEL, accepted at/above it.
89#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
90pub enum IntelAction {
91    /// Register a subnet (a task market). `tier` = verifiability tier (0 checkable,
92    /// 1 redundant/reference, 2 subjective). `schema` describes the task.
93    SubnetRegister { schema: Vec<u8>, tier: u8, gas_limit: u64 },
94    /// Register a neuron (miner `role`=0 / validator `role`=1) in a subnet, locking a
95    /// `bond` of BLOCK (from inputs; recoverable later via Unbond). `hotkey` is the
96    /// serving/identity key.
97    NeuronRegister { subnet_id: u32, role: u8, hotkey: Vec<u8>, bond: u64, gas_limit: u64 },
98    /// Validator commits a hash of its weight vector for the epoch (commit-reveal §5.4).
99    SetWeightsCommit { subnet_id: u32, commit: [u8; 32], gas_limit: u64 },
100    /// Validator reveals the weights committed last epoch. `uids` are miner UIDs and
101    /// `weights` the matching fixed-point weights (0..1_000_000). The reveal is accepted
102    /// only if sha256(canonical(uids,weights) ‖ nonce) equals the stored commit.
103    SetWeightsReveal { subnet_id: u32, uids: Vec<u32>, weights: Vec<u32>, nonce: [u8; 32], gas_limit: u64 },
104    /// Pay out the sender's accrued intelligence emission to a new BLOCK output.
105    NeuronClaim { gas_limit: u64 },
106    /// Deregister (unbond) a neuron the sender owns, identified by `(subnet_id, uid)`.
107    /// The remaining (post-slash) bond is refunded to the owner and the neuron is
108    /// retired. Height-gated separately (not active until the unbond height) and
109    /// subject to a one-epoch cooldown after registration so a validator cannot
110    /// register, vote, and flee within a single epoch.
111    NeuronDeregister { subnet_id: u32, uid: u32, gas_limit: u64 },
112    /// Proof-of-validation (liveness): a validator acknowledges the current epoch's
113    /// in-node beacon (`beacon_ack` must equal it), proving it is live against fresh,
114    /// unpredictable randomness. Validators that reveal weights but skip this each epoch
115    /// are excluded from emission and have their bond slashed.
116    ValidationProof { subnet_id: u32, beacon_ack: [u8; 32], gas_limit: u64 },
117}
118
119impl IntelAction {
120    pub fn gas_limit(&self) -> u64 {
121        match self {
122            IntelAction::SubnetRegister { gas_limit, .. } => *gas_limit,
123            IntelAction::NeuronRegister { gas_limit, .. } => *gas_limit,
124            IntelAction::SetWeightsCommit { gas_limit, .. } => *gas_limit,
125            IntelAction::SetWeightsReveal { gas_limit, .. } => *gas_limit,
126            IntelAction::NeuronClaim { gas_limit, .. } => *gas_limit,
127            IntelAction::ValidationProof { gas_limit, .. } => *gas_limit,
128            IntelAction::NeuronDeregister { gas_limit, .. } => *gas_limit,
129        }
130    }
131    /// BLOCK moved from inputs into locked state (the neuron bond). Weight/claim actions
132    /// move no BLOCK in (claim pays OUT via a payout).
133    pub fn value(&self) -> u64 {
134        match self {
135            IntelAction::NeuronRegister { bond, .. } => *bond,
136            _ => 0,
137        }
138    }
139}
140
141#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
142pub enum ContractAction {
143    /// Store `code` as a new contract. The contract id is derived from the
144    /// deploying txid.
145    Deploy { code: Vec<u8>, gas_limit: u64 },
146    /// Execute a deployed contract. `value` moves from this transaction's
147    /// inputs into the contract's balance before execution (refunded to the
148    /// sender if the call fails).
149    Call { contract: Hash32, input: Vec<u8>, value: u64, gas_limit: u64 },
150    /// Native AMM — create a BLOCK/`token` pool seeded with `block_amt` BLOCK
151    /// (from inputs) and `token_amt` of the BLOCK-20 token (from the sender's
152    /// token balance). LP shares lock for one week.
153    PoolCreate { token: Hash32, block_amt: u64, token_amt: u64, gas_limit: u64 },
154    /// Add liquidity at the current ratio: `block_amt` BLOCK plus up to
155    /// `token_max` of the token. Re-locks the position for one week.
156    PoolAdd { token: Hash32, block_amt: u64, token_max: u64, gas_limit: u64 },
157    /// Withdraw `shares` of LP (BLOCK paid out, token credited). Fails if the
158    /// position is still within its one-week lock.
159    PoolRemove { token: Hash32, shares: u64, gas_limit: u64 },
160    /// Swap `block_in` BLOCK (from inputs) for the token; reverts (refund) if
161    /// the output would be below `min_token_out`.
162    PoolSwapBuy { token: Hash32, block_in: u64, min_token_out: u64, gas_limit: u64 },
163    /// Swap `token_in` of the token for BLOCK (paid out); no-op if the output
164    /// would be below `min_block_out`.
165    PoolSwapSell { token: Hash32, token_in: u64, min_block_out: u64, gas_limit: u64 },
166    /// Atomic token launch — deploy `code`, run its `init()` (minting the baked
167    /// supply to the sender), and seed a BLOCK/token AMM pool with `block_amt`
168    /// BLOCK (from inputs) and `token_amt` of the freshly minted supply, all in
169    /// ONE transaction (one block). Removes the deploy→init→pool race entirely:
170    /// the pool can't fire before the mint exists. If any step fails the whole
171    /// action reverts and `block_amt` is refunded to the sender.
172    LaunchToken { code: Vec<u8>, block_amt: u64, token_amt: u64, gas_limit: u64 },
173    /// Atomic NFT-collection launch — deploy `code` (a BLOCK-721 collection) and
174    /// run its `init()` (selector 0, which fixes the deployer as the minter) in
175    /// ONE transaction. Removes the deploy→init race for collections; subsequent
176    /// `mint`s are independent single calls once this confirms.
177    LaunchNft { code: Vec<u8>, gas_limit: u64 },
178    /// Blockle Intelligence action (height-gated; see `IntelAction`).
179    Intel(IntelAction),
180}
181
182impl ContractAction {
183    pub fn gas_limit(&self) -> u64 {
184        match self {
185            ContractAction::Deploy { gas_limit, .. } => *gas_limit,
186            ContractAction::Call { gas_limit, .. } => *gas_limit,
187            ContractAction::PoolCreate { gas_limit, .. } => *gas_limit,
188            ContractAction::PoolAdd { gas_limit, .. } => *gas_limit,
189            ContractAction::PoolRemove { gas_limit, .. } => *gas_limit,
190            ContractAction::PoolSwapBuy { gas_limit, .. } => *gas_limit,
191            ContractAction::PoolSwapSell { gas_limit, .. } => *gas_limit,
192            ContractAction::LaunchToken { gas_limit, .. } => *gas_limit,
193            ContractAction::LaunchNft { gas_limit, .. } => *gas_limit,
194            ContractAction::Intel(a) => a.gas_limit(),
195        }
196    }
197
198    /// BLOCK that moves from this tx's inputs into a pool/contract.
199    pub fn value(&self) -> u64 {
200        match self {
201            ContractAction::Deploy { .. } => 0,
202            ContractAction::Call { value, .. } => *value,
203            ContractAction::PoolCreate { block_amt, .. } => *block_amt,
204            ContractAction::PoolAdd { block_amt, .. } => *block_amt,
205            ContractAction::PoolRemove { .. } => 0,
206            ContractAction::PoolSwapBuy { block_in, .. } => *block_in,
207            ContractAction::PoolSwapSell { .. } => 0,
208            ContractAction::LaunchToken { block_amt, .. } => *block_amt,
209            ContractAction::LaunchNft { .. } => 0,
210            ContractAction::Intel(a) => a.value(),
211        }
212    }
213}
214
215#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
216pub struct Transaction {
217    pub version: u32,
218    /// Empty for coinbase transactions.
219    pub inputs: Vec<TxInput>,
220    pub outputs: Vec<TxOutput>,
221    /// Coinbase-only payload (block height + arbitrary tag); makes coinbase
222    /// txids unique. Must be empty for regular transactions.
223    pub coinbase_data: Vec<u8>,
224    pub shielded: Option<ShieldedBundle>,
225    /// Optional Blockle VM deploy/call.
226    #[serde(default)]
227    pub contract: Option<ContractAction>,
228}
229
230impl Transaction {
231    /// Coinbase: no transparent inputs, no shielded bundle, and no
232    /// (the other input-less transaction kind).
233    pub fn is_coinbase(&self) -> bool {
234        self.inputs.is_empty() && self.shielded.is_none()
235    }
236
237    /// Total value entering from the shielded pool.
238    pub fn shielded_in(&self) -> Option<u64> {
239        self.shielded.as_ref().map_or(Some(0), |b| {
240            b.spends.iter().try_fold(0u64, |acc, s| acc.checked_add(s.value))
241        })
242    }
243
244    /// Total value leaving into the shielded pool.
245    pub fn shielded_out(&self) -> Option<u64> {
246        self.shielded.as_ref().map_or(Some(0), |b| {
247            b.outputs.iter().try_fold(0u64, |acc, o| acc.checked_add(o.value))
248        })
249    }
250
251    /// Fees paid by hidden-amount transfers (their only public value flow).
252    pub fn transfer_fees(&self) -> Option<u64> {
253        self.shielded.as_ref().map_or(Some(0), |b| {
254            b.transfers.iter().try_fold(0u64, |acc, t| acc.checked_add(t.fee))
255        })
256    }
257
258    /// All nullifiers this transaction reveals.
259    pub fn nullifiers(&self) -> Vec<Hash32> {
260        self.shielded
261            .as_ref()
262            .map(|b| {
263                b.spends
264                    .iter()
265                    .map(|s| s.nullifier)
266                    .chain(b.transfers.iter().map(|t| t.nullifier))
267                    .collect()
268            })
269            .unwrap_or_default()
270    }
271
272    /// Canonical consensus encoding. With `for_sighash` set, signatures are
273    /// replaced by empty strings so inputs can be signed.
274    pub fn encode(&self, for_sighash: bool) -> Vec<u8> {
275        let mut out = Vec::new();
276        put_u32(&mut out, self.version);
277        put_u32(&mut out, self.inputs.len() as u32);
278        for input in &self.inputs {
279            put_fixed(&mut out, &input.prev.txid);
280            put_u32(&mut out, input.prev.vout);
281            put_bytes(&mut out, &input.pubkey);
282            if for_sighash {
283                put_bytes(&mut out, &[]);
284            } else {
285                put_bytes(&mut out, &input.signature);
286            }
287        }
288        put_u32(&mut out, self.outputs.len() as u32);
289        for output in &self.outputs {
290            put_fixed(&mut out, &output.recipient);
291            put_u64(&mut out, output.amount);
292        }
293        put_bytes(&mut out, &self.coinbase_data);
294        match &self.shielded {
295            None => put_u32(&mut out, 0),
296            Some(s) => {
297                put_u32(&mut out, 1);
298                put_u32(&mut out, s.spends.len() as u32);
299                for spend in &s.spends {
300                    put_fixed(&mut out, &spend.anchor);
301                    put_fixed(&mut out, &spend.nullifier);
302                    put_u64(&mut out, spend.value);
303                    // Like signatures, proofs are excluded from the sighash:
304                    // each proof binds the sighash through its transcript.
305                    if for_sighash {
306                        put_bytes(&mut out, &[]);
307                    } else {
308                        put_bytes(&mut out, &spend.proof);
309                    }
310                }
311                put_u32(&mut out, s.outputs.len() as u32);
312                for output in &s.outputs {
313                    put_fixed(&mut out, &output.commitment);
314                    put_u64(&mut out, output.value);
315                }
316                put_u32(&mut out, s.transfers.len() as u32);
317                for t in &s.transfers {
318                    put_fixed(&mut out, &t.anchor);
319                    put_fixed(&mut out, &t.nullifier);
320                    put_fixed(&mut out, &t.commitment1);
321                    put_fixed(&mut out, &t.commitment2);
322                    put_u64(&mut out, t.fee);
323                    put_bytes(&mut out, &t.memo);
324                    if for_sighash {
325                        put_bytes(&mut out, &[]);
326                    } else {
327                        put_bytes(&mut out, &t.proof);
328                    }
329                }
330            }
331        }
332        match &self.contract {
333            None => put_u32(&mut out, 0),
334            Some(ContractAction::Deploy { code, gas_limit }) => {
335                put_u32(&mut out, 1);
336                put_bytes(&mut out, code);
337                put_u64(&mut out, *gas_limit);
338            }
339            Some(ContractAction::Call { contract, input, value, gas_limit }) => {
340                put_u32(&mut out, 2);
341                put_fixed(&mut out, contract);
342                put_bytes(&mut out, input);
343                put_u64(&mut out, *value);
344                put_u64(&mut out, *gas_limit);
345            }
346            Some(ContractAction::PoolCreate { token, block_amt, token_amt, gas_limit }) => {
347                put_u32(&mut out, 3);
348                put_fixed(&mut out, token);
349                put_u64(&mut out, *block_amt);
350                put_u64(&mut out, *token_amt);
351                put_u64(&mut out, *gas_limit);
352            }
353            Some(ContractAction::PoolAdd { token, block_amt, token_max, gas_limit }) => {
354                put_u32(&mut out, 4);
355                put_fixed(&mut out, token);
356                put_u64(&mut out, *block_amt);
357                put_u64(&mut out, *token_max);
358                put_u64(&mut out, *gas_limit);
359            }
360            Some(ContractAction::PoolRemove { token, shares, gas_limit }) => {
361                put_u32(&mut out, 5);
362                put_fixed(&mut out, token);
363                put_u64(&mut out, *shares);
364                put_u64(&mut out, *gas_limit);
365            }
366            Some(ContractAction::PoolSwapBuy { token, block_in, min_token_out, gas_limit }) => {
367                put_u32(&mut out, 6);
368                put_fixed(&mut out, token);
369                put_u64(&mut out, *block_in);
370                put_u64(&mut out, *min_token_out);
371                put_u64(&mut out, *gas_limit);
372            }
373            Some(ContractAction::PoolSwapSell { token, token_in, min_block_out, gas_limit }) => {
374                put_u32(&mut out, 7);
375                put_fixed(&mut out, token);
376                put_u64(&mut out, *token_in);
377                put_u64(&mut out, *min_block_out);
378                put_u64(&mut out, *gas_limit);
379            }
380            Some(ContractAction::LaunchToken { code, block_amt, token_amt, gas_limit }) => {
381                put_u32(&mut out, 8);
382                put_bytes(&mut out, code);
383                put_u64(&mut out, *block_amt);
384                put_u64(&mut out, *token_amt);
385                put_u64(&mut out, *gas_limit);
386            }
387            Some(ContractAction::LaunchNft { code, gas_limit }) => {
388                put_u32(&mut out, 9);
389                put_bytes(&mut out, code);
390                put_u64(&mut out, *gas_limit);
391            }
392            // Intelligence actions: tag 10, then an inner sub-tag + fields.
393            Some(ContractAction::Intel(IntelAction::SubnetRegister { schema, tier, gas_limit })) => {
394                put_u32(&mut out, 10);
395                put_u32(&mut out, 0); // inner: SubnetRegister
396                put_bytes(&mut out, schema);
397                out.push(*tier);
398                put_u64(&mut out, *gas_limit);
399            }
400            Some(ContractAction::Intel(IntelAction::NeuronRegister { subnet_id, role, hotkey, bond, gas_limit })) => {
401                put_u32(&mut out, 10);
402                put_u32(&mut out, 1); // inner: NeuronRegister
403                put_u32(&mut out, *subnet_id);
404                out.push(*role);
405                put_bytes(&mut out, hotkey);
406                put_u64(&mut out, *bond);
407                put_u64(&mut out, *gas_limit);
408            }
409            Some(ContractAction::Intel(IntelAction::SetWeightsCommit { subnet_id, commit, gas_limit })) => {
410                put_u32(&mut out, 10);
411                put_u32(&mut out, 2); // inner: SetWeightsCommit
412                put_u32(&mut out, *subnet_id);
413                put_fixed(&mut out, commit);
414                put_u64(&mut out, *gas_limit);
415            }
416            Some(ContractAction::Intel(IntelAction::SetWeightsReveal { subnet_id, uids, weights, nonce, gas_limit })) => {
417                put_u32(&mut out, 10);
418                put_u32(&mut out, 3); // inner: SetWeightsReveal
419                put_u32(&mut out, *subnet_id);
420                put_u32(&mut out, uids.len() as u32);
421                for u in uids { put_u32(&mut out, *u); }
422                put_u32(&mut out, weights.len() as u32);
423                for w in weights { put_u32(&mut out, *w); }
424                put_fixed(&mut out, nonce);
425                put_u64(&mut out, *gas_limit);
426            }
427            Some(ContractAction::Intel(IntelAction::NeuronClaim { gas_limit })) => {
428                put_u32(&mut out, 10);
429                put_u32(&mut out, 4); // inner: NeuronClaim
430                put_u64(&mut out, *gas_limit);
431            }
432            Some(ContractAction::Intel(IntelAction::ValidationProof { subnet_id, beacon_ack, gas_limit })) => {
433                put_u32(&mut out, 10);
434                put_u32(&mut out, 5); // inner: ValidationProof
435                put_u32(&mut out, *subnet_id);
436                put_fixed(&mut out, beacon_ack);
437                put_u64(&mut out, *gas_limit);
438            }
439            Some(ContractAction::Intel(IntelAction::NeuronDeregister { subnet_id, uid, gas_limit })) => {
440                put_u32(&mut out, 10);
441                put_u32(&mut out, 6); // inner: NeuronDeregister
442                put_u32(&mut out, *subnet_id);
443                put_u32(&mut out, *uid);
444                put_u64(&mut out, *gas_limit);
445            }
446        }
447        out
448    }
449
450    pub fn txid(&self) -> Hash32 {
451        blake2b_256(&self.encode(false))
452    }
453
454    /// The message each input signs: the transaction with signatures blanked.
455    pub fn sighash(&self) -> Hash32 {
456        blake2b_256(&self.encode(true))
457    }
458
459    pub fn total_output(&self) -> Option<u64> {
460        self.outputs
461            .iter()
462            .try_fold(0u64, |acc, o| acc.checked_add(o.amount))
463    }
464
465    pub fn serialized_size(&self) -> usize {
466        self.encode(false).len()
467    }
468
469    /// Build a coinbase transaction paying `recipient` at `height`.
470    pub fn coinbase(height: u64, recipient: Address, amount: u64, tag: &[u8]) -> Self {
471        let mut coinbase_data = height.to_le_bytes().to_vec();
472        coinbase_data.extend_from_slice(tag);
473        Transaction {
474            version: 1,
475            inputs: vec![],
476            outputs: vec![TxOutput { recipient, amount }],
477            coinbase_data,
478            shielded: None,
479            contract: None,
480        }
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use crate::keys::Keypair;
488
489    #[test]
490    fn txid_changes_with_content_sighash_ignores_sigs() {
491        let kp = Keypair::generate();
492        let mut tx = Transaction {
493            version: 1,
494            inputs: vec![TxInput {
495                prev: OutPoint { txid: [1; 32], vout: 0 },
496                pubkey: kp.public_bytes(),
497                signature: vec![],
498            }],
499            outputs: vec![TxOutput { recipient: kp.address(), amount: 42 }],
500            coinbase_data: vec![],
501            shielded: None,
502            contract: None,
503        };
504        let sighash_before = tx.sighash();
505        let txid_before = tx.txid();
506        tx.inputs[0].signature = kp.sign(&sighash_before).to_vec();
507        assert_eq!(tx.sighash(), sighash_before, "sighash must ignore signatures");
508        assert_ne!(tx.txid(), txid_before, "txid commits to signatures");
509
510        tx.outputs[0].amount = 43;
511        assert_ne!(tx.sighash(), sighash_before);
512    }
513
514    #[test]
515    fn coinbase_txids_unique_per_height() {
516        let addr = [9u8; 32];
517        let a = Transaction::coinbase(1, addr, 50, b"blockle");
518        let b = Transaction::coinbase(2, addr, 50, b"blockle");
519        assert!(a.is_coinbase());
520        assert_ne!(a.txid(), b.txid());
521    }
522}