1use {
4 crate::{
5 scheduler::ScheduleError,
6 sui,
7 types::{
8 AgentId,
9 NexusData,
10 SkillId,
11 DEFAULT_PRIORITY_FEE_PERCENTAGE,
12 MAX_PRIORITY_FEE_PERCENTAGE,
13 MIN_PRIORITY_FEE_PERCENTAGE,
14 },
15 },
16 serde::{Deserialize, Serialize},
17 std::collections::BTreeMap,
18};
19
20pub type TaskInputs = BTreeMap<String, BTreeMap<String, NexusData>>;
22
23#[derive(Clone, Debug, Serialize, Deserialize)]
25pub struct TaskSpec {
26 operation: TaskOperation,
27 entry_group: String,
28 inputs: TaskInputs,
29 funding: TaskFunding,
30 occurrence_budget_mist: u64,
31 failure_policy: FailurePolicy,
32}
33
34impl TaskSpec {
35 pub fn new(
43 operation: TaskOperation,
44 entry_group: impl Into<String>,
45 funding: TaskFunding,
46 occurrence_budget_mist: u64,
47 ) -> Result<Self, ScheduleError> {
48 let task = Self {
49 operation,
50 entry_group: entry_group.into(),
51 inputs: BTreeMap::new(),
52 funding,
53 occurrence_budget_mist,
54 failure_policy: FailurePolicy::Continue,
55 };
56 task.validate()?;
57 Ok(task)
58 }
59
60 #[must_use]
62 pub fn with_inputs(mut self, inputs: TaskInputs) -> Self {
63 self.inputs = inputs;
64 self
65 }
66
67 #[must_use]
69 pub const fn with_failure_policy(mut self, policy: FailurePolicy) -> Self {
70 self.failure_policy = policy;
71 self
72 }
73
74 pub const fn operation(&self) -> &TaskOperation {
76 &self.operation
77 }
78
79 pub fn entry_group(&self) -> &str {
81 &self.entry_group
82 }
83
84 pub const fn inputs(&self) -> &TaskInputs {
86 &self.inputs
87 }
88
89 pub const fn funding(&self) -> TaskFunding {
91 self.funding
92 }
93
94 pub const fn occurrence_budget_mist(&self) -> u64 {
96 self.occurrence_budget_mist
97 }
98
99 pub const fn failure_policy(&self) -> FailurePolicy {
101 self.failure_policy
102 }
103
104 pub(crate) fn validate(&self) -> Result<(), ScheduleError> {
105 if self.entry_group.trim().is_empty() {
106 return Err(ScheduleError::EmptyEntryGroup);
107 }
108 if self.occurrence_budget_mist == 0 {
109 return Err(ScheduleError::ZeroOccurrenceBudget);
110 }
111 if matches!(self.operation, TaskOperation::DefaultDag { .. })
112 && matches!(self.funding, TaskFunding::Agent { .. })
113 {
114 return Err(ScheduleError::IncompatibleFunding {
115 message: "a default DAG Task cannot use Agent vault funding",
116 });
117 }
118 Ok(())
119 }
120}
121
122#[derive(Clone, Debug, Serialize, Deserialize)]
124#[serde(tag = "kind", rename_all = "snake_case")]
125pub enum TaskOperation {
126 DefaultDag {
128 dag_id: sui::types::Address,
130 },
131 AgentSkill {
133 agent_id: AgentId,
135 skill_id: SkillId,
137 selected_dag: Option<sui::types::Address>,
139 authorization_bindings: AuthorizationBindings,
141 },
142}
143
144impl TaskOperation {
145 pub const fn default_dag(dag_id: sui::types::Address) -> Self {
147 Self::DefaultDag { dag_id }
148 }
149
150 pub fn agent_skill(
152 agent_id: AgentId,
153 skill_id: SkillId,
154 selected_dag: Option<sui::types::Address>,
155 authorization_bindings: AuthorizationBindings,
156 ) -> Self {
157 Self::AgentSkill {
158 agent_id,
159 skill_id,
160 selected_dag,
161 authorization_bindings,
162 }
163 }
164
165 pub const fn agent_id(&self) -> Option<AgentId> {
167 match self {
168 Self::DefaultDag { .. } => None,
169 Self::AgentSkill { agent_id, .. } => Some(*agent_id),
170 }
171 }
172
173 pub const fn selected_dag_id(&self) -> Option<sui::types::Address> {
178 match self {
179 Self::DefaultDag { dag_id } => Some(*dag_id),
180 Self::AgentSkill { selected_dag, .. } => *selected_dag,
181 }
182 }
183}
184
185pub type AuthorizationBindings = BTreeMap<String, sui::types::Address>;
192
193#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(tag = "kind", rename_all = "snake_case")]
196pub enum TaskFunding {
197 Address {
199 prepay_amount_mist: u64,
201 refund_recipient: Option<sui::types::Address>,
203 },
204 Agent {
206 prepay_amount_mist: u64,
208 },
209}
210
211impl TaskFunding {
212 pub const fn address(prepay_amount_mist: u64) -> Self {
214 Self::Address {
215 prepay_amount_mist,
216 refund_recipient: None,
217 }
218 }
219
220 pub const fn address_with_refund(
222 prepay_amount_mist: u64,
223 refund_recipient: sui::types::Address,
224 ) -> Self {
225 Self::Address {
226 prepay_amount_mist,
227 refund_recipient: Some(refund_recipient),
228 }
229 }
230
231 pub const fn agent(prepay_amount_mist: u64) -> Self {
233 Self::Agent { prepay_amount_mist }
234 }
235
236 pub const fn prepay_amount_mist(self) -> u64 {
238 match self {
239 Self::Address {
240 prepay_amount_mist, ..
241 }
242 | Self::Agent { prepay_amount_mist } => prepay_amount_mist,
243 }
244 }
245}
246
247#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(rename_all = "snake_case")]
250pub enum FailurePolicy {
251 #[default]
253 Continue,
254 Pause,
256}
257
258#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
260pub struct Schedule {
261 occurrences: Vec<Occurrence>,
262 recurrence: Option<Recurrence>,
263}
264
265impl Schedule {
266 pub const fn new() -> Self {
268 Self {
269 occurrences: Vec::new(),
270 recurrence: None,
271 }
272 }
273
274 #[must_use]
276 pub fn with_occurrence(mut self, occurrence: Occurrence) -> Self {
277 self.occurrences.push(occurrence);
278 self
279 }
280
281 #[must_use]
283 pub fn with_recurrence(mut self, recurrence: Recurrence) -> Self {
284 self.recurrence = Some(recurrence);
285 self
286 }
287
288 pub fn is_empty(&self) -> bool {
290 self.occurrences.is_empty() && self.recurrence.is_none()
291 }
292
293 pub fn occurrences(&self) -> &[Occurrence] {
295 &self.occurrences
296 }
297
298 pub const fn recurrence(&self) -> Option<&Recurrence> {
300 self.recurrence.as_ref()
301 }
302
303 pub fn validate_for_task_creation(&self) -> Result<(), ScheduleError> {
312 self.validate()?;
313 if self.is_empty() {
314 Err(ScheduleError::EmptySchedule)
315 } else {
316 Ok(())
317 }
318 }
319
320 pub(crate) fn validate(&self) -> Result<(), ScheduleError> {
321 for occurrence in &self.occurrences {
322 occurrence.validate()?;
323 }
324 if let Some(recurrence) = &self.recurrence {
325 recurrence.validate()?;
326 }
327 Ok(())
328 }
329}
330
331#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
333pub struct Recurrence {
334 first: Occurrence,
335 interval_ms: u64,
336 occurrences: Option<u64>,
337}
338
339impl Recurrence {
340 pub fn new(first: Occurrence, interval_ms: u64) -> Result<Self, ScheduleError> {
347 let recurrence = Self {
348 first,
349 interval_ms,
350 occurrences: None,
351 };
352 recurrence.validate()?;
353 Ok(recurrence)
354 }
355
356 #[must_use]
358 pub const fn unbounded(mut self) -> Self {
359 self.occurrences = None;
360 self
361 }
362
363 pub fn finite(mut self, occurrences: u64) -> Result<Self, ScheduleError> {
370 if occurrences == 0 {
371 return Err(ScheduleError::ZeroRecurrenceCount);
372 }
373 self.occurrences = Some(occurrences);
374 Ok(self)
375 }
376
377 pub const fn first(&self) -> &Occurrence {
379 &self.first
380 }
381
382 pub const fn interval_ms(&self) -> u64 {
384 self.interval_ms
385 }
386
387 pub const fn occurrences(&self) -> Option<u64> {
389 self.occurrences
390 }
391
392 pub(crate) fn validate(&self) -> Result<(), ScheduleError> {
393 self.first.validate()?;
394 if self.interval_ms == 0 {
395 return Err(ScheduleError::ZeroRecurrenceInterval);
396 }
397 if self.occurrences == Some(0) {
398 return Err(ScheduleError::ZeroRecurrenceCount);
399 }
400 Ok(())
401 }
402}
403
404#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
405#[serde(tag = "kind", rename_all = "snake_case")]
406pub(crate) enum StartTime {
407 Now,
408 At { timestamp_ms: u64 },
409 After { offset_ms: u64 },
410}
411
412#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
413#[serde(tag = "kind", rename_all = "snake_case")]
414pub(crate) enum Deadline {
415 At { timestamp_ms: u64 },
416 AfterStart { offset_ms: u64 },
417}
418
419#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
421pub struct Occurrence {
422 start: StartTime,
423 deadline: Option<Deadline>,
424 priority_fee_percentage: u64,
425}
426
427impl Occurrence {
428 pub const fn now() -> Self {
430 Self {
431 start: StartTime::Now,
432 deadline: None,
433 priority_fee_percentage: DEFAULT_PRIORITY_FEE_PERCENTAGE,
434 }
435 }
436
437 pub const fn at_ms(timestamp_ms: u64) -> Self {
439 Self {
440 start: StartTime::At { timestamp_ms },
441 deadline: None,
442 priority_fee_percentage: DEFAULT_PRIORITY_FEE_PERCENTAGE,
443 }
444 }
445
446 pub const fn after_ms(offset_ms: u64) -> Self {
448 Self {
449 start: StartTime::After { offset_ms },
450 deadline: None,
451 priority_fee_percentage: DEFAULT_PRIORITY_FEE_PERCENTAGE,
452 }
453 }
454
455 pub fn deadline_at_ms(mut self, timestamp_ms: u64) -> Result<Self, ScheduleError> {
463 if let StartTime::At {
464 timestamp_ms: start_time_ms,
465 } = self.start
466 {
467 if timestamp_ms < start_time_ms {
468 return Err(ScheduleError::DeadlineBeforeStart {
469 start_time_ms,
470 deadline_ms: timestamp_ms,
471 });
472 }
473 }
474 self.deadline = Some(Deadline::At { timestamp_ms });
475 Ok(self)
476 }
477
478 #[must_use]
480 pub const fn deadline_after_ms(mut self, offset_ms: u64) -> Self {
481 self.deadline = Some(Deadline::AfterStart { offset_ms });
482 self
483 }
484
485 pub fn with_priority_fee_percentage(mut self, percentage: u64) -> Result<Self, ScheduleError> {
492 if !(MIN_PRIORITY_FEE_PERCENTAGE..=MAX_PRIORITY_FEE_PERCENTAGE).contains(&percentage) {
493 return Err(ScheduleError::PriorityFeeOutOfRange {
494 percentage,
495 minimum: MIN_PRIORITY_FEE_PERCENTAGE,
496 maximum: MAX_PRIORITY_FEE_PERCENTAGE,
497 });
498 }
499 self.priority_fee_percentage = percentage;
500 Ok(self)
501 }
502
503 pub const fn priority_fee_percentage(&self) -> u64 {
505 self.priority_fee_percentage
506 }
507
508 pub(crate) const fn start(&self) -> StartTime {
509 self.start
510 }
511
512 pub(crate) const fn deadline(&self) -> Option<Deadline> {
513 self.deadline
514 }
515
516 pub(crate) fn validate(&self) -> Result<(), ScheduleError> {
517 if !(MIN_PRIORITY_FEE_PERCENTAGE..=MAX_PRIORITY_FEE_PERCENTAGE)
518 .contains(&self.priority_fee_percentage)
519 {
520 return Err(ScheduleError::PriorityFeeOutOfRange {
521 percentage: self.priority_fee_percentage,
522 minimum: MIN_PRIORITY_FEE_PERCENTAGE,
523 maximum: MAX_PRIORITY_FEE_PERCENTAGE,
524 });
525 }
526 if let (
527 StartTime::At {
528 timestamp_ms: start_time_ms,
529 },
530 Some(Deadline::At {
531 timestamp_ms: deadline_ms,
532 }),
533 ) = (self.start, self.deadline)
534 {
535 if deadline_ms < start_time_ms {
536 return Err(ScheduleError::DeadlineBeforeStart {
537 start_time_ms,
538 deadline_ms,
539 });
540 }
541 }
542 Ok(())
543 }
544}
545
546#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
548pub struct OccurrenceRef {
549 task_id: sui::types::Address,
550 occurrence_id: u64,
551}
552
553impl OccurrenceRef {
554 pub const fn new(task_id: sui::types::Address, occurrence_id: u64) -> Self {
556 Self {
557 task_id,
558 occurrence_id,
559 }
560 }
561
562 pub const fn task_id(self) -> sui::types::Address {
564 self.task_id
565 }
566
567 pub const fn occurrence_id(self) -> u64 {
569 self.occurrence_id
570 }
571}
572
573#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
575pub struct DispatchOffer {
576 occurrence: OccurrenceRef,
577 effective_start_time_ms: u64,
578 deadline_ms: Option<u64>,
579 priority_fee_percentage: u64,
580}
581
582impl DispatchOffer {
583 pub fn new(
591 occurrence: OccurrenceRef,
592 effective_start_time_ms: u64,
593 deadline_ms: Option<u64>,
594 priority_fee_percentage: u64,
595 ) -> Result<Self, ScheduleError> {
596 if let Some(deadline_ms) = deadline_ms {
597 if deadline_ms < effective_start_time_ms {
598 return Err(ScheduleError::DeadlineBeforeStart {
599 start_time_ms: effective_start_time_ms,
600 deadline_ms,
601 });
602 }
603 }
604 if !(MIN_PRIORITY_FEE_PERCENTAGE..=MAX_PRIORITY_FEE_PERCENTAGE)
605 .contains(&priority_fee_percentage)
606 {
607 return Err(ScheduleError::PriorityFeeOutOfRange {
608 percentage: priority_fee_percentage,
609 minimum: MIN_PRIORITY_FEE_PERCENTAGE,
610 maximum: MAX_PRIORITY_FEE_PERCENTAGE,
611 });
612 }
613 Ok(Self {
614 occurrence,
615 effective_start_time_ms,
616 deadline_ms,
617 priority_fee_percentage,
618 })
619 }
620
621 pub const fn occurrence(&self) -> OccurrenceRef {
623 self.occurrence
624 }
625
626 pub const fn effective_start_time_ms(&self) -> u64 {
628 self.effective_start_time_ms
629 }
630
631 pub const fn deadline_ms(&self) -> Option<u64> {
633 self.deadline_ms
634 }
635
636 pub const fn priority_fee_percentage(&self) -> u64 {
638 self.priority_fee_percentage
639 }
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645
646 fn address(value: &'static str) -> sui::types::Address {
647 sui::types::Address::from_static(value)
648 }
649
650 #[test]
651 fn empty_schedule_is_composable_but_not_a_creation_shortcut() {
652 let schedule = Schedule::new();
653
654 assert!(schedule.is_empty());
655 assert_eq!(
656 schedule.validate_for_task_creation(),
657 Err(ScheduleError::EmptySchedule)
658 );
659 }
660
661 #[test]
662 fn mixed_schedule_preserves_insertion_order_and_recurrence() {
663 let recurrence = Recurrence::new(Occurrence::now(), 1_000)
664 .expect("nonzero interval")
665 .finite(3)
666 .expect("nonzero count");
667 let mixed = Schedule::new()
668 .with_occurrence(Occurrence::now())
669 .with_occurrence(Occurrence::at_ms(2_000))
670 .with_recurrence(recurrence);
671
672 assert_eq!(mixed.occurrences().len(), 2);
673 assert!(mixed.recurrence().is_some());
674 }
675
676 #[test]
677 fn invalid_authoring_values_are_rejected() {
678 let address = sui::types::Address::from_static("0x1");
679 assert_eq!(
680 TaskSpec::new(
681 TaskOperation::default_dag(address),
682 "main",
683 TaskFunding::address(1),
684 0,
685 )
686 .expect_err("zero budget"),
687 ScheduleError::ZeroOccurrenceBudget
688 );
689 assert!(matches!(
690 Occurrence::at_ms(20).deadline_at_ms(10),
691 Err(ScheduleError::DeadlineBeforeStart { .. })
692 ));
693 assert!(matches!(
694 Occurrence::now().with_priority_fee_percentage(MAX_PRIORITY_FEE_PERCENTAGE + 1),
695 Err(ScheduleError::PriorityFeeOutOfRange { .. })
696 ));
697 assert_eq!(
698 Recurrence::new(Occurrence::now(), 0).expect_err("zero interval"),
699 ScheduleError::ZeroRecurrenceInterval
700 );
701 assert_eq!(
702 Recurrence::new(Occurrence::now(), 1)
703 .expect("valid recurrence")
704 .finite(0)
705 .expect_err("zero count"),
706 ScheduleError::ZeroRecurrenceCount
707 );
708 }
709
710 #[test]
711 fn task_spec_preserves_authored_operation_funding_and_inputs() {
712 let agent_id = address("0xa");
713 let selected_dag = address("0xb");
714 let recipient_id = address("0xc");
715 let authorization_bindings = BTreeMap::from([("summarize".to_owned(), recipient_id)]);
716
717 let operation =
718 TaskOperation::agent_skill(agent_id, 11, Some(selected_dag), authorization_bindings);
719 assert_eq!(operation.agent_id(), Some(agent_id));
720 assert_eq!(TaskOperation::default_dag(selected_dag).agent_id(), None);
721
722 let mut inputs = TaskInputs::new();
723 inputs.insert("summarize".to_owned(), BTreeMap::new());
724 let task = TaskSpec::new(operation, "main", TaskFunding::agent(90), 30)
725 .expect("Agent funding matches an Agent skill")
726 .with_inputs(inputs)
727 .with_failure_policy(FailurePolicy::Pause);
728
729 assert!(matches!(
730 task.operation(),
731 TaskOperation::AgentSkill {
732 agent_id: stored_agent,
733 skill_id: 11,
734 selected_dag: Some(stored_dag),
735 authorization_bindings,
736 } if *stored_agent == agent_id
737 && *stored_dag == selected_dag
738 && authorization_bindings.get("summarize") == Some(&recipient_id)
739 ));
740 assert_eq!(task.entry_group(), "main");
741 assert!(task.inputs().contains_key("summarize"));
742 assert_eq!(task.funding(), TaskFunding::agent(90));
743 assert_eq!(task.funding().prepay_amount_mist(), 90);
744 assert_eq!(task.occurrence_budget_mist(), 30);
745 assert_eq!(task.failure_policy(), FailurePolicy::Pause);
746 }
747
748 #[test]
749 fn task_authoring_rejects_blank_names_and_incompatible_funding() {
750 let dag_id = address("0xd");
751 assert_eq!(
752 TaskSpec::new(
753 TaskOperation::default_dag(dag_id),
754 " ",
755 TaskFunding::address(1),
756 1,
757 )
758 .expect_err("blank entry group"),
759 ScheduleError::EmptyEntryGroup
760 );
761 assert!(matches!(
762 TaskSpec::new(
763 TaskOperation::default_dag(dag_id),
764 "main",
765 TaskFunding::agent(1),
766 1,
767 ),
768 Err(ScheduleError::IncompatibleFunding { .. })
769 ));
770
771 let refund_recipient = address("0xe");
772 let funding = TaskFunding::address_with_refund(40, refund_recipient);
773 assert_eq!(funding.prepay_amount_mist(), 40);
774 assert!(matches!(
775 funding,
776 TaskFunding::Address {
777 prepay_amount_mist: 40,
778 refund_recipient: Some(stored_recipient),
779 } if stored_recipient == refund_recipient
780 ));
781 assert_eq!(TaskFunding::address(20).prepay_amount_mist(), 20);
782 }
783
784 #[test]
785 fn schedule_composition_preserves_every_time_form() {
786 let absolute = Occurrence::at_ms(100)
787 .deadline_at_ms(120)
788 .expect("deadline follows start")
789 .with_priority_fee_percentage(MIN_PRIORITY_FEE_PERCENTAGE)
790 .expect("minimum fee is valid");
791 assert_eq!(absolute.start(), StartTime::At { timestamp_ms: 100 });
792 assert_eq!(
793 absolute.deadline(),
794 Some(Deadline::At { timestamp_ms: 120 })
795 );
796 assert_eq!(
797 absolute.priority_fee_percentage(),
798 MIN_PRIORITY_FEE_PERCENTAGE
799 );
800
801 let relative = Occurrence::after_ms(10).deadline_after_ms(5);
802 assert_eq!(relative.start(), StartTime::After { offset_ms: 10 });
803 assert_eq!(
804 relative.deadline(),
805 Some(Deadline::AfterStart { offset_ms: 5 })
806 );
807 assert_eq!(Occurrence::now().start(), StartTime::Now);
808
809 let recurrence = Recurrence::new(relative, 25)
810 .expect("interval advances time")
811 .finite(3)
812 .expect("finite recurrence is nonempty");
813 assert_eq!(recurrence.first(), &relative);
814 assert_eq!(recurrence.interval_ms(), 25);
815 assert_eq!(recurrence.occurrences(), Some(3));
816 assert_eq!(recurrence.clone().unbounded().occurrences(), None);
817
818 let schedule = Schedule::new()
819 .with_occurrence(absolute)
820 .with_recurrence(recurrence);
821 assert!(!schedule.is_empty());
822 assert_eq!(schedule.occurrences(), &[absolute]);
823 assert!(schedule.recurrence().is_some());
824 assert_eq!(schedule.validate_for_task_creation(), Ok(()));
825 }
826
827 #[test]
828 fn nested_schedule_validation_rejects_invalid_stored_values() {
829 let invalid_priority = Occurrence {
830 start: StartTime::Now,
831 deadline: None,
832 priority_fee_percentage: MAX_PRIORITY_FEE_PERCENTAGE + 1,
833 };
834 assert!(matches!(
835 Schedule::new().with_occurrence(invalid_priority).validate(),
836 Err(ScheduleError::PriorityFeeOutOfRange { .. })
837 ));
838
839 let invalid_deadline = Occurrence {
840 start: StartTime::At { timestamp_ms: 20 },
841 deadline: Some(Deadline::At { timestamp_ms: 10 }),
842 priority_fee_percentage: DEFAULT_PRIORITY_FEE_PERCENTAGE,
843 };
844 assert!(matches!(
845 invalid_deadline.validate(),
846 Err(ScheduleError::DeadlineBeforeStart { .. })
847 ));
848
849 let invalid_recurrence = Recurrence {
850 first: Occurrence::now(),
851 interval_ms: 1,
852 occurrences: Some(0),
853 };
854 assert_eq!(
855 invalid_recurrence.validate(),
856 Err(ScheduleError::ZeroRecurrenceCount)
857 );
858 assert_eq!(
859 Schedule::new()
860 .with_recurrence(invalid_recurrence)
861 .validate(),
862 Err(ScheduleError::ZeroRecurrenceCount)
863 );
864 }
865
866 #[test]
867 fn dispatch_offer_validates_and_exposes_protocol_values() {
868 let reference = OccurrenceRef::new(address("0xf"), 9);
869 assert_eq!(reference.task_id(), address("0xf"));
870 assert_eq!(reference.occurrence_id(), 9);
871
872 let offer = DispatchOffer::new(reference, 100, Some(120), MAX_PRIORITY_FEE_PERCENTAGE)
873 .expect("offer values are valid");
874 assert_eq!(offer.occurrence(), reference);
875 assert_eq!(offer.effective_start_time_ms(), 100);
876 assert_eq!(offer.deadline_ms(), Some(120));
877 assert_eq!(offer.priority_fee_percentage(), MAX_PRIORITY_FEE_PERCENTAGE);
878
879 assert!(matches!(
880 DispatchOffer::new(reference, 100, Some(99), DEFAULT_PRIORITY_FEE_PERCENTAGE),
881 Err(ScheduleError::DeadlineBeforeStart { .. })
882 ));
883 assert!(matches!(
884 DispatchOffer::new(reference, 100, None, MIN_PRIORITY_FEE_PERCENTAGE - 1),
885 Err(ScheduleError::PriorityFeeOutOfRange { .. })
886 ));
887 }
888}