Skip to content

Commit ea73c38

Browse files
committed
feat: add chainsync http account fetches
1 parent 324318e commit ea73c38

13 files changed

Lines changed: 937 additions & 0 deletions

File tree

‎Cargo.lock‎

Lines changed: 26 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ members = [
1414
"aml",
1515
"aperture",
1616
"chainlink",
17+
"chainsync",
1718
"programs/committor-interface",
1819
"programs/committor-program",
1920
"committor",
@@ -63,6 +64,7 @@ unwrap_used = "warn"
6364
wildcard_dependencies = "deny"
6465

6566
[workspace.dependencies]
67+
chainsync = { package = "magicblock-chainsync", path = "chainsync" }
6668
aml = { package = "magicblock-aml", path = "aml" }
6769
aperture = { package = "magicblock-aperture", path = "aperture" }
6870
chainlink = { package = "magicblock-chainlink", path = "chainlink" }
@@ -244,6 +246,8 @@ spl-token = { package = "spl-token-interface", version = "2.0" }
244246
spl-token-2022 = { package = "spl-token-2022-interface", version = "2.1" }
245247

246248

249+
zstd = "0.13"
250+
247251
[patch.crates-io]
248252
libsodium-rs = { git = "https://github.com/jedisct1/libsodium-rs.git", rev = "0397a6c5785233f9f2ac91f3eedc3cceb74e0060" }
249253

‎chainsync/Cargo.toml‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
[package]
2+
name = "magicblock-chainsync"
3+
description = "Base-layer account synchronization for MagicBlock"
4+
readme = "README.md"
5+
authors.workspace = true
6+
edition.workspace = true
7+
homepage.workspace = true
8+
license.workspace = true
9+
repository.workspace = true
10+
version.workspace = true
11+
12+
[dependencies]
13+
base64 = { workspace = true }
14+
derive_more = { workspace = true, features = ["debug", "display", "error"] }
15+
hyper = { workspace = true, features = ["client", "http1"] }
16+
json = { workspace = true }
17+
engine = { workspace = true }
18+
nucleus = { workspace = true, features = ["metrics", "runtime"] }
19+
reqwest = { workspace = true, features = ["rustls-tls"] }
20+
serde = { workspace = true, features = ["derive"] }
21+
solana-account = { workspace = true }
22+
solana-pubkey = { workspace = true }
23+
thiserror = { workspace = true }
24+
tokio = { workspace = true, features = ["macros", "net", "rt", "sync", "time"] }
25+
tracing = { workspace = true }
26+
url = { workspace = true, features = ["serde"] }
27+
zstd = { workspace = true }
28+
[dev-dependencies]
29+
http-body-util = { workspace = true }
30+
hyper-util = { workspace = true, features = ["tokio"] }
31+
hyper = { workspace = true, features = ["server"] }
32+
serde_json = { workspace = true }
33+
tokio-util = { workspace = true, features = ["rt"] }
34+
engine = { workspace = true, features = ["testkit"] }
35+
nucleus = { workspace = true, features = ["testkit"] }
36+
solana-account = { workspace = true, features = ["testkit"] }
37+
tokio = { workspace = true, features = ["test-util"] }
38+
[lints]
39+
workspace = true

‎chainsync/README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# ChainSync
2+
3+
Base-chain account synchronization for MagicBlock.
4+
5+
Confirmed HTTP snapshots preserve response slots and explicit account absence,
6+
retrying transient failures without lowering the freshness floor.
7+
8+
These components remain private until service assembly. The validator still uses Chainlink.

‎chainsync/src/http/fetcher.rs‎

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
use std::{
2+
sync::atomic::{AtomicU64, AtomicUsize, Ordering::*},
3+
time::Duration,
4+
};
5+
6+
use engine::Engine;
7+
use hyper::{body::Bytes, header::CONTENT_TYPE};
8+
use json::LazyValue;
9+
use reqwest::{Client, redirect::Policy, retry};
10+
use serde::Deserialize;
11+
use solana_account::AccountBuilder;
12+
use solana_pubkey::Pubkey;
13+
use tokio::time::{self, Instant};
14+
use tracing::{error, info, warn};
15+
use url::Url;
16+
17+
use crate::metrics::{self, Op};
18+
use crate::rpc::{AccountConfig, ContextValue, Request, WireAccount};
19+
20+
use super::{Error, Result};
21+
22+
/// One confirmed RPC response with an optional account image per key, in request order.
23+
/// Decoded images enter in `Uninit` mode; separate fetches may have different context slots.
24+
pub(crate) struct FetchedAccounts {
25+
/// `None` only for an explicit RPC null; invalid accounts fail the batch.
26+
pub(crate) accounts: Vec<Option<AccountBuilder>>,
27+
/// Confirmed bank context of this response, not an account's last modification slot.
28+
/// Stamped on decoded images; delegation resolution may restore a recorded delegation slot.
29+
pub(crate) context_slot: u64,
30+
}
31+
32+
/// Fetches confirmed account batches, retrying transient failures across providers on the same chain.
33+
/// Callers split requests into batches of at most 100 keys and arrange subscriptions.
34+
pub(crate) struct Fetcher {
35+
client: Client,
36+
/// Stable endpoint order used for error reporting.
37+
providers: Vec<Provider>,
38+
/// Supplies the confirmed chain slot used as the minimum for each HTTP fetch.
39+
engine: Engine,
40+
cursor: AtomicUsize,
41+
/// Monotonic origin for cooldown timestamps.
42+
epoch: Instant,
43+
}
44+
45+
/// Endpoint whose retry cooldown is shared by all concurrent fetches.
46+
struct Provider {
47+
url: Url,
48+
/// Milliseconds since `Fetcher::epoch` when this endpoint's cooldown ends.
49+
until: AtomicU64,
50+
}
51+
52+
impl Fetcher {
53+
/// Creates shared provider cooldowns with redirects and reqwest retries disabled.
54+
/// Fetch attempts and their overall budget are controlled by this fetcher.
55+
pub(crate) fn new(providers: Vec<Url>, engine: Engine) -> Result<Self> {
56+
let client = Client::builder().redirect(Policy::none()).retry(retry::never()).build()?;
57+
let providers = providers
58+
.into_iter()
59+
.map(|url| Provider { url, until: AtomicU64::new(0) })
60+
.collect();
61+
Ok(Self {
62+
client,
63+
providers,
64+
engine,
65+
cursor: AtomicUsize::new(0),
66+
epoch: Instant::now(),
67+
})
68+
}
69+
70+
/// Fetches 1–100 keys at confirmed commitment. Uses the greater of `min_context_slot`
71+
/// and Engine's confirmed chain slot as the minimum response context slot.
72+
/// This minimum stays fixed across retries; HTTP responses do not advance Engine's chain slot.
73+
///
74+
/// Transient failures retry within a ten-second budget. Malformed responses
75+
/// fail immediately.
76+
pub(crate) async fn fetch(
77+
&self,
78+
keys: &[Pubkey],
79+
min_context_slot: Option<u64>,
80+
) -> Result<FetchedAccounts> {
81+
let _timer = metrics::time(Op::HttpFetch);
82+
// Keep the same minimum slot when changing providers, so a retry cannot
83+
// accept a response older than this call requires.
84+
let min_context_slot =
85+
min_context_slot.unwrap_or(0).max(self.engine.accounts().chain_slot());
86+
let deadline = Instant::now() + OVERALL;
87+
// Preserve positions: getMultipleAccounts returns values in request order.
88+
let params = (
89+
keys.iter().map(ToString::to_string).collect::<Vec<_>>(),
90+
AccountConfig::new(Some(min_context_slot)),
91+
);
92+
let request = Request::new(1, GET_MULTIPLE_ACCOUNTS, params);
93+
let body = Bytes::from(json::to_vec(&request)?);
94+
let mut last = None;
95+
let mut failures = 0;
96+
while let Some((index, provider)) = self.available(deadline).await {
97+
let end = (Instant::now() + ATTEMPT).min(deadline);
98+
let result = self.attempt(provider, body.clone(), end, keys.len()).await;
99+
metrics::http_attempt(&result);
100+
let hostname = provider.url.host_str();
101+
let error = match result {
102+
Ok(fetched) => {
103+
if failures > 0 {
104+
info!(hostname, failures, min_context_slot, "HTTP fetch recovered");
105+
}
106+
return Ok(fetched);
107+
}
108+
Err(error) => error,
109+
};
110+
let retry = error.retryable();
111+
if retry {
112+
warn!(hostname, %error, min_context_slot, "HTTP attempt failed; retrying");
113+
} else {
114+
error!(hostname, %error, min_context_slot, "HTTP attempt failed");
115+
}
116+
let error = Error::Provider {
117+
provider: index,
118+
source: Box::new(error),
119+
};
120+
if !retry {
121+
return Err(error);
122+
}
123+
// Share the cooldown so other batches skip this endpoint while it recovers.
124+
let until = (self.epoch.elapsed() + COOLDOWN).as_millis() as u64;
125+
provider.until.fetch_max(until, Relaxed);
126+
last = Some(Box::new(error));
127+
failures += 1;
128+
}
129+
let error = Error::Deadline { last };
130+
warn!(cause = ?error, min_context_slot, failures, "HTTP retries exhausted");
131+
Err(error)
132+
}
133+
134+
/// Selects a provider, waiting for the earliest cooldown only if all are cooling down; returns
135+
/// `None` once the overall deadline has elapsed.
136+
async fn available(&self, deadline: Instant) -> Option<(usize, &Provider)> {
137+
while Instant::now() < deadline {
138+
let len = self.providers.len();
139+
let start = self.cursor.fetch_add(1, Relaxed) % len;
140+
let now = self.epoch.elapsed().as_millis() as u64;
141+
let mut wake = deadline;
142+
for index in (start..len).chain(0..start) {
143+
let provider = &self.providers[index];
144+
let until = provider.until.load(Relaxed);
145+
if until <= now {
146+
return Some((index, provider));
147+
}
148+
wake = wake.min(self.epoch + Duration::from_millis(until));
149+
}
150+
time::sleep_until(wake).await;
151+
}
152+
None
153+
}
154+
155+
/// Makes one provider request and decodes its account images.
156+
/// Rejects a mismatched account count before decoding images so request positions stay valid.
157+
async fn attempt(
158+
&self,
159+
provider: &Provider,
160+
body: Bytes,
161+
end: Instant,
162+
expected: usize,
163+
) -> Result<FetchedAccounts> {
164+
let _timer = metrics::time(Op::HttpAttempt);
165+
let response = self
166+
.client
167+
.post(provider.url.clone())
168+
.header(CONTENT_TYPE, "application/json")
169+
.body(body)
170+
.timeout(end.saturating_duration_since(Instant::now()))
171+
.send()
172+
.await?;
173+
if !response.status().is_success() {
174+
return Err(Error::Status(response.status()));
175+
}
176+
let bytes = response.bytes().await?;
177+
let response: Response<'_> = json::from_slice(&bytes)?;
178+
if let Some(error) = response.error {
179+
return Err(Error::Rpc(json::from_str(error.as_raw_str())?));
180+
}
181+
let result = response.result.ok_or(Error::Protocol("missing HTTP result"))?;
182+
let result: ContextValue<Vec<Option<WireAccount<'_>>>> =
183+
json::from_str(result.as_raw_str())?;
184+
if result.value.len() != expected {
185+
return Err(Error::Protocol("account result count differs from request"));
186+
}
187+
let context_slot = result.context.slot;
188+
let mut accounts = Vec::with_capacity(expected);
189+
for account in result.value {
190+
accounts.push(account.map(|account| account.decode(context_slot)).transpose()?);
191+
}
192+
Ok(FetchedAccounts { accounts, context_slot })
193+
}
194+
}
195+
196+
/// Borrowed success or provider rejection before payload decoding.
197+
#[derive(Deserialize)]
198+
struct Response<'a> {
199+
/// Success payload decoded after outer-envelope validation.
200+
#[serde(borrow)]
201+
result: Option<LazyValue<'a>>,
202+
/// Structured provider rejection retained for diagnostics.
203+
#[serde(borrow)]
204+
error: Option<LazyValue<'a>>,
205+
}
206+
207+
/// RPC operation that returns one shared context for the batch.
208+
const GET_MULTIPLE_ACCOUNTS: &str = "getMultipleAccounts";
209+
/// Total budget across attempts and provider cooldowns.
210+
const OVERALL: Duration = Duration::from_secs(10);
211+
/// Per-provider attempt budget, capped by the overall deadline.
212+
const ATTEMPT: Duration = Duration::from_secs(2);
213+
/// Shared delay after transient provider failure.
214+
const COOLDOWN: Duration = Duration::from_millis(100);

0 commit comments

Comments
 (0)