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    /// Native AMM — create a BLOCK/`token` pool seeded with `block_amt` BLOCK
94    /// (from inputs) and `token_amt` of the BLOCK-20 token (from the sender's
95    /// token balance). LP shares lock for one week.
96    PoolCreate { token: Hash32, block_amt: u64, token_amt: u64, gas_limit: u64 },
97    /// Add liquidity at the current ratio: `block_amt` BLOCK plus up to
98    /// `token_max` of the token. Re-locks the position for one week.
99    PoolAdd { token: Hash32, block_amt: u64, token_max: u64, gas_limit: u64 },
100    /// Withdraw `shares` of LP (BLOCK paid out, token credited). Fails if the
101    /// position is still within its one-week lock.
102    PoolRemove { token: Hash32, shares: u64, gas_limit: u64 },
103    /// Swap `block_in` BLOCK (from inputs) for the token; reverts (refund) if
104    /// the output would be below `min_token_out`.
105    PoolSwapBuy { token: Hash32, block_in: u64, min_token_out: u64, gas_limit: u64 },
106    /// Swap `token_in` of the token for BLOCK (paid out); no-op if the output
107    /// would be below `min_block_out`.
108    PoolSwapSell { token: Hash32, token_in: u64, min_block_out: u64, gas_limit: u64 },
109}
110
111impl ContractAction {
112    pub fn gas_limit(&self) -> u64 {
113        match self {
114            ContractAction::Deploy { gas_limit, .. } => *gas_limit,
115            ContractAction::Call { gas_limit, .. } => *gas_limit,
116            ContractAction::PoolCreate { gas_limit, .. } => *gas_limit,
117            ContractAction::PoolAdd { gas_limit, .. } => *gas_limit,
118            ContractAction::PoolRemove { gas_limit, .. } => *gas_limit,
119            ContractAction::PoolSwapBuy { gas_limit, .. } => *gas_limit,
120            ContractAction::PoolSwapSell { gas_limit, .. } => *gas_limit,
121        }
122    }
123
124    /// BLOCK that moves from this tx's inputs into a pool/contract.
125    pub fn value(&self) -> u64 {
126        match self {
127            ContractAction::Deploy { .. } => 0,
128            ContractAction::Call { value, .. } => *value,
129            ContractAction::PoolCreate { block_amt, .. } => *block_amt,
130            ContractAction::PoolAdd { block_amt, .. } => *block_amt,
131            ContractAction::PoolRemove { .. } => 0,
132            ContractAction::PoolSwapBuy { block_in, .. } => *block_in,
133            ContractAction::PoolSwapSell { .. } => 0,
134        }
135    }
136}
137
138#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
139pub struct Transaction {
140    pub version: u32,
141    /// Empty for coinbase transactions.
142    pub inputs: Vec<TxInput>,
143    pub outputs: Vec<TxOutput>,
144    /// Coinbase-only payload (block height + arbitrary tag); makes coinbase
145    /// txids unique. Must be empty for regular transactions.
146    pub coinbase_data: Vec<u8>,
147    pub shielded: Option<ShieldedBundle>,
148    /// Optional Blockle VM deploy/call.
149    #[serde(default)]
150    pub contract: Option<ContractAction>,
151}
152
153impl Transaction {
154    /// Coinbase: no transparent inputs, no shielded bundle, and no
155    /// (the other input-less transaction kind).
156    pub fn is_coinbase(&self) -> bool {
157        self.inputs.is_empty() && self.shielded.is_none()
158    }
159
160    /// Total value entering from the shielded pool.
161    pub fn shielded_in(&self) -> Option<u64> {
162        self.shielded.as_ref().map_or(Some(0), |b| {
163            b.spends.iter().try_fold(0u64, |acc, s| acc.checked_add(s.value))
164        })
165    }
166
167    /// Total value leaving into the shielded pool.
168    pub fn shielded_out(&self) -> Option<u64> {
169        self.shielded.as_ref().map_or(Some(0), |b| {
170            b.outputs.iter().try_fold(0u64, |acc, o| acc.checked_add(o.value))
171        })
172    }
173
174    /// Fees paid by hidden-amount transfers (their only public value flow).
175    pub fn transfer_fees(&self) -> Option<u64> {
176        self.shielded.as_ref().map_or(Some(0), |b| {
177            b.transfers.iter().try_fold(0u64, |acc, t| acc.checked_add(t.fee))
178        })
179    }
180
181    /// All nullifiers this transaction reveals.
182    pub fn nullifiers(&self) -> Vec<Hash32> {
183        self.shielded
184            .as_ref()
185            .map(|b| {
186                b.spends
187                    .iter()
188                    .map(|s| s.nullifier)
189                    .chain(b.transfers.iter().map(|t| t.nullifier))
190                    .collect()
191            })
192            .unwrap_or_default()
193    }
194
195    /// Canonical consensus encoding. With `for_sighash` set, signatures are
196    /// replaced by empty strings so inputs can be signed.
197    pub fn encode(&self, for_sighash: bool) -> Vec<u8> {
198        let mut out = Vec::new();
199        put_u32(&mut out, self.version);
200        put_u32(&mut out, self.inputs.len() as u32);
201        for input in &self.inputs {
202            put_fixed(&mut out, &input.prev.txid);
203            put_u32(&mut out, input.prev.vout);
204            put_bytes(&mut out, &input.pubkey);
205            if for_sighash {
206                put_bytes(&mut out, &[]);
207            } else {
208                put_bytes(&mut out, &input.signature);
209            }
210        }
211        put_u32(&mut out, self.outputs.len() as u32);
212        for output in &self.outputs {
213            put_fixed(&mut out, &output.recipient);
214            put_u64(&mut out, output.amount);
215        }
216        put_bytes(&mut out, &self.coinbase_data);
217        match &self.shielded {
218            None => put_u32(&mut out, 0),
219            Some(s) => {
220                put_u32(&mut out, 1);
221                put_u32(&mut out, s.spends.len() as u32);
222                for spend in &s.spends {
223                    put_fixed(&mut out, &spend.anchor);
224                    put_fixed(&mut out, &spend.nullifier);
225                    put_u64(&mut out, spend.value);
226                    // Like signatures, proofs are excluded from the sighash:
227                    // each proof binds the sighash through its transcript.
228                    if for_sighash {
229                        put_bytes(&mut out, &[]);
230                    } else {
231                        put_bytes(&mut out, &spend.proof);
232                    }
233                }
234                put_u32(&mut out, s.outputs.len() as u32);
235                for output in &s.outputs {
236                    put_fixed(&mut out, &output.commitment);
237                    put_u64(&mut out, output.value);
238                }
239                put_u32(&mut out, s.transfers.len() as u32);
240                for t in &s.transfers {
241                    put_fixed(&mut out, &t.anchor);
242                    put_fixed(&mut out, &t.nullifier);
243                    put_fixed(&mut out, &t.commitment1);
244                    put_fixed(&mut out, &t.commitment2);
245                    put_u64(&mut out, t.fee);
246                    put_bytes(&mut out, &t.memo);
247                    if for_sighash {
248                        put_bytes(&mut out, &[]);
249                    } else {
250                        put_bytes(&mut out, &t.proof);
251                    }
252                }
253            }
254        }
255        match &self.contract {
256            None => put_u32(&mut out, 0),
257            Some(ContractAction::Deploy { code, gas_limit }) => {
258                put_u32(&mut out, 1);
259                put_bytes(&mut out, code);
260                put_u64(&mut out, *gas_limit);
261            }
262            Some(ContractAction::Call { contract, input, value, gas_limit }) => {
263                put_u32(&mut out, 2);
264                put_fixed(&mut out, contract);
265                put_bytes(&mut out, input);
266                put_u64(&mut out, *value);
267                put_u64(&mut out, *gas_limit);
268            }
269            Some(ContractAction::PoolCreate { token, block_amt, token_amt, gas_limit }) => {
270                put_u32(&mut out, 3);
271                put_fixed(&mut out, token);
272                put_u64(&mut out, *block_amt);
273                put_u64(&mut out, *token_amt);
274                put_u64(&mut out, *gas_limit);
275            }
276            Some(ContractAction::PoolAdd { token, block_amt, token_max, gas_limit }) => {
277                put_u32(&mut out, 4);
278                put_fixed(&mut out, token);
279                put_u64(&mut out, *block_amt);
280                put_u64(&mut out, *token_max);
281                put_u64(&mut out, *gas_limit);
282            }
283            Some(ContractAction::PoolRemove { token, shares, gas_limit }) => {
284                put_u32(&mut out, 5);
285                put_fixed(&mut out, token);
286                put_u64(&mut out, *shares);
287                put_u64(&mut out, *gas_limit);
288            }
289            Some(ContractAction::PoolSwapBuy { token, block_in, min_token_out, gas_limit }) => {
290                put_u32(&mut out, 6);
291                put_fixed(&mut out, token);
292                put_u64(&mut out, *block_in);
293                put_u64(&mut out, *min_token_out);
294                put_u64(&mut out, *gas_limit);
295            }
296            Some(ContractAction::PoolSwapSell { token, token_in, min_block_out, gas_limit }) => {
297                put_u32(&mut out, 7);
298                put_fixed(&mut out, token);
299                put_u64(&mut out, *token_in);
300                put_u64(&mut out, *min_block_out);
301                put_u64(&mut out, *gas_limit);
302            }
303        }
304        out
305    }
306
307    pub fn txid(&self) -> Hash32 {
308        blake2b_256(&self.encode(false))
309    }
310
311    /// The message each input signs: the transaction with signatures blanked.
312    pub fn sighash(&self) -> Hash32 {
313        blake2b_256(&self.encode(true))
314    }
315
316    pub fn total_output(&self) -> Option<u64> {
317        self.outputs
318            .iter()
319            .try_fold(0u64, |acc, o| acc.checked_add(o.amount))
320    }
321
322    pub fn serialized_size(&self) -> usize {
323        self.encode(false).len()
324    }
325
326    /// Build a coinbase transaction paying `recipient` at `height`.
327    pub fn coinbase(height: u64, recipient: Address, amount: u64, tag: &[u8]) -> Self {
328        let mut coinbase_data = height.to_le_bytes().to_vec();
329        coinbase_data.extend_from_slice(tag);
330        Transaction {
331            version: 1,
332            inputs: vec![],
333            outputs: vec![TxOutput { recipient, amount }],
334            coinbase_data,
335            shielded: None,
336            contract: None,
337        }
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use crate::keys::Keypair;
345
346    #[test]
347    fn txid_changes_with_content_sighash_ignores_sigs() {
348        let kp = Keypair::generate();
349        let mut tx = Transaction {
350            version: 1,
351            inputs: vec![TxInput {
352                prev: OutPoint { txid: [1; 32], vout: 0 },
353                pubkey: kp.public_bytes(),
354                signature: vec![],
355            }],
356            outputs: vec![TxOutput { recipient: kp.address(), amount: 42 }],
357            coinbase_data: vec![],
358            shielded: None,
359            contract: None,
360        };
361        let sighash_before = tx.sighash();
362        let txid_before = tx.txid();
363        tx.inputs[0].signature = kp.sign(&sighash_before).to_vec();
364        assert_eq!(tx.sighash(), sighash_before, "sighash must ignore signatures");
365        assert_ne!(tx.txid(), txid_before, "txid commits to signatures");
366
367        tx.outputs[0].amount = 43;
368        assert_ne!(tx.sighash(), sighash_before);
369    }
370
371    #[test]
372    fn coinbase_txids_unique_per_height() {
373        let addr = [9u8; 32];
374        let a = Transaction::coinbase(1, addr, 50, b"blockle");
375        let b = Transaction::coinbase(2, addr, 50, b"blockle");
376        assert!(a.is_coinbase());
377        assert_ne!(a.txid(), b.txid());
378    }
379}