pic_continuity/
proposal.rs1use crate::authority::AuthorityValue;
34use crate::error::{ContinuityError, RejectReason};
35use base64::Engine;
36use base64::engine::general_purpose::URL_SAFE_NO_PAD;
37use serde::{Deserialize, Serialize};
38use std::collections::BTreeMap;
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct InitialContinuityProposal {
44 #[serde(rename = "type")]
47 pub proposal_type: String,
48 #[serde(rename = "executionContract")]
52 pub execution_contract: BTreeMap<String, AuthorityValue>,
53}
54
55impl InitialContinuityProposal {
56 pub fn new(execution_contract: BTreeMap<String, AuthorityValue>) -> Self {
58 Self {
59 proposal_type: crate::PROPOSAL_TYPE_CONTINUITY_INITIAL.to_string(),
60 execution_contract,
61 }
62 }
63
64 pub fn validate(&self) -> Result<(), RejectReason> {
69 if self.proposal_type != crate::PROPOSAL_TYPE_CONTINUITY_INITIAL {
70 return Err(RejectReason::Malformed(format!(
71 "unknown continuity proposal type: {}",
72 self.proposal_type
73 )));
74 }
75 if self.execution_contract.is_empty() {
76 return Err(RejectReason::EmptyExecutionContract);
77 }
78 for (k, v) in &self.execution_contract {
79 v.validate(k)?;
80 }
81 Ok(())
82 }
83
84 pub fn to_continuity_proposal(&self) -> Result<String, ContinuityError> {
87 let json = serde_json::to_string(self).map_err(|e| ContinuityError::Json(e.to_string()))?;
88 Ok(URL_SAFE_NO_PAD.encode(json.as_bytes()))
89 }
90
91 pub fn from_continuity_proposal(value: &str) -> Result<Self, ContinuityError> {
93 let bytes = URL_SAFE_NO_PAD.decode(value).map_err(|e| {
94 ContinuityError::Json(format!("continuity_proposal is not valid base64url: {e}"))
95 })?;
96 let proposal: Self = serde_json::from_slice(&bytes)
97 .map_err(|e| ContinuityError::Json(format!("invalid proposal JSON: {e}")))?;
98 proposal.validate()?;
99 Ok(proposal)
100 }
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106
107 fn walkthrough_proposal() -> InitialContinuityProposal {
110 let mut contract = BTreeMap::new();
111 contract.insert("corporation".into(), AuthorityValue::One("ACME".into()));
112 contract.insert(
113 "department".into(),
114 AuthorityValue::One("sensitive-documents".into()),
115 );
116 InitialContinuityProposal::new(contract)
117 }
118
119 #[test]
120 fn json_shape_matches_reference() {
121 let proposal = walkthrough_proposal();
122 let json = serde_json::to_value(&proposal).unwrap();
123 assert_eq!(
124 json["type"],
125 "https://pic-protocol.org/definitions/proposal-types/continuity-initial"
126 );
127 assert_eq!(json["executionContract"]["corporation"], "ACME");
128 assert_eq!(
129 json["executionContract"]["department"],
130 "sensitive-documents"
131 );
132 }
133
134 #[test]
135 fn continuity_proposal_roundtrip() {
136 let proposal = walkthrough_proposal();
137 let encoded = proposal.to_continuity_proposal().unwrap();
138 assert!(!encoded.contains('=') && !encoded.contains('+') && !encoded.contains('/'));
140 let decoded = InitialContinuityProposal::from_continuity_proposal(&encoded).unwrap();
141 assert_eq!(decoded, proposal);
142 }
143
144 #[test]
145 fn rejects_invalid_contracts() {
146 let empty = InitialContinuityProposal::new(BTreeMap::new());
148 assert_eq!(
149 empty.validate().unwrap_err(),
150 RejectReason::EmptyExecutionContract
151 );
152
153 let mut contract = BTreeMap::new();
155 contract.insert("corporation".into(), AuthorityValue::One("".into()));
156 assert!(matches!(
157 InitialContinuityProposal::new(contract)
158 .validate()
159 .unwrap_err(),
160 RejectReason::InvalidAuthorityValue(_)
161 ));
162
163 let mut contract = BTreeMap::new();
165 contract.insert("departments".into(), AuthorityValue::Many(vec![]));
166 assert!(matches!(
167 InitialContinuityProposal::new(contract)
168 .validate()
169 .unwrap_err(),
170 RejectReason::InvalidAuthorityValue(_)
171 ));
172
173 let mut wrong = walkthrough_proposal();
175 wrong.proposal_type = "https://example.com/other".into();
176 assert!(matches!(
177 wrong.validate().unwrap_err(),
178 RejectReason::Malformed(_)
179 ));
180 }
181
182 #[test]
183 fn rejects_unsupported_json_value_types() {
184 let json = r#"{
186 "type": "https://pic-protocol.org/definitions/proposal-types/continuity-initial",
187 "executionContract": { "retryCount": 3 }
188 }"#;
189 let encoded = URL_SAFE_NO_PAD.encode(json.as_bytes());
190 assert!(InitialContinuityProposal::from_continuity_proposal(&encoded).is_err());
191 }
192}