|
| 1 | +//! Withdrawal-intent log decoding. |
| 2 | +
|
| 3 | +use alloy_sol_types::SolEvent; |
| 4 | +use alpen_reth_primitives::WithdrawalIntentEvent; |
| 5 | +use strata_primitives::buf::Buf32; |
| 6 | + |
| 7 | +use crate::db::types::DbWithdrawalRequest; |
| 8 | + |
| 9 | +use super::{rpc::RpcLog, BRIDGEOUT_PRECOMPILE_ADDRESS, FIXED_WITHDRAWAL_SATS}; |
| 10 | + |
| 11 | +/// Hard correctness bound on sub-units per `WithdrawalIntentEvent`, set by |
| 12 | +/// `DbWithdrawalRequest::sub_idx: u32`: any value above this can't be stored |
| 13 | +/// without overflowing the cast. A tighter operational ceiling would need a |
| 14 | +/// protocol-defined constant or operator-tunable config — neither alpen nor |
| 15 | +/// strata-bridge expose one, so we don't invent one dashboard-side. |
| 16 | +const MAX_SUB_UNITS_PER_LOG: u64 = u32::MAX as u64; |
| 17 | + |
| 18 | +#[derive(Debug, thiserror::Error)] |
| 19 | +pub(crate) enum DecodeError { |
| 20 | + #[error("unexpected log address (expected {expected}, got {actual})")] |
| 21 | + UnexpectedAddress { |
| 22 | + expected: alloy_primitives::Address, |
| 23 | + actual: alloy_primitives::Address, |
| 24 | + }, |
| 25 | + |
| 26 | + #[error("unexpected log topic[0] {0:?}")] |
| 27 | + UnexpectedSignature(alloy_primitives::B256), |
| 28 | + |
| 29 | + #[error("alloy decode: {0}")] |
| 30 | + AbiDecode(#[from] alloy_sol_types::Error), |
| 31 | + |
| 32 | + #[error( |
| 33 | + "withdrawal amount {0} sats is not a positive multiple of {} sats", |
| 34 | + FIXED_WITHDRAWAL_SATS |
| 35 | + )] |
| 36 | + AmountNotMultiple(u64), |
| 37 | + |
| 38 | + #[error("withdrawal amount {amount} sats expands to more than {max_sub_units} sub-units")] |
| 39 | + AmountTooLarge { amount: u64, max_sub_units: u64 }, |
| 40 | +} |
| 41 | + |
| 42 | +/// Decode `log` into N rows, where N = `amount / FIXED_WITHDRAWAL_SATS`. |
| 43 | +/// |
| 44 | +/// Each row carries the same `(tx_hash, log_index)` and a distinct |
| 45 | +/// `sub_idx ∈ 0..N`. Returns an error if the log doesn't belong to the |
| 46 | +/// bridgeout precompile, the topic doesn't match the event signature, or the |
| 47 | +/// amount isn't a positive exact multiple of the denomination. |
| 48 | +pub(crate) fn decode(log: &RpcLog) -> Result<Vec<DbWithdrawalRequest>, DecodeError> { |
| 49 | + if log.address != BRIDGEOUT_PRECOMPILE_ADDRESS { |
| 50 | + return Err(DecodeError::UnexpectedAddress { |
| 51 | + expected: BRIDGEOUT_PRECOMPILE_ADDRESS, |
| 52 | + actual: log.address, |
| 53 | + }); |
| 54 | + } |
| 55 | + match log.topics.first() { |
| 56 | + Some(t0) if *t0 == WithdrawalIntentEvent::SIGNATURE_HASH => {} |
| 57 | + Some(t0) => return Err(DecodeError::UnexpectedSignature(*t0)), |
| 58 | + None => { |
| 59 | + return Err(DecodeError::UnexpectedSignature( |
| 60 | + alloy_primitives::B256::ZERO, |
| 61 | + )) |
| 62 | + } |
| 63 | + } |
| 64 | + |
| 65 | + let evt = WithdrawalIntentEvent::decode_raw_log(log.topics.iter().copied(), &log.data)?; |
| 66 | + |
| 67 | + if evt.amount == 0 || evt.amount % FIXED_WITHDRAWAL_SATS != 0 { |
| 68 | + return Err(DecodeError::AmountNotMultiple(evt.amount)); |
| 69 | + } |
| 70 | + let n = evt.amount / FIXED_WITHDRAWAL_SATS; |
| 71 | + if n > MAX_SUB_UNITS_PER_LOG { |
| 72 | + return Err(DecodeError::AmountTooLarge { |
| 73 | + amount: evt.amount, |
| 74 | + max_sub_units: MAX_SUB_UNITS_PER_LOG, |
| 75 | + }); |
| 76 | + } |
| 77 | + |
| 78 | + let tx_hash = Buf32(log.transaction_hash.0); |
| 79 | + let destination = evt.destination.to_vec(); |
| 80 | + let block_number = log.block_number; |
| 81 | + let log_index = log.log_index; |
| 82 | + let selected_operator = evt.selectedOperator; |
| 83 | + |
| 84 | + let mut out = Vec::with_capacity(n as usize); |
| 85 | + for sub_idx in 0..n { |
| 86 | + out.push(DbWithdrawalRequest { |
| 87 | + tx_hash, |
| 88 | + log_index, |
| 89 | + sub_idx: sub_idx as u32, |
| 90 | + amount_sats: FIXED_WITHDRAWAL_SATS, |
| 91 | + destination: destination.clone(), |
| 92 | + selected_operator, |
| 93 | + block_number, |
| 94 | + }); |
| 95 | + } |
| 96 | + Ok(out) |
| 97 | +} |
| 98 | + |
| 99 | +#[cfg(test)] |
| 100 | +mod tests { |
| 101 | + use super::*; |
| 102 | + use alloy_primitives::{Bytes, LogData, B256}; |
| 103 | + |
| 104 | + fn make_log( |
| 105 | + amount_sats: u64, |
| 106 | + selected_operator: u32, |
| 107 | + address: alloy_primitives::Address, |
| 108 | + ) -> RpcLog { |
| 109 | + let evt = WithdrawalIntentEvent { |
| 110 | + amount: amount_sats, |
| 111 | + selectedOperator: selected_operator, |
| 112 | + destination: Bytes::from(vec![0xAB; 22]), |
| 113 | + }; |
| 114 | + let data = LogData::from(&evt); |
| 115 | + RpcLog { |
| 116 | + address, |
| 117 | + topics: data.topics().to_vec(), |
| 118 | + data: data.data.to_vec(), |
| 119 | + block_number: 1234, |
| 120 | + transaction_hash: B256::repeat_byte(0x77), |
| 121 | + log_index: 5, |
| 122 | + } |
| 123 | + } |
| 124 | + |
| 125 | + #[test] |
| 126 | + fn expands_into_sub_units() { |
| 127 | + let log = make_log(3 * FIXED_WITHDRAWAL_SATS, 2, BRIDGEOUT_PRECOMPILE_ADDRESS); |
| 128 | + let rows = decode(&log).expect("decode"); |
| 129 | + assert_eq!(rows.len(), 3); |
| 130 | + for (i, row) in rows.iter().enumerate() { |
| 131 | + assert_eq!(row.sub_idx, i as u32); |
| 132 | + assert_eq!(row.tx_hash.0, [0x77; 32]); |
| 133 | + assert_eq!(row.log_index, 5); |
| 134 | + assert_eq!(row.amount_sats, FIXED_WITHDRAWAL_SATS); |
| 135 | + assert_eq!(row.selected_operator, 2); |
| 136 | + assert_eq!(row.destination, vec![0xAB; 22]); |
| 137 | + assert_eq!(row.block_number, 1234); |
| 138 | + } |
| 139 | + } |
| 140 | + |
| 141 | + #[test] |
| 142 | + fn single_sub_unit_for_one_denom() { |
| 143 | + let log = make_log( |
| 144 | + FIXED_WITHDRAWAL_SATS, |
| 145 | + u32::MAX, |
| 146 | + BRIDGEOUT_PRECOMPILE_ADDRESS, |
| 147 | + ); |
| 148 | + let rows = decode(&log).expect("decode"); |
| 149 | + assert_eq!(rows.len(), 1); |
| 150 | + assert_eq!(rows[0].selected_operator, u32::MAX); |
| 151 | + } |
| 152 | + |
| 153 | + #[test] |
| 154 | + fn rejects_non_multiple_amount() { |
| 155 | + let log = make_log(FIXED_WITHDRAWAL_SATS + 1, 0, BRIDGEOUT_PRECOMPILE_ADDRESS); |
| 156 | + assert!(matches!( |
| 157 | + decode(&log), |
| 158 | + Err(DecodeError::AmountNotMultiple(..)) |
| 159 | + )); |
| 160 | + } |
| 161 | + |
| 162 | + #[test] |
| 163 | + fn rejects_zero_amount() { |
| 164 | + let log = make_log(0, 0, BRIDGEOUT_PRECOMPILE_ADDRESS); |
| 165 | + assert!(matches!( |
| 166 | + decode(&log), |
| 167 | + Err(DecodeError::AmountNotMultiple(..)) |
| 168 | + )); |
| 169 | + } |
| 170 | + |
| 171 | + #[test] |
| 172 | + fn rejects_amount_exceeding_sub_idx_capacity() { |
| 173 | + // n = u32::MAX + 1 → would overflow `sub_idx: u32`. Decoder must |
| 174 | + // reject before allocating. |
| 175 | + let log = make_log( |
| 176 | + (MAX_SUB_UNITS_PER_LOG + 1) * FIXED_WITHDRAWAL_SATS, |
| 177 | + 0, |
| 178 | + BRIDGEOUT_PRECOMPILE_ADDRESS, |
| 179 | + ); |
| 180 | + assert!(matches!( |
| 181 | + decode(&log), |
| 182 | + Err(DecodeError::AmountTooLarge { .. }) |
| 183 | + )); |
| 184 | + } |
| 185 | + |
| 186 | + #[test] |
| 187 | + fn rejects_wrong_address() { |
| 188 | + let other = alloy_primitives::address!("0000000000000000000000000000000000000099"); |
| 189 | + let log = make_log(FIXED_WITHDRAWAL_SATS, 0, other); |
| 190 | + assert!(matches!( |
| 191 | + decode(&log), |
| 192 | + Err(DecodeError::UnexpectedAddress { .. }) |
| 193 | + )); |
| 194 | + } |
| 195 | + |
| 196 | + #[test] |
| 197 | + fn rejects_wrong_topic() { |
| 198 | + let mut log = make_log(FIXED_WITHDRAWAL_SATS, 0, BRIDGEOUT_PRECOMPILE_ADDRESS); |
| 199 | + log.topics[0] = B256::ZERO; |
| 200 | + assert!(matches!( |
| 201 | + decode(&log), |
| 202 | + Err(DecodeError::UnexpectedSignature(..)) |
| 203 | + )); |
| 204 | + } |
| 205 | +} |
0 commit comments