use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use borsh::BorshSerialize;
use bullet_exchange_interface::address::Address;
use sha2::{Digest, Sha256};
use crate::{SDKError, SDKResult, UnsignedTransaction};
pub const MAX_MULTISIG_SIGNERS: usize = 21;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MultisigConfig {
min_signers: u8,
pubkeys: Vec<[u8; 32]>,
}
impl MultisigConfig {
pub fn new(min_signers: u8, mut pubkeys: Vec<[u8; 32]>) -> SDKResult<Self> {
if pubkeys.len() < 2 || pubkeys.len() > MAX_MULTISIG_SIGNERS {
return Err(SDKError::InvalidMultisig(format!(
"expected 2-{MAX_MULTISIG_SIGNERS} signers, got {}",
pubkeys.len()
)));
}
pubkeys.sort_unstable();
if pubkeys.windows(2).any(|pair| pair[0] == pair[1]) {
return Err(SDKError::InvalidMultisig(
"duplicate public key".to_string(),
));
}
for pubkey in &pubkeys {
let key = ed25519_dalek::VerifyingKey::from_bytes(pubkey).map_err(|_| {
SDKError::InvalidMultisig(format!(
"invalid Ed25519 public key: {}",
Address(*pubkey)
))
})?;
if key.is_weak() {
return Err(SDKError::InvalidMultisig(format!(
"weak Ed25519 public key: {}",
Address(*pubkey)
)));
}
}
if min_signers == 0 || min_signers as usize > pubkeys.len() {
return Err(SDKError::InvalidMultisig(format!(
"min_signers must be 1-{}, got {min_signers}",
pubkeys.len()
)));
}
Ok(Self {
min_signers,
pubkeys,
})
}
pub fn min_signers(&self) -> u8 {
self.min_signers
}
pub fn pubkeys(&self) -> &[[u8; 32]] {
&self.pubkeys
}
pub fn credential_id(&self) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(self.min_signers.to_le_bytes());
hasher.update(
borsh::to_vec(&self.pubkeys).expect("serializing Vec<[u8; 32]> to vec is infallible"),
);
hasher.finalize().into()
}
pub fn multisig_id(&self) -> String {
Address(self.credential_id()).to_string()
}
pub(crate) fn signer_index(&self, pubkey: &[u8; 32]) -> Option<usize> {
self.pubkeys.iter().position(|key| key == pubkey)
}
}
impl UnsignedTransaction {
pub fn to_multisig_message_bytes(&self, config: &MultisigConfig) -> SDKResult<Vec<u8>> {
let bytes = self.to_message_bytes()?;
let mut message: serde_json::Map<String, serde_json::Value> =
serde_json::from_slice(&bytes)?;
message.remove("version");
message.insert(
"multisig_id".to_string(),
serde_json::Value::String(config.multisig_id()),
);
message.insert("version".to_string(), serde_json::Value::from(1u8));
serde_json::to_vec(&message).map_err(Into::into)
}
pub fn to_ledger_multisig_signable_bytes(&self, config: &MultisigConfig) -> SDKResult<Vec<u8>> {
let json_bytes = self.to_multisig_message_bytes(config)?;
let message_len = u16::try_from(json_bytes.len()).map_err(|_| {
SDKError::SerializationError(format!(
"JSON message too large for Solana preamble: {} bytes (max {})",
json_bytes.len(),
u16::MAX
))
})?;
let mut result = crate::transaction_builder::make_solana_preamble(
config.pubkeys(),
&self.chain_hash,
message_len,
);
result.extend_from_slice(&json_bytes);
Ok(result)
}
}
#[derive(BorshSerialize)]
struct SpecCompliantMultisigMessage {
signed_message_with_preamble: Vec<u8>,
signatures: Vec<[u8; 64]>,
signer_bitfield: u32,
min_signers: u8,
}
#[derive(Clone, Debug)]
pub struct SolanaLedgerMultisigTransaction {
signed_message: Vec<u8>,
config: MultisigConfig,
signatures: std::collections::BTreeMap<usize, [u8; 64]>,
}
impl SolanaLedgerMultisigTransaction {
pub fn new(tx: UnsignedTransaction, config: MultisigConfig) -> SDKResult<Self> {
Ok(Self {
signed_message: tx.to_ledger_multisig_signable_bytes(&config)?,
config,
signatures: std::collections::BTreeMap::new(),
})
}
pub fn signable_bytes(&self) -> &[u8] {
&self.signed_message
}
pub fn config(&self) -> &MultisigConfig {
&self.config
}
pub fn add_signature(&mut self, pubkey: [u8; 32], signature: [u8; 64]) -> SDKResult<()> {
let index = self.config.signer_index(&pubkey).ok_or_else(|| {
SDKError::InvalidMultisig(format!(
"signer {} is not part of the multisig",
Address(pubkey)
))
})?;
if self.signatures.contains_key(&index) {
return Err(SDKError::InvalidMultisig(format!(
"signer {} has already signed",
Address(pubkey)
)));
}
let verifying_key = ed25519_dalek::VerifyingKey::from_bytes(&pubkey)
.map_err(|e| SDKError::InvalidMultisig(format!("invalid public key: {e}")))?;
let signature = ed25519_dalek::Signature::from_bytes(&signature);
verifying_key
.verify_strict(&self.signed_message, &signature)
.map_err(|_| {
SDKError::InvalidMultisig(format!(
"signature from {} does not verify against the signable bytes",
Address(pubkey)
))
})?;
self.signatures.insert(index, signature.to_bytes());
Ok(())
}
pub fn signature_count(&self) -> usize {
self.signatures.len()
}
pub fn is_complete(&self) -> bool {
self.signatures.len() >= self.config.min_signers() as usize
}
pub fn to_bytes(&self) -> SDKResult<Vec<u8>> {
if !self.is_complete() {
return Err(SDKError::InvalidMultisig(format!(
"not enough signatures: need {}, got {}",
self.config.min_signers(),
self.signatures.len()
)));
}
let mut signer_bitfield: u32 = 0;
for index in self.signatures.keys() {
signer_bitfield |= 1 << index;
}
let message = SpecCompliantMultisigMessage {
signed_message_with_preamble: self.signed_message.clone(),
signatures: self.signatures.values().copied().collect(),
signer_bitfield,
min_signers: self.config.min_signers(),
};
borsh::to_vec(&message).map_err(|e| SDKError::SerializationError(e.to_string()))
}
pub fn to_base64(&self) -> SDKResult<String> {
Ok(BASE64.encode(self.to_bytes()?))
}
}
impl crate::Client {
pub async fn send_ledger_multisig_transaction(
&self,
signed: &SolanaLedgerMultisigTransaction,
) -> SDKResult<crate::SubmitTxResponse> {
self.submit_offchain(signed.to_base64()?).await
}
}
#[cfg(test)]
mod tests {
use bullet_exchange_interface::message::PublicAction;
use bullet_exchange_interface::transaction::{
Amount, PriorityFeeBips, RuntimeCall, TxDetails, UniquenessData,
UnsignedTransaction as RawUnsignedTransaction,
};
use super::*;
use crate::types::CallMessage;
use crate::{Keypair, SDKError, UnsignedTransaction};
fn test_keys() -> Vec<[u8; 32]> {
let mut seed3 = [0u8; 32];
seed3[0] = 3;
[[2u8; 32], [1u8; 32], seed3]
.into_iter()
.map(|seed| Keypair::from_bytes(seed).public_key().try_into().unwrap())
.collect()
}
fn test_config() -> MultisigConfig {
MultisigConfig::new(2, test_keys()).unwrap()
}
fn test_unsigned_tx() -> UnsignedTransaction {
let inner = RawUnsignedTransaction {
runtime_call: RuntimeCall::Exchange(CallMessage::Public(PublicAction::ApplyFunding {
addresses: vec![],
})),
uniqueness: UniquenessData::Nonce(5),
details: TxDetails {
chain_hash_fragment: 1,
max_fee: Amount(10_000_000),
gas_limit: None,
max_priority_fee_bips: PriorityFeeBips(0),
},
address_override: None,
};
UnsignedTransaction {
inner,
chain_hash: [42u8; 32],
chain_name: "TestChain".to_string(),
}
}
#[test]
fn multisig_message_bytes_commit_to_multisig_id_and_version() {
let unsigned = test_unsigned_tx();
let config = test_config();
let bytes = unsigned.to_multisig_message_bytes(&config).unwrap();
let message: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(message["multisig_id"], config.multisig_id());
assert_eq!(message["version"], 1);
assert_eq!(message["chain_name"], "TestChain");
assert_eq!(message["uniqueness"]["nonce"], 5);
assert_eq!(message["details"]["max_fee"], "10000000");
assert!(message.get("runtime_call").is_some());
}
#[test]
fn multisig_message_bytes_field_order_matches_sovereign_js() {
let bytes = test_unsigned_tx()
.to_multisig_message_bytes(&test_config())
.unwrap();
let message: serde_json::Map<String, serde_json::Value> =
serde_json::from_slice(&bytes).unwrap();
let keys: Vec<&str> = message.keys().map(String::as_str).collect();
assert_eq!(
keys,
[
"runtime_call",
"uniqueness",
"details",
"chain_name",
"multisig_id",
"version"
]
);
}
#[test]
fn multisig_preamble_layout_matches_sovereign_reference() {
let config = test_config();
let chain_hash = [7u8; 32];
let message_length: u16 = 0x1234;
let preamble = crate::transaction_builder::make_solana_preamble(
config.pubkeys(),
&chain_hash,
message_length,
);
assert_eq!(preamble.len(), 53 + 3 * 32);
assert_eq!(preamble[0], 0xff);
assert_eq!(&preamble[1..16], b"solana offchain");
assert_eq!(preamble[16], 0);
assert_eq!(&preamble[17..49], &chain_hash);
assert_eq!(preamble[49], 0);
assert_eq!(preamble[50], 3);
assert_eq!(&preamble[51..83], &config.pubkeys()[0]);
assert_eq!(&preamble[83..115], &config.pubkeys()[1]);
assert_eq!(&preamble[115..147], &config.pubkeys()[2]);
assert_eq!(&preamble[147..149], &message_length.to_le_bytes());
}
#[test]
fn ledger_multisig_signable_bytes_are_preamble_plus_json() {
let unsigned = test_unsigned_tx();
let config = test_config();
let json_bytes = unsigned.to_multisig_message_bytes(&config).unwrap();
let signable = unsigned.to_ledger_multisig_signable_bytes(&config).unwrap();
let preamble_len = 53 + 3 * 32;
assert_eq!(signable.len(), preamble_len + json_bytes.len());
assert_eq!(&signable[preamble_len..], json_bytes.as_slice());
assert_eq!(signable[0], 0xff);
assert_eq!(signable[50], 3); }
fn test_signers() -> (Vec<Keypair>, MultisigConfig) {
let mut keypairs: Vec<Keypair> = (0..3).map(|_| Keypair::generate()).collect();
keypairs.sort_by_key(|kp| kp.public_key());
let pubkeys: Vec<[u8; 32]> = keypairs
.iter()
.map(|kp| kp.public_key().try_into().unwrap())
.collect();
(keypairs, MultisigConfig::new(2, pubkeys).unwrap())
}
fn sign_with(keypair: &Keypair, message: &[u8]) -> ([u8; 32], [u8; 64]) {
let pubkey: [u8; 32] = keypair.public_key().try_into().unwrap();
let signature: [u8; 64] = keypair.sign(message).try_into().unwrap();
(pubkey, signature)
}
#[test]
fn multisig_transaction_signable_bytes_match_unsigned_tx() {
let (_, config) = test_signers();
let unsigned = test_unsigned_tx();
let expected = unsigned.to_ledger_multisig_signable_bytes(&config).unwrap();
let tx = SolanaLedgerMultisigTransaction::new(unsigned, config).unwrap();
assert_eq!(tx.signable_bytes(), expected.as_slice());
}
#[test]
fn multisig_transaction_tracks_threshold_completion() {
let (keypairs, config) = test_signers();
let mut tx = SolanaLedgerMultisigTransaction::new(test_unsigned_tx(), config).unwrap();
assert_eq!(tx.signature_count(), 0);
assert!(!tx.is_complete());
let (pk, sig) = sign_with(&keypairs[0], tx.signable_bytes());
tx.add_signature(pk, sig).unwrap();
assert_eq!(tx.signature_count(), 1);
assert!(!tx.is_complete());
let (pk, sig) = sign_with(&keypairs[2], tx.signable_bytes());
tx.add_signature(pk, sig).unwrap();
assert_eq!(tx.signature_count(), 2);
assert!(tx.is_complete());
}
#[test]
fn multisig_wire_format_orders_signatures_by_canonical_index() {
let (keypairs, config) = test_signers();
let min_signers = config.min_signers();
let mut tx = SolanaLedgerMultisigTransaction::new(test_unsigned_tx(), config).unwrap();
let (pk2, sig2) = sign_with(&keypairs[2], tx.signable_bytes());
let (pk0, sig0) = sign_with(&keypairs[0], tx.signable_bytes());
tx.add_signature(pk2, sig2).unwrap();
tx.add_signature(pk0, sig0).unwrap();
let wire = tx.to_bytes().unwrap();
let msg_len = u32::from_le_bytes(wire[0..4].try_into().unwrap()) as usize;
assert_eq!(&wire[4..4 + msg_len], tx.signable_bytes());
let mut offset = 4 + msg_len;
let sig_count = u32::from_le_bytes(wire[offset..offset + 4].try_into().unwrap());
assert_eq!(sig_count, 2);
offset += 4;
assert_eq!(&wire[offset..offset + 64], &sig0);
offset += 64;
assert_eq!(&wire[offset..offset + 64], &sig2);
offset += 64;
let bitfield = u32::from_le_bytes(wire[offset..offset + 4].try_into().unwrap());
assert_eq!(bitfield, 0b101);
offset += 4;
assert_eq!(wire[offset], min_signers);
assert_eq!(wire.len(), offset + 1);
}
#[test]
fn multisig_add_signature_rejects_unknown_signer() {
let (_, config) = test_signers();
let mut tx = SolanaLedgerMultisigTransaction::new(test_unsigned_tx(), config).unwrap();
let outsider = Keypair::generate();
let (pk, sig) = sign_with(&outsider, tx.signable_bytes());
let err = tx.add_signature(pk, sig).unwrap_err();
assert!(matches!(err, SDKError::InvalidMultisig(_)), "{err:?}");
}
#[test]
fn multisig_add_signature_rejects_duplicate_signer() {
let (keypairs, config) = test_signers();
let mut tx = SolanaLedgerMultisigTransaction::new(test_unsigned_tx(), config).unwrap();
let (pk, sig) = sign_with(&keypairs[0], tx.signable_bytes());
tx.add_signature(pk, sig).unwrap();
let err = tx.add_signature(pk, sig).unwrap_err();
assert!(matches!(err, SDKError::InvalidMultisig(_)), "{err:?}");
}
#[test]
fn multisig_add_signature_rejects_invalid_signature() {
let (keypairs, config) = test_signers();
let mut tx = SolanaLedgerMultisigTransaction::new(test_unsigned_tx(), config).unwrap();
let pubkey: [u8; 32] = keypairs[0].public_key().try_into().unwrap();
let err = tx.add_signature(pubkey, [9u8; 64]).unwrap_err();
assert!(matches!(err, SDKError::InvalidMultisig(_)), "{err:?}");
}
#[test]
fn multisig_to_bytes_errors_below_threshold() {
let (keypairs, config) = test_signers();
let mut tx = SolanaLedgerMultisigTransaction::new(test_unsigned_tx(), config).unwrap();
let err = tx.to_bytes().unwrap_err();
assert!(matches!(err, SDKError::InvalidMultisig(_)), "{err:?}");
let (pk, sig) = sign_with(&keypairs[1], tx.signable_bytes());
tx.add_signature(pk, sig).unwrap();
let err = tx.to_bytes().unwrap_err();
assert!(matches!(err, SDKError::InvalidMultisig(_)), "{err:?}");
}
#[test]
fn ledger_multisig_signable_bytes_errors_when_json_exceeds_u16() {
let mut unsigned = test_unsigned_tx();
unsigned.inner.runtime_call =
RuntimeCall::Exchange(CallMessage::Public(PublicAction::ApplyFunding {
addresses: vec![bullet_exchange_interface::address::Address([0u8; 32]); 3000],
}));
let err = unsigned
.to_ledger_multisig_signable_bytes(&test_config())
.unwrap_err();
assert!(err.to_string().contains("too large"), "{err}");
}
#[test]
fn credential_id_matches_sovereign_js_reference() {
let config = MultisigConfig::new(2, test_keys()).unwrap();
assert_eq!(
hex::encode(config.credential_id()),
"f316a57cd06be916c2c51677163de282c53b80d85b3208d680f6e9448b25c56b"
);
}
#[test]
fn multisig_id_is_base58_of_credential_id() {
let config = MultisigConfig::new(2, test_keys()).unwrap();
assert_eq!(
config.multisig_id(),
"HMv6kdvx7sVBr59PJXfpeHYYoMktAhYP5iX41V4KakFC"
);
}
#[test]
fn pubkeys_are_canonicalized_bytewise() {
let config = MultisigConfig::new(2, test_keys()).unwrap();
let mut expected = test_keys();
expected.sort_unstable();
assert_eq!(config.pubkeys(), expected.as_slice());
let mut reversed = test_keys();
reversed.reverse();
let config2 = MultisigConfig::new(2, reversed).unwrap();
assert_eq!(config.credential_id(), config2.credential_id());
}
#[test]
fn rejects_too_few_or_too_many_signers() {
let err = MultisigConfig::new(1, vec![[1u8; 32]]).unwrap_err();
assert!(matches!(err, SDKError::InvalidMultisig(_)), "{err:?}");
let many: Vec<[u8; 32]> = (0..22u8)
.map(|i| {
let mut k = [0u8; 32];
k[0] = i;
k
})
.collect();
let err = MultisigConfig::new(2, many).unwrap_err();
assert!(matches!(err, SDKError::InvalidMultisig(_)), "{err:?}");
}
#[test]
fn rejects_duplicate_pubkeys() {
let err = MultisigConfig::new(2, vec![[1u8; 32], [1u8; 32], [2u8; 32]]).unwrap_err();
assert!(matches!(err, SDKError::InvalidMultisig(_)), "{err:?}");
}
#[test]
fn rejects_invalid_min_signers() {
let err = MultisigConfig::new(0, test_keys()).unwrap_err();
assert!(matches!(err, SDKError::InvalidMultisig(_)), "{err:?}");
let err = MultisigConfig::new(4, test_keys()).unwrap_err();
assert!(matches!(err, SDKError::InvalidMultisig(_)), "{err:?}");
}
}