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#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
85pub enum ContractAction {
86    /// Store `code` as a new contract. The contract id is derived from the
87    /// deploying txid.
88    Deploy { code: Vec<u8>, gas_limit: u64 },
89    /// Execute a deployed contract. `value` moves from this transaction's
90    /// inputs into the contract's balance before execution (refunded to the
91    /// sender if the call fails).
92    Call { contract: Hash32, input: Vec<u8>, value: u64, gas_limit: u64 },
93}
94
95impl ContractAction {
96    pub fn gas_limit(&self) -> u64 {
97        match self {
98            ContractAction::Deploy { gas_limit, .. } => *gas_limit,
99            ContractAction::Call { gas_limit, .. } => *gas_limit,
100        }
101    }
102
103    pub fn value(&self) -> u64 {
104        match self {
105            ContractAction::Deploy { .. } => 0,
106            ContractAction::Call { value, .. } => *value,
107        }
108    }
109}
110
111#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
112pub struct Transaction {
113    pub version: u32,
114    /// Empty for coinbase transactions.
115    pub inputs: Vec<TxInput>,
116    pub outputs: Vec<TxOutput>,
117    /// Coinbase-only payload (block height + arbitrary tag); makes coinbase
118    /// txids unique. Must be empty for regular transactions.
119    pub coinbase_data: Vec<u8>,
120    pub shielded: Option<ShieldedBundle>,
121    /// Optional Blockle VM deploy/call.
122    #[serde(default)]
123    pub contract: Option<ContractAction>,
124}
125
126impl Transaction {
127    /// Coinbase: no transparent inputs, no shielded bundle, and no
128    /// (the other input-less transaction kind).
129    pub fn is_coinbase(&self) -> bool {
130        self.inputs.is_empty() && self.shielded.is_none()
131    }
132
133    /// Total value entering from the shielded pool.
134    pub fn shielded_in(&self) -> Option<u64> {
135        self.shielded.as_ref().map_or(Some(0), |b| {
136            b.spends.iter().try_fold(0u64, |acc, s| acc.checked_add(s.value))
137        })
138    }
139
140    /// Total value leaving into the shielded pool.
141    pub fn shielded_out(&self) -> Option<u64> {
142        self.shielded.as_ref().map_or(Some(0), |b| {
143            b.outputs.iter().try_fold(0u64, |acc, o| acc.checked_add(o.value))
144        })
145    }
146
147    /// Fees paid by hidden-amount transfers (their only public value flow).
148    pub fn transfer_fees(&self) -> Option<u64> {
149        self.shielded.as_ref().map_or(Some(0), |b| {
150            b.transfers.iter().try_fold(0u64, |acc, t| acc.checked_add(t.fee))
151        })
152    }
153
154    /// All nullifiers this transaction reveals.
155    pub fn nullifiers(&self) -> Vec<Hash32> {
156        self.shielded
157            .as_ref()
158            .map(|b| {
159                b.spends
160                    .iter()
161                    .map(|s| s.nullifier)
162                    .chain(b.transfers.iter().map(|t| t.nullifier))
163                    .collect()
164            })
165            .unwrap_or_default()
166    }
167
168    /// Canonical consensus encoding. With `for_sighash` set, signatures are
169    /// replaced by empty strings so inputs can be signed.
170    pub fn encode(&self, for_sighash: bool) -> Vec<u8> {
171        let mut out = Vec::new();
172        put_u32(&mut out, self.version);
173        put_u32(&mut out, self.inputs.len() as u32);
174        for input in &self.inputs {
175            put_fixed(&mut out, &input.prev.txid);
176            put_u32(&mut out, input.prev.vout);
177            put_bytes(&mut out, &input.pubkey);
178            if for_sighash {
179                put_bytes(&mut out, &[]);
180            } else {
181                put_bytes(&mut out, &input.signature);
182            }
183        }
184        put_u32(&mut out, self.outputs.len() as u32);
185        for output in &self.outputs {
186            put_fixed(&mut out, &output.recipient);
187            put_u64(&mut out, output.amount);
188        }
189        put_bytes(&mut out, &self.coinbase_data);
190        match &self.shielded {
191            None => put_u32(&mut out, 0),
192            Some(s) => {
193                put_u32(&mut out, 1);
194                put_u32(&mut out, s.spends.len() as u32);
195                for spend in &s.spends {
196                    put_fixed(&mut out, &spend.anchor);
197                    put_fixed(&mut out, &spend.nullifier);
198                    put_u64(&mut out, spend.value);
199                    // Like signatures, proofs are excluded from the sighash:
200                    // each proof binds the sighash through its transcript.
201                    if for_sighash {
202                        put_bytes(&mut out, &[]);
203                    } else {
204                        put_bytes(&mut out, &spend.proof);
205                    }
206                }
207                put_u32(&mut out, s.outputs.len() as u32);
208                for output in &s.outputs {
209                    put_fixed(&mut out, &output.commitment);
210                    put_u64(&mut out, output.value);
211                }
212                put_u32(&mut out, s.transfers.len() as u32);
213                for t in &s.transfers {
214                    put_fixed(&mut out, &t.anchor);
215                    put_fixed(&mut out, &t.nullifier);
216                    put_fixed(&mut out, &t.commitment1);
217                    put_fixed(&mut out, &t.commitment2);
218                    put_u64(&mut out, t.fee);
219                    put_bytes(&mut out, &t.memo);
220                    if for_sighash {
221                        put_bytes(&mut out, &[]);
222                    } else {
223                        put_bytes(&mut out, &t.proof);
224                    }
225                }
226            }
227        }
228        match &self.contract {
229            None => put_u32(&mut out, 0),
230            Some(ContractAction::Deploy { code, gas_limit }) => {
231                put_u32(&mut out, 1);
232                put_bytes(&mut out, code);
233                put_u64(&mut out, *gas_limit);
234            }
235            Some(ContractAction::Call { contract, input, value, gas_limit }) => {
236                put_u32(&mut out, 2);
237                put_fixed(&mut out, contract);
238                put_bytes(&mut out, input);
239                put_u64(&mut out, *value);
240                put_u64(&mut out, *gas_limit);
241            }
242        }
243        out
244    }
245
246    pub fn txid(&self) -> Hash32 {
247        blake2b_256(&self.encode(false))
248    }
249
250    /// The message each input signs: the transaction with signatures blanked.
251    pub fn sighash(&self) -> Hash32 {
252        blake2b_256(&self.encode(true))
253    }
254
255    pub fn total_output(&self) -> Option<u64> {
256        self.outputs
257            .iter()
258            .try_fold(0u64, |acc, o| acc.checked_add(o.amount))
259    }
260
261    pub fn serialized_size(&self) -> usize {
262        self.encode(false).len()
263    }
264
265    /// Build a coinbase transaction paying `recipient` at `height`.
266    pub fn coinbase(height: u64, recipient: Address, amount: u64, tag: &[u8]) -> Self {
267        let mut coinbase_data = height.to_le_bytes().to_vec();
268        coinbase_data.extend_from_slice(tag);
269        Transaction {
270            version: 1,
271            inputs: vec![],
272            outputs: vec![TxOutput { recipient, amount }],
273            coinbase_data,
274            shielded: None,
275            contract: None,
276        }
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use crate::keys::Keypair;
284
285    #[test]
286    fn txid_changes_with_content_sighash_ignores_sigs() {
287        let kp = Keypair::generate();
288        let mut tx = Transaction {
289            version: 1,
290            inputs: vec![TxInput {
291                prev: OutPoint { txid: [1; 32], vout: 0 },
292                pubkey: kp.public_bytes(),
293                signature: vec![],
294            }],
295            outputs: vec![TxOutput { recipient: kp.address(), amount: 42 }],
296            coinbase_data: vec![],
297            shielded: None,
298            contract: None,
299        };
300        let sighash_before = tx.sighash();
301        let txid_before = tx.txid();
302        tx.inputs[0].signature = kp.sign(&sighash_before).to_vec();
303        assert_eq!(tx.sighash(), sighash_before, "sighash must ignore signatures");
304        assert_ne!(tx.txid(), txid_before, "txid commits to signatures");
305
306        tx.outputs[0].amount = 43;
307        assert_ne!(tx.sighash(), sighash_before);
308    }
309
310    #[test]
311    fn coinbase_txids_unique_per_height() {
312        let addr = [9u8; 32];
313        let a = Transaction::coinbase(1, addr, 50, b"blockle");
314        let b = Transaction::coinbase(2, addr, 50, b"blockle");
315        assert!(a.is_coinbase());
316        assert_ne!(a.txid(), b.txid());
317    }
318}