use crate::authority::AuthorityValue;
use crate::error::{ContinuityError, RejectReason};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InitialContinuityProposal {
#[serde(rename = "type")]
pub proposal_type: String,
#[serde(rename = "executionContract")]
pub execution_contract: BTreeMap<String, AuthorityValue>,
}
impl InitialContinuityProposal {
pub fn new(execution_contract: BTreeMap<String, AuthorityValue>) -> Self {
Self {
proposal_type: crate::PROPOSAL_TYPE_CONTINUITY_INITIAL.to_string(),
execution_contract,
}
}
pub fn validate(&self) -> Result<(), RejectReason> {
if self.proposal_type != crate::PROPOSAL_TYPE_CONTINUITY_INITIAL {
return Err(RejectReason::Malformed(format!(
"unknown continuity proposal type: {}",
self.proposal_type
)));
}
if self.execution_contract.is_empty() {
return Err(RejectReason::EmptyExecutionContract);
}
for (k, v) in &self.execution_contract {
v.validate(k)?;
}
Ok(())
}
pub fn to_continuity_proposal(&self) -> Result<String, ContinuityError> {
let json = serde_json::to_string(self).map_err(|e| ContinuityError::Json(e.to_string()))?;
Ok(URL_SAFE_NO_PAD.encode(json.as_bytes()))
}
pub fn from_continuity_proposal(value: &str) -> Result<Self, ContinuityError> {
let bytes = URL_SAFE_NO_PAD.decode(value).map_err(|e| {
ContinuityError::Json(format!("continuity_proposal is not valid base64url: {e}"))
})?;
let proposal: Self = serde_json::from_slice(&bytes)
.map_err(|e| ContinuityError::Json(format!("invalid proposal JSON: {e}")))?;
proposal.validate()?;
Ok(proposal)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn walkthrough_proposal() -> InitialContinuityProposal {
let mut contract = BTreeMap::new();
contract.insert("corporation".into(), AuthorityValue::One("ACME".into()));
contract.insert(
"department".into(),
AuthorityValue::One("sensitive-documents".into()),
);
InitialContinuityProposal::new(contract)
}
#[test]
fn json_shape_matches_reference() {
let proposal = walkthrough_proposal();
let json = serde_json::to_value(&proposal).unwrap();
assert_eq!(
json["type"],
"https://pic-protocol.org/definitions/proposal-types/continuity-initial"
);
assert_eq!(json["executionContract"]["corporation"], "ACME");
assert_eq!(
json["executionContract"]["department"],
"sensitive-documents"
);
}
#[test]
fn continuity_proposal_roundtrip() {
let proposal = walkthrough_proposal();
let encoded = proposal.to_continuity_proposal().unwrap();
assert!(!encoded.contains('=') && !encoded.contains('+') && !encoded.contains('/'));
let decoded = InitialContinuityProposal::from_continuity_proposal(&encoded).unwrap();
assert_eq!(decoded, proposal);
}
#[test]
fn rejects_invalid_contracts() {
let empty = InitialContinuityProposal::new(BTreeMap::new());
assert_eq!(
empty.validate().unwrap_err(),
RejectReason::EmptyExecutionContract
);
let mut contract = BTreeMap::new();
contract.insert("corporation".into(), AuthorityValue::One("".into()));
assert!(matches!(
InitialContinuityProposal::new(contract)
.validate()
.unwrap_err(),
RejectReason::InvalidAuthorityValue(_)
));
let mut contract = BTreeMap::new();
contract.insert("departments".into(), AuthorityValue::Many(vec![]));
assert!(matches!(
InitialContinuityProposal::new(contract)
.validate()
.unwrap_err(),
RejectReason::InvalidAuthorityValue(_)
));
let mut wrong = walkthrough_proposal();
wrong.proposal_type = "https://example.com/other".into();
assert!(matches!(
wrong.validate().unwrap_err(),
RejectReason::Malformed(_)
));
}
#[test]
fn rejects_unsupported_json_value_types() {
let json = r#"{
"type": "https://pic-protocol.org/definitions/proposal-types/continuity-initial",
"executionContract": { "retryCount": 3 }
}"#;
let encoded = URL_SAFE_NO_PAD.encode(json.as_bytes());
assert!(InitialContinuityProposal::from_continuity_proposal(&encoded).is_err());
}
}