iuna

iuna - experimental devnet protocol
git clone https://iuna.jhx.app/git/iuna.git
Log | Files | Refs | README | LICENSE

blinded.rs (12283B)


      1 use std::collections::BTreeMap;
      2 
      3 use anyhow::{Context, Result, anyhow, bail};
      4 use chacha20poly1305::{
      5     ChaCha20Poly1305, Nonce,
      6     aead::{Aead, KeyInit},
      7 };
      8 use ed25519_dalek::{Signature, Verifier, VerifyingKey};
      9 
     10 use super::transaction::{
     11     BlindedTransactionPayload, canonical_inputs, signed_blinded_inputs, unsigned_inputs,
     12 };
     13 use super::{
     14     Amount, BLINDED_COMMITTER_FEE_BPS, BLINDED_FEE_BPS_DENOMINATOR, BLINDED_KEY_BYTES,
     15     BLINDED_NONCE_BYTES, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BlindedReveal, BlindedTransaction,
     16     OutPoint, PUBLIC_KEY_BYTES, RevealBundleSignature, SIGNATURE_BYTES, Transaction, TxInput,
     17     TxOutput, blinded_reveal_finalizer_fee, decode_hex, decode_hex_array,
     18     ensure_outputs_do_not_overflow, ensure_single_input_owner_for_inputs, hex_hash,
     19 };
     20 
     21 #[derive(Clone, Debug, Eq, PartialEq)]
     22 pub(super) struct ActiveBlindedTransaction {
     23     pub(super) transaction: BlindedTransaction,
     24     pub(super) locked_outputs: Vec<TxOutput>,
     25     pub(super) included_height: u64,
     26     pub(super) included_by: String,
     27 }
     28 
     29 pub(super) fn decrypt_blinded_transaction(
     30     transaction: &BlindedTransaction,
     31     reveal: &BlindedReveal,
     32 ) -> Result<Transaction> {
     33     if reveal.commitment != transaction.commitment {
     34         bail!("blinded reveal commitment does not match transaction");
     35     }
     36     let key =
     37         decode_hex_array::<BLINDED_KEY_BYTES>(&reveal.key).context("invalid blinded reveal key")?;
     38     let nonce = decode_hex_array::<BLINDED_NONCE_BYTES>(&transaction.nonce)
     39         .context("invalid blinded transaction nonce")?;
     40     let ciphertext =
     41         decode_hex(&transaction.ciphertext).context("invalid blinded transaction ciphertext")?;
     42     let plaintext = decrypt_blinded_payload(
     43         &key,
     44         &nonce,
     45         &signed_blinded_inputs(&unsigned_inputs(&transaction.inputs), ""),
     46         transaction.fee,
     47         transaction.expires_at_height,
     48         &ciphertext,
     49     )?;
     50     if hex_hash(&plaintext) != transaction.payload_hash {
     51         bail!("blinded transaction payload hash is invalid");
     52     }
     53     let payload = serde_json::from_slice(&plaintext)
     54         .context("failed to decode blinded transaction payload")?;
     55     transaction_from_blinded_payload(payload, &transaction.inputs, transaction.fee)
     56 }
     57 
     58 pub(super) fn blinded_payload_from_transaction(
     59     transaction: &Transaction,
     60 ) -> Result<BlindedTransactionPayload> {
     61     match transaction {
     62         Transaction::Transfer {
     63             outputs, signature, ..
     64         } => Ok(BlindedTransactionPayload::Transfer {
     65             outputs: outputs.clone(),
     66             signature: signature.clone(),
     67         }),
     68         Transaction::Burn {
     69             change,
     70             amount,
     71             signature,
     72             ..
     73         } => Ok(BlindedTransactionPayload::Burn {
     74             change: change.clone(),
     75             amount: *amount,
     76             signature: signature.clone(),
     77         }),
     78         Transaction::Mine { .. } => bail!("mine actions are public and cannot be blinded"),
     79     }
     80 }
     81 
     82 pub(super) fn transaction_from_blinded_payload(
     83     payload: BlindedTransactionPayload,
     84     envelope_inputs: &[TxInput],
     85     fee: Amount,
     86 ) -> Result<Transaction> {
     87     match payload {
     88         BlindedTransactionPayload::Transfer { outputs, signature } => {
     89             let inputs = signed_blinded_inputs(&unsigned_inputs(envelope_inputs), &signature);
     90             Ok(Transaction::Transfer {
     91                 inputs,
     92                 outputs,
     93                 fee,
     94                 signature,
     95             })
     96         }
     97         BlindedTransactionPayload::Burn {
     98             change,
     99             amount,
    100             signature,
    101         } => {
    102             let inputs = signed_blinded_inputs(&unsigned_inputs(envelope_inputs), &signature);
    103             Ok(Transaction::Burn {
    104                 inputs,
    105                 change,
    106                 amount,
    107                 fee,
    108                 signature,
    109             })
    110         }
    111     }
    112 }
    113 
    114 pub(super) fn encrypt_blinded_payload(
    115     key: &[u8; BLINDED_KEY_BYTES],
    116     nonce: &[u8; BLINDED_NONCE_BYTES],
    117     inputs: &[TxInput],
    118     fee: Amount,
    119     expires_at_height: u64,
    120     plaintext: &[u8],
    121 ) -> Result<Vec<u8>> {
    122     let cipher = ChaCha20Poly1305::new(key.into());
    123     cipher
    124         .encrypt(
    125             Nonce::from_slice(nonce),
    126             chacha20poly1305::aead::Payload {
    127                 msg: plaintext,
    128                 aad: blinded_payload_aad(inputs, fee, expires_at_height).as_bytes(),
    129             },
    130         )
    131         .map_err(|_| anyhow!("failed to encrypt blinded transaction payload"))
    132 }
    133 
    134 pub(super) fn decrypt_blinded_payload(
    135     key: &[u8; BLINDED_KEY_BYTES],
    136     nonce: &[u8; BLINDED_NONCE_BYTES],
    137     inputs: &[TxInput],
    138     fee: Amount,
    139     expires_at_height: u64,
    140     ciphertext: &[u8],
    141 ) -> Result<Vec<u8>> {
    142     let cipher = ChaCha20Poly1305::new(key.into());
    143     cipher
    144         .decrypt(
    145             Nonce::from_slice(nonce),
    146             chacha20poly1305::aead::Payload {
    147                 msg: ciphertext,
    148                 aad: blinded_payload_aad(inputs, fee, expires_at_height).as_bytes(),
    149             },
    150         )
    151         .map_err(|_| anyhow!("failed to decrypt blinded transaction payload"))
    152 }
    153 
    154 fn blinded_payload_aad(inputs: &[TxInput], fee: Amount, expires_at_height: u64) -> String {
    155     format!(
    156         "iuna-blinded-payload-v3:{}:{fee}:{expires_at_height}",
    157         canonical_inputs(&unsigned_inputs(inputs))
    158     )
    159 }
    160 
    161 pub(super) fn blinded_transaction_commitment(transaction: &BlindedTransaction) -> Result<String> {
    162     let mut without_commitment = transaction.clone();
    163     without_commitment.commitment.clear();
    164     Ok(hex_hash(without_commitment.canonical()))
    165 }
    166 
    167 pub(super) fn blinded_transaction_signing_payload(transaction: &BlindedTransaction) -> String {
    168     format!(
    169         "blinded-tx-inputs:{}:{}:{}:{}:{}:{}:{}",
    170         canonical_inputs(&unsigned_inputs(&transaction.inputs)),
    171         transaction.fee,
    172         transaction.encrypted_size,
    173         transaction.expires_at_height,
    174         transaction.nonce,
    175         transaction.ciphertext,
    176         transaction.payload_hash
    177     )
    178 }
    179 
    180 pub(super) fn verify_blinded_input_signatures(transaction: &BlindedTransaction) -> Result<()> {
    181     if transaction.inputs.is_empty() {
    182         return Ok(());
    183     }
    184     ensure_single_input_owner_for_inputs(&transaction.inputs)?;
    185     let signature = transaction.inputs[0].signature.clone();
    186     if !transaction
    187         .inputs
    188         .iter()
    189         .all(|input| input.signature == signature)
    190     {
    191         bail!("blinded transaction input signature mismatch");
    192     }
    193     let mut unsigned = transaction.clone();
    194     for input in &mut unsigned.inputs {
    195         input.signature.clear();
    196     }
    197     let public_key = decode_hex_array::<PUBLIC_KEY_BYTES>(&transaction.inputs[0].owner)
    198         .context("invalid blinded transaction input owner")?;
    199     let signature = decode_hex_array::<SIGNATURE_BYTES>(&signature)
    200         .context("invalid blinded input signature")?;
    201     let verifying_key =
    202         VerifyingKey::from_bytes(&public_key).context("invalid blinded input public key")?;
    203     let signature = Signature::from_bytes(&signature);
    204     verifying_key
    205         .verify(
    206             blinded_transaction_signing_payload(&unsigned).as_bytes(),
    207             &signature,
    208         )
    209         .context("blinded transaction input signature is invalid")
    210 }
    211 
    212 pub(super) fn credit_blinded_fee_outputs(
    213     utxos: &mut BTreeMap<OutPoint, TxOutput>,
    214     active: &ActiveBlindedTransaction,
    215     reveal_executor: &str,
    216     transaction: &Transaction,
    217     reveal_bundle_signatures: &[RevealBundleSignature],
    218     available_bundle_slots: usize,
    219     aggregate_finalizer_fee: bool,
    220 ) -> Result<()> {
    221     let fee = transaction.fee();
    222     if fee == 0 {
    223         return Ok(());
    224     }
    225     let committer_fee = blinded_fee_share(fee, BLINDED_COMMITTER_FEE_BPS);
    226     let reveal_finalizer_fee =
    227         blinded_reveal_finalizer_fee(fee, reveal_bundle_signatures.len(), available_bundle_slots);
    228     let reveal_bundle_signer_fee = blinded_fee_share(fee, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS);
    229     let mut outputs = Vec::new();
    230     if committer_fee > 0 {
    231         outputs.push((
    232             blinded_committer_fee_outpoint(&active.transaction.commitment),
    233             TxOutput {
    234                 address: active.included_by.clone(),
    235                 amount: committer_fee,
    236             },
    237         ));
    238     }
    239     if reveal_finalizer_fee > 0 && !aggregate_finalizer_fee {
    240         outputs.push((
    241             blinded_executor_fee_outpoint(&active.transaction.commitment),
    242             TxOutput {
    243                 address: reveal_executor.to_string(),
    244                 amount: reveal_finalizer_fee,
    245             },
    246         ));
    247     }
    248     for signature in reveal_bundle_signatures {
    249         if reveal_bundle_signer_fee > 0 {
    250             outputs.push((
    251                 blinded_reveal_bundle_signer_fee_outpoint(
    252                     &active.transaction.commitment,
    253                     signature.slot,
    254                 ),
    255                 TxOutput {
    256                     address: signature.member.clone(),
    257                     amount: reveal_bundle_signer_fee,
    258                 },
    259             ));
    260         }
    261     }
    262     let tx_outputs = outputs
    263         .iter()
    264         .map(|(_, output)| output.clone())
    265         .collect::<Vec<_>>();
    266     ensure_outputs_do_not_overflow(utxos, &tx_outputs)?;
    267     for (outpoint, output) in outputs {
    268         utxos.insert(outpoint, output);
    269     }
    270     Ok(())
    271 }
    272 
    273 pub(super) fn blinded_fee_share(fee: Amount, bps: u64) -> Amount {
    274     ((fee as u128 * bps as u128) / BLINDED_FEE_BPS_DENOMINATOR as u128) as Amount
    275 }
    276 
    277 pub(super) fn blinded_envelope_fee_for_transaction(transaction: &Transaction) -> Amount {
    278     match transaction {
    279         Transaction::Mine { .. } => 0,
    280         Transaction::Transfer { .. } | Transaction::Burn { .. } => transaction.fee(),
    281     }
    282 }
    283 
    284 pub(super) fn blinded_locked_output_total(active: &ActiveBlindedTransaction) -> Result<Amount> {
    285     active
    286         .locked_outputs
    287         .iter()
    288         .try_fold(0_u64, |total, output| {
    289             total
    290                 .checked_add(output.amount)
    291                 .context("blinded transaction locked input total overflows")
    292         })
    293 }
    294 
    295 pub(super) fn blinded_reveal_inputs_match(
    296     active: &ActiveBlindedTransaction,
    297     transaction: &Transaction,
    298 ) -> bool {
    299     let visible = active
    300         .transaction
    301         .inputs
    302         .iter()
    303         .map(TxInput::without_signature)
    304         .collect::<Vec<_>>();
    305     let revealed = transaction
    306         .inputs()
    307         .iter()
    308         .map(TxInput::without_signature)
    309         .collect::<Vec<_>>();
    310     visible == revealed
    311 }
    312 
    313 pub(super) fn credit_expired_blinded_outputs(
    314     utxos: &mut BTreeMap<OutPoint, TxOutput>,
    315     active: &ActiveBlindedTransaction,
    316 ) -> Result<()> {
    317     let Some(first_input) = active.transaction.inputs.first() else {
    318         return Ok(());
    319     };
    320     let input_total = blinded_locked_output_total(active)?;
    321     if active.transaction.fee > input_total {
    322         bail!("blinded transaction fee exceeds locked inputs");
    323     }
    324     let change = input_total - active.transaction.fee;
    325     let mut outputs = Vec::new();
    326     if change > 0 {
    327         outputs.push((
    328             blinded_expiry_change_outpoint(&active.transaction.commitment),
    329             TxOutput {
    330                 address: first_input.owner.clone(),
    331                 amount: change,
    332             },
    333         ));
    334     }
    335     let tx_outputs = outputs
    336         .iter()
    337         .map(|(_, output)| output.clone())
    338         .collect::<Vec<_>>();
    339     ensure_outputs_do_not_overflow(utxos, &tx_outputs)?;
    340     for (outpoint, output) in outputs {
    341         utxos.insert(outpoint, output);
    342     }
    343     Ok(())
    344 }
    345 
    346 pub(super) fn blinded_committer_fee_outpoint(commitment: &str) -> OutPoint {
    347     OutPoint {
    348         txid: commitment.to_string(),
    349         index: u32::MAX - 1,
    350     }
    351 }
    352 
    353 pub(super) fn blinded_executor_fee_outpoint(commitment: &str) -> OutPoint {
    354     OutPoint {
    355         txid: commitment.to_string(),
    356         index: u32::MAX - 2,
    357     }
    358 }
    359 
    360 pub(super) fn blinded_reveal_bundle_signer_fee_outpoint(commitment: &str, slot: u8) -> OutPoint {
    361     OutPoint {
    362         txid: commitment.to_string(),
    363         index: u32::MAX - 3 - u32::from(slot),
    364     }
    365 }
    366 
    367 pub(super) fn blinded_expiry_change_outpoint(commitment: &str) -> OutPoint {
    368     OutPoint {
    369         txid: commitment.to_string(),
    370         index: 0,
    371     }
    372 }