1use alloy_primitives::LogData;
2use anyhow::bail;
3use iri_string::types::UriString;
4use semver::Version;
5use serde::{Deserialize, Serialize};
6use std::collections::{BTreeMap, BTreeSet};
7use std::num::{NonZeroU32, NonZeroU64};
8use std::str::FromStr;
9use thiserror::Error;
10use utoipa::ToSchema;
11use wasm_pkg_common::package::PackageRef;
12
13#[cfg(feature = "ts-bindings")]
14use ts_rs::TS;
15
16use crate::{ByteArray, ComponentDigest, ServiceDigest, Timestamp};
17
18use super::{ChainKey, ServiceId, WorkflowId};
19
20#[cfg_attr(feature = "ts-bindings", derive(TS))]
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ToSchema, Hash)]
23#[serde(rename_all = "snake_case")]
24pub enum AtProtoAction {
25 Create,
27 Update,
29 Delete,
31}
32
33impl std::fmt::Display for AtProtoAction {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 match self {
36 AtProtoAction::Create => write!(f, "create"),
37 AtProtoAction::Update => write!(f, "update"),
38 AtProtoAction::Delete => write!(f, "delete"),
39 }
40 }
41}
42
43impl FromStr for AtProtoAction {
44 type Err = anyhow::Error;
45
46 fn from_str(s: &str) -> Result<Self, Self::Err> {
47 match s.to_lowercase().as_str() {
48 "create" => Ok(AtProtoAction::Create),
49 "update" => Ok(AtProtoAction::Update),
50 "delete" => Ok(AtProtoAction::Delete),
51 _ => bail!(
52 "Invalid action '{}'. Must be one of: create, update, delete",
53 s
54 ),
55 }
56 }
57}
58
59#[derive(Error, Debug)]
60pub enum ServiceError {
61 #[error("Failed to serialize service for hashing: {0}")]
62 SerializationError(#[from] serde_json::Error),
63}
64
65#[cfg_attr(feature = "ts-bindings", derive(TS))]
71#[cfg_attr(feature = "ts-bindings", ts(export))]
72#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)]
73#[serde(rename_all = "snake_case")]
74pub struct Service {
75 pub name: String,
77
78 pub workflows: BTreeMap<WorkflowId, Workflow>,
80
81 pub status: ServiceStatus,
82
83 pub manager: ServiceManager,
84}
85
86impl Service {
87 pub fn hash(&self) -> Result<ServiceDigest, ServiceError> {
89 let service_bytes = serde_json::to_vec(self)?;
90 Ok(ServiceDigest::hash(&service_bytes))
91 }
92
93 pub fn id(&self) -> ServiceId {
94 ServiceId::from(&self.manager)
95 }
96}
97
98#[cfg_attr(feature = "ts-bindings", derive(TS))]
99#[cfg_attr(feature = "ts-bindings", ts(export))]
100#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema, PartialOrd, Ord)]
101#[serde(rename_all = "snake_case")]
102pub enum ServiceManager {
103 Evm {
104 chain: ChainKey,
105 #[schema(value_type = String)]
106 #[cfg_attr(feature = "ts-bindings", ts(type = "string"))]
107 address: alloy_primitives::Address,
108 },
109 Cosmos {
110 chain: ChainKey,
111 #[schema(value_type = String)]
112 #[cfg_attr(feature = "ts-bindings", ts(type = "string"))]
113 address: layer_climb_address::CosmosAddr,
114 },
115}
116
117impl From<&ServiceManager> for ServiceId {
118 fn from(manager: &ServiceManager) -> Self {
119 match manager {
120 ServiceManager::Evm { chain, address } => {
121 let mut bytes = Vec::new();
122 bytes.extend_from_slice(b"evm");
123 bytes.extend_from_slice(chain.to_string().as_bytes());
124 bytes.extend_from_slice(address.as_slice());
125 ServiceId::hash(bytes)
126 }
127 ServiceManager::Cosmos { chain, address } => {
128 let mut bytes = Vec::new();
129 bytes.extend_from_slice(b"cosmos");
130 bytes.extend_from_slice(chain.to_string().as_bytes());
131 bytes.extend_from_slice(&address.to_vec());
132 ServiceId::hash(bytes)
133 }
134 }
135 }
136}
137
138impl ServiceManager {
139 pub fn chain(&self) -> &ChainKey {
140 match self {
141 ServiceManager::Evm { chain, .. } => chain,
142 ServiceManager::Cosmos { chain, .. } => chain,
143 }
144 }
145 pub fn address(&self) -> layer_climb_address::Address {
146 match self {
147 ServiceManager::Evm { address, .. } => (*address).into(),
148 ServiceManager::Cosmos { address, .. } => address.clone().into(),
149 }
150 }
151}
152
153impl Service {
154 pub fn new_simple(
155 name: Option<String>,
156 trigger: Trigger,
157 source: ComponentSource,
158 submit: Submit,
159 manager: ServiceManager,
160 ) -> Self {
161 let workflow_id = WorkflowId::default();
162
163 let workflow = Workflow {
164 trigger,
165 component: Component::new(source),
166 submit,
167 };
168
169 let workflows = BTreeMap::from([(workflow_id, workflow)]);
170
171 Self {
172 name: name.unwrap_or_else(|| "Unknown".to_string()),
173 workflows,
174 status: ServiceStatus::Active,
175 manager,
176 }
177 }
178}
179
180#[cfg_attr(feature = "ts-bindings", derive(TS))]
181#[cfg_attr(feature = "ts-bindings", ts(export))]
182#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)]
183#[serde(rename_all = "snake_case")]
184pub struct Component {
185 pub source: ComponentSource,
186
187 pub permissions: Permissions,
190
191 pub fuel_limit: Option<u64>,
194
195 pub time_limit_seconds: Option<u64>,
198
199 pub config: BTreeMap<String, String>,
201
202 pub env_keys: BTreeSet<String>,
205}
206
207#[cfg_attr(feature = "ts-bindings", derive(TS))]
208#[cfg_attr(feature = "ts-bindings", ts(export))]
209#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, ToSchema)]
210#[serde(rename_all = "snake_case")]
211pub enum ComponentSource {
212 Download {
214 #[schema(value_type = String)]
215 #[cfg_attr(feature = "ts-bindings", ts(type = "string"))]
216 uri: UriString,
217 #[cfg_attr(feature = "ts-bindings", ts(type = "string"))]
218 digest: ComponentDigest,
219 },
220 Registry {
222 #[serde(flatten)]
223 registry: Registry,
224 },
225 #[cfg_attr(feature = "ts-bindings", ts(type = "string"))]
227 Digest(ComponentDigest),
228}
229
230#[cfg_attr(feature = "ts-bindings", derive(TS))]
231#[cfg_attr(feature = "ts-bindings", ts(export))]
232#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, ToSchema)]
233pub struct Registry {
234 pub digest: ComponentDigest,
235 pub domain: Option<String>,
239 #[schema(value_type = Option<String>)]
241 #[cfg_attr(feature = "ts-bindings", ts(type = "string | null"))]
242 pub version: Option<Version>,
243 #[schema(value_type = String)]
245 #[cfg_attr(feature = "ts-bindings", ts(type = "string"))]
246 pub package: PackageRef,
247}
248
249impl ComponentSource {
250 pub fn digest(&self) -> &ComponentDigest {
251 match self {
252 ComponentSource::Download { digest, .. } => digest,
253 ComponentSource::Registry { registry } => ®istry.digest,
254 ComponentSource::Digest(digest) => digest,
255 }
256 }
257}
258
259#[cfg_attr(feature = "ts-bindings", derive(TS))]
262#[cfg_attr(feature = "ts-bindings", ts(export))]
263#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)]
264#[serde(rename_all = "snake_case")]
265pub struct Workflow {
266 pub trigger: Trigger,
268
269 pub component: Component,
271
272 pub submit: Submit,
274}
275
276impl Workflow {
277 pub const DEFAULT_FUEL_LIMIT: u64 = u64::MAX;
278 pub const DEFAULT_TIME_LIMIT_SECONDS: u64 = u64::MAX;
279}
280
281#[cfg_attr(feature = "ts-bindings", derive(TS))]
283#[cfg_attr(feature = "ts-bindings", ts(export))]
284#[derive(Hash, Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)]
285#[serde(rename_all = "snake_case")]
286pub enum Trigger {
287 CosmosContractEvent {
289 #[schema(value_type = String)]
290 #[cfg_attr(feature = "ts-bindings", ts(type = "string"))]
291 address: layer_climb_address::CosmosAddr,
292 chain: ChainKey,
293 event_type: String,
294 },
295 EvmContractEvent {
296 #[schema(value_type = String)]
297 #[cfg_attr(feature = "ts-bindings", ts(type = "string"))]
298 address: alloy_primitives::Address,
299 chain: ChainKey,
300 #[cfg_attr(feature = "ts-bindings", ts(type = "string"))]
301 event_hash: ByteArray<32>,
302 },
303 BlockInterval {
304 chain: ChainKey,
306 #[schema(value_type = u32)]
308 n_blocks: NonZeroU32,
309 #[schema(value_type = Option<u64>)]
311 start_block: Option<NonZeroU64>,
312 #[schema(value_type = Option<u64>)]
314 end_block: Option<NonZeroU64>,
315 },
316 Cron {
317 schedule: String,
319 start_time: Option<Timestamp>,
321 end_time: Option<Timestamp>,
323 },
324 AtProtoEvent {
326 collection: String,
329 repo_did: Option<String>,
332 action: Option<AtProtoAction>,
335 },
336 HypercoreAppend {
338 feed_key: String,
340 },
341 Manual,
343}
344
345#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)]
347pub enum TriggerData {
348 CosmosContractEvent {
349 #[schema(value_type = String)]
351 contract_address: layer_climb_address::CosmosAddr,
352 chain: ChainKey,
354 #[schema(value_type = Object)]
356 event: cosmwasm_std::Event,
357 block_height: u64,
359 event_index: u64,
361 },
362 EvmContractEvent {
363 chain: ChainKey,
365 #[schema(value_type = String)]
367 contract_address: alloy_primitives::Address,
368 #[schema(value_type = Object)]
370 log_data: LogData,
371 #[schema(value_type = String)]
373 tx_hash: alloy_primitives::TxHash,
374 block_number: u64,
376 log_index: u64,
378 #[schema(value_type = String)]
381 block_hash: alloy_primitives::B256,
382 block_timestamp: Option<u64>,
386 tx_index: u64,
388 },
389 BlockInterval {
390 chain: ChainKey,
392 block_height: u64,
394 },
395 Cron {
396 trigger_time: Timestamp,
398 },
399 AtProtoEvent {
401 sequence: i64,
403 timestamp: i64,
405 repo: String,
407 collection: String,
409 rkey: String,
411 action: AtProtoAction,
413 cid: Option<String>,
415 record: Option<serde_json::Value>,
417 rev: Option<String>,
419 op_index: Option<u32>,
421 },
422 HypercoreAppend {
423 feed_key: String,
425 index: u64,
427 data: Vec<u8>,
429 },
430 Raw(Vec<u8>),
431}
432
433impl Default for TriggerData {
434 fn default() -> Self {
435 Self::new_raw(vec![])
436 }
437}
438
439impl TriggerData {
440 pub fn new_raw(data: impl AsRef<[u8]>) -> Self {
441 TriggerData::Raw(data.as_ref().to_vec())
442 }
443
444 pub fn trigger_type(&self) -> &str {
445 match self {
446 TriggerData::CosmosContractEvent { .. } => "cosmos_contract_event",
447 TriggerData::EvmContractEvent { .. } => "evm_contract_event",
448 TriggerData::BlockInterval { .. } => "block_interval",
449 TriggerData::Cron { .. } => "cron",
450 TriggerData::AtProtoEvent { .. } => "atproto_event",
451 TriggerData::HypercoreAppend { .. } => "hypercore_append",
452 TriggerData::Raw(_) => "manual",
453 }
454 }
455
456 pub fn chain(&self) -> Option<&ChainKey> {
457 match self {
458 TriggerData::CosmosContractEvent { chain, .. }
459 | TriggerData::EvmContractEvent { chain, .. }
460 | TriggerData::BlockInterval { chain, .. } => Some(chain),
461 TriggerData::Cron { .. }
462 | TriggerData::AtProtoEvent { .. }
463 | TriggerData::HypercoreAppend { .. }
464 | TriggerData::Raw(_) => None,
465 }
466 }
467}
468
469#[derive(
471 Serialize, Deserialize, Clone, Debug, PartialEq, Eq, bincode::Decode, bincode::Encode, ToSchema,
472)]
473pub struct TriggerAction {
474 #[bincode(with_serde)]
475 pub config: TriggerConfig,
477
478 #[bincode(with_serde)]
479 pub data: TriggerData,
481}
482
483#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)]
484pub struct TriggerConfig {
486 pub service_id: ServiceId,
487 pub workflow_id: WorkflowId,
488 pub trigger: Trigger,
489}
490
491#[cfg_attr(feature = "ts-bindings", derive(TS))]
493#[cfg_attr(feature = "ts-bindings", ts(export))]
494#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)]
495#[serde(rename_all = "snake_case")]
496pub enum Submit {
497 None,
499 Aggregator {
500 component: Box<Component>,
502 signature_kind: SignatureKind,
503 },
504}
505
506#[cfg_attr(feature = "ts-bindings", derive(TS))]
528#[cfg_attr(feature = "ts-bindings", ts(export))]
529#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema, Hash)]
530pub struct SignatureKind {
531 pub algorithm: SignatureAlgorithm,
536
537 pub prefix: Option<SignaturePrefix>,
543}
544
545impl SignatureKind {
546 pub fn evm_default() -> Self {
547 Self {
548 algorithm: SignatureAlgorithm::Secp256k1,
549 prefix: Some(SignaturePrefix::Eip191),
550 }
551 }
552}
553
554#[cfg_attr(feature = "ts-bindings", derive(TS))]
555#[cfg_attr(feature = "ts-bindings", ts(export))]
556#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema, Hash)]
557#[serde(rename_all = "snake_case")]
558pub enum SignatureAlgorithm {
559 Secp256k1,
560 }
562
563#[cfg_attr(feature = "ts-bindings", derive(TS))]
564#[cfg_attr(feature = "ts-bindings", ts(export))]
565#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema, Hash)]
566#[serde(rename_all = "snake_case")]
567pub enum SignaturePrefix {
568 Eip191,
569}
570
571#[cfg_attr(feature = "ts-bindings", derive(TS))]
572#[cfg_attr(feature = "ts-bindings", ts(export))]
573#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Copy, ToSchema)]
574#[serde(rename_all = "snake_case")]
575pub enum ServiceStatus {
576 Active,
577 Paused,
580}
581
582impl FromStr for ServiceStatus {
583 type Err = anyhow::Error;
584
585 fn from_str(s: &str) -> Result<Self, Self::Err> {
586 match s.to_lowercase().as_str() {
587 "active" => Ok(ServiceStatus::Active),
588 "paused" => Ok(ServiceStatus::Paused),
589 _ => Err(anyhow::anyhow!("Invalid service status: {}", s)),
590 }
591 }
592}
593
594#[cfg_attr(feature = "ts-bindings", derive(TS))]
595#[cfg_attr(feature = "ts-bindings", ts(export))]
596#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema, Default)]
597#[serde(default, rename_all = "snake_case")]
598pub struct Permissions {
599 pub allowed_http_hosts: AllowedHostPermission,
601 pub file_system: bool,
603 pub raw_sockets: bool,
605 pub dns_resolution: bool,
607}
608
609#[test]
610fn permission_defaults() {
611 let permissions_json: Permissions = serde_json::from_str("{}").unwrap();
612 let permissions_default: Permissions = Permissions::default();
613
614 assert_eq!(permissions_json, permissions_default);
615 assert_eq!(
616 permissions_default.allowed_http_hosts,
617 AllowedHostPermission::None
618 );
619 assert!(!permissions_default.file_system);
620}
621
622#[cfg_attr(feature = "ts-bindings", derive(TS))]
625#[cfg_attr(feature = "ts-bindings", ts(export))]
626#[derive(Serialize, Deserialize, Clone, Default, Debug, PartialEq, Eq, ToSchema)]
627#[serde(rename_all = "snake_case")]
628pub enum AllowedHostPermission {
629 All,
630 Only(Vec<String>),
631 #[default]
632 None,
633}
634
635#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)]
636#[serde(default, rename_all = "snake_case")]
637#[derive(Default)]
638pub struct WasmResponse {
639 #[serde(with = "const_hex")]
640 pub payload: Vec<u8>,
641 pub ordering: Option<u64>,
642 #[serde(with = "crate::serde_helpers::option_const_hex")]
643 pub event_id_salt: Option<Vec<u8>>,
644}
645
646impl WasmResponse {
647 pub const DEFAULT_MAX_PAYLOAD_SIZE: usize = 50 * 1024 * 1024;
649 pub const DEFAULT_MAX_SALT_SIZE: usize = 1024 * 1024;
651
652 pub fn validate_size(
654 &self,
655 max_payload_size: usize,
656 max_salt_size: usize,
657 ) -> Result<(), WasmResponseSizeError> {
658 if self.payload.len() > max_payload_size {
659 return Err(WasmResponseSizeError::PayloadTooLarge {
660 size: self.payload.len(),
661 max: max_payload_size,
662 });
663 }
664 if let Some(salt) = &self.event_id_salt {
665 if salt.len() > max_salt_size {
666 return Err(WasmResponseSizeError::SaltTooLarge {
667 size: salt.len(),
668 max: max_salt_size,
669 });
670 }
671 }
672 Ok(())
673 }
674}
675
676#[derive(Debug, thiserror::Error)]
677pub enum WasmResponseSizeError {
678 #[error("Payload size {size} bytes exceeds maximum of {max} bytes")]
679 PayloadTooLarge { size: usize, max: usize },
680 #[error("Event ID salt size {size} bytes exceeds maximum of {max} bytes")]
681 SaltTooLarge { size: usize, max: usize },
682}
683
684mod test_ext {
687 use std::{
688 collections::{BTreeMap, BTreeSet},
689 num::NonZeroU32,
690 };
691
692 use crate::{
693 ByteArray, ChainKey, ChainKeyError, ComponentSource, ServiceId, WorkflowId, WorkflowIdError,
694 };
695
696 use super::{Component, Trigger, TriggerConfig};
697
698 impl Component {
699 pub fn new(source: ComponentSource) -> Component {
700 Self {
701 source,
702 permissions: Default::default(),
703 fuel_limit: None,
704 time_limit_seconds: None,
705 config: BTreeMap::new(),
706 env_keys: BTreeSet::new(),
707 }
708 }
709 }
710
711 impl Trigger {
712 pub fn cosmos_contract_event(
713 address: layer_climb_address::CosmosAddr,
714 chain: impl TryInto<ChainKey, Error = ChainKeyError>,
715 event_type: impl ToString,
716 ) -> Self {
717 Trigger::CosmosContractEvent {
718 address,
719 chain: chain.try_into().unwrap(),
720 event_type: event_type.to_string(),
721 }
722 }
723 pub fn evm_contract_event(
724 address: alloy_primitives::Address,
725 chain: impl TryInto<ChainKey, Error = ChainKeyError>,
726 event_hash: ByteArray<32>,
727 ) -> Self {
728 Trigger::EvmContractEvent {
729 address,
730 chain: chain.try_into().unwrap(),
731 event_hash,
732 }
733 }
734 }
735
736 impl TriggerConfig {
737 pub fn cosmos_contract_event(
738 service_id: ServiceId,
739 workflow_id: impl TryInto<WorkflowId, Error = WorkflowIdError>,
740 contract_address: layer_climb_address::CosmosAddr,
741 chain: impl TryInto<ChainKey, Error = ChainKeyError>,
742 event_type: impl ToString,
743 ) -> Self {
744 Self {
745 service_id,
746 workflow_id: workflow_id.try_into().unwrap(),
747 trigger: Trigger::cosmos_contract_event(contract_address, chain, event_type),
748 }
749 }
750
751 pub fn evm_contract_event(
752 service_id: ServiceId,
753 workflow_id: impl TryInto<WorkflowId, Error = WorkflowIdError>,
754 contract_address: alloy_primitives::Address,
755 chain: impl TryInto<ChainKey, Error = ChainKeyError>,
756 event_hash: ByteArray<32>,
757 ) -> Self {
758 Self {
759 service_id,
760 workflow_id: workflow_id.try_into().unwrap(),
761 trigger: Trigger::evm_contract_event(contract_address, chain, event_hash),
762 }
763 }
764
765 pub fn block_interval_event(
766 service_id: ServiceId,
767 workflow_id: impl TryInto<WorkflowId, Error = WorkflowIdError>,
768 chain: impl TryInto<ChainKey, Error = ChainKeyError>,
769 n_blocks: NonZeroU32,
770 ) -> Self {
771 Self {
772 service_id,
773 workflow_id: workflow_id.try_into().unwrap(),
774 trigger: Trigger::BlockInterval {
775 chain: chain.try_into().unwrap(),
776 n_blocks,
777 start_block: None,
778 end_block: None,
779 },
780 }
781 }
782
783 #[cfg(test)]
784 pub fn manual(
785 service_id: ServiceId,
786 workflow_id: impl TryInto<WorkflowId, Error = WorkflowIdError>,
787 ) -> Self {
788 Self {
789 service_id,
790 workflow_id: workflow_id.try_into().unwrap(),
791 trigger: Trigger::Manual,
792 }
793 }
794 }
795}