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 }