evm_oracle_state/pending/
ethereum.rs1use std::{fmt, time::Duration};
4
5use alloy_consensus::Transaction as _;
6use alloy_eips::eip2718::Encodable2718;
7use alloy_network::TransactionResponse;
8use alloy_primitives::{Bytes, keccak256};
9use futures_util::{SinkExt, StreamExt};
10use serde_json::{Value, json};
11use tokio::sync::watch;
12use tokio_tungstenite::{connect_async, tungstenite::Message};
13
14use super::{
15 ETHEREUM_MAINNET_CHAIN_ID, PendingOracleCandidateSource, PendingOracleOrderingHandle,
16 PendingOracleSource, PendingOracleSourceDescriptor, PendingOracleSourceError,
17 PendingOracleSourceFuture, PendingOracleSourceId, PendingOracleSourceSink,
18 PendingOracleTransmissionId, PendingTransportCandidate,
19};
20
21#[derive(Clone)]
23pub struct AlchemyPendingTransactionSource {
24 ws_url: String,
25 source_id: PendingOracleSourceId,
26 chain_id: u64,
27}
28
29impl AlchemyPendingTransactionSource {
30 pub fn new(ws_url: impl Into<String>) -> Self {
32 Self {
33 ws_url: ws_url.into(),
34 source_id: PendingOracleSourceId::new("alchemy-pending-transactions"),
35 chain_id: ETHEREUM_MAINNET_CHAIN_ID,
36 }
37 }
38
39 pub fn source_id(mut self, source_id: PendingOracleSourceId) -> Self {
41 self.source_id = source_id;
42 self
43 }
44
45 pub fn chain_id(mut self, chain_id: u64) -> Self {
47 self.chain_id = chain_id;
48 self
49 }
50
51 async fn run_forever(
52 self,
53 sink: PendingOracleSourceSink,
54 mut shutdown: watch::Receiver<bool>,
55 ) -> Result<(), PendingOracleSourceError> {
56 let mut retry = Duration::from_secs(1);
57 loop {
58 if *shutdown.borrow() {
59 return Ok(());
60 }
61 match self.run_connection(&sink, &mut shutdown).await {
62 Ok(()) if *shutdown.borrow() => return Ok(()),
63 Ok(()) => sink.coverage_gap("Alchemy pending WebSocket ended"),
64 Err(error) => sink.coverage_gap(error.to_string()),
65 }
66 sink.reconnecting();
67 tokio::select! {
68 changed = shutdown.changed() => {
69 if changed.is_err() || *shutdown.borrow() {
70 return Ok(());
71 }
72 }
73 () = tokio::time::sleep(retry) => {}
74 }
75 retry = (retry * 2).min(Duration::from_secs(30));
76 }
77 }
78
79 async fn run_connection(
80 &self,
81 sink: &PendingOracleSourceSink,
82 shutdown: &mut watch::Receiver<bool>,
83 ) -> Result<(), PendingOracleSourceError> {
84 let (socket, _) = connect_async(self.ws_url.as_str())
85 .await
86 .map_err(transport_error)?;
87 let (mut writer, mut reader) = socket.split();
88 writer
89 .send(Message::Text(
90 json!({
91 "jsonrpc": "2.0",
92 "id": 1,
93 "method": "eth_subscribe",
94 "params": ["alchemy_pendingTransactions", {"hashesOnly": false}],
95 })
96 .to_string()
97 .into(),
98 ))
99 .await
100 .map_err(transport_error)?;
101 writer
102 .send(Message::Text(
103 json!({
104 "jsonrpc": "2.0",
105 "id": 2,
106 "method": "eth_subscribe",
107 "params": ["newHeads"],
108 })
109 .to_string()
110 .into(),
111 ))
112 .await
113 .map_err(transport_error)?;
114
115 let mut pending_subscription = None;
116 let mut head_subscription = None;
117 let mut current_head = None;
118 while pending_subscription.is_none() || head_subscription.is_none() {
119 let message = tokio::time::timeout(Duration::from_secs(15), reader.next())
120 .await
121 .map_err(|_| {
122 PendingOracleSourceError::Transport(
123 "timed out waiting for Alchemy subscription acknowledgements".to_string(),
124 )
125 })?
126 .ok_or_else(|| {
127 PendingOracleSourceError::Transport(
128 "Alchemy WebSocket closed during subscription".to_string(),
129 )
130 })?
131 .map_err(transport_error)?;
132 let Some(value) = message_json(message)? else {
133 continue;
134 };
135 if let Some(error) = value.get("error") {
136 return Err(PendingOracleSourceError::Transport(format!(
137 "Alchemy subscription failed: {error}"
138 )));
139 }
140 match value.get("id").and_then(Value::as_u64) {
141 Some(1) => pending_subscription = value_string(&value, "result"),
142 Some(2) => head_subscription = value_string(&value, "result"),
143 _ => {}
144 }
145 }
146 sink.ready();
147
148 loop {
149 tokio::select! {
150 changed = shutdown.changed() => {
151 if changed.is_err() || *shutdown.borrow() {
152 let _ = writer.close().await;
153 return Ok(());
154 }
155 }
156 message = reader.next() => {
157 let Some(message) = message else {
158 return Err(PendingOracleSourceError::Transport(
159 "Alchemy pending WebSocket closed".to_string(),
160 ));
161 };
162 let message = message.map_err(transport_error)?;
163 let Some(value) = message_json(message)? else {
164 continue;
165 };
166 sink.transport_message();
167 let Some(params) = value.get("params") else {
168 continue;
169 };
170 let subscription = params.get("subscription").and_then(Value::as_str);
171 let result = params.get("result");
172 if subscription == head_subscription.as_deref() {
173 current_head = result
174 .and_then(|header| header.get("number"))
175 .and_then(Value::as_str)
176 .and_then(parse_quantity);
177 } else if subscription == pending_subscription.as_deref() {
178 let Some(result) = result else {
179 continue;
180 };
181 let Some(candidate) = self.transaction_candidate(result.clone(), current_head)
182 else {
183 continue;
184 };
185 if sink
186 .runtime()
187 .interests()
188 .iter()
189 .any(|interest| interest.matches(&candidate))
190 {
191 sink.candidate();
192 match sink.runtime().observe_candidate(candidate) {
193 Ok(report) => {
194 for failure in report.failures {
195 tracing::debug!(
196 adapter_id = %failure.adapter_id,
197 error = %failure.error,
198 "pending oracle adapter rejected Alchemy candidate"
199 );
200 }
201 }
202 Err(error) => {
203 tracing::debug!(%error, "pending oracle runtime rejected Alchemy candidate");
204 }
205 }
206 }
207 }
208 }
209 }
210 }
211 }
212
213 fn transaction_candidate(
214 &self,
215 value: Value,
216 observed_at_head: Option<u64>,
217 ) -> Option<PendingTransportCandidate> {
218 let transaction: alloy_rpc_types_eth::Transaction = serde_json::from_value(value).ok()?;
219 let to = transaction.to()?;
220 let calldata = transaction.input().clone();
221 if calldata.len() < 4 {
222 return None;
223 }
224 let tx_hash = transaction.tx_hash();
225 let signed_envelope = Bytes::from(transaction.inner.inner().encoded_2718());
226 let ordering = if keccak256(&signed_envelope) == tx_hash {
227 PendingOracleOrderingHandle::RawEthereumTransaction {
228 tx_hash,
229 signed_envelope,
230 }
231 } else {
232 PendingOracleOrderingHandle::TransactionHashOnly { tx_hash }
233 };
234 Some(PendingTransportCandidate::new(
235 self.chain_id,
236 PendingOracleTransmissionId::from_hash(tx_hash),
237 PendingOracleSource::PublicMempool,
238 self.source_id.clone(),
239 to,
240 calldata,
241 ordering,
242 observed_at_head,
243 ))
244 }
245}
246
247impl fmt::Debug for AlchemyPendingTransactionSource {
248 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
249 formatter
250 .debug_struct("AlchemyPendingTransactionSource")
251 .field("ws_url", &"<redacted>")
252 .field("source_id", &self.source_id)
253 .field("chain_id", &self.chain_id)
254 .finish()
255 }
256}
257
258impl PendingOracleCandidateSource for AlchemyPendingTransactionSource {
259 fn descriptor(&self) -> PendingOracleSourceDescriptor {
260 PendingOracleSourceDescriptor::new(
261 PendingOracleSource::PublicMempool,
262 self.source_id.clone(),
263 )
264 }
265
266 fn run(
267 self: Box<Self>,
268 sink: PendingOracleSourceSink,
269 shutdown: watch::Receiver<bool>,
270 ) -> PendingOracleSourceFuture {
271 Box::pin(async move { self.run_forever(sink, shutdown).await })
272 }
273}
274
275fn value_string(value: &Value, key: &str) -> Option<String> {
276 value.get(key).and_then(Value::as_str).map(str::to_owned)
277}
278
279fn parse_quantity(value: &str) -> Option<u64> {
280 u64::from_str_radix(value.strip_prefix("0x")?, 16).ok()
281}
282
283fn message_json(message: Message) -> Result<Option<Value>, PendingOracleSourceError> {
284 match message {
285 Message::Text(text) => serde_json::from_str(text.as_str())
286 .map(Some)
287 .map_err(transport_error),
288 Message::Binary(bytes) => serde_json::from_slice(&bytes)
289 .map(Some)
290 .map_err(transport_error),
291 Message::Close(_) => Err(PendingOracleSourceError::Transport(
292 "pending WebSocket closed".to_string(),
293 )),
294 Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => Ok(None),
295 }
296}
297
298fn transport_error(error: impl fmt::Display) -> PendingOracleSourceError {
299 PendingOracleSourceError::Transport(error.to_string())
300}
301
302#[cfg(test)]
303mod tests {
304 use std::str::FromStr;
305
306 use alloy_primitives::B256;
307
308 use super::*;
309
310 #[test]
311 fn rpc_transaction_reencodes_to_its_declared_hash() {
312 let value = serde_json::from_str(
313 r#"{
314 "type":"0x2","chainId":"0x1","nonce":"0x84974","gas":"0x30d40",
315 "maxFeePerGas":"0x7a4e39a","maxPriorityFeePerGas":"0x1ef37be",
316 "to":"0xe73d53e3a982ab2750a0b76f9012e18b256cc243","value":"0x0",
317 "accessList":[],"input":"0x1249c58b",
318 "r":"0x7432676eb4f3b0f8e2c44044e6cf25e5421ac2d1d93412ccbec481e88b8102c1",
319 "s":"0x1800a3996abb76a262638c58c51693d410ca6b24b6a196851e36b1b4513953ab",
320 "yParity":"0x0","v":"0x0",
321 "hash":"0x483b92a2d30693c28bc812a1e6747ce2af8b04694ccf08c1c85ec67bb04962ca",
322 "blockHash":null,"blockNumber":null,"transactionIndex":null,
323 "from":"0x82a53178e7a7e454ab31eea6063fdca338418f74","gasPrice":"0x7a4e39a"
324 }"#,
325 )
326 .expect("transaction fixture JSON");
327 let candidate = AlchemyPendingTransactionSource::new("wss://redacted")
328 .transaction_candidate(value, Some(100))
329 .expect("full transaction candidate");
330 let expected =
331 B256::from_str("0x483b92a2d30693c28bc812a1e6747ce2af8b04694ccf08c1c85ec67bb04962ca")
332 .unwrap();
333 let PendingOracleOrderingHandle::RawEthereumTransaction {
334 tx_hash,
335 signed_envelope,
336 } = candidate.ordering
337 else {
338 panic!("expected exact signed transaction");
339 };
340 assert_eq!(tx_hash, expected);
341 assert_eq!(keccak256(signed_envelope), expected);
342 }
343}