wavs_types/
service.rs

1use alloy_primitives::{hex, LogData};
2use iri_string::types::UriString;
3use semver::Version;
4use serde::{Deserialize, Serialize};
5use std::collections::{BTreeMap, BTreeSet};
6use std::num::{NonZeroU32, NonZeroU64};
7use std::str::FromStr;
8use utoipa::ToSchema;
9use wasm_pkg_common::package::PackageRef;
10
11use crate::{ByteArray, ComponentDigest, ServiceDigest, Timestamp};
12
13use super::{ChainKey, ServiceId, WorkflowId};
14
15/// Service validation is a runtime check, and depends on:
16///
17/// 1. All service handlers on a given chain use the same service manager
18/// 2. All service managers on non-source chains properly mirror the operator set of the source
19/// 3. All components are legitimate (e.g. can be downloaded, match the provided digest, execute as expected, etc.)
20#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)]
21#[serde(rename_all = "snake_case")]
22pub struct Service {
23    /// This is any utf-8 string, for human-readable display.
24    pub name: String,
25
26    /// We support multiple workflows in one service with unique service-scoped IDs.
27    pub workflows: BTreeMap<WorkflowId, Workflow>,
28
29    pub status: ServiceStatus,
30
31    pub manager: ServiceManager,
32}
33
34impl Service {
35    // this is only used for local/tests, but we want to keep it consistent
36    pub fn hash(&self) -> anyhow::Result<ServiceDigest> {
37        let service_bytes = serde_json::to_vec(self)?;
38        Ok(ServiceDigest::hash(&service_bytes))
39    }
40
41    pub fn id(&self) -> ServiceId {
42        ServiceId::from(&self.manager)
43    }
44}
45
46#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema, PartialOrd, Ord)]
47#[serde(rename_all = "snake_case")]
48pub enum ServiceManager {
49    Evm {
50        chain: ChainKey,
51        #[schema(value_type = String)]
52        address: alloy_primitives::Address,
53    },
54}
55
56impl From<&ServiceManager> for ServiceId {
57    fn from(manager: &ServiceManager) -> Self {
58        match manager {
59            ServiceManager::Evm { chain, address } => {
60                let mut bytes = Vec::new();
61                bytes.extend_from_slice(b"evm");
62                bytes.extend_from_slice(chain.to_string().as_bytes());
63                bytes.extend_from_slice(address.as_slice());
64                ServiceId::hash(bytes)
65            }
66        }
67    }
68}
69
70impl ServiceManager {
71    pub fn chain(&self) -> &ChainKey {
72        match self {
73            ServiceManager::Evm { chain, .. } => chain,
74        }
75    }
76
77    pub fn evm_address_unchecked(&self) -> alloy_primitives::Address {
78        match self {
79            ServiceManager::Evm { address, .. } => *address,
80        }
81    }
82}
83
84impl Service {
85    pub fn new_simple(
86        name: Option<String>,
87        trigger: Trigger,
88        source: ComponentSource,
89        submit: Submit,
90        manager: ServiceManager,
91    ) -> Self {
92        let workflow_id = WorkflowId::default();
93
94        let workflow = Workflow {
95            trigger,
96            component: Component::new(source),
97            submit,
98        };
99
100        let workflows = BTreeMap::from([(workflow_id, workflow)]);
101
102        Self {
103            name: name.unwrap_or_else(|| "Unknown".to_string()),
104            workflows,
105            status: ServiceStatus::Active,
106            manager,
107        }
108    }
109}
110
111#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)]
112#[serde(rename_all = "snake_case")]
113pub struct Component {
114    pub source: ComponentSource,
115
116    // What permissions this component has.
117    // These are currently not enforced, you can pass in Default::default() for now
118    pub permissions: Permissions,
119
120    /// The maximum amount of compute metering to allow for a single component execution
121    /// If not supplied, will be `Workflow::DEFAULT_FUEL_LIMIT`
122    pub fuel_limit: Option<u64>,
123
124    /// The maximum amount of time to allow for a single component execution, in seconds
125    /// If not supplied, default will be `Workflow::DEFAULT_TIME_LIMIT_SECONDS`
126    pub time_limit_seconds: Option<u64>,
127
128    /// Key-value pairs that are accessible in the components via host bindings.
129    pub config: BTreeMap<String, String>,
130
131    /// External env variable keys to be read from the system host on execute (i.e. API keys).
132    /// Must be prefixed with `WAVS_ENV_`.
133    pub env_keys: BTreeSet<String>,
134}
135
136#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, ToSchema)]
137#[serde(rename_all = "snake_case")]
138pub enum ComponentSource {
139    /// The wasm bytecode provided at fixed url, digest provided to ensure no tampering
140    Download {
141        #[schema(value_type = String)]
142        uri: UriString,
143        digest: ComponentDigest,
144    },
145    /// The wasm bytecode downloaded from a standard registry, digest provided to ensure no tampering
146    Registry {
147        #[serde(flatten)]
148        registry: Registry,
149    },
150    /// An already deployed component
151    Digest(ComponentDigest),
152}
153
154#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, ToSchema)]
155pub struct Registry {
156    pub digest: ComponentDigest,
157    /// Optional domain to use for a registry (such as ghcr.io)
158    /// if default of wa.dev (or whatever wavs uses in the future)
159    /// is not desired by user
160    pub domain: Option<String>,
161    /// Optional semver value, if absent then latest is used
162    #[schema(value_type = Option<String>)]
163    pub version: Option<Version>,
164    /// Package identifier of form <namespace>:<packagename>
165    #[schema(value_type = String)]
166    pub package: PackageRef,
167}
168
169impl ComponentSource {
170    pub fn digest(&self) -> &ComponentDigest {
171        match self {
172            ComponentSource::Download { digest, .. } => digest,
173            ComponentSource::Registry { registry } => &registry.digest,
174            ComponentSource::Digest(digest) => digest,
175        }
176    }
177}
178
179// FIXME: happy for a better name.
180/// This captures the triggers we listen to, the components we run, and how we submit the result
181#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)]
182#[serde(rename_all = "snake_case")]
183pub struct Workflow {
184    /// The trigger that fires this workflow
185    pub trigger: Trigger,
186
187    /// The component to run when the trigger fires
188    pub component: Component,
189
190    /// How to submit the result of the component.
191    pub submit: Submit,
192}
193
194impl Workflow {
195    pub const DEFAULT_FUEL_LIMIT: u64 = u64::MAX;
196    pub const DEFAULT_TIME_LIMIT_SECONDS: u64 = u64::MAX;
197}
198
199// The TriggerManager reacts to these triggers
200#[derive(Hash, Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)]
201#[serde(rename_all = "snake_case")]
202pub enum Trigger {
203    // A contract that emits an event
204    CosmosContractEvent {
205        #[schema(value_type = String)]
206        address: layer_climb_address::Address,
207        chain: ChainKey,
208        event_type: String,
209    },
210    EvmContractEvent {
211        #[schema(value_type = String)]
212        address: alloy_primitives::Address,
213        chain: ChainKey,
214        event_hash: ByteArray<32>,
215    },
216    BlockInterval {
217        /// The chain to use for the block interval
218        chain: ChainKey,
219        /// Number of blocks to wait between each execution
220        #[schema(value_type = u32)]
221        n_blocks: NonZeroU32,
222        /// Optional start block height indicating when the interval begins.
223        #[schema(value_type = Option<u64>)]
224        start_block: Option<NonZeroU64>,
225        /// Optional end block height indicating when the interval begins.
226        #[schema(value_type = Option<u64>)]
227        end_block: Option<NonZeroU64>,
228    },
229    Cron {
230        /// A cron expression defining the schedule for execution.
231        schedule: String,
232        /// Optional start time (timestamp in nanoseconds) indicating when the schedule begins.
233        start_time: Option<Timestamp>,
234        /// Optional end time (timestamp in nanoseconds) indicating when the schedule ends.
235        end_time: Option<Timestamp>,
236    },
237    // not a real trigger, just for testing
238    Manual,
239}
240
241/// The data that came from the trigger and is passed to the component after being converted into the WIT-friendly type
242#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)]
243pub enum TriggerData {
244    CosmosContractEvent {
245        /// The address of the contract that emitted the event
246        #[schema(value_type = String)]
247        contract_address: layer_climb_address::Address,
248        /// The chain where the event was emitted
249        chain: ChainKey,
250        /// The data that was emitted by the contract
251        #[schema(value_type = Object)]
252        event: cosmwasm_std::Event,
253        /// The block height where the event was emitted
254        block_height: u64,
255        /// The index of the event in this block, required for unique identification
256        event_index: u64,
257    },
258    EvmContractEvent {
259        /// The chain where the event was emitted
260        chain: ChainKey,
261        /// The address of the contract that emitted the event
262        #[schema(value_type = String)]
263        contract_address: alloy_primitives::Address,
264        /// The log data
265        #[schema(value_type = Object)]
266        log_data: LogData,
267        /// The transaction hash where the event was emitted
268        #[schema(value_type = String)]
269        tx_hash: alloy_primitives::TxHash,
270        /// The block height where the event was emitted
271        block_number: u64,
272        /// The index of the log in the block
273        log_index: u64,
274        // these are all optional because they may not be present in the log and we don't need them
275        /// Hash of the block the transaction that emitted this log was mined in
276        #[schema(value_type = String)]
277        block_hash: alloy_primitives::B256,
278        /// The timestamp of the block containing this event, as proposed in https://github.com/ethereum/execution-apis/issues/295
279        /// This field is optional since nodes are not required to include it in event logs.
280        /// If not provided, applications may need to fetch the block header directly to obtain the timestamp.
281        block_timestamp: Option<u64>,
282        /// Index of the Transaction in the block
283        tx_index: u64,
284    },
285    BlockInterval {
286        /// The chain where the blocks are checked
287        chain: ChainKey,
288        /// The block height where the event was emitted
289        block_height: u64,
290    },
291    Cron {
292        /// The trigger time
293        trigger_time: Timestamp,
294    },
295    Raw(Vec<u8>),
296}
297
298impl Default for TriggerData {
299    fn default() -> Self {
300        Self::new_raw(vec![])
301    }
302}
303
304impl TriggerData {
305    pub fn new_raw(data: impl AsRef<[u8]>) -> Self {
306        TriggerData::Raw(data.as_ref().to_vec())
307    }
308
309    pub fn trigger_type(&self) -> &str {
310        match self {
311            TriggerData::CosmosContractEvent { .. } => "cosmos_contract_event",
312            TriggerData::EvmContractEvent { .. } => "evm_contract_event",
313            TriggerData::BlockInterval { .. } => "block_interval",
314            TriggerData::Cron { .. } => "cron",
315            TriggerData::Raw(_) => "manual",
316        }
317    }
318
319    pub fn chain(&self) -> Option<&ChainKey> {
320        match self {
321            TriggerData::CosmosContractEvent { chain, .. }
322            | TriggerData::EvmContractEvent { chain, .. }
323            | TriggerData::BlockInterval { chain, .. } => Some(chain),
324            TriggerData::Cron { .. } | TriggerData::Raw(_) => None,
325        }
326    }
327}
328
329/// A bundle of the trigger and the associated data needed to take action on it
330#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, bincode::Decode, bincode::Encode)]
331pub struct TriggerAction {
332    #[bincode(with_serde)]
333    /// Identify which trigger this came from
334    pub config: TriggerConfig,
335
336    #[bincode(with_serde)]
337    /// The data that came from the trigger
338    pub data: TriggerData,
339}
340
341#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
342// Trigger with metadata so it can be identified in relation to services and workflows
343pub struct TriggerConfig {
344    pub service_id: ServiceId,
345    pub workflow_id: WorkflowId,
346    pub trigger: Trigger,
347}
348
349// TODO - rename this? Trigger is a noun, Submit is a verb.. feels a bit weird
350#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)]
351#[serde(rename_all = "snake_case")]
352pub enum Submit {
353    // useful for when the component just does something with its own state
354    None,
355    Aggregator {
356        /// The aggregator endpoint
357        url: String,
358        /// component dynamically determines the destination
359        component: Box<Component>,
360        signature_kind: SignatureKind,
361    },
362}
363
364/// Defines the signature configuration for cryptographic operations in WAVS.
365///
366/// This struct separates the cryptographic algorithm from the message formatting
367/// to provide flexibility in signature schemes while maintaining compatibility
368/// across different blockchain ecosystems.
369///
370/// ## Why Separate Algorithm and Prefix?
371///
372/// The separation of `algorithm` and `prefix` serves several important purposes:
373///
374/// 1. **Algorithm Independence**: The same cryptographic algorithm (e.g., secp256k1)
375///    can be used with different message formatting schemes. This allows the same
376///    private key to work across different contexts.
377///
378/// 2. **Ethereum Compatibility**: Some signatures need EIP-191 prefixing for
379///    Ethereum compatibility, while others work with raw message hashes. The
380///    optional prefix allows both modes.
381///
382/// 3. **Future Extensibility**: As new signature algorithms (BLS12-381, Ed25519, etc.)
383///    and prefix schemes are added, this structure can accommodate them without
384///    breaking changes.
385#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)]
386pub struct SignatureKind {
387    /// The cryptographic algorithm used for signature generation and verification.
388    ///
389    /// This determines the elliptic curve and mathematical operations used,
390    /// but not how the message is formatted before signing.
391    pub algorithm: SignatureAlgorithm,
392
393    /// Optional message prefix scheme applied before signing.
394    ///
395    /// When `Some(prefix)`, the message is formatted according to the specified
396    /// scheme (e.g., EIP-191 for Ethereum compatibility). When `None`, the raw
397    /// message hash is signed directly.
398    pub prefix: Option<SignaturePrefix>,
399}
400
401impl SignatureKind {
402    pub fn evm_default() -> Self {
403        Self {
404            algorithm: SignatureAlgorithm::Secp256k1,
405            prefix: Some(SignaturePrefix::Eip191),
406        }
407    }
408}
409
410#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)]
411#[serde(rename_all = "snake_case")]
412pub enum SignatureAlgorithm {
413    Secp256k1,
414    // Future: Bls12381, Ed25519, Secp256r1, etc.
415}
416
417#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)]
418#[serde(rename_all = "snake_case")]
419pub enum SignaturePrefix {
420    Eip191,
421}
422
423#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)]
424#[serde(rename_all = "snake_case")]
425pub enum Aggregator {
426    Evm(EvmContractSubmission),
427}
428
429#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)]
430#[serde(rename_all = "snake_case")]
431pub struct EvmContractSubmission {
432    pub chain: ChainKey,
433    /// Should be an IWavsServiceHandler contract
434    #[schema(value_type = String)]
435    pub address: alloy_primitives::Address,
436    /// max gas for the submission
437    /// with an aggregator, that will be for all the signed envelopes combined
438    /// without an aggregator, it's just the single signed envelope
439    pub max_gas: Option<u64>,
440}
441
442impl EvmContractSubmission {
443    pub fn new(chain: ChainKey, address: alloy_primitives::Address, max_gas: Option<u64>) -> Self {
444        Self {
445            chain,
446            address,
447            max_gas,
448        }
449    }
450}
451
452#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Copy, ToSchema)]
453#[serde(rename_all = "snake_case")]
454pub enum ServiceStatus {
455    Active,
456    // Service is paused, no workflows will be executed
457    // however the service can still be queried for AVS Key etc.
458    Paused,
459}
460
461impl FromStr for ServiceStatus {
462    type Err = anyhow::Error;
463
464    fn from_str(s: &str) -> Result<Self, Self::Err> {
465        match s.to_lowercase().as_str() {
466            "active" => Ok(ServiceStatus::Active),
467            "paused" => Ok(ServiceStatus::Paused),
468            _ => Err(anyhow::anyhow!("Invalid service status: {}", s)),
469        }
470    }
471}
472
473#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)]
474#[serde(default, rename_all = "snake_case")]
475#[derive(Default)]
476pub struct Permissions {
477    /// If it can talk to http hosts on the network
478    pub allowed_http_hosts: AllowedHostPermission,
479    /// If it can write to it's own local directory in the filesystem
480    pub file_system: bool,
481}
482
483#[test]
484fn permission_defaults() {
485    let permissions_json: Permissions = serde_json::from_str("{}").unwrap();
486    let permissions_default: Permissions = Permissions::default();
487
488    assert_eq!(permissions_json, permissions_default);
489    assert_eq!(
490        permissions_default.allowed_http_hosts,
491        AllowedHostPermission::None
492    );
493    assert!(!permissions_default.file_system);
494}
495
496// TODO: remove / change defaults?
497
498#[derive(Serialize, Deserialize, Clone, Default, Debug, PartialEq, Eq, ToSchema)]
499#[serde(rename_all = "snake_case")]
500pub enum AllowedHostPermission {
501    All,
502    Only(Vec<String>),
503    #[default]
504    None,
505}
506
507#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
508#[serde(default, rename_all = "snake_case")]
509#[derive(Default)]
510pub struct WasmResponse {
511    #[serde(with = "hex")]
512    pub payload: Vec<u8>,
513    pub ordering: Option<u64>,
514}
515
516// TODO - these shouldn't be needed in main code... gate behind `debug_assertions`
517// will need to go through use-cases of `test-utils`, maybe move into layer-tests or something
518mod test_ext {
519    use std::{
520        collections::{BTreeMap, BTreeSet},
521        num::NonZeroU32,
522    };
523
524    use crate::{
525        ByteArray, ChainKey, ChainKeyError, ComponentSource, ServiceId, WorkflowId, WorkflowIdError,
526    };
527
528    use super::{Component, Trigger, TriggerConfig};
529
530    impl Component {
531        pub fn new(source: ComponentSource) -> Component {
532            Self {
533                source,
534                permissions: Default::default(),
535                fuel_limit: None,
536                time_limit_seconds: None,
537                config: BTreeMap::new(),
538                env_keys: BTreeSet::new(),
539            }
540        }
541    }
542
543    impl Trigger {
544        pub fn cosmos_contract_event(
545            address: layer_climb_address::Address,
546            chain: impl TryInto<ChainKey, Error = ChainKeyError>,
547            event_type: impl ToString,
548        ) -> Self {
549            Trigger::CosmosContractEvent {
550                address,
551                chain: chain.try_into().unwrap(),
552                event_type: event_type.to_string(),
553            }
554        }
555        pub fn evm_contract_event(
556            address: alloy_primitives::Address,
557            chain: impl TryInto<ChainKey, Error = ChainKeyError>,
558            event_hash: ByteArray<32>,
559        ) -> Self {
560            Trigger::EvmContractEvent {
561                address,
562                chain: chain.try_into().unwrap(),
563                event_hash,
564            }
565        }
566    }
567
568    impl TriggerConfig {
569        pub fn cosmos_contract_event(
570            service_id: ServiceId,
571            workflow_id: impl TryInto<WorkflowId, Error = WorkflowIdError>,
572            contract_address: layer_climb_address::Address,
573            chain: impl TryInto<ChainKey, Error = ChainKeyError>,
574            event_type: impl ToString,
575        ) -> Self {
576            Self {
577                service_id,
578                workflow_id: workflow_id.try_into().unwrap(),
579                trigger: Trigger::cosmos_contract_event(contract_address, chain, event_type),
580            }
581        }
582
583        pub fn evm_contract_event(
584            service_id: ServiceId,
585            workflow_id: impl TryInto<WorkflowId, Error = WorkflowIdError>,
586            contract_address: alloy_primitives::Address,
587            chain: impl TryInto<ChainKey, Error = ChainKeyError>,
588            event_hash: ByteArray<32>,
589        ) -> Self {
590            Self {
591                service_id,
592                workflow_id: workflow_id.try_into().unwrap(),
593                trigger: Trigger::evm_contract_event(contract_address, chain, event_hash),
594            }
595        }
596
597        pub fn block_interval_event(
598            service_id: ServiceId,
599            workflow_id: impl TryInto<WorkflowId, Error = WorkflowIdError>,
600            chain: impl TryInto<ChainKey, Error = ChainKeyError>,
601            n_blocks: NonZeroU32,
602        ) -> Self {
603            Self {
604                service_id,
605                workflow_id: workflow_id.try_into().unwrap(),
606                trigger: Trigger::BlockInterval {
607                    chain: chain.try_into().unwrap(),
608                    n_blocks,
609                    start_block: None,
610                    end_block: None,
611                },
612            }
613        }
614
615        #[cfg(test)]
616        pub fn manual(
617            service_id: ServiceId,
618            workflow_id: impl TryInto<WorkflowId, Error = WorkflowIdError>,
619        ) -> Self {
620            Self {
621                service_id,
622                workflow_id: workflow_id.try_into().unwrap(),
623                trigger: Trigger::Manual,
624            }
625        }
626    }
627}