├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── README.md └── src └── main.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "execution-payload-builder" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | clap = { version = "4.4.7", features = ["derive"] } 10 | reth = { git = "https://github.com/paradigmxyz/reth" } 11 | serde_json = "1.0.108" 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # execution-payload-builder 2 | 3 | Builds an `ExecutionPayload` from a JSON `Block` obtained from RPC. 4 | 5 | ## Usage 6 | For example, using raw `cast rpc` output: 7 | ``` 8 | cast rpc eth_getBlockByHash '["0x58fee1cac2a8ef87a84e6a77cef27b4935e1cf8ae8320afdd5c176ef17b5d94a", true]' --raw > ~/devnet-43510-1.json 9 | ``` 10 | 11 | Then run: 12 | ``` 13 | cargo run -- --path ~/devnet-43510-1.json 14 | ``` 15 | 16 | It will spit out a `cast rpc engine_newPayloadV3` command: 17 | ``` 18 | cargo run -- --path ~/devnet-43511-2.json --jwt-secret blah --rpc-url foo 19 | Compiling execution-payload-builder v0.1.0 (/Users/dan/projects/execution-payload-builder) 20 | Finished dev [unoptimized + debuginfo] target(s) in 1.50s 21 | Running `target/debug/execution-payload-builder --path /Users/dan/devnet-43511-2.json --jwt-secret blah --rpc-url foo` 22 | cast rpc --rpc-url foo --jwt-secret blah engine_newPayloadV3 --raw '[{"parentHash":"0x58fee1cac2a8ef87a84e6a77cef27b4935e1cf8ae8320afdd5c176ef17b5d94a","feeRecipient":"0xf97e180c050e5ab072211ad2c213eb5aee4df134","stateRoot":"0x110ab4c2a60046b0495821b7205e9779e5c82e272578dcf6da2f99e151d232be","receiptsRoot":"0xba987831fa678a1548ce8a6accab6f97cf8018f408e2ae2db73d119fdb4ac4e1","logsBloom":"0x002000000000000000000000800000020000000000001000080000800000000000000800 23 | ... response is huge 24 | ``` 25 | 26 | For example, this can be piped directly into `sh` to run: 27 | ```sh 28 | cargo run -- --path ~/devnet-43511-2.json --jwt-secret --rpc-url http://127.0.0.1:8551 | sh 29 | Finished dev [unoptimized + debuginfo] target(s) in 0.43s 30 | Running `target/debug/execution-payload-builder --path /Users/dan/devnet-43511-2.json --jwt-secret --rpc-url 'http://127.0.0.1:8551'` 31 | {"status":"VALID","latestValidHash":"0xad3eea923bcc1abe60ec666f47a8fcf13f5bc37fa6a36f1da247d870103262ad","validationError":null} 32 | ``` 33 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::io::Read; 2 | 3 | use clap::Parser; 4 | use reth::{ 5 | primitives::Withdrawals, 6 | rpc::{ 7 | compat::engine::payload::try_block_to_payload, 8 | types::{Block, BlockTransactions, ExecutionPayload, Header, Transaction, Withdrawal}, 9 | }, 10 | }; 11 | use reth::{ 12 | primitives::{ 13 | transaction::{TxEip1559, TxEip2930, TxEip4844, TxLegacy}, 14 | AccessList, AccessListItem, Header as PrimitiveHeader, SealedBlock, Signature, 15 | Transaction as PrimitiveTransaction, TransactionKind, TransactionSigned, 16 | Withdrawal as PrimitiveWithdrawal, 17 | }, 18 | rpc::types::Parity, 19 | }; 20 | 21 | /// Parses the given json file, creating an execution payload from it. 22 | #[derive(Parser, Debug)] 23 | #[command(author, version, about, long_about = None)] 24 | struct Args { 25 | /// Path to the json file to parse. If this is not specified, then stdin will be used. 26 | #[arg(short, long)] 27 | path: Option, 28 | 29 | /// The engine rpc url to use 30 | #[arg(short, long)] 31 | rpc_url: Option, 32 | 33 | /// The jwt secret to use 34 | #[arg(short, long)] 35 | jwt_secret: Option, 36 | 37 | /// Output the raw payload, instead of including the command text. When used with stdin this 38 | /// can be very powerful, for example: 39 | /// 40 | /// ```sh 41 | /// cast block latest -r http://45.250.253.66:8544 --full -j | execution-payload-builder --raw | cast rpc --jwt-secret engine_newPayloadV3 --raw 42 | /// ``` 43 | #[arg(long)] 44 | raw: bool, 45 | } 46 | 47 | fn main() { 48 | let args = Args::parse(); 49 | 50 | // read the file specified in `--path` otherwise read from stdin 51 | let block_json = if let Some(path) = &args.path { 52 | std::fs::read_to_string(path).unwrap() 53 | } else { 54 | let mut buffer = String::new(); 55 | std::io::stdin().read_to_string(&mut buffer).unwrap(); 56 | buffer 57 | }; 58 | 59 | // parse the file 60 | let block: Block = serde_json::from_str(&block_json).unwrap(); 61 | 62 | // extract the parent beacon block root 63 | let parent_beacon_block_root = block.header.parent_beacon_block_root; 64 | 65 | // convert transactions into primitive txs 66 | // TODO: upstream into rpc compat 67 | let txs = match block.transactions { 68 | // this would be an error in upstream 69 | BlockTransactions::Hashes(_hashes) => { 70 | panic!("send the eth_getBlockByHash request with full: `true`") 71 | } 72 | BlockTransactions::Full(txs) => txs, 73 | // this would be an error in upstream 74 | BlockTransactions::Uncle => panic!("this should not be run on uncle blocks"), 75 | }; 76 | 77 | // convert transactions into primitive transactions 78 | let body: Vec = txs 79 | .into_iter() 80 | .map(rpc_transaction_to_primitive_transaction) 81 | .collect(); 82 | 83 | // convert header into a primitive header 84 | let header = rpc_header_to_primitive_header(block.header).seal_slow(); 85 | 86 | // extract blob versioned hashes from txs 87 | let mut blob_versioned_hashes = Vec::new(); 88 | for tx in &body { 89 | if let PrimitiveTransaction::Eip4844(tx) = &tx.transaction { 90 | blob_versioned_hashes.extend(tx.blob_versioned_hashes.clone()); 91 | } 92 | } 93 | 94 | // convert withdrawals into primitive withdrawals 95 | let withdrawals: Option> = block.withdrawals.map(|withdrawals| { 96 | withdrawals 97 | .into_iter() 98 | .map(rpc_withdrawal_to_primitive_withdrawal) 99 | .collect() 100 | }); 101 | 102 | // convert into an execution payload 103 | // TODO: upstream into rpc compat 104 | let sealed = SealedBlock { 105 | header, 106 | ommers: Vec::new(), 107 | body, 108 | withdrawals: withdrawals.map(Withdrawals::new), 109 | }; 110 | 111 | // convert to execution payload 112 | let execution_payload = try_block_to_payload(sealed); 113 | 114 | // convert into something that can be sent to the engine, ie `cast rpc` or something 115 | // this needs to be combined with the parent beacon block root, and blob versioned hashes 116 | let json_payload = match execution_payload { 117 | ExecutionPayload::V1(payload) => serde_json::to_string(&payload).unwrap(), 118 | ExecutionPayload::V2(payload) => serde_json::to_string(&payload).unwrap(), 119 | ExecutionPayload::V3(payload) => serde_json::to_string(&payload).unwrap(), 120 | }; 121 | 122 | // print blob versioned hashes and parent beacon block root 123 | // let json_versioned_hashes = serde_json::to_string(&blob_versioned_hashes.into_iter().map(|versioned_hash| format!("{versioned_hash}")).collect::>()).unwrap(); 124 | let json_versioned_hashes = serde_json::to_string(&blob_versioned_hashes).unwrap(); 125 | let json_parent_beacon_block_root = serde_json::to_string(&parent_beacon_block_root).unwrap(); 126 | 127 | // if raw is set, print the raw payload, without quotes 128 | if args.raw { 129 | // craft the request to pass into `cast rpc --raw`, as stdin 130 | let json_request = "[".to_string() 131 | + &[ 132 | json_payload, 133 | json_versioned_hashes, 134 | json_parent_beacon_block_root, 135 | ] 136 | .join(",") 137 | + "]"; 138 | println!("{}", json_request); 139 | return; 140 | } 141 | 142 | // craft the request to pass into `cast rpc --raw` 143 | let json_request = "'[".to_string() 144 | + &[ 145 | json_payload, 146 | json_versioned_hashes, 147 | json_parent_beacon_block_root, 148 | ] 149 | .join(",") 150 | + "]'"; 151 | 152 | // construct the cast rpc command 153 | let mut prefix = "cast rpc".to_string(); 154 | let suffix = "engine_newPayloadV3 --raw ".to_string() + &json_request; 155 | 156 | if let Some(rpc_url) = args.rpc_url { 157 | prefix += &format!(" --rpc-url {}", rpc_url); 158 | } 159 | 160 | if let Some(secret) = args.jwt_secret { 161 | prefix += &format!(" --jwt-secret {}", secret); 162 | } 163 | 164 | // add the suffix and request 165 | prefix += &format!(" {}", suffix); 166 | 167 | // print the payload 168 | println!("{prefix}"); 169 | } 170 | 171 | /// Converts a rpc header into primitive header 172 | // TODO: upstream into rpc compat 173 | fn rpc_header_to_primitive_header(header: Header) -> PrimitiveHeader { 174 | PrimitiveHeader { 175 | parent_hash: header.parent_hash, 176 | timestamp: header.timestamp, 177 | ommers_hash: header.uncles_hash, 178 | beneficiary: header.miner, 179 | state_root: header.state_root, 180 | receipts_root: header.receipts_root, 181 | transactions_root: header.transactions_root, 182 | base_fee_per_gas: header.base_fee_per_gas.map(|x| x.try_into().unwrap()), 183 | logs_bloom: header.logs_bloom, 184 | withdrawals_root: header.withdrawals_root, 185 | difficulty: header.difficulty.to(), 186 | number: header.number.unwrap(), 187 | gas_used: header.gas_used.try_into().unwrap(), 188 | gas_limit: header.gas_limit.try_into().unwrap(), 189 | mix_hash: header.mix_hash.unwrap(), 190 | nonce: header.nonce.unwrap().into(), 191 | extra_data: header.extra_data, 192 | blob_gas_used: header.blob_gas_used.map(|x| x.try_into().unwrap()), 193 | excess_blob_gas: header.excess_blob_gas.map(|x| x.try_into().unwrap()), 194 | parent_beacon_block_root: header.parent_beacon_block_root, 195 | } 196 | } 197 | 198 | // convert a rpc withdrawal into a primitive withdrawal 199 | fn rpc_withdrawal_to_primitive_withdrawal(withdrawal: Withdrawal) -> PrimitiveWithdrawal { 200 | PrimitiveWithdrawal { 201 | index: withdrawal.index, 202 | amount: withdrawal.amount, 203 | validator_index: withdrawal.validator_index, 204 | address: withdrawal.address, 205 | } 206 | } 207 | 208 | // convert a rpc transaction to a primitive transaction 209 | fn rpc_transaction_to_primitive_transaction(transaction: Transaction) -> TransactionSigned { 210 | let nonce = transaction.nonce; 211 | let to = match transaction.to { 212 | Some(addr) => TransactionKind::Call(addr), 213 | None => TransactionKind::Create, 214 | }; 215 | let value = transaction.value; 216 | let chain_id = transaction.chain_id.unwrap(); 217 | let input = transaction.input; 218 | let access_list = AccessList( 219 | transaction 220 | .access_list 221 | .unwrap_or_default() 222 | .iter() 223 | .map(|item| AccessListItem { 224 | address: item.address, 225 | storage_keys: item.storage_keys.clone(), 226 | }) 227 | .collect(), 228 | ); 229 | let gas_limit = transaction.gas; 230 | 231 | // this is definitely a signed tx 232 | let rpc_signature = transaction.signature.unwrap(); 233 | 234 | // massive chain ids can be ignored here 235 | let v: u64 = rpc_signature.v.to(); 236 | 237 | // if y parity is defined use that 238 | // TODO: ugh eip155 v math 239 | let odd_y_parity = if let Some(Parity(parity)) = rpc_signature.y_parity { 240 | parity 241 | } else if v >= 35 { 242 | // EIP-155: v = {0, 1} + CHAIN_ID * 2 + 35 243 | ((v - 35) % 2) != 0 244 | } else if v == 0 || v == 1 { 245 | v == 1 246 | } else { 247 | // non-EIP-155 legacy scheme, v = 27 for even y-parity, v = 28 for odd y-parity 248 | if v != 27 && v != 28 { 249 | panic!("non-eip-155 legacy v value") 250 | } 251 | v == 28 252 | }; 253 | 254 | // convert the signature 255 | let signature = Signature { 256 | r: rpc_signature.r, 257 | s: rpc_signature.s, 258 | odd_y_parity, 259 | }; 260 | 261 | // just condition on tx type 262 | let tx = if transaction.transaction_type == Some(3) { 263 | PrimitiveTransaction::Eip4844(TxEip4844 { 264 | chain_id, 265 | nonce, 266 | gas_limit: gas_limit.try_into().unwrap(), 267 | max_fee_per_gas: transaction.max_fee_per_gas.unwrap(), 268 | max_priority_fee_per_gas: transaction.max_priority_fee_per_gas.unwrap(), 269 | to, 270 | value, 271 | access_list, 272 | blob_versioned_hashes: transaction.blob_versioned_hashes.unwrap_or_default(), 273 | max_fee_per_blob_gas: transaction.max_fee_per_blob_gas.unwrap(), 274 | input, 275 | }) 276 | } else if transaction.transaction_type == Some(2) { 277 | PrimitiveTransaction::Eip1559(TxEip1559 { 278 | chain_id, 279 | nonce, 280 | gas_limit: gas_limit.try_into().unwrap(), 281 | max_fee_per_gas: transaction.max_fee_per_gas.unwrap(), 282 | max_priority_fee_per_gas: transaction.max_priority_fee_per_gas.unwrap(), 283 | to, 284 | value, 285 | access_list, 286 | input, 287 | }) 288 | } else if transaction.transaction_type == Some(1) { 289 | PrimitiveTransaction::Eip2930(TxEip2930 { 290 | chain_id, 291 | nonce, 292 | gas_price: transaction.gas_price.unwrap(), 293 | gas_limit: gas_limit.try_into().unwrap(), 294 | to, 295 | value, 296 | access_list, 297 | input, 298 | }) 299 | } else { 300 | // otherwise legacy 301 | PrimitiveTransaction::Legacy(TxLegacy { 302 | chain_id: Some(chain_id), 303 | nonce, 304 | gas_price: transaction.gas_price.unwrap(), 305 | gas_limit: gas_limit.try_into().unwrap(), 306 | to, 307 | value, 308 | input, 309 | }) 310 | }; 311 | 312 | TransactionSigned::from_transaction_and_signature(tx, signature) 313 | } 314 | --------------------------------------------------------------------------------