Skip to content

Commit 27c22b7

Browse files
committed
feat(bridge): add withdrawal indexer task
1 parent 3002bcc commit 27c22b7

12 files changed

Lines changed: 2907 additions & 1239 deletions

File tree

backend/Cargo.lock

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

backend/Cargo.toml

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,25 @@
1919
status-network = { path = "crates/network" }
2020
status-utils = { path = "crates/utils" }
2121

22+
# alpen pinned to the rev that strata-bridge HEAD uses, so the transitive
23+
# `strata-primitives`/`Buf32` matches our direct pin (no double-resolution).
24+
# Both are deploy-current as of 2026-04-30.
25+
alpen-reth-primitives = { git = "https://github.com/alpenlabs/alpen.git", rev = "565fafccf2fe181d2f93003f9135ceed126a01ad" }
2226
strata-bridge-rpc = { git = "https://github.com/alpenlabs/strata-bridge.git", features = [
2327
"client",
24-
], tag = "v0.2.0-rc3" }
25-
strata-primitives = { git = "https://github.com/alpenlabs/alpen.git", tag = "v0.2.0-rc4" }
28+
], rev = "851a681200658a19d88493a70f3bed66e515a08b" }
29+
strata-primitives = { git = "https://github.com/alpenlabs/alpen.git", rev = "565fafccf2fe181d2f93003f9135ceed126a01ad" }
30+
strata-tasks = { git = "https://github.com/alpenlabs/strata-common", tag = "v0.1.0-alpha-rc16" }
2631
typed-sled = { git = "https://github.com/alpenlabs/typed-sled" }
2732

2833
# External
34+
anyhow = { version = "1" }
35+
alloy-primitives = { version = "1.4.1", features = [ "serde" ] }
36+
alloy-sol-types = { version = "1.4.1" }
2937
axum = { version = "0.8" }
3038
bitcoin = { version = "0.32.8", features = [ "serde" ] }
3139
hex = { version = "0.4" }
32-
jsonrpsee = { version = "0.24", features = [ "http-client" ] }
40+
jsonrpsee = { version = "0.26", features = [ "http-client" ] }
3341
reqwest = { version = "0.13.3", features = [ "json" ] }
3442
serde = { version = "1", features = [ "derive" ] }
3543
serde_json = { version = "1.0", default-features = false, features = [

backend/crates/bridge/Cargo.toml

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,28 @@
77
path = "src/lib.rs"
88

99
[dependencies]
10-
status-config.workspace = true
11-
status-utils.workspace = true
12-
strata-bridge-rpc.workspace = true
13-
strata-primitives.workspace = true
14-
typed-sled.workspace = true
10+
alpen-reth-primitives.workspace = true
11+
anyhow.workspace = true
12+
status-config.workspace = true
13+
status-utils.workspace = true
14+
strata-bridge-rpc.workspace = true
15+
strata-primitives.workspace = true
16+
strata-tasks.workspace = true
17+
typed-sled.workspace = true
1518

16-
axum.workspace = true
17-
bitcoin.workspace = true
18-
hex.workspace = true
19-
jsonrpsee.workspace = true
20-
reqwest.workspace = true
21-
serde.workspace = true
22-
serde_json.workspace = true
23-
sled.workspace = true
24-
thiserror.workspace = true
25-
tokio.workspace = true
26-
tracing.workspace = true
19+
alloy-primitives.workspace = true
20+
alloy-sol-types.workspace = true
21+
axum.workspace = true
22+
bitcoin.workspace = true
23+
hex.workspace = true
24+
jsonrpsee.workspace = true
25+
reqwest.workspace = true
26+
serde.workspace = true
27+
serde_json.workspace = true
28+
sled.workspace = true
29+
thiserror.workspace = true
30+
tokio.workspace = true
31+
tracing.workspace = true
32+
33+
[dev-dependencies]
34+
toml.workspace = true

backend/crates/bridge/src/db/error.rs

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,6 @@ pub enum WithdrawalIndexConsistencyError {
2828
}
2929

3030
#[derive(Debug, thiserror::Error)]
31-
#[cfg_attr(
32-
not(test),
33-
expect(
34-
dead_code,
35-
reason = "withdrawal indexer DB errors are constructed by follow-up indexer/pairing commits"
36-
)
37-
)]
3831
pub enum DbError {
3932
#[error("create data dir {0:?}: {1}")]
4033
CreateDataDir(PathBuf, #[source] std::io::Error),

backend/crates/bridge/src/db/traits.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use crate::db::{
1515
not(test),
1616
expect(
1717
dead_code,
18-
reason = "consumed by the indexer task in a follow-up commit"
18+
reason = "pairing methods are consumed by the pairing task in a follow-up commit"
1919
)
2020
)]
2121
pub(crate) trait WithdrawalIndexerDb: Send + Sync {

backend/crates/bridge/src/db/withdrawal_index/db.rs

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -20,26 +20,12 @@ use super::schema::{
2020
/// because it can wrap typed-sled codec errors. Use the shared consistency
2121
/// error type as the transactional abort payload and wrap it in [`DbError`]
2222
/// after the transaction.
23-
#[cfg_attr(
24-
not(test),
25-
expect(
26-
dead_code,
27-
reason = "used by withdrawal indexer and pairing DB writes in follow-up commits"
28-
)
29-
)]
3023
fn abort_tx<T>(
3124
err: WithdrawalIndexConsistencyError,
3225
) -> sled::transaction::ConflictableTransactionResult<T, TSledError> {
3326
Err(TSledError::abort(err).into())
3427
}
3528

36-
#[cfg_attr(
37-
not(test),
38-
expect(
39-
dead_code,
40-
reason = "used by withdrawal indexer and pairing DB writes in follow-up commits"
41-
)
42-
)]
4329
fn map_tx_result<T>(result: sled::transaction::TransactionResult<T, TSledError>) -> DbResult<T> {
4430
match result {
4531
Ok(value) => Ok(value),
@@ -70,16 +56,16 @@ pub(crate) struct WithdrawalIndexerDbSled {
7056
requests: SledTree<WithdrawalRequestSchema>,
7157
event_index: SledTree<WithdrawalEventIndexSchema>,
7258
assignments: SledTree<WithdrawalAssignmentSchema>,
59+
#[cfg_attr(
60+
not(test),
61+
expect(
62+
dead_code,
63+
reason = "consumed by the pairing task in a follow-up commit"
64+
)
65+
)]
7366
seq_by_deposit_idx: SledTree<WithdrawalSeqByDepositIdxSchema>,
7467
}
7568

76-
#[cfg_attr(
77-
not(test),
78-
expect(
79-
dead_code,
80-
reason = "wired in by the indexer task in a follow-up commit"
81-
)
82-
)]
8369
impl WithdrawalIndexerDbSled {
8470
/// Open the indexer database under `{datadir}/withdrawal_index`. Creates
8571
/// intermediate directories if they don't exist; sled itself only creates

backend/crates/bridge/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ mod cache;
22
mod db;
33
mod status;
44
mod types;
5+
mod withdrawal_indexer;
56

67
pub use status::{bridge_monitoring_task, get_bridge_status};
78
pub use types::{BridgeMonitoringContext, BridgeStatus};
9+
pub use withdrawal_indexer::task::run_withdrawal_indexer;
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
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

Comments
 (0)