Skip to content

Commit 6d3d807

Browse files
committed
Bundle complete
1 parent ced6aad commit 6d3d807

File tree

7 files changed

+156
-39
lines changed

7 files changed

+156
-39
lines changed

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
# Project: Tips - Transaction Inclusion Pipeline Services
22

33
## Notes
4+
- DO NOT ADD COMMENTS UNLESS INSTRUCTED
5+
- Put imports at the top of the file, never in functions
46
- Always run `just ci` before claiming a task is complete and fix any issues
57
- Use `just fix` to fix formatting and warnings
6-
- Only add comments when the implementation logic is unclear, i.e. do not comment insert item into database when the code is db.insert(item)
78
- Always add dependencies to the cargo.toml in the root and reference them in the crate cargo files
8-
- Use https://crates.io/ to find dependency versions when adding new deps
9+
- Always use the latest dependency versions. Use https://crates.io/ to find dependency versions when adding new deps
910

1011
## Project Structure
1112
```

Cargo.toml

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,23 @@ resolver = "2"
44

55
[workspace.dependencies]
66
jsonrpsee = { version = "0.26.0", features = ["server", "macros"] }
7+
8+
# alloy
9+
alloy-primitives = { version = "1.3.1", default-features = false, features = [
10+
"map-foldhash",
11+
] }
12+
alloy-rpc-types = { version = "1.0.30", default-features = false }
13+
alloy-consensus = { version = "1.0.30" }
14+
alloy-provider = { version = "1.0.30" }
15+
alloy-rpc-client = { version = "1.0.30" }
716
alloy-rpc-types-mev = "1.0.30"
8-
alloy-rpc-types = "1.0.30"
9-
alloy-primitives = "1.3.1"
10-
alloy-provider = "1.0.30"
1117
alloy-transport-http = "1.0.30"
12-
alloy-rpc-client = "1.0.30"
18+
alloy-rlp = "0.3.12"
19+
20+
# op-alloy
21+
op-alloy-rpc-types = { version = "0.19.0" }
22+
op-alloy-consensus = { version = "0.19.0", features = ["k256"] }
23+
1324
tokio = { version = "1.47.1", features = ["full"] }
1425
tracing = "0.1.41"
1526
tracing-subscriber = "0.3.20"

crates/datastore/Cargo.toml

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,18 @@ version = "0.1.0"
44
edition = "2024"
55

66
[dependencies]
7-
sqlx = { workspace = true }
8-
uuid = { workspace = true }
9-
serde = { workspace = true }
10-
chrono = { workspace = true }
11-
tokio = { workspace = true }
12-
anyhow = { workspace = true }
13-
async-trait = { workspace = true }
14-
alloy-rpc-types-mev = { workspace = true }
15-
alloy-primitives = { workspace = true }
16-
serde_json = { workspace = true }
17-
eyre = { workspace = true }
18-
tracing = { workspace = true }
7+
sqlx.workspace = true
8+
uuid.workspace = true
9+
tokio.workspace = true
10+
anyhow.workspace = true
11+
async-trait.workspace = true
12+
alloy-rpc-types-mev.workspace = true
13+
alloy-primitives.workspace = true
14+
alloy-consensus.workspace = true
15+
op-alloy-consensus.workspace = true
16+
eyre.workspace = true
17+
tracing.workspace = true
1918

2019
[dev-dependencies]
21-
testcontainers = { workspace = true }
22-
testcontainers-modules = { workspace = true }
20+
testcontainers.workspace = true
21+
testcontainers-modules.workspace = true

crates/datastore/migrations/1757444171_create_bundles_table.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS bundles (
33
id UUID PRIMARY KEY,
44

55
senders CHAR(42)[],
6-
minimum_base_fee BIGINT, -- todo validate it's large enough
6+
minimum_base_fee BIGINT, -- todo find a larger type
77
txn_hashes CHAR(66)[],
88

99
txs TEXT[] NOT NULL,

crates/datastore/src/postgres.rs

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,25 @@
11
use crate::traits::BundleDatastore;
2-
use alloy_primitives::TxHash;
2+
use alloy_consensus::Transaction;
3+
use alloy_consensus::private::alloy_eips::Decodable2718;
4+
use alloy_consensus::transaction::SignerRecoverable;
35
use alloy_primitives::hex::{FromHex, ToHexExt};
6+
use alloy_primitives::{Address, TxHash};
47
use alloy_rpc_types_mev::EthSendBundle;
58
use anyhow::Result;
9+
use op_alloy_consensus::OpTxEnvelope;
610
use sqlx::PgPool;
711
use tracing::info;
812
use uuid::Uuid;
913

14+
/// Extended bundle data that includes the original bundle plus extracted metadata
15+
#[derive(Debug, Clone)]
16+
pub struct BundleWithMetadata {
17+
pub bundle: EthSendBundle,
18+
pub txn_hashes: Vec<TxHash>,
19+
pub senders: Vec<Address>,
20+
pub min_base_fee: i64,
21+
}
22+
1023
/// PostgreSQL implementation of the BundleDatastore trait
1124
pub struct PostgresDatastore {
1225
pool: PgPool,
@@ -28,11 +41,46 @@ impl PostgresDatastore {
2841
}
2942
}
3043

44+
impl PostgresDatastore {
45+
fn extract_bundle_metadata(
46+
&self,
47+
bundle: &EthSendBundle,
48+
) -> Result<(Vec<String>, i64, Vec<String>)> {
49+
let mut senders = Vec::new();
50+
let mut txn_hashes = Vec::new();
51+
52+
let mut min_base_fee = i64::MAX;
53+
54+
for tx_bytes in &bundle.txs {
55+
let envelope = OpTxEnvelope::decode_2718_exact(tx_bytes)?;
56+
txn_hashes.push(envelope.hash().encode_hex_with_prefix());
57+
58+
let sender = match envelope.recover_signer() {
59+
Ok(signer) => signer,
60+
Err(err) => return Err(err.into()),
61+
};
62+
63+
senders.push(sender.encode_hex_with_prefix());
64+
min_base_fee = min_base_fee.min(envelope.max_fee_per_gas() as i64); // todo type and todo not right
65+
}
66+
67+
let minimum_base_fee = if min_base_fee == i64::MAX {
68+
0
69+
} else {
70+
min_base_fee
71+
};
72+
73+
Ok((senders, minimum_base_fee, txn_hashes))
74+
}
75+
}
76+
3177
#[async_trait::async_trait]
3278
impl BundleDatastore for PostgresDatastore {
3379
async fn insert_bundle(&self, bundle: EthSendBundle) -> Result<Uuid> {
3480
let id = Uuid::new_v4();
3581

82+
let (senders, minimum_base_fee, txn_hashes) = self.extract_bundle_metadata(&bundle)?;
83+
3684
let txs: Vec<String> = bundle
3785
.txs
3886
.iter()
@@ -52,13 +100,17 @@ impl BundleDatastore for PostgresDatastore {
52100
sqlx::query!(
53101
r#"
54102
INSERT INTO bundles (
55-
id, txs, reverting_tx_hashes, dropping_tx_hashes,
103+
id, senders, minimum_base_fee, txn_hashes,
104+
txs, reverting_tx_hashes, dropping_tx_hashes,
56105
block_number, min_timestamp, max_timestamp,
57106
created_at, updated_at
58107
)
59-
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
108+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
60109
"#,
61110
id,
111+
&senders,
112+
minimum_base_fee,
113+
&txn_hashes,
62114
&txs,
63115
&reverting_tx_hashes,
64116
&dropping_tx_hashes,
@@ -72,11 +124,11 @@ impl BundleDatastore for PostgresDatastore {
72124
Ok(id)
73125
}
74126

75-
async fn get_bundle(&self, id: Uuid) -> Result<Option<EthSendBundle>> {
127+
async fn get_bundle(&self, id: Uuid) -> Result<Option<BundleWithMetadata>> {
76128
let result = sqlx::query!(
77129
r#"
78-
SELECT txs, reverting_tx_hashes, dropping_tx_hashes,
79-
block_number, min_timestamp, max_timestamp
130+
SELECT senders, minimum_base_fee, txn_hashes, txs, reverting_tx_hashes,
131+
dropping_tx_hashes, block_number, min_timestamp, max_timestamp
80132
FROM bundles
81133
WHERE id = $1
82134
"#,
@@ -104,7 +156,7 @@ impl BundleDatastore for PostgresDatastore {
104156
.map(TxHash::from_hex)
105157
.collect();
106158

107-
Ok(Some(EthSendBundle {
159+
let bundle = EthSendBundle {
108160
txs: txs?,
109161
block_number: row.block_number.unwrap_or(0) as u64,
110162
min_timestamp: row.min_timestamp.map(|t| t as u64),
@@ -116,6 +168,27 @@ impl BundleDatastore for PostgresDatastore {
116168
refund_recipient: None,
117169
refund_tx_hashes: Vec::new(),
118170
extra_fields: Default::default(),
171+
};
172+
173+
let txn_hashes: Result<Vec<TxHash>, _> = row
174+
.txn_hashes
175+
.unwrap_or_default()
176+
.into_iter()
177+
.map(TxHash::from_hex)
178+
.collect();
179+
180+
let senders: Result<Vec<Address>, _> = row
181+
.senders
182+
.unwrap_or_default()
183+
.into_iter()
184+
.map(Address::from_hex)
185+
.collect();
186+
187+
Ok(Some(BundleWithMetadata {
188+
bundle,
189+
txn_hashes: txn_hashes?,
190+
senders: senders?,
191+
min_base_fee: row.minimum_base_fee.unwrap_or(0),
119192
}))
120193
}
121194
None => Ok(None),

crates/datastore/src/traits.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use crate::postgres::BundleWithMetadata;
12
use alloy_rpc_types_mev::EthSendBundle;
23
use anyhow::Result;
34
use uuid::Uuid;
@@ -8,8 +9,8 @@ pub trait BundleDatastore: Send + Sync {
89
/// Insert a new bundle into the datastore
910
async fn insert_bundle(&self, bundle: EthSendBundle) -> Result<Uuid>;
1011

11-
/// Fetch a bundle by its ID
12-
async fn get_bundle(&self, id: Uuid) -> Result<Option<EthSendBundle>>;
12+
/// Fetch a bundle with metadata by its ID
13+
async fn get_bundle(&self, id: Uuid) -> Result<Option<BundleWithMetadata>>;
1314

1415
/// Cancel a bundle by UUID
1516
async fn cancel_bundle(&self, id: Uuid) -> Result<()>;

crates/datastore/tests/datastore.rs

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use alloy_primitives::{Bytes, TxHash};
1+
use alloy_primitives::{Address, Bytes, TxHash};
22
use alloy_rpc_types_mev::EthSendBundle;
33
use datastore::{PostgresDatastore, traits::BundleDatastore};
44
use sqlx::PgPool;
@@ -35,34 +35,40 @@ async fn insert_and_get() -> eyre::Result<()> {
3535
let harness = setup_datastore().await?;
3636
let test_bundle = EthSendBundle {
3737
txs: vec![
38-
"0x02f86f0102843b9aca0085029e7822d68298f094d2c8e0b2e8f2a8e8f2a8e8f2a8e8f2a8e8f2a880b844a9059cbb000000000000000000000000d2c8e0b2e8f2a8e8f2a8e8f2a8e8f2a8e8f2a80000000000000000000000000000000000000000000000000de0b6b3a7640000c080a0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0a0fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210".parse::<Bytes>()?,
38+
"0x02f8bf8221058304f8c782038c83d2a76b833d0900942e85c218afcdeb3d3b3f0f72941b4861f915bbcf80b85102000e0000000bb800001010c78c430a094eb7ae67d41a7cca25cdb9315e63baceb03bf4529e57a6b1b900010001f4000a101010110111101111011011faa7efc8e6aa13b029547eecbf5d370b4e1e52eec080a009fc02a6612877cec7e1223f0a14f9a9507b82ef03af41fcf14bf5018ccf2242a0338b46da29a62d28745c828077327588dc82c03a4b0210e3ee1fd62c608f8fcd".parse::<Bytes>()?,
3939
],
4040
block_number: 12345,
4141
min_timestamp: Some(1640995200),
4242
max_timestamp: Some(1640995260),
4343
reverting_tx_hashes: vec![
44-
"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".parse::<TxHash>()?,
44+
"0x3ea7e1482485387e61150ee8e5c8cad48a14591789ac02cc2504046d96d0a5f4".parse::<TxHash>()?,
4545
],
4646
replacement_uuid: None,
47-
dropping_tx_hashes: vec![
48-
"0xfedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321".parse::<TxHash>()?,
49-
],
47+
dropping_tx_hashes: vec![],
5048
refund_percent: None,
5149
refund_recipient: None,
5250
refund_tx_hashes: vec![],
5351
extra_fields: Default::default(),
5452
};
5553

5654
let insert_result = harness.data_store.insert_bundle(test_bundle.clone()).await;
55+
if let Err(ref err) = insert_result {
56+
eprintln!("Insert failed with error: {:?}", err);
57+
}
5758
assert!(insert_result.is_ok());
5859
let bundle_id = insert_result.unwrap();
5960

6061
let query_result = harness.data_store.get_bundle(bundle_id).await;
6162
assert!(query_result.is_ok());
62-
let retrieved_bundle = query_result.unwrap();
63+
let retrieved_bundle_with_metadata = query_result.unwrap();
64+
65+
assert!(
66+
retrieved_bundle_with_metadata.is_some(),
67+
"Bundle should be found"
68+
);
69+
let metadata = retrieved_bundle_with_metadata.unwrap();
70+
let retrieved_bundle = &metadata.bundle;
6371

64-
assert!(retrieved_bundle.is_some(), "Bundle should be found");
65-
let retrieved_bundle = retrieved_bundle.unwrap();
6672
assert_eq!(retrieved_bundle.txs.len(), test_bundle.txs.len());
6773
assert_eq!(retrieved_bundle.block_number, test_bundle.block_number);
6874
assert_eq!(retrieved_bundle.min_timestamp, test_bundle.min_timestamp);
@@ -76,5 +82,31 @@ async fn insert_and_get() -> eyre::Result<()> {
7682
test_bundle.dropping_tx_hashes.len()
7783
);
7884

85+
assert!(
86+
!metadata.txn_hashes.is_empty(),
87+
"Transaction hashes should not be empty"
88+
);
89+
assert!(!metadata.senders.is_empty(), "Senders should not be empty");
90+
assert_eq!(
91+
metadata.txn_hashes.len(),
92+
1,
93+
"Should have one transaction hash"
94+
);
95+
assert_eq!(metadata.senders.len(), 1, "Should have one sender");
96+
assert!(
97+
metadata.min_base_fee >= 0,
98+
"Min base fee should be non-negative"
99+
);
100+
101+
let expected_hash: TxHash =
102+
"0x3ea7e1482485387e61150ee8e5c8cad48a14591789ac02cc2504046d96d0a5f4".parse()?;
103+
let expected_sender: Address = "0x24ae36512421f1d9f6e074f00ff5b8393f5dd925".parse()?;
104+
105+
assert_eq!(
106+
metadata.txn_hashes[0], expected_hash,
107+
"Transaction hash should match"
108+
);
109+
assert_eq!(metadata.senders[0], expected_sender, "Sender should match");
110+
79111
Ok(())
80112
}

0 commit comments

Comments
 (0)