Skip to main content

eth_valkyoth_protocol/
withdrawal.rs

1use eth_valkyoth_codec::{DecodeError, DecodeLimits, RlpInteger, RlpItem, RlpList, RlpScalar};
2use eth_valkyoth_primitives::Address;
3
4mod error;
5pub use error::{WithdrawalDecodeError, WithdrawalDecodeErrorCategory, WithdrawalField};
6
7const ADDRESS_BYTES: usize = 20;
8
9/// Number of fields in an EIP-4895 withdrawal entry.
10pub const WITHDRAWAL_FIELD_COUNT: usize = 4;
11
12/// EIP-4895 global withdrawal index.
13#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
14pub struct WithdrawalIndex(u64);
15
16impl WithdrawalIndex {
17    /// Creates a withdrawal index.
18    #[must_use]
19    pub const fn new(value: u64) -> Self {
20        Self(value)
21    }
22
23    /// Returns the raw integer value.
24    #[must_use]
25    pub const fn get(self) -> u64 {
26        self.0
27    }
28}
29
30/// Consensus-layer validator index referenced by a withdrawal.
31#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
32pub struct WithdrawalValidatorIndex(u64);
33
34impl WithdrawalValidatorIndex {
35    /// Creates a validator index.
36    #[must_use]
37    pub const fn new(value: u64) -> Self {
38        Self(value)
39    }
40
41    /// Returns the raw integer value.
42    #[must_use]
43    pub const fn get(self) -> u64 {
44        self.0
45    }
46}
47
48/// EIP-4895 withdrawal amount in Gwei.
49#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
50pub struct WithdrawalAmountGwei(u64);
51
52impl WithdrawalAmountGwei {
53    /// Creates a nonzero Gwei amount.
54    pub const fn try_new(value: u64) -> Result<Self, WithdrawalDecodeError> {
55        if value == 0 {
56            return Err(WithdrawalDecodeError::ZeroAmount);
57        }
58        Ok(Self(value))
59    }
60
61    /// Returns the raw Gwei amount.
62    #[must_use]
63    pub const fn get(self) -> u64 {
64        self.0
65    }
66}
67
68/// Borrowed withdrawals list decoded only into EIP-4895 field domains.
69///
70/// This type is intentionally unvalidated. It does not prove consensus-layer
71/// dequeue correctness, global index monotonicity, header `withdrawals_root`
72/// matching, trie-root membership, or state-balance application.
73#[derive(Clone, Copy, Debug, Eq, PartialEq)]
74pub struct UnvalidatedWithdrawals<'a> {
75    encoded_rlp: &'a [u8],
76    list: RlpList<'a>,
77}
78
79impl<'a> UnvalidatedWithdrawals<'a> {
80    /// Returns the exact canonical RLP list bytes that were decoded.
81    #[must_use]
82    pub const fn encoded_rlp(self) -> &'a [u8] {
83        self.encoded_rlp
84    }
85
86    /// Returns the number of withdrawal entries.
87    #[must_use]
88    pub const fn len(self) -> usize {
89        self.list.item_count()
90    }
91
92    /// Returns true when the withdrawals list is empty.
93    #[must_use]
94    pub const fn is_empty(self) -> bool {
95        self.list.is_empty()
96    }
97
98    /// Returns an iterator over decoded withdrawal entries.
99    #[must_use]
100    pub const fn entries(self) -> WithdrawalItems<'a> {
101        WithdrawalItems {
102            items: self.list.items(),
103        }
104    }
105}
106
107/// Unvalidated EIP-4895 withdrawal entry.
108#[derive(Clone, Copy, Debug, Eq, PartialEq)]
109pub struct UnvalidatedWithdrawal {
110    /// Global withdrawal index.
111    pub index: WithdrawalIndex,
112    /// Consensus-layer validator index.
113    pub validator_index: WithdrawalValidatorIndex,
114    /// Recipient execution-layer address.
115    pub address: Address,
116    /// Nonzero amount in Gwei.
117    pub amount: WithdrawalAmountGwei,
118}
119
120/// Iterator over borrowed withdrawal entries.
121#[derive(Clone, Debug)]
122pub struct WithdrawalItems<'a> {
123    items: eth_valkyoth_codec::RlpListItems<'a>,
124}
125
126impl Iterator for WithdrawalItems<'_> {
127    type Item = Result<UnvalidatedWithdrawal, WithdrawalDecodeError>;
128
129    fn next(&mut self) -> Option<Self::Item> {
130        self.items.next().map(decode_withdrawal_item)
131    }
132}
133
134impl core::iter::FusedIterator for WithdrawalItems<'_> {}
135
136/// Decodes an EIP-4895 withdrawals list under explicit limits.
137///
138/// This function is syntactic. It checks that the input is one canonical RLP
139/// list of withdrawal entries shaped `[index, validator_index, address,
140/// amount]`, with canonical `uint64` integers, a 20-byte address, and a
141/// nonzero Gwei amount. It does not compute or compare `withdrawals_root`.
142pub fn decode_withdrawals<'a>(
143    input: &'a [u8],
144    limits: DecodeLimits,
145) -> Result<UnvalidatedWithdrawals<'a>, WithdrawalDecodeError> {
146    let list = eth_valkyoth_codec::decode_rlp_list(input, limits)
147        .map_err(|source| field_error(WithdrawalField::List, source))?;
148    for item in list.items() {
149        let _ = decode_withdrawal_item(item)?;
150    }
151    Ok(UnvalidatedWithdrawals {
152        encoded_rlp: input,
153        list,
154    })
155}
156
157fn decode_withdrawal_item(
158    item: Result<RlpItem<'_>, DecodeError>,
159) -> Result<UnvalidatedWithdrawal, WithdrawalDecodeError> {
160    let item = item.map_err(|source| field_error(WithdrawalField::Withdrawal, source))?;
161    let RlpItem::List(list) = item else {
162        return Err(field_error(
163            WithdrawalField::Withdrawal,
164            DecodeError::UnexpectedScalar,
165        ));
166    };
167    if list.item_count() != WITHDRAWAL_FIELD_COUNT {
168        return Err(WithdrawalDecodeError::WrongFieldCount {
169            expected: WITHDRAWAL_FIELD_COUNT,
170            found: list.item_count(),
171        });
172    }
173
174    let mut fields = list.items();
175    Ok(UnvalidatedWithdrawal {
176        index: WithdrawalIndex::new(decode_u64(&mut fields, WithdrawalField::Index)?),
177        validator_index: WithdrawalValidatorIndex::new(decode_u64(
178            &mut fields,
179            WithdrawalField::ValidatorIndex,
180        )?),
181        address: Address::from_bytes(decode_address(&mut fields)?),
182        amount: WithdrawalAmountGwei::try_new(decode_u64(&mut fields, WithdrawalField::Amount)?)?,
183    })
184}
185
186fn decode_u64<'a>(
187    fields: &mut impl Iterator<Item = Result<RlpItem<'a>, DecodeError>>,
188    field: WithdrawalField,
189) -> Result<u64, WithdrawalDecodeError> {
190    RlpInteger::try_from_scalar(next_scalar(fields, field)?)
191        .and_then(RlpInteger::to_u64)
192        .map_err(|source| field_error(field, source))
193}
194
195fn decode_address<'a>(
196    fields: &mut impl Iterator<Item = Result<RlpItem<'a>, DecodeError>>,
197) -> Result<[u8; ADDRESS_BYTES], WithdrawalDecodeError> {
198    let scalar = next_scalar(fields, WithdrawalField::Address)?;
199    let found = scalar.payload().len();
200    scalar
201        .payload()
202        .try_into()
203        .map_err(|_| WithdrawalDecodeError::InvalidAddressLength { found })
204}
205
206fn next_scalar<'a>(
207    fields: &mut impl Iterator<Item = Result<RlpItem<'a>, DecodeError>>,
208    field: WithdrawalField,
209) -> Result<RlpScalar<'a>, WithdrawalDecodeError> {
210    let item = fields
211        .next()
212        .ok_or(field_error(field, DecodeError::Malformed))?
213        .map_err(|source| field_error(field, source))?;
214    match item {
215        RlpItem::Scalar(scalar) => Ok(scalar),
216        RlpItem::List(_) => Err(field_error(field, DecodeError::UnexpectedList)),
217    }
218}
219
220const fn field_error(field: WithdrawalField, source: DecodeError) -> WithdrawalDecodeError {
221    WithdrawalDecodeError::FieldDecode { field, source }
222}
223
224#[cfg(test)]
225#[path = "withdrawal_tests.rs"]
226mod tests;