use alloy_primitives::LogData;
use anyhow::bail;
use iri_string::types::UriString;
use semver::Version;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::num::{NonZeroU32, NonZeroU64};
use std::str::FromStr;
use thiserror::Error;
use utoipa::ToSchema;
use wasm_pkg_common::package::PackageRef;
#[cfg(feature = "ts-bindings")]
use ts_rs::TS;
use crate::{ByteArray, ComponentDigest, ServiceDigest, Timestamp};
use super::{ChainKey, ServiceId, WorkflowId};
#[cfg_attr(feature = "ts-bindings", derive(TS))]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ToSchema, Hash)]
#[serde(rename_all = "snake_case")]
pub enum AtProtoAction {
Create,
Update,
Delete,
}
impl std::fmt::Display for AtProtoAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AtProtoAction::Create => write!(f, "create"),
AtProtoAction::Update => write!(f, "update"),
AtProtoAction::Delete => write!(f, "delete"),
}
}
}
impl FromStr for AtProtoAction {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"create" => Ok(AtProtoAction::Create),
"update" => Ok(AtProtoAction::Update),
"delete" => Ok(AtProtoAction::Delete),
_ => bail!(
"Invalid action '{}'. Must be one of: create, update, delete",
s
),
}
}
}
#[derive(Error, Debug)]
pub enum ServiceError {
#[error("Failed to serialize service for hashing: {0}")]
SerializationError(#[from] serde_json::Error),
}
#[cfg_attr(feature = "ts-bindings", derive(TS))]
#[cfg_attr(feature = "ts-bindings", ts(export))]
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)]
#[serde(rename_all = "snake_case")]
pub struct Service {
pub name: String,
pub workflows: BTreeMap<WorkflowId, Workflow>,
pub status: ServiceStatus,
pub manager: ServiceManager,
}
impl Service {
pub fn hash(&self) -> Result<ServiceDigest, ServiceError> {
let service_bytes = serde_json::to_vec(self)?;
Ok(ServiceDigest::hash(&service_bytes))
}
pub fn id(&self) -> ServiceId {
ServiceId::from(&self.manager)
}
}
#[cfg_attr(feature = "ts-bindings", derive(TS))]
#[cfg_attr(feature = "ts-bindings", ts(export))]
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum ServiceManager {
Evm {
chain: ChainKey,
#[schema(value_type = String)]
#[cfg_attr(feature = "ts-bindings", ts(type = "string"))]
address: alloy_primitives::Address,
},
Cosmos {
chain: ChainKey,
#[schema(value_type = String)]
#[cfg_attr(feature = "ts-bindings", ts(type = "string"))]
address: layer_climb_address::CosmosAddr,
},
}
impl From<&ServiceManager> for ServiceId {
fn from(manager: &ServiceManager) -> Self {
match manager {
ServiceManager::Evm { chain, address } => {
let mut bytes = Vec::new();
bytes.extend_from_slice(b"evm");
bytes.extend_from_slice(chain.to_string().as_bytes());
bytes.extend_from_slice(address.as_slice());
ServiceId::hash(bytes)
}
ServiceManager::Cosmos { chain, address } => {
let mut bytes = Vec::new();
bytes.extend_from_slice(b"cosmos");
bytes.extend_from_slice(chain.to_string().as_bytes());
bytes.extend_from_slice(&address.to_vec());
ServiceId::hash(bytes)
}
}
}
}
impl ServiceManager {
pub fn chain(&self) -> &ChainKey {
match self {
ServiceManager::Evm { chain, .. } => chain,
ServiceManager::Cosmos { chain, .. } => chain,
}
}
pub fn address(&self) -> layer_climb_address::Address {
match self {
ServiceManager::Evm { address, .. } => (*address).into(),
ServiceManager::Cosmos { address, .. } => address.clone().into(),
}
}
}
impl Service {
pub fn new_simple(
name: Option<String>,
trigger: Trigger,
source: ComponentSource,
submit: Submit,
manager: ServiceManager,
) -> Self {
let workflow_id = WorkflowId::default();
let workflow = Workflow {
trigger,
component: Component::new(source),
submit,
};
let workflows = BTreeMap::from([(workflow_id, workflow)]);
Self {
name: name.unwrap_or_else(|| "Unknown".to_string()),
workflows,
status: ServiceStatus::Active,
manager,
}
}
}
#[cfg_attr(feature = "ts-bindings", derive(TS))]
#[cfg_attr(feature = "ts-bindings", ts(export))]
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)]
#[serde(rename_all = "snake_case")]
pub struct Component {
pub source: ComponentSource,
pub permissions: Permissions,
pub fuel_limit: Option<u64>,
pub time_limit_seconds: Option<u64>,
pub config: BTreeMap<String, String>,
pub env_keys: BTreeSet<String>,
}
#[cfg_attr(feature = "ts-bindings", derive(TS))]
#[cfg_attr(feature = "ts-bindings", ts(export))]
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ComponentSource {
Download {
#[schema(value_type = String)]
#[cfg_attr(feature = "ts-bindings", ts(type = "string"))]
uri: UriString,
#[cfg_attr(feature = "ts-bindings", ts(type = "string"))]
digest: ComponentDigest,
},
Registry {
#[serde(flatten)]
registry: Registry,
},
#[cfg_attr(feature = "ts-bindings", ts(type = "string"))]
Digest(ComponentDigest),
}
#[cfg_attr(feature = "ts-bindings", derive(TS))]
#[cfg_attr(feature = "ts-bindings", ts(export))]
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, ToSchema)]
pub struct Registry {
pub digest: ComponentDigest,
pub domain: Option<String>,
#[schema(value_type = Option<String>)]
#[cfg_attr(feature = "ts-bindings", ts(type = "string | null"))]
pub version: Option<Version>,
#[schema(value_type = String)]
#[cfg_attr(feature = "ts-bindings", ts(type = "string"))]
pub package: PackageRef,
}
impl ComponentSource {
pub fn digest(&self) -> &ComponentDigest {
match self {
ComponentSource::Download { digest, .. } => digest,
ComponentSource::Registry { registry } => ®istry.digest,
ComponentSource::Digest(digest) => digest,
}
}
}
#[cfg_attr(feature = "ts-bindings", derive(TS))]
#[cfg_attr(feature = "ts-bindings", ts(export))]
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)]
#[serde(rename_all = "snake_case")]
pub struct Workflow {
pub trigger: Trigger,
pub component: Component,
pub submit: Submit,
}
impl Workflow {
pub const DEFAULT_FUEL_LIMIT: u64 = u64::MAX;
pub const DEFAULT_TIME_LIMIT_SECONDS: u64 = u64::MAX;
}
#[cfg_attr(feature = "ts-bindings", derive(TS))]
#[cfg_attr(feature = "ts-bindings", ts(export))]
#[derive(Hash, Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Trigger {
CosmosContractEvent {
#[schema(value_type = String)]
#[cfg_attr(feature = "ts-bindings", ts(type = "string"))]
address: layer_climb_address::CosmosAddr,
chain: ChainKey,
event_type: String,
},
EvmContractEvent {
#[schema(value_type = String)]
#[cfg_attr(feature = "ts-bindings", ts(type = "string"))]
address: alloy_primitives::Address,
chain: ChainKey,
#[cfg_attr(feature = "ts-bindings", ts(type = "string"))]
event_hash: ByteArray<32>,
},
BlockInterval {
chain: ChainKey,
#[schema(value_type = u32)]
n_blocks: NonZeroU32,
#[schema(value_type = Option<u64>)]
start_block: Option<NonZeroU64>,
#[schema(value_type = Option<u64>)]
end_block: Option<NonZeroU64>,
},
Cron {
schedule: String,
start_time: Option<Timestamp>,
end_time: Option<Timestamp>,
},
AtProtoEvent {
collection: String,
repo_did: Option<String>,
action: Option<AtProtoAction>,
},
HypercoreAppend {
feed_key: String,
},
Manual,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)]
pub enum TriggerData {
CosmosContractEvent {
#[schema(value_type = String)]
contract_address: layer_climb_address::CosmosAddr,
chain: ChainKey,
#[schema(value_type = Object)]
event: cosmwasm_std::Event,
block_height: u64,
event_index: u64,
},
EvmContractEvent {
chain: ChainKey,
#[schema(value_type = String)]
contract_address: alloy_primitives::Address,
#[schema(value_type = Object)]
log_data: LogData,
#[schema(value_type = String)]
tx_hash: alloy_primitives::TxHash,
block_number: u64,
log_index: u64,
#[schema(value_type = String)]
block_hash: alloy_primitives::B256,
block_timestamp: Option<u64>,
tx_index: u64,
},
BlockInterval {
chain: ChainKey,
block_height: u64,
},
Cron {
trigger_time: Timestamp,
},
AtProtoEvent {
sequence: i64,
timestamp: i64,
repo: String,
collection: String,
rkey: String,
action: AtProtoAction,
cid: Option<String>,
record: Option<serde_json::Value>,
rev: Option<String>,
op_index: Option<u32>,
},
HypercoreAppend {
feed_key: String,
index: u64,
data: Vec<u8>,
},
Raw(Vec<u8>),
}
impl Default for TriggerData {
fn default() -> Self {
Self::new_raw(vec![])
}
}
impl TriggerData {
pub fn new_raw(data: impl AsRef<[u8]>) -> Self {
TriggerData::Raw(data.as_ref().to_vec())
}
pub fn trigger_type(&self) -> &str {
match self {
TriggerData::CosmosContractEvent { .. } => "cosmos_contract_event",
TriggerData::EvmContractEvent { .. } => "evm_contract_event",
TriggerData::BlockInterval { .. } => "block_interval",
TriggerData::Cron { .. } => "cron",
TriggerData::AtProtoEvent { .. } => "atproto_event",
TriggerData::HypercoreAppend { .. } => "hypercore_append",
TriggerData::Raw(_) => "manual",
}
}
pub fn chain(&self) -> Option<&ChainKey> {
match self {
TriggerData::CosmosContractEvent { chain, .. }
| TriggerData::EvmContractEvent { chain, .. }
| TriggerData::BlockInterval { chain, .. } => Some(chain),
TriggerData::Cron { .. }
| TriggerData::AtProtoEvent { .. }
| TriggerData::HypercoreAppend { .. }
| TriggerData::Raw(_) => None,
}
}
}
#[derive(
Serialize, Deserialize, Clone, Debug, PartialEq, Eq, bincode::Decode, bincode::Encode, ToSchema,
)]
pub struct TriggerAction {
#[bincode(with_serde)]
pub config: TriggerConfig,
#[bincode(with_serde)]
pub data: TriggerData,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)]
pub struct TriggerConfig {
pub service_id: ServiceId,
pub workflow_id: WorkflowId,
pub trigger: Trigger,
}
#[cfg_attr(feature = "ts-bindings", derive(TS))]
#[cfg_attr(feature = "ts-bindings", ts(export))]
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Submit {
None,
Aggregator {
component: Box<Component>,
signature_kind: SignatureKind,
},
}
#[cfg_attr(feature = "ts-bindings", derive(TS))]
#[cfg_attr(feature = "ts-bindings", ts(export))]
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema, Hash)]
pub struct SignatureKind {
pub algorithm: SignatureAlgorithm,
pub prefix: Option<SignaturePrefix>,
}
impl SignatureKind {
pub fn evm_default() -> Self {
Self {
algorithm: SignatureAlgorithm::Secp256k1,
prefix: Some(SignaturePrefix::Eip191),
}
}
}
#[cfg_attr(feature = "ts-bindings", derive(TS))]
#[cfg_attr(feature = "ts-bindings", ts(export))]
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema, Hash)]
#[serde(rename_all = "snake_case")]
pub enum SignatureAlgorithm {
Secp256k1,
}
#[cfg_attr(feature = "ts-bindings", derive(TS))]
#[cfg_attr(feature = "ts-bindings", ts(export))]
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema, Hash)]
#[serde(rename_all = "snake_case")]
pub enum SignaturePrefix {
Eip191,
}
#[cfg_attr(feature = "ts-bindings", derive(TS))]
#[cfg_attr(feature = "ts-bindings", ts(export))]
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Copy, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ServiceStatus {
Active,
Paused,
}
impl FromStr for ServiceStatus {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"active" => Ok(ServiceStatus::Active),
"paused" => Ok(ServiceStatus::Paused),
_ => Err(anyhow::anyhow!("Invalid service status: {}", s)),
}
}
}
#[cfg_attr(feature = "ts-bindings", derive(TS))]
#[cfg_attr(feature = "ts-bindings", ts(export))]
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema, Default)]
#[serde(default, rename_all = "snake_case")]
pub struct Permissions {
pub allowed_http_hosts: AllowedHostPermission,
pub file_system: bool,
pub raw_sockets: bool,
pub dns_resolution: bool,
}
#[test]
fn permission_defaults() {
let permissions_json: Permissions = serde_json::from_str("{}").unwrap();
let permissions_default: Permissions = Permissions::default();
assert_eq!(permissions_json, permissions_default);
assert_eq!(
permissions_default.allowed_http_hosts,
AllowedHostPermission::None
);
assert!(!permissions_default.file_system);
}
#[cfg_attr(feature = "ts-bindings", derive(TS))]
#[cfg_attr(feature = "ts-bindings", ts(export))]
#[derive(Serialize, Deserialize, Clone, Default, Debug, PartialEq, Eq, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum AllowedHostPermission {
All,
Only(Vec<String>),
#[default]
None,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, ToSchema)]
#[serde(default, rename_all = "snake_case")]
#[derive(Default)]
pub struct WasmResponse {
#[serde(with = "const_hex")]
pub payload: Vec<u8>,
pub ordering: Option<u64>,
#[serde(with = "crate::serde_helpers::option_const_hex")]
pub event_id_salt: Option<Vec<u8>>,
}
impl WasmResponse {
pub const DEFAULT_MAX_PAYLOAD_SIZE: usize = 50 * 1024 * 1024;
pub const DEFAULT_MAX_SALT_SIZE: usize = 1024 * 1024;
pub fn validate_size(
&self,
max_payload_size: usize,
max_salt_size: usize,
) -> Result<(), WasmResponseSizeError> {
if self.payload.len() > max_payload_size {
return Err(WasmResponseSizeError::PayloadTooLarge {
size: self.payload.len(),
max: max_payload_size,
});
}
if let Some(salt) = &self.event_id_salt {
if salt.len() > max_salt_size {
return Err(WasmResponseSizeError::SaltTooLarge {
size: salt.len(),
max: max_salt_size,
});
}
}
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub enum WasmResponseSizeError {
#[error("Payload size {size} bytes exceeds maximum of {max} bytes")]
PayloadTooLarge { size: usize, max: usize },
#[error("Event ID salt size {size} bytes exceeds maximum of {max} bytes")]
SaltTooLarge { size: usize, max: usize },
}
mod test_ext {
use std::{
collections::{BTreeMap, BTreeSet},
num::NonZeroU32,
};
use crate::{
ByteArray, ChainKey, ChainKeyError, ComponentSource, ServiceId, WorkflowId, WorkflowIdError,
};
use super::{Component, Trigger, TriggerConfig};
impl Component {
pub fn new(source: ComponentSource) -> Component {
Self {
source,
permissions: Default::default(),
fuel_limit: None,
time_limit_seconds: None,
config: BTreeMap::new(),
env_keys: BTreeSet::new(),
}
}
}
impl Trigger {
pub fn cosmos_contract_event(
address: layer_climb_address::CosmosAddr,
chain: impl TryInto<ChainKey, Error = ChainKeyError>,
event_type: impl ToString,
) -> Self {
Trigger::CosmosContractEvent {
address,
chain: chain.try_into().unwrap(),
event_type: event_type.to_string(),
}
}
pub fn evm_contract_event(
address: alloy_primitives::Address,
chain: impl TryInto<ChainKey, Error = ChainKeyError>,
event_hash: ByteArray<32>,
) -> Self {
Trigger::EvmContractEvent {
address,
chain: chain.try_into().unwrap(),
event_hash,
}
}
}
impl TriggerConfig {
pub fn cosmos_contract_event(
service_id: ServiceId,
workflow_id: impl TryInto<WorkflowId, Error = WorkflowIdError>,
contract_address: layer_climb_address::CosmosAddr,
chain: impl TryInto<ChainKey, Error = ChainKeyError>,
event_type: impl ToString,
) -> Self {
Self {
service_id,
workflow_id: workflow_id.try_into().unwrap(),
trigger: Trigger::cosmos_contract_event(contract_address, chain, event_type),
}
}
pub fn evm_contract_event(
service_id: ServiceId,
workflow_id: impl TryInto<WorkflowId, Error = WorkflowIdError>,
contract_address: alloy_primitives::Address,
chain: impl TryInto<ChainKey, Error = ChainKeyError>,
event_hash: ByteArray<32>,
) -> Self {
Self {
service_id,
workflow_id: workflow_id.try_into().unwrap(),
trigger: Trigger::evm_contract_event(contract_address, chain, event_hash),
}
}
pub fn block_interval_event(
service_id: ServiceId,
workflow_id: impl TryInto<WorkflowId, Error = WorkflowIdError>,
chain: impl TryInto<ChainKey, Error = ChainKeyError>,
n_blocks: NonZeroU32,
) -> Self {
Self {
service_id,
workflow_id: workflow_id.try_into().unwrap(),
trigger: Trigger::BlockInterval {
chain: chain.try_into().unwrap(),
n_blocks,
start_block: None,
end_block: None,
},
}
}
#[cfg(test)]
pub fn manual(
service_id: ServiceId,
workflow_id: impl TryInto<WorkflowId, Error = WorkflowIdError>,
) -> Self {
Self {
service_id,
workflow_id: workflow_id.try_into().unwrap(),
trigger: Trigger::Manual,
}
}
}
}