Skip to content

Commit 6220ce5

Browse files
committed
feature(rpc): Adds getBundleStatuses rpc method
1 parent 8f0895d commit 6220ce5

6 files changed

Lines changed: 239 additions & 4 deletions

File tree

crates/core/src/error.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@ impl SurfpoolError {
9999
Self(error)
100100
}
101101

102+
pub fn invalid_bundle_id() -> Self {
103+
let mut error = Error::invalid_request();
104+
error.data = Some(json!(format!("Solana RPC client error: Invalid bundle id")));
105+
Self(error)
106+
}
107+
102108
pub fn missing_context() -> Self {
103109
let mut error = Error::internal_error();
104110
error.data = Some(json!("Failed to access internal Surfnet context"));

crates/core/src/rpc/jito.rs

Lines changed: 206 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
use std::str::FromStr;
22

3-
use jsonrpc_core::{Error, Result};
3+
use jsonrpc_core::{BoxFuture, Error, Result};
44
use jsonrpc_derive::rpc;
55
use sha2::{Digest, Sha256};
66
use solana_client::{rpc_config::RpcSendTransactionConfig, rpc_custom_error::RpcCustomError};
7+
use solana_rpc_client_api::response::Response as RpcResponse;
78
use solana_signature::Signature;
9+
use solana_transaction_status::TransactionStatus;
810

911
use super::{
1012
RunloopContext,
@@ -59,6 +61,47 @@ pub trait Jito {
5961
transactions: Vec<String>,
6062
config: Option<RpcSendTransactionConfig>,
6163
) -> Result<String>;
64+
65+
/// Retrieves the statuses of all transactions in a previously submitted bundle.
66+
///
67+
/// This RPC method looks up a bundle by its `bundle_id` (the SHA-256 hash returned by
68+
/// [`sendBundle`](#method.send_bundle)) and returns the signature statuses for the bundle's
69+
/// transactions in the same order they were recorded.
70+
///
71+
/// ## Parameters
72+
/// - `bundle_id`: The bundle identifier returned by `sendBundle`.
73+
///
74+
/// ## Returns
75+
/// A contextualized response containing:
76+
/// - `value`: A list of optional transaction statuses corresponding to the bundle signatures.
77+
/// Each entry can be:
78+
/// - `null` if the signature is unknown or not sufficiently confirmed for status reporting
79+
/// - a `TransactionStatus` object if the transaction is found and its status can be returned
80+
///
81+
/// ## Example Request (JSON-RPC)
82+
/// ```json
83+
/// {
84+
/// "jsonrpc": "2.0",
85+
/// "id": 1,
86+
/// "method": "getBundleStatuses",
87+
/// "params": [
88+
/// "bundleIdHere"
89+
/// ]
90+
/// }
91+
/// ```
92+
///
93+
/// ## Notes
94+
/// - Bundles are stored locally as a mapping from `bundle_id` to a list of base-58 signatures.
95+
/// - If the bundle ID is not known locally, an error is returned.
96+
/// - Status resolution is delegated to the same logic used by `getSignatureStatuses`:
97+
/// statuses are computed from locally stored transactions (and may fall back to a remote
98+
/// datasource, if configured).
99+
#[rpc(meta, name = "getBundleStatuses")]
100+
fn get_bundle_statuses(
101+
&self,
102+
meta: Self::Metadata,
103+
bundle_id: String,
104+
) -> BoxFuture<Result<RpcResponse<Vec<Option<TransactionStatus>>>>>;
62105
}
63106

64107
#[derive(Clone)]
@@ -83,7 +126,7 @@ impl Jito for SurfpoolJitoRpc {
83126
)));
84127
}
85128

