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