1use std::collections::HashSet;
17
18use serde::Serialize;
19use serde_json::Value;
20
21use crate::approval::ApprovalSigner;
22use crate::signed::{Envelope, canonical_bytes};
23use crate::verify;
24
25pub const ANSWERED_STATE: &str = "answered";
28pub const DECLINED_STATE: &str = "declined";
32pub const AUTO_RESOLVED_STATE: &str = "auto_resolved";
39
40pub const ANSWER_TOKEN_TTL_MS: u64 = 24 * 60 * 60 * 1000;
47
48#[allow(clippy::too_many_arguments)] fn answer_canonical(
68 call_id: &str,
69 index: u32,
70 question_args_json: &str,
71 state: &str,
72 selected_index: Option<u32>,
73 selected_label: &str,
74 answered_by: &str,
75 conversation_id: &str,
76 nonce: &str,
77) -> Vec<u8> {
78 canonical_bytes(&answer_fields(
79 call_id,
80 index,
81 question_args_json,
82 state,
83 selected_index,
84 selected_label,
85 answered_by,
86 conversation_id,
87 nonce,
88 ))
89}
90
91#[derive(Serialize)]
99struct AnswerCanonical<'a> {
100 question_call_id: &'a str,
101 question_index: u32,
102 question_args_json: &'a str,
103 state: &'a str,
104 selected_index: Option<u32>,
105 selected_label: &'a str,
106 answered_by: &'a str,
107 conversation_id: &'a str,
108 nonce: &'a str,
109}
110
111#[allow(clippy::too_many_arguments)] const fn answer_fields<'a>(
116 call_id: &'a str,
117 index: u32,
118 question_args_json: &'a str,
119 state: &'a str,
120 selected_index: Option<u32>,
121 selected_label: &'a str,
122 answered_by: &'a str,
123 conversation_id: &'a str,
124 nonce: &'a str,
125) -> AnswerCanonical<'a> {
126 AnswerCanonical {
127 question_call_id: call_id,
128 question_index: index,
129 question_args_json,
130 state,
131 selected_index,
132 selected_label,
133 answered_by,
134 conversation_id,
135 nonce,
136 }
137}
138
139#[must_use]
150#[allow(clippy::too_many_arguments)] pub fn answer_payload(
152 call_id: &str,
153 index: u32,
154 question_args_json: &str,
155 state: &str,
156 selected_index: Option<u32>,
157 selected_label: &str,
158 answered_by: &str,
159 conversation_id: &str,
160 nonce: &str,
161 signer: &ApprovalSigner,
162) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
163 Envelope::seal(
164 answer_fields(
165 call_id,
166 index,
167 question_args_json,
168 state,
169 selected_index,
170 selected_label,
171 answered_by,
172 conversation_id,
173 nonce,
174 ),
175 signer.as_signer(),
176 )
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct VerifiedQuestionAnswer {
182 pub call_id: String,
184 pub index: u32,
186 pub question_args_json: String,
190 pub state: String,
193 pub selected_index: Option<u32>,
196 pub selected_label: String,
200 pub answered_by: String,
202 pub conversation_id: String,
206 pub nonce: String,
210 pub signer_public_key: Vec<u8>,
212 pub signer_pk_hex: String,
219 pub signature_hex: String,
224}
225
226impl VerifiedQuestionAnswer {
227 #[must_use]
232 pub fn binds_question(&self, call_id: &str, index: u32, question_args_json: &str) -> bool {
233 self.call_id == call_id
234 && self.index == index
235 && self.question_args_json == question_args_json
236 }
237}
238
239#[must_use]
246pub fn verify_signed_answer(payload: &[u8]) -> Option<VerifiedQuestionAnswer> {
247 let v: Value = serde_json::from_slice(payload).ok()?;
248 let call_id = v.get("question_call_id")?.as_str()?.to_owned();
249 let index = u32::try_from(v.get("question_index")?.as_u64()?).ok()?;
250 let question_args_json = v.get("question_args_json")?.as_str()?.to_owned();
251 let state = v.get("state")?.as_str()?.to_owned();
252 let selected_index = match v.get("selected_index") {
253 Some(Value::Null) | None => None,
254 Some(x) => Some(u32::try_from(x.as_u64()?).ok()?),
255 };
256 let selected_label = v.get("selected_label")?.as_str()?.to_owned();
257 let answered_by = v.get("answered_by")?.as_str()?.to_owned();
258 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
259 let nonce = v.get("nonce")?.as_str()?.to_owned();
260 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
261 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
262
263 let canonical = answer_canonical(
264 &call_id,
265 index,
266 &question_args_json,
267 &state,
268 selected_index,
269 &selected_label,
270 &answered_by,
271 &conversation_id,
272 &nonce,
273 );
274 if verify(&pk, &canonical, &sig) {
275 Some(VerifiedQuestionAnswer {
276 call_id,
277 index,
278 question_args_json,
279 state,
280 selected_index,
281 selected_label,
282 answered_by,
283 conversation_id,
284 nonce,
285 signer_pk_hex: crate::hex::lower(&pk),
286 signature_hex: crate::hex::lower(&sig),
287 signer_public_key: pk,
288 })
289 } else {
290 None
291 }
292}
293
294#[must_use]
302pub fn verify_signed_answer_pinned(
303 payload: &[u8],
304 trusted_signers: &[Vec<u8>],
305) -> Option<VerifiedQuestionAnswer> {
306 let verified = verify_signed_answer(payload)?;
307 if !trusted_signers
308 .iter()
309 .any(|k| k.as_slice() == verified.signer_public_key)
310 {
311 return None;
312 }
313 Some(verified)
314}
315
316#[must_use]
325pub fn verify_answer_capability<S: std::hash::BuildHasher>(
326 payload: &[u8],
327 conversation_id: &str,
328 consumed: &HashSet<String, S>,
329 trusted_signers: &[Vec<u8>],
330) -> Option<VerifiedQuestionAnswer> {
331 let verified = verify_signed_answer_pinned(payload, trusted_signers)?;
332 if verified.conversation_id != conversation_id {
333 return None;
334 }
335 if verified.nonce.is_empty() || consumed.contains(&verified.nonce) {
336 return None;
337 }
338 Some(verified)
339}
340
341#[must_use]
351#[allow(clippy::too_many_arguments)] pub fn verify_wire_answer(
353 call_id: &str,
354 index: u32,
355 question_args_json: &str,
356 state: &str,
357 selected_index: Option<u32>,
358 selected_label: &str,
359 answered_by: &str,
360 conversation_id: &str,
361 nonce: &str,
362 signer_pk_hex: &str,
363 signature_hex: &str,
364) -> bool {
365 let Some(pk) = crate::hex::decode(signer_pk_hex) else {
366 return false;
367 };
368 let Some(sig) = crate::hex::decode(signature_hex) else {
369 return false;
370 };
371 let canonical = answer_canonical(
372 call_id,
373 index,
374 question_args_json,
375 state,
376 selected_index,
377 selected_label,
378 answered_by,
379 conversation_id,
380 nonce,
381 );
382 verify(&pk, &canonical, &sig)
383}
384
385fn answer_token_canonical(
396 call_id: &str,
397 index: u32,
398 conversation_id: &str,
399 minted_at_ms: u64,
400) -> Vec<u8> {
401 canonical_bytes(&AnswerTokenCanonical {
402 question_call_id: call_id,
403 question_index: index,
404 conversation_id,
405 minted_at_ms,
406 })
407}
408
409#[derive(Serialize)]
411struct AnswerTokenCanonical<'a> {
412 question_call_id: &'a str,
413 question_index: u32,
414 conversation_id: &'a str,
415 minted_at_ms: u64,
416}
417
418#[derive(Serialize)]
427struct AnswerToken<'a> {
428 #[serde(flatten)]
429 body: AnswerTokenCanonical<'a>,
430 signature_hex: String,
431}
432
433#[must_use]
446pub fn mint_answer_token(
447 call_id: &str,
448 index: u32,
449 conversation_id: &str,
450 minted_at_ms: u64,
451 signer: &ApprovalSigner,
452) -> String {
453 let body = AnswerTokenCanonical {
457 question_call_id: call_id,
458 question_index: index,
459 conversation_id,
460 minted_at_ms,
461 };
462 let signature = signer.sign(&canonical_bytes(&body));
463 let full = AnswerToken {
464 body,
465 signature_hex: crate::hex::lower(&signature),
466 };
467 crate::hex::lower(&canonical_bytes(&full))
468}
469
470#[must_use]
480pub fn verify_answer_token(
481 token: &str,
482 call_id: &str,
483 index: u32,
484 conversation_id: &str,
485 now_ms: u64,
486 signer: &ApprovalSigner,
487) -> bool {
488 let Some(bytes) = crate::hex::decode(token) else {
489 return false;
490 };
491 let Ok(v) = serde_json::from_slice::<Value>(&bytes) else {
492 return false;
493 };
494 let (
495 Some(bound_call_id),
496 Some(bound_index),
497 Some(bound_conversation_id),
498 Some(minted_at_ms),
499 Some(signature_hex),
500 ) = (
501 v.get("question_call_id").and_then(Value::as_str),
502 v.get("question_index").and_then(Value::as_u64),
503 v.get("conversation_id").and_then(Value::as_str),
504 v.get("minted_at_ms").and_then(Value::as_u64),
505 v.get("signature_hex").and_then(Value::as_str),
506 )
507 else {
508 return false;
509 };
510 let Ok(bound_index) = u32::try_from(bound_index) else {
511 return false;
512 };
513 if bound_call_id != call_id || bound_index != index || bound_conversation_id != conversation_id
514 {
515 return false;
516 }
517 let elapsed = now_ms.abs_diff(minted_at_ms);
518 if elapsed > ANSWER_TOKEN_TTL_MS {
519 return false;
520 }
521 let Some(sig) = crate::hex::decode(signature_hex) else {
522 return false;
523 };
524 let canonical = answer_token_canonical(
525 bound_call_id,
526 bound_index,
527 bound_conversation_id,
528 minted_at_ms,
529 );
530 verify(&signer.public_key_bytes(), &canonical, &sig)
531}
532
533#[cfg(test)]
534mod tests {
535 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
536 use super::*;
537
538 fn valid_answer_payload(signer: &ApprovalSigner) -> Vec<u8> {
539 answer_payload(
540 "call-1",
541 0,
542 r#"{"questions":[]}"#,
543 ANSWERED_STATE,
544 Some(1),
545 "Production",
546 "slack:T1:U9",
547 "conv-A",
548 "nonce-A",
549 signer,
550 )
551 .0
552 }
553
554 #[test]
555 fn signed_answer_round_trips() {
556 let signer = ApprovalSigner::from_seed(1);
557 let payload = valid_answer_payload(&signer);
558 let verified = verify_signed_answer(&payload).expect("signature verifies");
559 assert_eq!(verified.call_id, "call-1");
560 assert_eq!(verified.index, 0);
561 assert_eq!(verified.state, ANSWERED_STATE);
562 assert_eq!(verified.selected_index, Some(1));
563 assert_eq!(verified.selected_label, "Production");
564 assert_eq!(verified.answered_by, "slack:T1:U9");
565 assert!(verified.binds_question("call-1", 0, r#"{"questions":[]}"#));
566 assert!(!verified.binds_question("call-2", 0, r#"{"questions":[]}"#));
567 }
568
569 #[test]
577 fn verified_answer_carries_raw_signer_and_signature_hex() {
578 let signer = ApprovalSigner::from_seed(1);
579 let payload = valid_answer_payload(&signer);
580 let verified = verify_signed_answer(&payload).expect("signature verifies");
581 assert_eq!(
582 verified.signer_pk_hex,
583 crate::hex::lower(&signer.public_key_bytes())
584 );
585 assert!(!verified.signature_hex.is_empty());
586 let sig_bytes = crate::hex::decode(&verified.signature_hex).expect("valid hex");
590 let canonical = answer_canonical(
591 &verified.call_id,
592 verified.index,
593 &verified.question_args_json,
594 &verified.state,
595 verified.selected_index,
596 &verified.selected_label,
597 &verified.answered_by,
598 &verified.conversation_id,
599 &verified.nonce,
600 );
601 assert!(verify(&signer.public_key_bytes(), &canonical, &sig_bytes));
602 }
603
604 #[test]
605 fn declined_and_auto_resolved_states_round_trip_with_no_selection_or_answerer() {
606 let signer = ApprovalSigner::from_seed(1);
607 let (payload, ..) = answer_payload(
608 "call-1",
609 0,
610 "{}",
611 DECLINED_STATE,
612 None,
613 "",
614 "slack:T1:U9",
615 "conv-A",
616 "nonce-B",
617 &signer,
618 );
619 let v = verify_signed_answer(&payload).expect("verifies");
620 assert_eq!(v.state, DECLINED_STATE);
621 assert_eq!(v.selected_index, None);
622
623 let (payload, ..) = answer_payload(
624 "call-1",
625 0,
626 "{}",
627 AUTO_RESOLVED_STATE,
628 Some(0),
629 "Staging",
630 "",
631 "conv-A",
632 "nonce-C",
633 &signer,
634 );
635 let v = verify_signed_answer(&payload).expect("verifies");
636 assert_eq!(v.state, AUTO_RESOLVED_STATE);
637 assert_eq!(
638 v.answered_by, "",
639 "nobody answered an auto-resolved question"
640 );
641 }
642
643 #[test]
647 fn tampered_state_or_selection_fails_verification() {
648 let signer = ApprovalSigner::from_seed(1);
649 let payload = valid_answer_payload(&signer);
650 let mut v: Value = serde_json::from_slice(&payload).unwrap();
651 for (field, val) in [
652 ("state", Value::String(DECLINED_STATE.to_owned())),
653 ("selected_index", Value::Number(2.into())),
654 ("selected_label", Value::String("Staging".into())),
655 ("answered_by", Value::String("slack:T1:U0".into())),
656 ("conversation_id", Value::String("conv-B".into())),
657 ("nonce", Value::String("nonce-Z".into())),
658 ("question_call_id", Value::String("call-2".into())),
659 ("question_index", Value::Number(1.into())),
660 ] {
661 let mut tampered = v.clone();
662 tampered[field] = val;
663 let bytes = serde_json::to_vec(&tampered).unwrap();
664 assert!(
665 verify_signed_answer(&bytes).is_none(),
666 "tampering `{field}` must invalidate the signature"
667 );
668 }
669 assert!(verify_signed_answer(&serde_json::to_vec(&v.take()).unwrap()).is_some());
671 }
672
673 #[test]
676 fn answer_from_non_allowlisted_signer_is_rejected() {
677 let trusted = ApprovalSigner::from_seed(1);
678 let attacker = ApprovalSigner::from_seed(666);
679 let payload = valid_answer_payload(&attacker);
680 assert!(
681 verify_signed_answer_pinned(&payload, &[trusted.public_key_bytes()]).is_none(),
682 "an untrusted signer's answer must never verify"
683 );
684 assert!(
685 verify_signed_answer_pinned(&payload, &[attacker.public_key_bytes()]).is_some(),
686 "sanity: the attacker's own key does verify its own signature"
687 );
688 }
689
690 #[test]
694 fn answer_rejected_across_conversations() {
695 let signer = ApprovalSigner::from_seed(1);
696 let payload = valid_answer_payload(&signer);
697 let consumed = HashSet::new();
698 assert!(
699 verify_answer_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
700 .is_some()
701 );
702 assert!(
703 verify_answer_capability(&payload, "conv-B", &consumed, &[signer.public_key_bytes()])
704 .is_none(),
705 "an answer signed for conv-A must be rejected when presented for conv-B"
706 );
707 }
708
709 #[test]
712 fn question_answer_capability_is_single_use() {
713 let signer = ApprovalSigner::from_seed(1);
714 let payload = valid_answer_payload(&signer);
715 let mut consumed = HashSet::new();
716 let v =
717 verify_answer_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
718 .expect("first use honored");
719 assert_eq!(v.nonce, "nonce-A");
720 consumed.insert(v.nonce.clone());
721 assert!(
722 verify_answer_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
723 .is_none(),
724 "a spent answer must be rejected on re-presentation"
725 );
726 }
727
728 #[test]
733 fn approval_payloads_never_verify_as_question_answers() {
734 let signer = ApprovalSigner::from_seed(1);
735 let (approval_payload, ..) = crate::approval::response_payload(
736 "call-1",
737 "ask_question",
738 r#"{"questions":[]}"#,
739 "",
740 true,
741 false,
742 &[],
743 "slack:T1:U9",
744 "",
745 "",
746 "",
747 "",
748 "conv-A",
749 "nonce-A",
750 &signer,
751 );
752 assert!(
753 verify_signed_answer(&approval_payload).is_none(),
754 "an approval_response payload must never decode+verify as a question_response"
755 );
756
757 let resolve_token = crate::approval::mint_resolve_token("call-1", "conv-A", 1_000, &signer);
758 assert!(
759 !verify_answer_token(&resolve_token, "call-1", 0, "conv-A", 1_000, &signer),
760 "an approval resolve_token must never verify as an answer token"
761 );
762 }
763
764 #[test]
773 fn proto_question_answered_text_recognizes_this_crates_real_state_consts() {
774 let answered = polyc_proto::question_answered_text(
775 "Deploy target",
776 ANSWERED_STATE,
777 "@ada",
778 "Production",
779 );
780 assert!(
781 answered.contains("@ada answered"),
782 "ANSWERED_STATE must render as a real answer, not the declined fallback: {answered}"
783 );
784
785 let auto_resolved = polyc_proto::question_answered_text(
786 "Deploy target",
787 AUTO_RESOLVED_STATE,
788 "",
789 "Production",
790 );
791 assert!(
792 auto_resolved.contains("Nobody answered"),
793 "AUTO_RESOLVED_STATE must render as an assumption, not the declined fallback: {auto_resolved}"
794 );
795
796 let declined =
797 polyc_proto::question_answered_text("Deploy target", DECLINED_STATE, "@ada", "");
798 assert!(
799 declined.contains("@ada declined"),
800 "DECLINED_STATE must render as a real decline: {declined}"
801 );
802 }
803}
804
805#[cfg(test)]
806mod answer_token_tests {
807 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
808 use super::*;
809
810 #[test]
811 fn answer_token_verifies_for_its_own_question() {
812 let signer = ApprovalSigner::from_seed(11);
813 let token = mint_answer_token("call-1", 0, "conv-A", 1_000, &signer);
814 assert!(verify_answer_token(
815 &token, "call-1", 0, "conv-A", 1_000, &signer
816 ));
817 }
818
819 #[test]
820 fn answer_token_rejects_a_different_question_index() {
821 let signer = ApprovalSigner::from_seed(11);
822 let token = mint_answer_token("call-1", 0, "conv-A", 1_000, &signer);
823 assert!(!verify_answer_token(
824 &token, "call-1", 1, "conv-A", 1_000, &signer
825 ));
826 }
827
828 #[test]
829 fn answer_token_rejects_a_different_call_id() {
830 let signer = ApprovalSigner::from_seed(11);
831 let token = mint_answer_token("call-1", 0, "conv-A", 1_000, &signer);
832 assert!(!verify_answer_token(
833 &token, "call-2", 0, "conv-A", 1_000, &signer
834 ));
835 }
836
837 #[test]
838 fn answer_token_rejects_a_different_conversation() {
839 let signer = ApprovalSigner::from_seed(11);
840 let token = mint_answer_token("call-1", 0, "conv-A", 1_000, &signer);
841 assert!(!verify_answer_token(
842 &token, "call-1", 0, "conv-B", 1_000, &signer
843 ));
844 }
845
846 #[test]
847 fn answer_token_rejects_wrong_signer() {
848 let signer = ApprovalSigner::from_seed(11);
849 let other = ApprovalSigner::from_seed(12);
850 let token = mint_answer_token("call-1", 0, "conv-A", 1_000, &signer);
851 assert!(!verify_answer_token(
852 &token, "call-1", 0, "conv-A", 1_000, &other
853 ));
854 }
855
856 #[test]
857 fn answer_token_rejects_after_ttl_elapses() {
858 let signer = ApprovalSigner::from_seed(11);
859 let token = mint_answer_token("call-1", 0, "conv-A", 0, &signer);
860 assert!(verify_answer_token(
861 &token,
862 "call-1",
863 0,
864 "conv-A",
865 ANSWER_TOKEN_TTL_MS,
866 &signer
867 ));
868 assert!(!verify_answer_token(
869 &token,
870 "call-1",
871 0,
872 "conv-A",
873 ANSWER_TOKEN_TTL_MS + 1,
874 &signer
875 ));
876 }
877
878 #[test]
879 fn answer_token_rejects_garbage() {
880 let signer = ApprovalSigner::from_seed(11);
881 assert!(!verify_answer_token(
882 "not-hex", "call-1", 0, "conv-A", 0, &signer
883 ));
884 assert!(!verify_answer_token("", "call-1", 0, "conv-A", 0, &signer));
885 }
886}
887
888#[cfg(test)]
889mod canonical_freeze {
890 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
915
916 use super::*;
917
918 fn frozen(label: &str, got: &[u8], want: &str) {
920 assert_eq!(
921 String::from_utf8(got.to_vec()).unwrap(),
922 want,
923 "{label}: canonical bytes moved — every signature over the old bytes is now unverifiable"
924 );
925 }
926
927 const QUESTION_ARGS: &str =
928 r#"{"questions":[{"prompt":"Ship it?","options":["Hold","Yes, proceed"]}]}"#;
929 const MINTED_AT_MS: u64 = 1_750_000_000_000;
930
931 #[test]
932 fn answered_canonical_and_payload_are_frozen() {
933 frozen(
934 "answer_canonical (answered)",
935 &answer_canonical(
936 "call-1",
937 0,
938 QUESTION_ARGS,
939 ANSWERED_STATE,
940 Some(1),
941 "Yes, proceed",
942 "persona:alice",
943 "conv-1",
944 "nonce-answer",
945 ),
946 ANSWERED_CANONICAL,
947 );
948 let (full, sig, _) = answer_payload(
949 "call-1",
950 0,
951 QUESTION_ARGS,
952 ANSWERED_STATE,
953 Some(1),
954 "Yes, proceed",
955 "persona:alice",
956 "conv-1",
957 "nonce-answer",
958 &ApprovalSigner::from_seed(99),
959 );
960 frozen("answer_payload (answered)", &full, ANSWERED_PAYLOAD);
961 assert_eq!(crate::hex::lower(&sig), ANSWERED_SIG);
962 }
963
964 #[test]
968 fn declined_canonical_and_payload_are_frozen() {
969 frozen(
970 "answer_canonical (declined)",
971 &answer_canonical(
972 "call-1",
973 0,
974 QUESTION_ARGS,
975 DECLINED_STATE,
976 None,
977 "",
978 "persona:alice",
979 "conv-1",
980 "nonce-answer",
981 ),
982 DECLINED_CANONICAL,
983 );
984 let (full, sig, _) = answer_payload(
985 "call-1",
986 0,
987 QUESTION_ARGS,
988 DECLINED_STATE,
989 None,
990 "",
991 "persona:alice",
992 "conv-1",
993 "nonce-answer",
994 &ApprovalSigner::from_seed(99),
995 );
996 frozen("answer_payload (declined)", &full, DECLINED_PAYLOAD);
997 assert_eq!(crate::hex::lower(&sig), DECLINED_SIG);
998 }
999
1000 #[test]
1001 fn answer_token_is_frozen() {
1002 frozen(
1003 "answer_token_canonical",
1004 &answer_token_canonical("call-1", 0, "conv-1", MINTED_AT_MS),
1005 TOKEN_CANONICAL,
1006 );
1007 assert_eq!(
1008 mint_answer_token(
1009 "call-1",
1010 0,
1011 "conv-1",
1012 MINTED_AT_MS,
1013 &ApprovalSigner::from_seed(99)
1014 ),
1015 TOKEN_MINTED,
1016 "a minted answer token's bytes are frozen — a token is hex of the whole object"
1017 );
1018 }
1019
1020 const ANSWERED_CANONICAL: &str = r#"{"question_call_id":"call-1","question_index":0,"question_args_json":"{\"questions\":[{\"prompt\":\"Ship it?\",\"options\":[\"Hold\",\"Yes, proceed\"]}]}","state":"answered","selected_index":1,"selected_label":"Yes, proceed","answered_by":"persona:alice","conversation_id":"conv-1","nonce":"nonce-answer"}"#;
1021 const ANSWERED_PAYLOAD: &str = r#"{"question_call_id":"call-1","question_index":0,"question_args_json":"{\"questions\":[{\"prompt\":\"Ship it?\",\"options\":[\"Hold\",\"Yes, proceed\"]}]}","state":"answered","selected_index":1,"selected_label":"Yes, proceed","answered_by":"persona:alice","conversation_id":"conv-1","nonce":"nonce-answer","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"e7b877065d853887c0188eff5fb971a9d8e531e2c36e3ffa8d02dc5da2b3cb17a8dc140bfb1d6b4c6c6529d0b4890851516d03b1d633d96b1496d6713b08a705"}"#;
1022 const ANSWERED_SIG: &str = "e7b877065d853887c0188eff5fb971a9d8e531e2c36e3ffa8d02dc5da2b3cb17a8dc140bfb1d6b4c6c6529d0b4890851516d03b1d633d96b1496d6713b08a705";
1023 const DECLINED_CANONICAL: &str = r#"{"question_call_id":"call-1","question_index":0,"question_args_json":"{\"questions\":[{\"prompt\":\"Ship it?\",\"options\":[\"Hold\",\"Yes, proceed\"]}]}","state":"declined","selected_index":null,"selected_label":"","answered_by":"persona:alice","conversation_id":"conv-1","nonce":"nonce-answer"}"#;
1024 const DECLINED_PAYLOAD: &str = r#"{"question_call_id":"call-1","question_index":0,"question_args_json":"{\"questions\":[{\"prompt\":\"Ship it?\",\"options\":[\"Hold\",\"Yes, proceed\"]}]}","state":"declined","selected_index":null,"selected_label":"","answered_by":"persona:alice","conversation_id":"conv-1","nonce":"nonce-answer","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"5961a4cc427c84674a0a60b0f51552143b511751c2295705dbd323e062b504858de0f2209e67bb0c7a6826e80ec9831c2864c9f910cf7b4e137656da273fa30d"}"#;
1025 const DECLINED_SIG: &str = "5961a4cc427c84674a0a60b0f51552143b511751c2295705dbd323e062b504858de0f2209e67bb0c7a6826e80ec9831c2864c9f910cf7b4e137656da273fa30d";
1026 const TOKEN_CANONICAL: &str = r#"{"question_call_id":"call-1","question_index":0,"conversation_id":"conv-1","minted_at_ms":1750000000000}"#;
1027 const TOKEN_MINTED: &str = "7b227175657374696f6e5f63616c6c5f6964223a2263616c6c2d31222c227175657374696f6e5f696e646578223a302c22636f6e766572736174696f6e5f6964223a22636f6e762d31222c226d696e7465645f61745f6d73223a313735303030303030303030302c227369676e61747572655f686578223a223337633731353962313761313236643935323466313766616636323766623565396461626133313535616638316163666539313966633634363833653634626530333833386539363033383165313931366533643033626437333338653830353762636636326565313365643166353639363866616366323034373936643065227d";
1028}