86-
let Some(_ctx) = &meta else {
129+
let Some(ctx) = &meta else {
87130
return Err(RpcCustomError::NodeUnhealthy {
88131
num_slots_behind: None,
89132
}
@@ -102,7 +145,7 @@ impl Jito for SurfpoolJitoRpc {
102145
let bundle_config = Some(SurfpoolRpcSendTransactionConfig {
103146
base: RpcSendTransactionConfig {
104147
skip_preflight: true,
105-
..base_config.clone()
148+
..base_config
106149
},
107150
skip_sig_verify: None,
108151
});
@@ -136,7 +179,40 @@ impl Jito for SurfpoolJitoRpc {
136179
let mut hasher = Sha256::new();
137180
hasher.update(concatenated_signatures.as_bytes());
138181
let bundle_id = hasher.finalize();
139-
Ok(hex::encode(bundle_id))
182+
let bundle_id = hex::encode(bundle_id);
183+
184+
let _ = ctx
185+
.simnet_commands_tx
186+
.send(surfpool_types::SimnetCommand::SendBundle((
187+
bundle_id.clone(),
188+
bundle_signatures
189+
.iter()
190+
.map(|sig| sig.to_string())
191+
.collect(),
192+
)));
193+
194+
Ok(bundle_id)
195+
}
196+
197+
fn get_bundle_statuses(
198+
&self,
199+
meta: Self::Metadata,
200+
bundle_id: String,
201+
) -> BoxFuture<Result<RpcResponse<Vec<Option<TransactionStatus>>>>> {
202+
Box::pin(async move {
203+
let Some(ctx) = &meta else {
204+
return Err(RpcCustomError::NodeUnhealthy {
205+
num_slots_behind: None,
206+
}
207+
.into());
208+
};
209+
210+
let signatures = ctx.svm_locker.get_bundle(bundle_id)?;
211+
212+
SurfpoolFullRpc
213+
.get_signature_statuses(meta.clone(), signatures, None)
214+
.await
215+
})
140216
}
141217
}
142218

@@ -421,4 +497,130 @@ mod tests {
421497
"Bundle ID should match SHA-256 of comma-separated signatures"
422498
);
423499
}
500+
501+
#[tokio::test(flavor = "multi_thread")]
502+
async fn test_send_bundle_persists_bundle_signatures() {
503+
let payer = Keypair::new();
504+
let recipient = Pubkey::new_unique();
505+
let (mempool_tx, mempool_rx) = crossbeam_channel::unbounded();
506+
let setup = TestSetup::new_with_mempool(SurfpoolJitoRpc, mempool_tx);
507+
508+
let recent_blockhash = setup
509+
.context
510+
.svm_locker
511+
.with_svm_reader(|svm_reader| svm_reader.latest_blockhash());
512+
513+
// Airdrop to payer so tx can succeed in our manual processing
514+
let _ = setup
515+
.context
516+
.svm_locker
517+
.0
518+
.write()
519+
.await
520+
.airdrop(&payer.pubkey(), 2 * LAMPORTS_PER_SOL);
521+
522+
let tx = build_v0_transaction(
523+
&payer.pubkey(),
524+
&[&payer],
525+
&[system_instruction::transfer(
526+
&payer.pubkey(),
527+
&recipient,
528+
LAMPORTS_PER_SOL,
529+
)],
530+
&recent_blockhash,
531+
);
532+
let tx_encoded = bs58::encode(bincode::serialize(&tx).unwrap()).into_string();
533+
534+
// Build expected signatures locally (what we expect to be persisted under bundle_id)
535+
let expected_sigs = vec![tx.signatures[0].to_string()];
536+
537+
let setup_clone = setup.clone();
538+
let handle = hiro_system_kit::thread_named("send_bundle")
539+
.spawn(move || {
540+
setup_clone
541+
.rpc
542+
.send_bundle(Some(setup_clone.context), vec![tx_encoded], None)
543+
})
544+
.unwrap();
545+
546+
let mut processed_tx = false;
547+
let mut processed_bundle = false;
548+
let mut bundle_id_from_cmd: Option<String> = None;
549+
let mut sigs_from_cmd: Option<Vec<String>> = None;
550+
551+
while !(processed_tx && processed_bundle) {
552+
match mempool_rx.recv() {
553+
Ok(SimnetCommand::ProcessTransaction(_, tx, status_tx, _, _)) => {
554+
let mut writer = setup.context.svm_locker.0.write().await;
555+
let slot = writer.get_latest_absolute_slot();
556+
writer.transactions_queued_for_confirmation.push_back((
557+
tx.clone(),
558+
status_tx.clone(),
559+
None,
560+
));
561+
let sig = tx.signatures[0];
562+
let tx_with_status_meta = TransactionWithStatusMeta {
563+
slot,
564+
transaction: tx,
565+
..Default::default()
566+
};
567+
writer
568+
.transactions
569+
.store(
570+
sig.to_string(),
571+
SurfnetTransactionStatus::processed(
572+
tx_with_status_meta,
573+
std::collections::HashSet::new(),
574+
),
575+
)
576+
.unwrap();
577+
status_tx
578+
.send(TransactionStatusEvent::Success(
579+
TransactionConfirmationStatus::Confirmed,
580+
))
581+
.unwrap();
582+
processed_tx = true;
583+
}
584+
Ok(SimnetCommand::SendBundle((bundle_id, signatures))) => {
585+
setup
586+
.context
587+
.svm_locker
588+
.process_bundle(bundle_id.clone(), signatures.clone())
589+
.unwrap();
590+
bundle_id_from_cmd = Some(bundle_id);
591+
sigs_from_cmd = Some(signatures);
592+
processed_bundle = true;
593+
}
594+
Ok(SimnetCommand::AirdropProcessed) => continue,
595+
other => panic!("unexpected simnet command: {:?}", other),
596+
}
597+
}
598+
599+
let result = handle.join().unwrap().expect("sendBundle should succeed");
600+
let stored_bundle_id = bundle_id_from_cmd.expect("should have received SendBundle command");
601+
assert_eq!(
602+
result, stored_bundle_id,
603+
"sendBundle result bundle id should match stored bundle id"
604+
);
605+
606+
let persisted = setup
607+
.context
608+
.svm_locker
609+
.get_bundle(stored_bundle_id.clone())
610+
.expect("bundle should be persisted");
611+
assert!(
612+
!persisted.is_empty(),
613+
"svm_locker.get_bundle(bundle_id) should not be empty"
614+
);
615+
616+
let sigs_from_cmd = sigs_from_cmd.expect("should have captured signatures from SendBundle");
617+
assert_eq!(
618+
sigs_from_cmd, expected_sigs,
619+
"Signatures in SendBundle command should match locally built signatures"
620+
);
621+
assert_eq!(
622+
persisted, expected_sigs,
623+
"Persisted bundle signatures should match locally built signatures"
624+
);
625+
}
424626
}

crates/core/src/runloops/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,11 @@ pub async fn start_block_production_runloop(
492492
do_produce_block = true;
493493
}
494494
}
495+
SimnetCommand::SendBundle((bundle_id, signatures)) => {
496+
if let Err(e) = svm_locker.process_bundle(bundle_id, signatures) {
497+
let _ = svm_locker.simnet_events_tx().send(SimnetEvent::error(format!("Failed to send jito bundle: {}", e)));
498+
}
499+
}
495500
SimnetCommand::Terminate(_) => {
496501
// Explicitly shutdown storage to trigger WAL checkpoint before exiting
497502
svm_locker.shutdown();

crates/core/src/surfnet/locker.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -984,6 +984,18 @@ impl SurfnetSvmLocker {
984984
}
985985
}
986986

