use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use bon::bon;
use borsh::{BorshDeserialize, BorshSerialize};
use bullet_exchange_interface::schema::Schema;
use bullet_exchange_interface::transaction::{
Amount, Gas, PriorityFeeBips, RuntimeCall, Transaction as SignedTransaction, TxDetails,
UniquenessData, UnsignedTransaction as RawUnsignedTransaction, Version0,
};
use serde_json::Value;
use web_time::{SystemTime, UNIX_EPOCH};
use crate::codegen::Error::ErrorResponse;
use crate::generated::types::{ApiErrorResponse, SubmitTxRequest, SubmitTxResponse};
use crate::types::CallMessage;
use crate::{Client, Keypair, SDKError, SDKResult};
pub struct UnsignedTransaction {
inner: RawUnsignedTransaction,
chain_hash: [u8; 32],
chain_name: String,
}
#[bon]
impl UnsignedTransaction {
pub fn to_bytes(&self) -> SDKResult<Vec<u8>> {
let mut data =
borsh::to_vec(&self.inner).map_err(|e| SDKError::SerializationError(e.to_string()))?;
data.extend_from_slice(&self.chain_hash);
Ok(data)
}
pub fn to_display_message(&self) -> SDKResult<String> {
let schema = Schema::of_single_type::<RawUnsignedTransaction>()
.map_err(|e| SDKError::SerializationError(e.to_string()))?;
let bytes =
borsh::to_vec(&self.inner).map_err(|e| SDKError::SerializationError(e.to_string()))?;
schema.display(0, &bytes).map_err(|e| SDKError::SerializationError(e.to_string()))
}
pub fn to_message_bytes(&self) -> SDKResult<Vec<u8>> {
let message = serde_json::to_value(&self.inner)?;
let Value::Object(mut message) = message else {
return Err(SDKError::SerializationError(
"unsigned transaction serialized to non-object JSON".to_string(),
));
};
stringify_offchain_max_fee(&mut message, self.inner.details.max_fee.0)?;
message.insert("chain_name".to_string(), Value::String(self.chain_name.clone()));
serde_json::to_vec(&Value::Object(message)).map_err(Into::into)
}
#[builder]
pub fn new(
call_message: CallMessage,
max_fee: u128,
priority_fee_bips: u64,
gas_limit: Option<Gas>,
client: &Client,
) -> SDKResult<UnsignedTransaction> {
if !Client::call_message_was_validated(&call_message, client.user_actions().as_deref()) {
return Err(SDKError::UnsupportedCallMessage(call_message.msg_type()));
}
let runtime_call = RuntimeCall::Exchange(call_message);
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| SDKError::SystemTimeError)?
.as_micros() as u64;
let uniqueness = UniquenessData::Generation(timestamp);
let details = TxDetails {
chain_id: client.chain_id(),
max_fee: Amount(max_fee),
gas_limit,
max_priority_fee_bips: PriorityFeeBips(priority_fee_bips),
};
Ok(UnsignedTransaction {
inner: RawUnsignedTransaction { runtime_call, uniqueness, details },
chain_hash: client.chain_hash(),
chain_name: client.chain_name(),
})
}
}
#[derive(Clone, Debug, Eq, PartialEq, BorshDeserialize, BorshSerialize)]
pub struct SolanaOffchainTransaction {
pub signed_message: Vec<u8>,
pub chain_hash: [u8; 32],
pub pubkey: [u8; 32],
pub signature: [u8; 64],
}
impl SolanaOffchainTransaction {
pub fn from_parts(
tx: UnsignedTransaction,
signature: [u8; 64],
pubkey: [u8; 32],
) -> SDKResult<Self> {
Ok(Self {
signed_message: tx.to_message_bytes()?,
chain_hash: tx.chain_hash,
pubkey,
signature,
})
}
pub fn to_bytes(&self) -> SDKResult<Vec<u8>> {
borsh::to_vec(self).map_err(|e| SDKError::SerializationError(e.to_string()))
}
pub fn to_base64(&self) -> SDKResult<String> {
let bytes = self.to_bytes()?;
Ok(BASE64.encode(&bytes))
}
}
pub struct Transaction;
#[bon]
impl Transaction {
#[builder]
pub fn new(
call_message: CallMessage,
max_fee: Option<u128>,
priority_fee_bips: Option<u64>,
gas_limit: Option<Gas>,
signer: Option<&Keypair>,
client: &Client,
) -> SDKResult<SignedTransaction> {
let signer = signer.or_else(|| client.keypair()).ok_or(SDKError::MissingKeypair)?;
let max_fee = max_fee.unwrap_or_else(|| client.max_fee().0);
let priority_fee_bips =
priority_fee_bips.unwrap_or_else(|| client.max_priority_fee_bips().0);
let gas_limit = gas_limit.or_else(|| client.gas_limit());
let unsigned = UnsignedTransaction::builder()
.call_message(call_message)
.max_fee(max_fee)
.priority_fee_bips(priority_fee_bips)
.maybe_gas_limit(gas_limit)
.client(client)
.build()?;
let data = unsigned.to_bytes()?;
let sig_bytes: [u8; 64] = signer
.sign(&data)
.try_into()
.map_err(|v: Vec<u8>| SDKError::InvalidSignatureLength(v.len()))?;
let pub_key: [u8; 32] = signer
.public_key()
.try_into()
.map_err(|v: Vec<u8>| SDKError::InvalidPublicKeyLength(v.len()))?;
Ok(Self::from_parts(unsigned, sig_bytes, pub_key))
}
pub fn from_parts(
tx: UnsignedTransaction,
signature: [u8; 64],
pub_key: [u8; 32],
) -> SignedTransaction {
let RawUnsignedTransaction { runtime_call, uniqueness, details } = tx.inner;
SignedTransaction::V0(Version0 { runtime_call, uniqueness, details, pub_key, signature })
}
pub fn to_bytes(signed: &SignedTransaction) -> SDKResult<Vec<u8>> {
borsh::to_vec(signed).map_err(|e| SDKError::SerializationError(e.to_string()))
}
pub fn to_base64(signed: &SignedTransaction) -> SDKResult<String> {
let bytes = Self::to_bytes(signed)?;
Ok(BASE64.encode(&bytes))
}
}
impl Client {
pub async fn send_transaction(
&self,
signed: &SignedTransaction,
) -> SDKResult<SubmitTxResponse> {
let body = Transaction::to_base64(signed)?;
let response = self.client().submit_tx(&SubmitTxRequest { body }).await;
match response {
Err(ErrorResponse(response)) if response.status() == 401 => {
let inner = response.into_inner();
Err(self.submit_tx_api_error(inner).await?)
}
Ok(r) => Ok(r.into_inner()),
Err(e) => Err(e.into()),
}
}
pub async fn send_offchain_transaction(
&self,
signed: &SolanaOffchainTransaction,
) -> SDKResult<SubmitTxResponse> {
let body = signed.to_base64()?;
let response = self
.http_client
.post(self.solana_offchain_url())
.json(&SubmitTxRequest { body })
.send()
.await?;
let status = response.status();
let bytes = response.bytes().await?;
if status.is_success() {
return serde_json::from_slice::<SubmitTxResponse>(&bytes).map_err(Into::into);
}
let error = serde_json::from_slice::<ApiErrorResponse>(&bytes).unwrap_or_else(|_| {
ApiErrorResponse {
status: status.as_u16(),
message: String::from_utf8_lossy(&bytes).into_owned(),
details: None,
}
});
Err(self.submit_tx_api_error(error).await?)
}
async fn submit_tx_api_error(&self, error: ApiErrorResponse) -> SDKResult<SDKError> {
if error.status == 401 && error.message.contains("Invalid signature") {
self.update_schema().await?;
return Ok(SDKError::TransactionOutdated);
}
Ok(SDKError::ApiError(error))
}
}
fn stringify_offchain_max_fee(
message: &mut serde_json::Map<String, Value>,
max_fee: u128,
) -> SDKResult<()> {
let details = message.get_mut("details").and_then(Value::as_object_mut).ok_or_else(|| {
SDKError::SerializationError(
"unsigned transaction JSON missing object field details".to_string(),
)
})?;
let max_fee_value = details.get_mut("max_fee").ok_or_else(|| {
SDKError::SerializationError(
"unsigned transaction JSON missing field details.max_fee".to_string(),
)
})?;
*max_fee_value = Value::String(max_fee.to_string());
Ok(())
}
#[cfg(test)]
mod tests {
use bullet_exchange_interface::message::{CancelOrderArgs, PublicAction, UserAction};
use bullet_exchange_interface::schema::Schema;
use bullet_exchange_interface::transaction::{
Amount, PriorityFeeBips, RuntimeCall, Transaction as InterfaceTransaction, TxDetails,
UniquenessData,
};
use bullet_exchange_interface::types::{MarketId, OrderId};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
fn test_unsigned_tx() -> UnsignedTransaction {
let inner = RawUnsignedTransaction {
runtime_call: RuntimeCall::Exchange(CallMessage::Public(PublicAction::ApplyFunding {
addresses: vec![],
})),
uniqueness: UniquenessData::Generation(12345),
details: TxDetails {
chain_id: 1,
max_fee: Amount(10_000_000),
gas_limit: None,
max_priority_fee_bips: PriorityFeeBips(0),
},
};
UnsignedTransaction { inner, chain_hash: [42u8; 32], chain_name: "TestChain".to_string() }
}
fn schema_response(chain_byte: u8) -> serde_json::Value {
let schema = Schema::of_single_type::<InterfaceTransaction>().unwrap();
serde_json::json!({
"chain_hash": format!("0x{}", hex::encode([chain_byte; 32])),
"schema": schema,
})
}
fn exchange_info_response() -> serde_json::Value {
serde_json::json!({
"assets": [],
"rateLimits": [],
"symbols": [],
})
}
async fn mock_client_for_offchain_submission(
offchain_response: ResponseTemplate,
) -> (MockServer, Client) {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rollup/schema"))
.respond_with(ResponseTemplate::new(200).set_body_json(schema_response(7)))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/fapi/v1/exchangeInfo"))
.respond_with(ResponseTemplate::new(200).set_body_json(exchange_info_response()))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/sequencer/solana_offchain_txs"))
.respond_with(offchain_response)
.mount(&server)
.await;
let client = Client::builder()
.network(server.uri())
.solana_offchain_url(format!("{}/sequencer/solana_offchain_txs", server.uri()))
.build()
.await
.unwrap();
(server, client)
}
fn test_solana_offchain_transaction() -> SolanaOffchainTransaction {
let chain_hash = [7u8; 32];
SolanaOffchainTransaction {
signed_message: serde_json::json!({
"chain_name": "TestChain",
})
.to_string()
.into_bytes(),
chain_hash,
pubkey: [8u8; 32],
signature: [9u8; 64],
}
}
fn verify_signature(pub_key: [u8; 32], message: &[u8], signature: [u8; 64]) -> bool {
let verifying_key = VerifyingKey::from_bytes(&pub_key).unwrap();
let signature = Signature::from_bytes(&signature);
verifying_key.verify(message, &signature).is_ok()
}
#[test]
fn to_bytes_is_borsh_plus_chain_hash() {
let unsigned = test_unsigned_tx();
let bytes = unsigned.to_bytes().unwrap();
let mut expected = borsh::to_vec(&unsigned.inner).unwrap();
expected.extend_from_slice(&unsigned.chain_hash);
assert_eq!(bytes, expected);
}
#[test]
fn to_display_message_renders_unsigned_payload_without_chain_hash() {
let unsigned = test_unsigned_tx();
let display = unsigned.to_display_message().unwrap();
assert!(display.contains("ApplyFunding"), "{display}");
assert!(display.contains("max_fee"), "{display}");
assert!(display.contains("10000000"), "{display}");
assert!(!display.contains("signature"), "{display}");
}
#[test]
fn to_message_bytes_serializes_readable_json() {
let unsigned = test_unsigned_tx();
let bytes = unsigned.to_message_bytes().unwrap();
let message: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(message["chain_name"], "TestChain");
assert_eq!(message["uniqueness"]["generation"], 12345);
assert_eq!(message["details"]["chain_id"], 1);
assert_eq!(message["details"]["max_fee"], "10000000");
assert!(message.get("runtime_call").is_some());
}
#[test]
fn to_message_bytes_omits_envelope_chain_hash() {
let unsigned = test_unsigned_tx();
let bytes = unsigned.to_message_bytes().unwrap();
let message: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(message.get("chain_hash").is_none());
}
#[test]
fn to_message_bytes_serializes_large_max_fee_as_string() {
let mut unsigned = test_unsigned_tx();
let max_fee = u128::from(u64::MAX) + 1;
unsigned.inner.details.max_fee = Amount(max_fee);
let bytes = unsigned.to_message_bytes().unwrap();
let message: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(message["details"]["max_fee"], max_fee.to_string());
}
#[test]
fn stringify_offchain_max_fee_errors_when_details_shape_changes() {
let mut message = serde_json::json!({
"runtime_call": {},
})
.as_object()
.unwrap()
.clone();
let err = stringify_offchain_max_fee(&mut message, 1).unwrap_err();
assert!(err.to_string().contains("missing object field details"));
}
#[test]
fn stringify_offchain_max_fee_errors_when_max_fee_is_missing() {
let mut message = serde_json::json!({
"details": {
"chain_id": 1,
},
})
.as_object()
.unwrap()
.clone();
let err = stringify_offchain_max_fee(&mut message, 1).unwrap_err();
assert!(err.to_string().contains("missing field details.max_fee"));
}
#[test]
fn to_message_bytes_matches_solana_authenticator_json_numbers() {
let mut unsigned = test_unsigned_tx();
unsigned.inner.details.max_priority_fee_bips = PriorityFeeBips(u64::MAX);
let bytes = unsigned.to_message_bytes().unwrap();
let message: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(message["details"]["max_priority_fee_bips"], u64::MAX);
assert_eq!(message["details"]["chain_id"], 1);
assert_eq!(message["uniqueness"]["generation"], 12345);
}
#[test]
fn to_message_bytes_serializes_order_ids_as_json_numbers() {
let inner = RawUnsignedTransaction {
runtime_call: RuntimeCall::Exchange(CallMessage::User(UserAction::CancelOrders {
market_id: MarketId(0),
orders: vec![CancelOrderArgs {
order_id: Some(OrderId(10_000_000_000)),
client_order_id: None,
}],
sub_account_index: None,
})),
uniqueness: UniquenessData::Generation(12345),
details: TxDetails {
chain_id: 1,
max_fee: Amount(10_000_000),
gas_limit: None,
max_priority_fee_bips: PriorityFeeBips(0),
},
};
let unsigned = UnsignedTransaction {
inner,
chain_hash: [42u8; 32],
chain_name: "TestChain".to_string(),
};
let bytes = unsigned.to_message_bytes().unwrap();
let message: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
message["runtime_call"]["exchange"]["user"]["cancel_orders"]["orders"][0]["order_id"],
10_000_000_000u64
);
}
#[test]
fn solana_offchain_transaction_wraps_signed_json_message() {
let keypair = Keypair::generate();
let unsigned = test_unsigned_tx();
let signable = unsigned.to_message_bytes().unwrap();
let signature: [u8; 64] = keypair.sign(&signable).try_into().unwrap();
let pub_key: [u8; 32] = keypair.public_key().try_into().unwrap();
let tx = SolanaOffchainTransaction::from_parts(unsigned, signature, pub_key).unwrap();
assert_eq!(tx.signed_message, signable);
assert_eq!(tx.chain_hash, [42u8; 32]);
assert_eq!(tx.pubkey, pub_key);
assert_eq!(tx.signature, signature);
assert!(!tx.to_base64().unwrap().is_empty());
let roundtrip: SolanaOffchainTransaction =
borsh::from_slice(&tx.to_bytes().unwrap()).expect("should deserialize");
assert_eq!(roundtrip, tx);
}
#[test]
fn signed_transaction_signature_verifies_against_borsh_bytes() {
let keypair = Keypair::generate();
let unsigned = test_unsigned_tx();
let signable = unsigned.to_bytes().unwrap();
let signature: [u8; 64] = keypair.sign(&signable).try_into().unwrap();
let pub_key: [u8; 32] = keypair.public_key().try_into().unwrap();
let signed = Transaction::from_parts(unsigned, signature, pub_key);
let version_0 = match signed {
SignedTransaction::V0(version_0) => version_0,
_ => panic!("expected Version0 signed transaction"),
};
assert!(verify_signature(version_0.pub_key, &signable, version_0.signature));
}
#[test]
fn solana_offchain_signature_verifies_against_message_bytes_only() {
let keypair = Keypair::generate();
let unsigned = test_unsigned_tx();
let message = unsigned.to_message_bytes().unwrap();
let borsh_bytes = unsigned.to_bytes().unwrap();
let signature: [u8; 64] = keypair.sign(&message).try_into().unwrap();
let pub_key: [u8; 32] = keypair.public_key().try_into().unwrap();
let tx = SolanaOffchainTransaction::from_parts(unsigned, signature, pub_key).unwrap();
assert!(verify_signature(tx.pubkey, &tx.signed_message, tx.signature));
assert!(!verify_signature(tx.pubkey, &borsh_bytes, tx.signature));
}
#[tokio::test]
async fn send_offchain_transaction_requires_resign_on_invalid_signature() {
let (server, client) = mock_client_for_offchain_submission(
ResponseTemplate::new(401).set_body_json(serde_json::json!({
"status": 401,
"message": "Invalid signature",
})),
)
.await;
let signed = test_solana_offchain_transaction();
let err = client.send_offchain_transaction(&signed).await.unwrap_err();
assert!(matches!(err, SDKError::TransactionOutdated), "{err:?}");
let requests = server.received_requests().await.unwrap();
let schema_requests =
requests.iter().filter(|request| request.url.path() == "/rollup/schema").count();
assert_eq!(schema_requests, 2);
}
#[test]
fn from_parts_matches_direct_construction() {
let keypair = Keypair::generate();
let unsigned = test_unsigned_tx();
let signable = unsigned.to_bytes().unwrap();
let sig: [u8; 64] = keypair.sign(&signable).try_into().unwrap();
let pk: [u8; 32] = keypair.public_key().try_into().unwrap();
let chain_hash = unsigned.chain_hash;
let inner_clone = RawUnsignedTransaction {
runtime_call: unsigned.inner.runtime_call.clone(),
uniqueness: unsigned.inner.uniqueness.clone(),
details: unsigned.inner.details.clone(),
};
let assembled = Transaction::from_parts(unsigned, sig, pk);
let mut data = borsh::to_vec(&inner_clone).unwrap();
data.extend_from_slice(&chain_hash);
let sig2: [u8; 64] = keypair.sign(&data).try_into().unwrap();
let direct = SignedTransaction::V0(Version0 {
runtime_call: inner_clone.runtime_call,
uniqueness: inner_clone.uniqueness,
details: inner_clone.details,
pub_key: pk,
signature: sig2,
});
assert_eq!(assembled, direct);
assert_eq!(
Transaction::to_bytes(&assembled).unwrap(),
Transaction::to_bytes(&direct).unwrap(),
);
}
#[test]
fn signed_to_bytes_roundtrips() {
let keypair = Keypair::generate();
let unsigned = test_unsigned_tx();
let signable = unsigned.to_bytes().unwrap();
let sig: [u8; 64] = keypair.sign(&signable).try_into().unwrap();
let pk: [u8; 32] = keypair.public_key().try_into().unwrap();
let signed = Transaction::from_parts(unsigned, sig, pk);
let bytes = Transaction::to_bytes(&signed).unwrap();
assert!(!bytes.is_empty());
let deserialized: SignedTransaction =
borsh::from_slice(&bytes).expect("should deserialize");
assert_eq!(bytes, Transaction::to_bytes(&deserialized).unwrap());
}
#[test]
fn to_base64_is_nonempty() {
let keypair = Keypair::generate();
let unsigned = test_unsigned_tx();
let signable = unsigned.to_bytes().unwrap();
let sig: [u8; 64] = keypair.sign(&signable).try_into().unwrap();
let pk: [u8; 32] = keypair.public_key().try_into().unwrap();
let signed = Transaction::from_parts(unsigned, sig, pk);
assert!(!Transaction::to_base64(&signed).unwrap().is_empty());
}
#[cfg(feature = "integration")]
mod integration {
use bullet_exchange_interface::message::PublicAction;
use super::*;
use crate::Network;
#[tokio::test]
async fn test_builder_build() {
let network = std::env::var("BULLET_API_ENDPOINT")
.map(|e| Network::from(e.as_str()))
.unwrap_or(Network::Mainnet);
let client =
Client::builder().network(network).build().await.expect("could not connect");
let keypair = Keypair::generate();
let call_msg = CallMessage::Public(PublicAction::ApplyFunding { addresses: vec![] });
let signed = Transaction::builder()
.call_message(call_msg)
.max_fee(10_000_000)
.signer(&keypair)
.client(&client)
.build()
.expect("Failed to build transaction");
assert!(!Transaction::to_base64(&signed).unwrap().is_empty());
}
}
}