use std::collections::HashSet;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use guardian_client::DeltaObject;
use guardian_shared::FromJson;
use guardian_shared::hex::FromHex;
use guardian_shared::{ProposalSignature, SignatureScheme};
use miden_protocol::account::AccountId;
use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{
PublicKey as EcdsaPublicKey, Signature as EcdsaSignature,
};
use miden_protocol::crypto::dsa::falcon512_poseidon2::Signature as Poseidon2FalconSignature;
use miden_protocol::note::{Note, NoteId};
use miden_protocol::transaction::TransactionSummary;
use miden_protocol::utils::serde::{Deserializable, Serializable};
use miden_protocol::{Felt, Word};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::error::{MultisigError, Result};
use crate::keystore::{ensure_hex_prefix, word_from_hex};
use crate::payload::ProposalPayload;
use crate::procedures::ProcedureName;
pub const MAX_CONSUME_NOTES_METADATA_BYTES: usize = 256 * 1024;
pub const CONSUME_NOTES_METADATA_VERSION_V2: u32 = 2;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SerializedNote(String);
impl SerializedNote {
pub fn from_note(note: &Note) -> Self {
Self(BASE64.encode(Serializable::to_bytes(note)))
}
pub fn from_base64(s: String) -> Self {
Self(s)
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
pub fn to_note(&self) -> Result<Note> {
let bytes = BASE64
.decode(self.0.as_bytes())
.map_err(|e| MultisigError::InvalidConfig(format!("invalid base64 in note: {}", e)))?;
Note::read_from_bytes(&bytes)
.map_err(|e| MultisigError::InvalidConfig(format!("failed to deserialize note: {}", e)))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProposalStatus {
Pending,
Ready,
Finalized,
}
impl ProposalStatus {
pub fn is_ready(&self) -> bool {
matches!(self, ProposalStatus::Ready)
}
pub fn is_pending(&self) -> bool {
matches!(self, ProposalStatus::Pending)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum TransactionType {
P2ID {
recipient: AccountId,
faucet_id: AccountId,
amount: u64,
},
ConsumeNotes {
note_ids: Vec<NoteId>,
metadata_version: Option<u32>,
notes: Vec<SerializedNote>,
},
AddCosigner {
new_commitment: Word,
},
RemoveCosigner {
commitment: Word,
},
SwitchGuardian {
new_endpoint: String,
new_commitment: Word,
},
UpdateProcedureThreshold {
procedure: ProcedureName,
new_threshold: u32,
},
UpdateSigners {
new_threshold: u32,
signer_commitments: Vec<Word>,
},
Custom,
}
impl TransactionType {
pub fn transfer(recipient: AccountId, faucet_id: AccountId, amount: u64) -> Self {
Self::P2ID {
recipient,
faucet_id,
amount,
}
}
pub fn consume_notes(note_ids: Vec<NoteId>) -> Self {
Self::ConsumeNotes {
note_ids,
metadata_version: None,
notes: Vec::new(),
}
}
pub fn consume_notes_v2(note_ids: Vec<NoteId>, notes: Vec<SerializedNote>) -> Self {
Self::ConsumeNotes {
note_ids,
metadata_version: Some(CONSUME_NOTES_METADATA_VERSION_V2),
notes,
}
}
pub fn add_cosigner(new_commitment: Word) -> Self {
Self::AddCosigner { new_commitment }
}
pub fn remove_cosigner(commitment: Word) -> Self {
Self::RemoveCosigner { commitment }
}
pub fn switch_guardian(new_endpoint: impl Into<String>, new_commitment: Word) -> Self {
Self::SwitchGuardian {
new_endpoint: new_endpoint.into(),
new_commitment,
}
}
pub fn update_procedure_threshold(procedure: ProcedureName, new_threshold: u32) -> Self {
Self::UpdateProcedureThreshold {
procedure,
new_threshold,
}
}
pub fn update_signers(new_threshold: u32, signer_commitments: Vec<Word>) -> Self {
Self::UpdateSigners {
new_threshold,
signer_commitments,
}
}
pub(crate) fn proposal_type(&self) -> Option<&'static str> {
match self {
Self::P2ID { .. } => Some("p2id"),
Self::ConsumeNotes { .. } => Some("consume_notes"),
Self::AddCosigner { .. } => Some("add_signer"),
Self::RemoveCosigner { .. } => Some("remove_signer"),
Self::SwitchGuardian { .. } => Some("switch_guardian"),
Self::UpdateProcedureThreshold { .. } => Some("update_procedure_threshold"),
Self::UpdateSigners { .. } => None,
Self::Custom => None,
}
}
pub fn type_name(&self) -> &'static str {
match self {
Self::P2ID { .. } => "P2ID",
Self::ConsumeNotes { .. } => "ConsumeNotes",
Self::AddCosigner { .. } => "AddCosigner",
Self::RemoveCosigner { .. } => "RemoveCosigner",
Self::SwitchGuardian { .. } => "SwitchGuardian",
Self::UpdateProcedureThreshold { .. } => "UpdateProcedureThreshold",
Self::UpdateSigners { .. } => "UpdateSigners",
Self::Custom => "Custom",
}
}
pub fn supports_offline_execution(&self) -> bool {
matches!(self, Self::SwitchGuardian { .. })
}
pub fn requires_guardian_ack(&self) -> bool {
!self.supports_offline_execution()
}
}
const BUILTIN_PROPOSAL_TYPES: &[&str] = &[
"add_signer",
"remove_signer",
"change_threshold",
"update_procedure_threshold",
"switch_guardian",
"consume_notes",
"p2id",
"custom",
];
pub(crate) fn is_builtin_proposal_type(proposal_type: &str) -> bool {
BUILTIN_PROPOSAL_TYPES.contains(&proposal_type)
}
#[derive(Debug, Clone, Default)]
pub struct ProposalMetadata {
pub tx_summary_json: Option<Value>,
pub proposal_type: Option<String>,
pub new_threshold: Option<u64>,
pub signer_commitments_hex: Vec<String>,
pub salt_hex: Option<String>,
pub recipient_hex: Option<String>,
pub faucet_id_hex: Option<String>,
pub amount: Option<u64>,
pub note_ids_hex: Vec<String>,
pub consume_notes_metadata_version: Option<u32>,
pub consume_notes_notes: Vec<SerializedNote>,
pub new_guardian_pubkey_hex: Option<String>,
pub new_guardian_endpoint: Option<String>,
pub target_procedure: Option<String>,
pub required_signatures: Option<usize>,
pub signers: Vec<String>,
}
impl ProposalMetadata {
pub fn is_consume_notes_v1(&self) -> bool {
matches!(self.consume_notes_metadata_version, None | Some(1))
}
pub fn is_consume_notes_v2(&self) -> bool {
self.consume_notes_metadata_version == Some(CONSUME_NOTES_METADATA_VERSION_V2)
}
pub fn salt(&self) -> Result<Word> {
match &self.salt_hex {
Some(value) => word_from_hex(value).map_err(MultisigError::InvalidConfig),
None => Ok(Word::from([Felt::new_unchecked(0); 4])),
}
}
pub fn signer_commitments(&self) -> Result<Vec<Word>> {
let mut seen = HashSet::new();
let mut commitments = Vec::with_capacity(self.signer_commitments_hex.len());
for hex in &self.signer_commitments_hex {
let commitment = word_from_hex(hex).map_err(MultisigError::InvalidConfig)?;
let key = ensure_hex_prefix(hex).to_lowercase();
if !seen.insert(key) {
return Err(MultisigError::InvalidConfig(format!(
"duplicate signer commitment in metadata: {}",
hex
)));
}
commitments.push(commitment);
}
Ok(commitments)
}
pub fn note_ids(&self) -> Result<Vec<NoteId>> {
self.note_ids_hex
.iter()
.map(|hex| {
let word = word_from_hex(hex).map_err(MultisigError::InvalidConfig)?;
Ok(NoteId::from_raw(word))
})
.collect()
}
pub(crate) fn to_transaction_type(&self, proposal_type: &str) -> Result<TransactionType> {
if proposal_type.is_empty() {
return Err(MultisigError::InvalidConfig(
"proposal metadata.proposal_type is required".to_string(),
));
}
match proposal_type {
"consume_notes" => {
if self.note_ids_hex.is_empty() {
return Err(MultisigError::InvalidConfig(
"consume_notes proposal requires metadata.note_ids".to_string(),
));
}
Ok(TransactionType::ConsumeNotes {
note_ids: self.note_ids()?,
metadata_version: self.consume_notes_metadata_version,
notes: self.consume_notes_notes.clone(),
})
}
"p2id" => {
let recipient_str = self.recipient_hex.as_ref().ok_or_else(|| {
MultisigError::InvalidConfig(
"p2id proposal requires metadata.recipient_id".to_string(),
)
})?;
let faucet_str = self.faucet_id_hex.as_ref().ok_or_else(|| {
MultisigError::InvalidConfig(
"p2id proposal requires metadata.faucet_id".to_string(),
)
})?;
let parsed_amount = self.amount.ok_or_else(|| {
MultisigError::InvalidConfig(
"p2id proposal requires metadata.amount".to_string(),
)
})?;
let recipient = AccountId::from_hex(recipient_str).map_err(|e| {
MultisigError::InvalidConfig(format!("invalid recipient: {}", e))
})?;
let faucet_id = AccountId::from_hex(faucet_str).map_err(|e| {
MultisigError::InvalidConfig(format!("invalid faucet_id: {}", e))
})?;
Ok(TransactionType::P2ID {
recipient,
faucet_id,
amount: parsed_amount,
})
}
"switch_guardian" => {
let pubkey_hex = self.new_guardian_pubkey_hex.as_ref().ok_or_else(|| {
MultisigError::InvalidConfig(
"switch_guardian proposal requires metadata.new_guardian_pubkey"
.to_string(),
)
})?;
let endpoint = self.new_guardian_endpoint.as_ref().ok_or_else(|| {
MultisigError::InvalidConfig(
"switch_guardian proposal requires metadata.new_guardian_endpoint"
.to_string(),
)
})?;
let new_commitment =
word_from_hex(pubkey_hex).map_err(MultisigError::InvalidConfig)?;
Ok(TransactionType::SwitchGuardian {
new_endpoint: endpoint.clone(),
new_commitment,
})
}
"update_procedure_threshold" => {
let threshold = self.new_threshold.ok_or_else(|| {
MultisigError::InvalidConfig(
"update_procedure_threshold proposal requires metadata.target_threshold"
.to_string(),
)
})?;
let procedure_name = self.target_procedure.as_ref().ok_or_else(|| {
MultisigError::InvalidConfig(
"update_procedure_threshold proposal requires metadata.target_procedure"
.to_string(),
)
})?;
let procedure = procedure_name
.parse()
.map_err(MultisigError::InvalidConfig)?;
Ok(TransactionType::UpdateProcedureThreshold {
procedure,
new_threshold: threshold as u32,
})
}
"add_signer" => {
let threshold = self.new_threshold.ok_or_else(|| {
MultisigError::InvalidConfig(
"add_signer proposal requires metadata.target_threshold".to_string(),
)
})?;
let proposed_signers = self.signer_commitments()?;
if proposed_signers.is_empty() {
return Err(MultisigError::InvalidConfig(
"add_signer proposal requires metadata.signer_commitments".to_string(),
));
}
Ok(TransactionType::UpdateSigners {
new_threshold: threshold as u32,
signer_commitments: proposed_signers,
})
}
"remove_signer" => {
let threshold = self.new_threshold.ok_or_else(|| {
MultisigError::InvalidConfig(
"remove_signer proposal requires metadata.target_threshold".to_string(),
)
})?;
let proposed_signers = self.signer_commitments()?;
if proposed_signers.is_empty() {
return Err(MultisigError::InvalidConfig(
"remove_signer proposal requires metadata.signer_commitments".to_string(),
));
}
Ok(TransactionType::UpdateSigners {
new_threshold: threshold as u32,
signer_commitments: proposed_signers,
})
}
"change_threshold" => {
let threshold = self.new_threshold.ok_or_else(|| {
MultisigError::InvalidConfig(
"change_threshold proposal requires metadata.target_threshold".to_string(),
)
})?;
let proposed_signers = self.signer_commitments()?;
if proposed_signers.is_empty() {
return Err(MultisigError::InvalidConfig(
"change_threshold proposal requires metadata.signer_commitments"
.to_string(),
));
}
Ok(TransactionType::UpdateSigners {
new_threshold: threshold as u32,
signer_commitments: proposed_signers,
})
}
_ => Ok(TransactionType::Custom),
}
}
}
#[derive(Debug, Clone)]
pub struct ProposalSignatureEntry {
pub signer_commitment: String,
pub signature_hex: String,
pub scheme: SignatureScheme,
pub public_key_hex: Option<String>,
}
impl ProposalSignatureEntry {
fn validate(&self) -> Result<()> {
word_from_hex(&self.signer_commitment).map_err(MultisigError::InvalidConfig)?;
let signature_hex = ensure_hex_prefix(&self.signature_hex);
match self.scheme {
SignatureScheme::Falcon => {
Poseidon2FalconSignature::from_hex(&signature_hex).map_err(|e| {
MultisigError::Signature(format!("invalid proposal signature: {}", e))
})?;
}
SignatureScheme::Ecdsa => {
let signature_bytes =
hex::decode(signature_hex.trim_start_matches("0x")).map_err(|e| {
MultisigError::Signature(format!("invalid ECDSA signature hex: {}", e))
})?;
EcdsaSignature::read_from_bytes(&signature_bytes).map_err(|e| {
MultisigError::Signature(format!(
"invalid ECDSA proposal signature bytes: {}",
e
))
})?;
let public_key_hex = self.public_key_hex.as_ref().ok_or_else(|| {
MultisigError::Signature(
"ECDSA proposal signatures require a public key".to_string(),
)
})?;
let public_key_bytes = hex::decode(public_key_hex.trim_start_matches("0x"))
.map_err(|e| {
MultisigError::Signature(format!("invalid ECDSA public key hex: {}", e))
})?;
EcdsaPublicKey::read_from_bytes(&public_key_bytes).map_err(|e| {
MultisigError::Signature(format!(
"invalid ECDSA proposal public key bytes: {}",
e
))
})?;
}
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct Proposal {
pub id: String,
pub nonce: u64,
pub transaction_type: TransactionType,
pub status: ProposalStatus,
pub tx_summary: TransactionSummary,
pub signatures: Vec<ProposalSignatureEntry>,
pub metadata: ProposalMetadata,
}
impl Proposal {
pub fn from(delta: &DeltaObject) -> Result<Self> {
let payload: ProposalPayload = serde_json::from_str(&delta.delta_payload)?;
let tx_summary = TransactionSummary::from_json(&payload.tx_summary).map_err(|e| {
MultisigError::MidenClient(format!("failed to parse tx_summary: {}", e))
})?;
let metadata_payload = payload.metadata.clone().ok_or_else(|| {
MultisigError::InvalidConfig("proposal is missing metadata".to_string())
})?;
let proposal_type = metadata_payload.proposal_type.clone();
let required_signatures = metadata_payload.required_signatures.ok_or_else(|| {
MultisigError::InvalidConfig(
"proposal metadata.required_signatures is required".to_string(),
)
})?;
let required_signatures: usize = usize::try_from(required_signatures).map_err(|_| {
MultisigError::InvalidConfig(
"proposal metadata.required_signatures exceeds platform limits".to_string(),
)
})?;
let new_threshold = metadata_payload.target_threshold;
let signer_commitments_hex = metadata_payload.signer_commitments;
let salt_hex = metadata_payload.salt;
let recipient_hex = metadata_payload.recipient_id;
let faucet_id_hex = metadata_payload.faucet_id;
let amount = metadata_payload.amount.as_deref().map(|value| {
value.parse::<u64>().map_err(|e| {
MultisigError::InvalidConfig(format!(
"invalid metadata.amount value '{}': {}",
value, e
))
})
});
let amount = match amount {
Some(parsed) => Some(parsed?),
None => None,
};
let note_ids_hex = metadata_payload.note_ids;
let consume_notes_metadata_version = metadata_payload.consume_notes_metadata_version;
let consume_notes_notes = metadata_payload
.consume_notes_notes
.into_iter()
.map(SerializedNote::from_base64)
.collect();
let new_guardian_pubkey_hex = metadata_payload.new_guardian_pubkey;
let new_guardian_endpoint = metadata_payload.new_guardian_endpoint;
let target_procedure = metadata_payload.target_procedure;
let mut metadata = ProposalMetadata {
tx_summary_json: Some(payload.tx_summary.clone()),
proposal_type: Some(proposal_type.clone()),
new_threshold,
signer_commitments_hex: signer_commitments_hex.clone(),
salt_hex,
recipient_hex: recipient_hex.clone(),
faucet_id_hex: faucet_id_hex.clone(),
amount,
note_ids_hex: note_ids_hex.clone(),
consume_notes_metadata_version,
consume_notes_notes,
new_guardian_pubkey_hex: new_guardian_pubkey_hex.clone(),
new_guardian_endpoint: new_guardian_endpoint.clone(),
target_procedure: target_procedure.clone(),
required_signatures: Some(required_signatures),
signers: Vec::new(),
};
let transaction_type = metadata.to_transaction_type(&proposal_type)?;
let mut seen_signers = HashSet::new();
let mut signatures = Vec::with_capacity(payload.signatures.len());
for signature in &payload.signatures {
let (scheme, signature_hex, public_key_hex) = match &signature.signature {
ProposalSignature::Falcon { signature } => {
(SignatureScheme::Falcon, signature.clone(), None)
}
ProposalSignature::Ecdsa {
signature,
public_key,
} => (
SignatureScheme::Ecdsa,
signature.clone(),
public_key.clone(),
),
};
let entry = ProposalSignatureEntry {
signer_commitment: signature.signer_id.clone(),
signature_hex,
scheme,
public_key_hex,
};
entry.validate()?;
if !seen_signers.insert(entry.signer_commitment.to_lowercase()) {
return Err(MultisigError::InvalidConfig(format!(
"duplicate proposal signature for signer {}",
entry.signer_commitment
)));
}
metadata.signers.push(entry.signer_commitment.clone());
signatures.push(entry);
}
let commitment = tx_summary.to_commitment();
let id = format!("0x{}", hex::encode(word_to_bytes(&commitment)));
let mut proposal = Proposal {
id,
nonce: delta.nonce,
transaction_type,
status: ProposalStatus::Pending,
tx_summary,
signatures,
metadata,
};
proposal.refresh_status();
Ok(proposal)
}
pub fn new(
tx_summary: TransactionSummary,
nonce: u64,
transaction_type: TransactionType,
mut metadata: ProposalMetadata,
) -> Self {
let commitment = tx_summary.to_commitment();
let id = format!("0x{}", hex::encode(word_to_bytes(&commitment)));
let signatures_required = metadata
.required_signatures
.unwrap_or(metadata.signer_commitments_hex.len());
metadata
.required_signatures
.get_or_insert(signatures_required);
if metadata.proposal_type.is_none() {
metadata.proposal_type = transaction_type.proposal_type().map(str::to_string);
}
let mut proposal = Self {
id,
nonce,
transaction_type,
status: ProposalStatus::Pending,
tx_summary,
signatures: Vec::new(),
metadata,
};
proposal.refresh_status();
proposal
}
pub fn has_signed(&self, signer_commitment_hex: &str) -> bool {
self.metadata
.signers
.iter()
.any(|s| s.eq_ignore_ascii_case(signer_commitment_hex))
}
pub fn signatures_collected(&self) -> usize {
self.metadata.signers.len()
}
pub fn signatures_required(&self) -> usize {
self.metadata
.required_signatures
.unwrap_or(self.metadata.signer_commitments_hex.len())
}
pub fn signature_counts(&self) -> (usize, usize) {
(self.signatures_collected(), self.signatures_required())
}
pub fn signatures_needed(&self) -> usize {
self.signatures_required()
.saturating_sub(self.signatures_collected())
}
pub fn missing_signers(&self) -> Vec<String> {
if !self.status.is_pending() {
return Vec::new();
}
let signed: HashSet<_> = self
.metadata
.signers
.iter()
.map(|s| s.to_lowercase())
.collect();
self.metadata
.signer_commitments_hex
.iter()
.filter(|c| !signed.contains(&c.to_lowercase()))
.cloned()
.collect()
}
fn refresh_status(&mut self) {
let signatures_required = self.signatures_required();
self.status =
if self.metadata.signers.len() >= signatures_required && signatures_required > 0 {
ProposalStatus::Ready
} else {
ProposalStatus::Pending
};
}
}
fn word_to_bytes(word: &Word) -> Vec<u8> {
word.iter()
.flat_map(|felt| felt.as_canonical_u64().to_le_bytes())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use miden_protocol::account::delta::{AccountDelta, AccountStorageDelta, AccountVaultDelta};
use miden_protocol::transaction::{InputNotes, RawOutputNotes};
fn create_test_tx_summary() -> TransactionSummary {
let account_id = AccountId::from_hex("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b").unwrap();
let delta = AccountDelta::new(
account_id,
AccountStorageDelta::default(),
AccountVaultDelta::default(),
Felt::ZERO,
)
.expect("Valid empty delta");
TransactionSummary::new(
delta,
InputNotes::new(Vec::new()).unwrap(),
RawOutputNotes::new(Vec::new()).unwrap(),
Word::default(),
)
}
#[test]
fn test_word_from_hex_roundtrip() {
let original = "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
let word = word_from_hex(original).expect("hex should decode");
let bytes = word_to_bytes(&word);
let result = format!("0x{}", hex::encode(bytes));
assert_eq!(original, result);
}
#[test]
fn test_word_from_hex_rejects_non_canonical_field_element() {
let invalid = format!("0x{}{}", "ff".repeat(8), "00".repeat(24));
let err = word_from_hex(&invalid).expect_err("non-canonical field element should fail");
assert!(err.contains("invalid field element"));
}
#[test]
fn test_proposal_status_checks() {
let pending = ProposalStatus::Pending;
assert!(pending.is_pending());
assert!(!pending.is_ready());
let ready = ProposalStatus::Ready;
assert!(ready.is_ready());
assert!(!ready.is_pending());
}
#[test]
fn test_transaction_type_transfer() {
let recipient = AccountId::from_hex("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b").unwrap();
let faucet_id = AccountId::from_hex("0x7c7c7c7c7c7c7c017c7c7c7c7c7c7c").unwrap();
let amount = 1000u64;
let tx = TransactionType::transfer(recipient, faucet_id, amount);
assert_eq!(
tx,
TransactionType::P2ID {
recipient,
faucet_id,
amount
}
);
}
#[test]
fn test_transaction_type_consume_notes() {
let note_id = NoteId::from_raw(Word::default());
let tx = TransactionType::consume_notes(vec![note_id]);
assert_eq!(
tx,
TransactionType::ConsumeNotes {
note_ids: vec![note_id],
metadata_version: None,
notes: Vec::new(),
}
);
}
#[test]
fn test_transaction_type_add_cosigner() {
let commitment = Word::default();
let tx = TransactionType::add_cosigner(commitment);
assert_eq!(
tx,
TransactionType::AddCosigner {
new_commitment: commitment
}
);
}
#[test]
fn test_transaction_type_remove_cosigner() {
let commitment = Word::default();
let tx = TransactionType::remove_cosigner(commitment);
assert_eq!(tx, TransactionType::RemoveCosigner { commitment });
}
#[test]
fn test_transaction_type_switch_guardian() {
let endpoint = "http://new-guardian.example.com";
let commitment = Word::default();
let tx = TransactionType::switch_guardian(endpoint, commitment);
assert_eq!(
tx,
TransactionType::SwitchGuardian {
new_endpoint: endpoint.to_string(),
new_commitment: commitment
}
);
}
#[test]
fn test_transaction_type_switch_guardian_rejects_non_canonical_commitment() {
let metadata = ProposalMetadata {
new_guardian_pubkey_hex: Some(format!("0x{}{}", "ff".repeat(8), "00".repeat(24))),
new_guardian_endpoint: Some("http://new-guardian.example.com".to_string()),
..Default::default()
};
let err = metadata
.to_transaction_type("switch_guardian")
.expect_err("non-canonical GUARDIAN commitment should be rejected");
assert!(err.to_string().contains("invalid field element"));
}
#[test]
fn test_transaction_type_update_signers() {
let threshold = 2u32;
let signers = vec![Word::default()];
let tx = TransactionType::update_signers(threshold, signers.clone());
assert_eq!(
tx,
TransactionType::UpdateSigners {
new_threshold: threshold,
signer_commitments: signers
}
);
}
#[test]
fn test_transaction_type_requires_guardian_ack() {
let recipient = AccountId::from_hex("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b").unwrap();
let faucet_id = AccountId::from_hex("0x7c7c7c7c7c7c7c017c7c7c7c7c7c7c").unwrap();
assert!(TransactionType::transfer(recipient, faucet_id, 1).requires_guardian_ack());
assert!(
!TransactionType::switch_guardian("http://new-guardian.example.com", Word::default())
.requires_guardian_ack()
);
}
#[test]
fn test_transaction_type_supports_offline_execution() {
let note_id = NoteId::from_raw(Word::default());
assert!(!TransactionType::consume_notes(vec![note_id]).supports_offline_execution());
assert!(
TransactionType::switch_guardian("http://new-guardian.example.com", Word::default())
.supports_offline_execution()
);
}
#[test]
fn test_proposal_signature_counts() {
let pending = ProposalStatus::Pending;
let proposal = Proposal {
id: "0x123".to_string(),
nonce: 1,
transaction_type: TransactionType::add_cosigner(Word::default()),
status: pending,
tx_summary: create_test_tx_summary(),
signatures: Vec::new(),
metadata: ProposalMetadata {
required_signatures: Some(3),
signers: vec!["0xabc".to_string()],
signer_commitments_hex: vec![
"0xabc".to_string(),
"0xdef".to_string(),
"0x123".to_string(),
],
..Default::default()
},
};
assert_eq!(proposal.signature_counts(), (1, 3));
assert_eq!(proposal.signatures_needed(), 2);
}
#[test]
fn test_proposal_missing_signers() {
let pending = ProposalStatus::Pending;
let proposal = Proposal {
id: "0x123".to_string(),
nonce: 1,
transaction_type: TransactionType::add_cosigner(Word::default()),
status: pending,
tx_summary: create_test_tx_summary(),
signatures: Vec::new(),
metadata: ProposalMetadata {
signers: vec!["0xABC".to_string()], signer_commitments_hex: vec![
"0xabc".to_string(), "0xdef".to_string(),
"0x456".to_string(),
],
..Default::default()
},
};
let missing = proposal.missing_signers();
assert_eq!(missing.len(), 2);
assert!(missing.contains(&"0xdef".to_string()));
assert!(missing.contains(&"0x456".to_string()));
assert!(!missing.contains(&"0xabc".to_string()));
}
#[test]
fn test_proposal_signatures_needed_when_ready() {
let ready = ProposalStatus::Ready;
let proposal = Proposal {
id: "0x123".to_string(),
nonce: 1,
transaction_type: TransactionType::add_cosigner(Word::default()),
status: ready,
tx_summary: create_test_tx_summary(),
signatures: Vec::new(),
metadata: ProposalMetadata {
required_signatures: Some(2),
signers: vec!["0xabc".to_string(), "0xdef".to_string()],
..Default::default()
},
};
assert_eq!(proposal.signatures_needed(), 0);
}
#[test]
fn transaction_type_consume_notes_legacy_constructor_marks_v1() {
let note_id = NoteId::from_raw(Word::default());
let tx = TransactionType::consume_notes(vec![note_id]);
match tx {
TransactionType::ConsumeNotes {
metadata_version,
notes,
..
} => {
assert!(
metadata_version.is_none(),
"legacy constructor must not stamp a version"
);
assert!(notes.is_empty(), "legacy constructor must not embed notes");
}
other => panic!("expected ConsumeNotes, got {:?}", other),
}
}
#[test]
fn transaction_type_consume_notes_v2_constructor_stamps_version_and_notes() {
let note_id = NoteId::from_raw(Word::default());
let embedded = vec![SerializedNote::from_base64("YmFzZTY0Tm90ZQ==".to_string())];
let tx = TransactionType::consume_notes_v2(vec![note_id], embedded.clone());
match tx {
TransactionType::ConsumeNotes {
metadata_version,
notes,
..
} => {
assert_eq!(metadata_version, Some(CONSUME_NOTES_METADATA_VERSION_V2));
assert_eq!(notes, embedded);
}
other => panic!("expected ConsumeNotes, got {:?}", other),
}
}
#[test]
fn to_transaction_type_threads_v2_metadata() {
let note_id_hex =
"0x0100000000000000000000000000000000000000000000000000000000000000".to_string();
let metadata = ProposalMetadata {
note_ids_hex: vec![note_id_hex],
consume_notes_metadata_version: Some(CONSUME_NOTES_METADATA_VERSION_V2),
consume_notes_notes: vec![SerializedNote::from_base64("YmFzZTY0Tm90ZQ==".to_string())],
..Default::default()
};
let tx = metadata
.to_transaction_type("consume_notes")
.expect("to_transaction_type");
match tx {
TransactionType::ConsumeNotes {
metadata_version,
notes,
..
} => {
assert_eq!(metadata_version, Some(CONSUME_NOTES_METADATA_VERSION_V2));
assert_eq!(notes.len(), 1);
assert_eq!(notes[0].as_str(), "YmFzZTY0Tm90ZQ==");
}
other => panic!("expected ConsumeNotes, got {:?}", other),
}
}
#[test]
fn serialized_note_is_transparent_string_on_wire() {
let sn = SerializedNote::from_base64("YmFzZTY0LWVuY29kZWQtbm90ZQ==".to_string());
let json = serde_json::to_value(&sn).unwrap();
assert_eq!(
json,
serde_json::Value::String("YmFzZTY0LWVuY29kZWQtbm90ZQ==".to_string())
);
let parsed: SerializedNote =
serde_json::from_str(r#""YmFzZTY0LWVuY29kZWQtbm90ZQ==""#).unwrap();
assert_eq!(parsed.0, sn.0);
}
#[test]
fn serialized_note_rejects_invalid_base64() {
let sn = SerializedNote::from_base64("@@@not-base64@@@".to_string());
let err = sn.to_note().unwrap_err();
assert!(matches!(err, MultisigError::InvalidConfig(_)));
}
#[test]
fn max_consume_notes_metadata_bytes_is_256_kib() {
assert_eq!(MAX_CONSUME_NOTES_METADATA_BYTES, 256 * 1024);
assert_eq!(MAX_CONSUME_NOTES_METADATA_BYTES, 262_144);
}
#[test]
fn proposal_metadata_consume_notes_version_helpers() {
let v1_absent = ProposalMetadata::default();
assert!(v1_absent.is_consume_notes_v1());
assert!(!v1_absent.is_consume_notes_v2());
let v1_explicit = ProposalMetadata {
consume_notes_metadata_version: Some(1),
..Default::default()
};
assert!(v1_explicit.is_consume_notes_v1());
assert!(!v1_explicit.is_consume_notes_v2());
let v2 = ProposalMetadata {
consume_notes_metadata_version: Some(CONSUME_NOTES_METADATA_VERSION_V2),
..Default::default()
};
assert!(v2.is_consume_notes_v2());
assert!(!v2.is_consume_notes_v1());
let unknown = ProposalMetadata {
consume_notes_metadata_version: Some(99),
..Default::default()
};
assert!(!unknown.is_consume_notes_v1());
assert!(!unknown.is_consume_notes_v2());
}
#[test]
fn test_metadata_salt_valid() {
let metadata = ProposalMetadata {
salt_hex: Some(
"0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string(),
),
..Default::default()
};
let salt = metadata.salt().expect("salt should parse");
assert_ne!(salt, Word::default());
}
#[test]
fn test_metadata_salt_rejects_non_canonical_field_element() {
let metadata = ProposalMetadata {
salt_hex: Some(format!("0x{}{}", "ff".repeat(8), "00".repeat(24))),
..Default::default()
};
let err = metadata
.salt()
.expect_err("non-canonical salt should be rejected");
assert!(err.to_string().contains("invalid field element"));
}
#[test]
fn test_metadata_salt_none_returns_default() {
let metadata = ProposalMetadata::default();
let salt = metadata.salt().expect("salt should return default");
assert_eq!(salt, Word::default());
}
#[test]
fn test_metadata_signer_commitments_valid() {
let hex1 = "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
let hex2 = "0x2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40";
let metadata = ProposalMetadata {
signer_commitments_hex: vec![hex1.to_string(), hex2.to_string()],
..Default::default()
};
let commitments = metadata.signer_commitments().expect("should parse");
assert_eq!(commitments.len(), 2);
}
#[test]
fn test_metadata_signer_commitments_invalid_hex() {
let metadata = ProposalMetadata {
signer_commitments_hex: vec!["not_valid_hex".to_string()],
..Default::default()
};
assert!(metadata.signer_commitments().is_err());
}
#[test]
fn test_metadata_note_ids_valid() {
let note_hex = "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
let metadata = ProposalMetadata {
note_ids_hex: vec![note_hex.to_string()],
..Default::default()
};
let note_ids = metadata.note_ids().expect("should parse");
assert_eq!(note_ids.len(), 1);
}
#[test]
fn to_transaction_type_maps_unmodeled_label_to_custom() {
let metadata = ProposalMetadata::default();
let tx_type = metadata
.to_transaction_type("b2agg")
.expect("unmodeled proposal type should map to Custom");
assert_eq!(tx_type, TransactionType::Custom);
assert_eq!(tx_type.type_name(), "Custom");
assert_eq!(tx_type.proposal_type(), None);
}
#[test]
fn to_transaction_type_still_rejects_empty_label() {
let metadata = ProposalMetadata::default();
let err = metadata
.to_transaction_type("")
.expect_err("empty proposal type must be rejected");
assert!(err.to_string().contains("proposal_type is required"));
}
#[test]
fn builtin_proposal_types_are_recognized() {
for label in [
"add_signer",
"remove_signer",
"change_threshold",
"update_procedure_threshold",
"switch_guardian",
"consume_notes",
"p2id",
"custom",
] {
assert!(
is_builtin_proposal_type(label),
"{label} should be reserved"
);
}
}
#[test]
fn custom_labels_are_not_builtin() {
assert!(!is_builtin_proposal_type("b2agg"));
assert!(!is_builtin_proposal_type(""));
assert!(!is_builtin_proposal_type("P2ID"));
}
}