987+
pub fn get_bundle(&self, bundle_id: String) -> SurfpoolResult<Vec<String>> {
988+
self.with_svm_reader(|svm_reader| {
989+
if let Ok(get_signatures_result) = svm_reader.jito_bundles.get(&bundle_id)
990+
&& let Some(signatures) = get_signatures_result
991+
{
992+
Ok(signatures)
993+
} else {
994+
Err(SurfpoolError::invalid_bundle_id())
995+
}
996+
})
997+
}
998+
987999
/// Retrieves a transaction from local cache, returning a contextualized result.
9881000
pub fn get_transaction_local(
9891001
&self,
@@ -1113,6 +1125,11 @@ impl SurfnetSvmLocker {
11131125
Ok(())
11141126
}
11151127

1128+
pub fn process_bundle(&self, bundle_id: String, signatures: Vec<String>) -> SurfpoolResult<()> {
1129+
self.with_svm_writer(|svm_writer| svm_writer.jito_bundles.store(bundle_id, signatures))?;
1130+
Ok(())
1131+
}
1132+
11161133
pub async fn profile_transaction(
11171134
&self,
11181135
remote_ctx: &Option<(SurfnetRemoteClient, CommitmentConfig)>,

crates/core/src/surfnet/svm.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ pub struct SurfnetSvm {
223223
pub chain_tip: BlockIdentifier,
224224
pub blocks: Box<dyn Storage<u64, BlockHeader>>,
225225
pub transactions: Box<dyn Storage<String, SurfnetTransactionStatus>>,
226+
pub jito_bundles: Box<dyn Storage<String, Vec<String>>>,
226227
pub transactions_queued_for_confirmation: VecDeque<(
227228
VersionedTransaction,
228229
Sender<TransactionStatusEvent>,
@@ -343,6 +344,7 @@ impl SurfnetSvm {
343344
// Wrap all storage fields with OverlayStorage
344345
blocks: OverlayStorage::wrap(self.blocks.clone_box()),
345346
transactions: OverlayStorage::wrap(self.transactions.clone_box()),
347+
jito_bundles: OverlayStorage::wrap(self.jito_bundles.clone_box()),
346348
profile_tag_map: OverlayStorage::wrap(self.profile_tag_map.clone_box()),
347349
simulated_transaction_profiles: OverlayStorage::wrap(
348350
self.simulated_transaction_profiles.clone_box(),
@@ -456,6 +458,7 @@ impl SurfnetSvm {
456458
)?;
457459
let blocks_db = new_kv_store(&database_url, "blocks", surfnet_id)?;
458460
let transactions_db = new_kv_store(&database_url, "transactions", surfnet_id)?;
461+
let jito_bundles_db = new_kv_store(&database_url, "jito_bundles", surfnet_id)?;
459462
let token_accounts_db = new_kv_store(&database_url, "token_accounts", surfnet_id)?;
460463
let mut token_mints_db: Box<dyn Storage<String, MintAccount>> =
461464
new_kv_store(&database_url, "token_mints", surfnet_id)?;
@@ -545,6 +548,7 @@ impl SurfnetSvm {
545548
chain_tip,
546549
blocks: blocks_db,
547550
transactions: transactions_db,
551+
jito_bundles: jito_bundles_db,
548552
perf_samples: VecDeque::new(),
549553
transactions_processed,
550554
simnet_events_tx,

crates/types/src/types.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,7 @@ pub enum SimnetCommand {
559559
CompleteRunbookExecution(String, Option<Vec<String>>),
560560
FetchRemoteAccounts(Vec<Pubkey>, String),
561561
AirdropProcessed,
562+
SendBundle((String, Vec<String>)),
562563
}
563564

564565
#[derive(Debug)]

0 commit comments

Comments
 (0)