Skip to main content

evm_oracle_state/pending/
chainlink.rs

1//! Pure Chainlink OCR2 pending-calldata recognition.
2
3use alloy_primitives::{Address, B256, I256, U256, keccak256};
4
5use super::{PendingOracleRoute, PendingOracleUpdateId};
6
7/// Chainlink OCR2 `transmit` selector observed on current Ethereum aggregators.
8pub const CHAINLINK_TRANSMIT_SELECTOR: [u8; 4] = [0xb1, 0xdc, 0x65, 0xa4];
9/// Chainlink OCR2 `transmitSecondary` selector used by Smart Value Recapture feeds.
10pub const CHAINLINK_TRANSMIT_SECONDARY_SELECTOR: [u8; 4] = [0xba, 0x0c, 0xb2, 0x9e];
11/// Chainlink forwarder `forward(address,bytes)` selector.
12pub const CHAINLINK_FORWARD_SELECTOR: [u8; 4] = [0x6f, 0xad, 0xcf, 0x72];
13
14const MINIMUM_PLAUSIBLE_TIMESTAMP: u64 = 1_577_836_800;
15const MAXIMUM_OBSERVATIONS: usize = 256;
16
17/// Decoded report-level evidence shared by every transport variant carrying it.
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct ChainlinkPendingReport {
20    /// Aggregator that would receive the OCR2 report.
21    pub aggregator: Address,
22    /// Outer forwarder when the report is wrapped in `forward(address,bytes)`.
23    pub forwarder: Option<Address>,
24    /// Whether the `transmitSecondary` entry point is used.
25    pub secondary: bool,
26    /// Observation timestamp encoded by the report.
27    pub observations_timestamp: u64,
28    /// Signed oracle-node observations in report order.
29    pub observations: Vec<I256>,
30    /// Median observation selected by the OCR report.
31    pub median_answer: I256,
32    /// Lowest decoded observation.
33    pub minimum_answer: I256,
34    /// Highest decoded observation.
35    pub maximum_answer: I256,
36    /// Content hash of the raw OCR report bytes.
37    pub report_hash: B256,
38    /// Content hash of the complete outer calldata.
39    pub calldata_hash: B256,
40    /// Content hash of the inner `transmit` calldata.
41    pub inner_calldata_hash: B256,
42}
43
44/// A recognized Chainlink OCR2 call plus its generic pending route.
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct DecodedChainlinkOcr2 {
47    /// Generic outer and inner call path.
48    pub route: PendingOracleRoute,
49    /// Decoded report evidence.
50    pub report: ChainlinkPendingReport,
51}
52
53impl DecodedChainlinkOcr2 {
54    /// Derive report identity from the destination aggregator and report content.
55    pub fn update_id(&self) -> PendingOracleUpdateId {
56        let mut identity = Vec::with_capacity(52);
57        identity.extend_from_slice(self.report.aggregator.as_slice());
58        identity.extend_from_slice(self.report.report_hash.as_slice());
59        PendingOracleUpdateId::from_hash(keccak256(identity))
60    }
61}
62
63/// Failure to decode calldata whose selector identifies a supported Chainlink path.
64#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
65pub enum PendingOracleDecodeError {
66    /// A required ABI word or dynamic byte range was outside the payload.
67    #[error("Chainlink pending calldata is ABI-truncated at {field}")]
68    Truncated {
69        /// Logical field whose bytes were unavailable.
70        field: &'static str,
71    },
72    /// A dynamic ABI offset or length cannot fit this platform or payload.
73    #[error("Chainlink pending calldata has an invalid ABI offset for {field}")]
74    InvalidOffset {
75        /// Logical field containing the invalid offset.
76        field: &'static str,
77    },
78    /// A report timestamp was too old to plausibly represent supported OCR2 traffic.
79    #[error("Chainlink report has implausible observations timestamp {timestamp}")]
80    ImplausibleTimestamp {
81        /// Decoded timestamp.
82        timestamp: u64,
83    },
84    /// The report contained no observations or an implausibly large collection.
85    #[error("Chainlink report has implausible observation count {count}")]
86    ImplausibleObservationCount {
87        /// Decoded observation count.
88        count: usize,
89    },
90}
91
92/// Decode supported direct or forwarded Chainlink OCR2 calldata.
93///
94/// Unknown selectors return `Ok(None)`. Once a supported outer or inner selector
95/// is recognized, malformed ABI or report content returns a typed error rather
96/// than being silently treated as unrelated traffic.
97pub fn decode_chainlink_ocr2_calldata(
98    to: Address,
99    outer_calldata: &[u8],
100) -> Result<Option<DecodedChainlinkOcr2>, PendingOracleDecodeError> {
101    let Some(outer_selector) = outer_calldata.get(..4) else {
102        return Ok(None);
103    };
104
105    let (aggregator, forwarder, inner_calldata) = if outer_selector == CHAINLINK_FORWARD_SELECTOR {
106        let args = &outer_calldata[4..];
107        let target = abi_word(args, 0, "forward target")?;
108        let aggregator = Address::from_slice(&target[12..]);
109        let inner = dynamic_bytes(args, 32, "forward calldata")?;
110        (aggregator, Some(to), inner)
111    } else {
112        (to, None, outer_calldata)
113    };
114
115    let selector = inner_calldata
116        .get(..4)
117        .ok_or(PendingOracleDecodeError::Truncated {
118            field: "transmit selector",
119        })?;
120    let secondary = if selector == CHAINLINK_TRANSMIT_SELECTOR {
121        false
122    } else if selector == CHAINLINK_TRANSMIT_SECONDARY_SELECTOR {
123        true
124    } else {
125        return Ok(None);
126    };
127
128    let transmit_args = &inner_calldata[4..];
129    let report_bytes = dynamic_bytes(transmit_args, 3 * 32, "OCR report")?;
130    let (observations_timestamp, observations) = decode_report(report_bytes)?;
131    let mut ordered = observations.clone();
132    ordered.sort_unstable();
133    let median_answer = ordered[ordered.len() / 2];
134    let minimum_answer = ordered[0];
135    let maximum_answer = ordered[ordered.len() - 1];
136    let route = match forwarder {
137        Some(forwarder) => PendingOracleRoute::ChainlinkForwarded {
138            forwarder,
139            aggregator,
140            secondary,
141        },
142        None => PendingOracleRoute::ChainlinkDirect {
143            aggregator,
144            secondary,
145        },
146    };
147
148    Ok(Some(DecodedChainlinkOcr2 {
149        route,
150        report: ChainlinkPendingReport {
151            aggregator,
152            forwarder,
153            secondary,
154            observations_timestamp,
155            observations,
156            median_answer,
157            minimum_answer,
158            maximum_answer,
159            report_hash: keccak256(report_bytes),
160            calldata_hash: keccak256(outer_calldata),
161            inner_calldata_hash: keccak256(inner_calldata),
162        },
163    }))
164}
165
166fn decode_report(report: &[u8]) -> Result<(u64, Vec<I256>), PendingOracleDecodeError> {
167    let timestamp = abi_u64(report, 0, "observations timestamp")?;
168    if timestamp < MINIMUM_PLAUSIBLE_TIMESTAMP {
169        return Err(PendingOracleDecodeError::ImplausibleTimestamp { timestamp });
170    }
171    let observations_offset = abi_usize(report, 64, "observations offset")?;
172    let count = abi_usize(report, observations_offset, "observation count")?;
173    if count == 0 || count > MAXIMUM_OBSERVATIONS {
174        return Err(PendingOracleDecodeError::ImplausibleObservationCount { count });
175    }
176
177    let mut observations = Vec::with_capacity(count);
178    for index in 0..count {
179        let offset = observations_offset
180            .checked_add(32)
181            .and_then(|offset| offset.checked_add(index * 32))
182            .ok_or(PendingOracleDecodeError::InvalidOffset {
183                field: "observation",
184            })?;
185        let value = U256::from_be_slice(abi_word(report, offset, "observation")?);
186        observations.push(I256::from_raw(value));
187    }
188    Ok((timestamp, observations))
189}
190
191fn dynamic_bytes<'a>(
192    args: &'a [u8],
193    offset_position: usize,
194    field: &'static str,
195) -> Result<&'a [u8], PendingOracleDecodeError> {
196    let offset = abi_usize(args, offset_position, field)?;
197    let length = abi_usize(args, offset, field)?;
198    let start = offset
199        .checked_add(32)
200        .ok_or(PendingOracleDecodeError::InvalidOffset { field })?;
201    let end = start
202        .checked_add(length)
203        .ok_or(PendingOracleDecodeError::InvalidOffset { field })?;
204    args.get(start..end)
205        .ok_or(PendingOracleDecodeError::Truncated { field })
206}
207
208fn abi_u64(
209    data: &[u8],
210    offset: usize,
211    field: &'static str,
212) -> Result<u64, PendingOracleDecodeError> {
213    let word = abi_word(data, offset, field)?;
214    if word[..24].iter().any(|byte| *byte != 0) {
215        return Err(PendingOracleDecodeError::InvalidOffset { field });
216    }
217    Ok(u64::from_be_bytes(
218        word[24..].try_into().expect("eight bytes"),
219    ))
220}
221
222fn abi_usize(
223    data: &[u8],
224    offset: usize,
225    field: &'static str,
226) -> Result<usize, PendingOracleDecodeError> {
227    let value = abi_u64(data, offset, field)?;
228    usize::try_from(value).map_err(|_| PendingOracleDecodeError::InvalidOffset { field })
229}
230
231fn abi_word<'a>(
232    data: &'a [u8],
233    offset: usize,
234    field: &'static str,
235) -> Result<&'a [u8], PendingOracleDecodeError> {
236    let end = offset
237        .checked_add(32)
238        .ok_or(PendingOracleDecodeError::InvalidOffset { field })?;
239    data.get(offset..end)
240        .ok_or(PendingOracleDecodeError::Truncated { field })
241}