From aa9bbc353acbca378dc7398c577783ea746c0ced Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 20:22:27 -0500 Subject: [PATCH 1/9] feat: add the dash-sdk-contract declaration model, grammar and diagnostics Add packages/rs-dash-sdk-contract (Cargo dash-sdk-contract, import dash_sdk_contract), the contract-author declaration model for DashVM: the attribute grammar as data, the typed declaration model and builders, the validator with typed append-only diagnostics, the sorted canonical manifest and the persistence semantics enums. The crate is no_std plus alloc without default features and depends on thiserror only. Refs #4680 Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 7 + Cargo.toml | 1 + packages/rs-dash-sdk-contract/Cargo.toml | 15 + .../src/declare/capability.rs | 136 ++ .../src/declare/collection.rs | 434 +++++ .../src/declare/collections.rs | 118 ++ .../rs-dash-sdk-contract/src/declare/entry.rs | 195 ++ .../rs-dash-sdk-contract/src/declare/field.rs | 291 +++ .../rs-dash-sdk-contract/src/declare/index.rs | 277 +++ .../rs-dash-sdk-contract/src/declare/mod.rs | 143 ++ .../src/declare/module.rs | 110 ++ .../rs-dash-sdk-contract/src/declare/rule.rs | 371 ++++ packages/rs-dash-sdk-contract/src/grammar.rs | 1561 +++++++++++++++++ packages/rs-dash-sdk-contract/src/identity.rs | 525 ++++++ packages/rs-dash-sdk-contract/src/lib.rs | 45 + .../src/manifest/bundle.rs | 56 + .../src/manifest/capability.rs | 37 + .../src/manifest/collection.rs | 134 ++ .../src/manifest/method.rs | 58 + .../rs-dash-sdk-contract/src/manifest/mod.rs | 61 + .../rs-dash-sdk-contract/src/persistence.rs | 114 ++ packages/rs-dash-sdk-contract/src/prelude.rs | 18 + .../src/validate/capabilities.rs | 88 + .../src/validate/collections.rs | 676 +++++++ .../src/validate/diagnostic.rs | 763 ++++++++ .../src/validate/entries.rs | 172 ++ .../src/validate/merge.rs | 81 + .../rs-dash-sdk-contract/src/validate/mod.rs | 83 + .../src/validate/modules.rs | 238 +++ .../src/validate/rules.rs | 106 ++ .../src/validate/tests.rs | 1386 +++++++++++++++ .../tests/alloc_profile.rs | 63 + 32 files changed, 8363 insertions(+) create mode 100644 packages/rs-dash-sdk-contract/Cargo.toml create mode 100644 packages/rs-dash-sdk-contract/src/declare/capability.rs create mode 100644 packages/rs-dash-sdk-contract/src/declare/collection.rs create mode 100644 packages/rs-dash-sdk-contract/src/declare/collections.rs create mode 100644 packages/rs-dash-sdk-contract/src/declare/entry.rs create mode 100644 packages/rs-dash-sdk-contract/src/declare/field.rs create mode 100644 packages/rs-dash-sdk-contract/src/declare/index.rs create mode 100644 packages/rs-dash-sdk-contract/src/declare/mod.rs create mode 100644 packages/rs-dash-sdk-contract/src/declare/module.rs create mode 100644 packages/rs-dash-sdk-contract/src/declare/rule.rs create mode 100644 packages/rs-dash-sdk-contract/src/grammar.rs create mode 100644 packages/rs-dash-sdk-contract/src/identity.rs create mode 100644 packages/rs-dash-sdk-contract/src/lib.rs create mode 100644 packages/rs-dash-sdk-contract/src/manifest/bundle.rs create mode 100644 packages/rs-dash-sdk-contract/src/manifest/capability.rs create mode 100644 packages/rs-dash-sdk-contract/src/manifest/collection.rs create mode 100644 packages/rs-dash-sdk-contract/src/manifest/method.rs create mode 100644 packages/rs-dash-sdk-contract/src/manifest/mod.rs create mode 100644 packages/rs-dash-sdk-contract/src/persistence.rs create mode 100644 packages/rs-dash-sdk-contract/src/prelude.rs create mode 100644 packages/rs-dash-sdk-contract/src/validate/capabilities.rs create mode 100644 packages/rs-dash-sdk-contract/src/validate/collections.rs create mode 100644 packages/rs-dash-sdk-contract/src/validate/diagnostic.rs create mode 100644 packages/rs-dash-sdk-contract/src/validate/entries.rs create mode 100644 packages/rs-dash-sdk-contract/src/validate/merge.rs create mode 100644 packages/rs-dash-sdk-contract/src/validate/mod.rs create mode 100644 packages/rs-dash-sdk-contract/src/validate/modules.rs create mode 100644 packages/rs-dash-sdk-contract/src/validate/rules.rs create mode 100644 packages/rs-dash-sdk-contract/src/validate/tests.rs create mode 100644 packages/rs-dash-sdk-contract/tests/alloc_profile.rs diff --git a/Cargo.lock b/Cargo.lock index b0e4a6ff648..9c771e160a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1757,6 +1757,13 @@ dependencies = [ "zeroize", ] +[[package]] +name = "dash-sdk-contract" +version = "4.2.0-dev.8" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "dash-spv" version = "0.45.0" diff --git a/Cargo.toml b/Cargo.toml index 09d41d94b47..4459b5916f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ resolver = "2" members = [ "packages/dapi-grpc", "packages/rs-dash-platform-macros", + "packages/rs-dash-sdk-contract", "packages/rs-dpp", "packages/rs-drive", "packages/rs-platform-value", diff --git a/packages/rs-dash-sdk-contract/Cargo.toml b/packages/rs-dash-sdk-contract/Cargo.toml new file mode 100644 index 00000000000..2152c9fdee5 --- /dev/null +++ b/packages/rs-dash-sdk-contract/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "dash-sdk-contract" +version.workspace = true +edition = "2021" +rust-version.workspace = true +authors = ["Dash Core Team"] +license = "MIT" +description = "Contract-author SDK for DashVM: declaration model, attribute grammar, diagnostics and canonical manifest" + +[dependencies] +thiserror = { version = "2.0.17", default-features = false } + +[features] +default = ["std"] +std = ["thiserror/std"] diff --git a/packages/rs-dash-sdk-contract/src/declare/capability.rs b/packages/rs-dash-sdk-contract/src/declare/capability.rs new file mode 100644 index 00000000000..6dca26dae17 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/declare/capability.rs @@ -0,0 +1,136 @@ +//! The capability catalogue: what a contract may require, and whether the +//! native host supports it today. + +use core::fmt; + +use super::collections::TypedCollectionKind; + +/// Support status of a capability in the native host. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum CapabilityStatus { + /// Supported by the native host today; a manifest requesting it can be + /// deployed. + Native, + /// Specified, declarable, but without a native implementation yet. The + /// validator accepts the declaration; the build crate reports it as a + /// native gap and refuses to call the manifest deployable. + PendingNative, + /// Catalogued but disabled: the declaration is rejected until the + /// capability's semantics are specified. Today only the private document + /// store, whose encryption, key control, query visibility and proof + /// behaviour are not yet specified. + InterfaceDisabled, +} + +/// A capability a contract requires, either explicitly (`requires = [...]`) +/// or derived from its declarations. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum CapabilityRequirement { + /// The native access-control primitive. Explicit. + Acl, + /// The native randomness primitive. Explicit. + Randomness, + /// `write = "contract"`: only the contract's own code may create + /// documents. Derived. + ContractWrites, + /// A rule with a native guard expression. Derived. + NativeGuards, + /// A rule with a read-only WASM predicate. Derived. + WasmPredicates, + /// A typed specialized collection of the given kind. Derived. + TypedCollections(TypedCollectionKind), + /// `store = "private"`. Derived. + PrivateStore, + /// Stored receipts (the default policy). Derived. + StoredReceipts, + /// At least one entry, which needs the DashVM runtime. Derived. + Entries, + /// More than one module, which needs bundle linking. Derived. + Modules, +} + +impl CapabilityRequirement { + /// Whether the author writes this requirement (as opposed to the validator + /// deriving it). + pub fn is_explicit(&self) -> bool { + matches!( + self, + CapabilityRequirement::Acl | CapabilityRequirement::Randomness + ) + } + + /// The catalogue status of the requirement. + pub fn status(&self) -> CapabilityStatus { + match self { + CapabilityRequirement::PrivateStore => CapabilityStatus::InterfaceDisabled, + CapabilityRequirement::Acl + | CapabilityRequirement::Randomness + | CapabilityRequirement::ContractWrites + | CapabilityRequirement::NativeGuards + | CapabilityRequirement::WasmPredicates + | CapabilityRequirement::TypedCollections(_) + | CapabilityRequirement::StoredReceipts + | CapabilityRequirement::Entries + | CapabilityRequirement::Modules => CapabilityStatus::PendingNative, + } + } +} + +impl fmt::Display for CapabilityRequirement { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CapabilityRequirement::Acl => f.write_str("acl"), + CapabilityRequirement::Randomness => f.write_str("randomness"), + CapabilityRequirement::ContractWrites => f.write_str("contract writes"), + CapabilityRequirement::NativeGuards => f.write_str("native guards"), + CapabilityRequirement::WasmPredicates => f.write_str("wasm predicates"), + CapabilityRequirement::TypedCollections(kind) => { + write!(f, "typed collections ({kind})") + } + CapabilityRequirement::PrivateStore => f.write_str("private store"), + CapabilityRequirement::StoredReceipts => f.write_str("stored receipts"), + CapabilityRequirement::Entries => f.write_str("entries"), + CapabilityRequirement::Modules => f.write_str("modules"), + } + } +} + +/// Whether the host stores a receipt for every outer invocation. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ReceiptPolicy { + /// One bounded receipt per outer invocation, the default. + #[default] + Stored, + /// No receipts. + Disabled, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_keep_the_private_store_catalogued_but_disabled() { + assert_eq!( + CapabilityRequirement::PrivateStore.status(), + CapabilityStatus::InterfaceDisabled + ); + assert!(!CapabilityRequirement::PrivateStore.is_explicit()); + } + + #[test] + fn should_mark_acl_and_randomness_explicit_and_pending() { + for capability in [ + CapabilityRequirement::Acl, + CapabilityRequirement::Randomness, + ] { + assert!(capability.is_explicit()); + assert_eq!(capability.status(), CapabilityStatus::PendingNative); + } + } + + #[test] + fn should_default_receipts_to_stored() { + assert_eq!(ReceiptPolicy::default(), ReceiptPolicy::Stored); + } +} diff --git a/packages/rs-dash-sdk-contract/src/declare/collection.rs b/packages/rs-dash-sdk-contract/src/declare/collection.rs new file mode 100644 index 00000000000..5d85f4ef6c8 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/declare/collection.rs @@ -0,0 +1,434 @@ +//! Document collections and singletons: the native document type switches, +//! the stored fields and the indexes. + +use alloc::string::String; +use alloc::vec::Vec; + +use super::field::FieldSpec; +use super::index::IndexSpec; +use super::rule::ActionScope; +use super::DeclarationOrigin; +use crate::identity::{CollectionName, PropertyName}; + +/// Whether a collection holds many documents or one reserved record. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum CollectionKind { + /// Ordinary documents addressed by document id. + Documents, + /// One native document under a reserved key: configuration, not a + /// separate storage engine. + Singleton, +} + +/// Who may create documents. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum WritePolicy { + /// Anyone (native creation restriction 0). + #[default] + Any, + /// The contract owner only (native creation restriction 1). + Owner, + /// The contract's own code only. Pending native support; derives the + /// `ContractWrites` capability requirement. + Contract, +} + +/// Marketplace trade mode. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TradeMode { + /// No marketplace. + #[default] + None, + /// Direct purchase at the listed price. + DirectPurchase, +} + +/// Signature security level required to write. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SecurityLevel { + /// Critical. + Critical, + /// High, the default. + #[default] + High, + /// Medium. + Medium, +} + +/// Identity bounded key requirement (native storage key requirements). +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum BoundedKeyRequirement { + /// One non-replaceable key. + Unique, + /// Several keys. + Multiple, + /// Several keys with a reference to the latest. + MultipleReferenceToLatest, +} + +/// Document store kind. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Store { + /// Ordinary documents, readable by anyone with a proof. + #[default] + Public, + /// Private document store. Catalogued, interface disabled: private Rust + /// fields and access control are not encryption, and the store's + /// encryption, key control, query visibility and proof behaviour are + /// specified separately before this can be enabled. + Private, +} + +/// What happens to the tokens a priced action charges. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TokenCostEffect { + /// Transferred to the contract owner, the default. + #[default] + TransferToContractOwner, + /// Burned. + Burn, +} + +/// Who pays the gas of a priced action. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum GasPaidBy { + /// The document owner, the default. + #[default] + DocumentOwner, + /// The contract owner. + ContractOwner, + /// The contract owner when it can pay, else the document owner. + PreferContractOwner, +} + +/// A token price on one document action. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct TokenCost { + /// The token contract; the declaring contract when absent. + pub contract: Option<[u8; 32]>, + /// Token position in the token contract. + pub token_position: u16, + /// Amount charged. + pub amount: u64, + /// What happens to the tokens. + pub effect: TokenCostEffect, + /// Who pays gas. + pub gas_paid_by: GasPaidBy, +} + +impl TokenCost { + /// A cost in the declaring contract's token with default effect and payer. + pub fn new(token_position: u16, amount: u64) -> Self { + TokenCost { + contract: None, + token_position, + amount, + effect: TokenCostEffect::default(), + gas_paid_by: GasPaidBy::default(), + } + } + + /// Prices in another contract's token. + pub fn contract(mut self, contract: [u8; 32]) -> Self { + self.contract = Some(contract); + self + } + + /// Sets the effect. + pub fn effect(mut self, effect: TokenCostEffect) -> Self { + self.effect = effect; + self + } + + /// Sets the gas payer. + pub fn gas_paid_by(mut self, gas_paid_by: GasPaidBy) -> Self { + self.gas_paid_by = gas_paid_by; + self + } +} + +/// A token cost bound to an action. One per action. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct TokenCostSpec { + /// The priced action. + pub action: ActionScope, + /// The price. + pub cost: TokenCost, +} + +/// A document collection or singleton. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CollectionSpec { + /// Where the spec came from. + pub origin: DeclarationOrigin, + /// The collection's identity and native document type name. + pub name: CollectionName, + /// Documents or singleton. + pub kind: CollectionKind, + /// Author-declared schema revision, at least 1. Recorded for compatibility + /// reports; provisional meaning. + pub schema_revision: u32, + /// Who may create documents. + pub write: WritePolicy, + /// Documents may be replaced. + pub mutable: bool, + /// Documents may be deleted. + pub deletable: bool, + /// Every revision is kept. + pub keep_history: bool, + /// Transfer revisions are kept. + pub keep_transfer_history: bool, + /// Purchase revisions are kept. + pub keep_purchase_history: bool, + /// Price-update revisions are kept. + pub keep_pricing_history: bool, + /// Documents may be transferred. + pub transferable: bool, + /// Marketplace mode. + pub trade: TradeMode, + /// Signature security level required to write. + pub security_level: SecurityLevel, + /// Identity encryption bounded key requirement. + pub encryption_key: Option, + /// Identity decryption bounded key requirement. + pub decryption_key: Option, + /// Count tree on the primary key. + pub count: bool, + /// Provable count on the primary key. + pub range_count: bool, + /// Integer property summed on the primary key. + pub sum: Option, + /// Provable sum on the primary key. + pub range_sum: bool, + /// Sugar for `count` plus `sum`; expanded by the validator, never stored in + /// the manifest. + pub average: Option, + /// Sugar for `range_count` plus `range_sum`; expanded by the validator. + pub range_average: bool, + /// Documents live only in their indexes. + pub index_only: bool, + /// Token prices per action. + pub token_costs: Vec, + /// Document store. + pub store: Store, + /// The Rust field marked `#[document_id]`; required on document + /// collections, forbidden on singletons. A Rust name, never part of the + /// manifest. + pub document_id_field: Option, + /// Stored fields. + pub fields: Vec, + /// Indexes. + pub indexes: Vec, +} + +impl CollectionSpec { + fn with_kind(name: CollectionName, kind: CollectionKind) -> Self { + CollectionSpec { + origin: DeclarationOrigin::Builder, + name, + kind, + schema_revision: 1, + write: WritePolicy::default(), + mutable: true, + deletable: true, + keep_history: false, + keep_transfer_history: false, + keep_purchase_history: false, + keep_pricing_history: false, + transferable: false, + trade: TradeMode::default(), + security_level: SecurityLevel::default(), + encryption_key: None, + decryption_key: None, + count: false, + range_count: false, + sum: None, + range_sum: false, + average: None, + range_average: false, + index_only: false, + token_costs: Vec::new(), + store: Store::default(), + document_id_field: None, + fields: Vec::new(), + indexes: Vec::new(), + } + } + + /// A document collection with default switches. + pub fn documents(name: CollectionName) -> Self { + Self::with_kind(name, CollectionKind::Documents) + } + + /// A singleton with default switches. + pub fn singleton(name: CollectionName) -> Self { + Self::with_kind(name, CollectionKind::Singleton) + } + + /// Records the origin. + pub fn with_origin(mut self, origin: DeclarationOrigin) -> Self { + self.origin = origin; + self + } + + /// Sets the schema revision. + pub fn schema_revision(mut self, revision: u32) -> Self { + self.schema_revision = revision; + self + } + + /// Sets who may create documents. + pub fn write(mut self, write: WritePolicy) -> Self { + self.write = write; + self + } + + /// Sets whether documents may be replaced. + pub fn mutable(mut self, mutable: bool) -> Self { + self.mutable = mutable; + self + } + + /// Sets whether documents may be deleted. + pub fn deletable(mut self, deletable: bool) -> Self { + self.deletable = deletable; + self + } + + /// Keeps every revision. + pub fn keep_history(mut self, keep: bool) -> Self { + self.keep_history = keep; + self + } + + /// Keeps transfer revisions. + pub fn keep_transfer_history(mut self, keep: bool) -> Self { + self.keep_transfer_history = keep; + self + } + + /// Keeps purchase revisions. + pub fn keep_purchase_history(mut self, keep: bool) -> Self { + self.keep_purchase_history = keep; + self + } + + /// Keeps price-update revisions. + pub fn keep_pricing_history(mut self, keep: bool) -> Self { + self.keep_pricing_history = keep; + self + } + + /// Sets whether documents may be transferred. + pub fn transferable(mut self, transferable: bool) -> Self { + self.transferable = transferable; + self + } + + /// Sets the marketplace mode. + pub fn trade(mut self, trade: TradeMode) -> Self { + self.trade = trade; + self + } + + /// Sets the signature security level. + pub fn security_level(mut self, level: SecurityLevel) -> Self { + self.security_level = level; + self + } + + /// Sets the encryption key requirement. + pub fn encryption_key(mut self, requirement: BoundedKeyRequirement) -> Self { + self.encryption_key = Some(requirement); + self + } + + /// Sets the decryption key requirement. + pub fn decryption_key(mut self, requirement: BoundedKeyRequirement) -> Self { + self.decryption_key = Some(requirement); + self + } + + /// Counts documents on the primary key. + pub fn count(mut self, count: bool) -> Self { + self.count = count; + self + } + + /// Provable counts on the primary key. + pub fn range_count(mut self, range_count: bool) -> Self { + self.range_count = range_count; + self + } + + /// Sums a property on the primary key. + pub fn sum(mut self, property: PropertyName) -> Self { + self.sum = Some(property); + self + } + + /// Provable sums on the primary key. + pub fn range_sum(mut self, range_sum: bool) -> Self { + self.range_sum = range_sum; + self + } + + /// Average sugar: `count` plus `sum` on the property. + pub fn average(mut self, property: PropertyName) -> Self { + self.average = Some(property); + self + } + + /// Range average sugar: `range_count` plus `range_sum`. + pub fn range_average(mut self, range_average: bool) -> Self { + self.range_average = range_average; + self + } + + /// Stores documents only in their indexes. + pub fn index_only(mut self, index_only: bool) -> Self { + self.index_only = index_only; + self + } + + /// Sets the document store. + pub fn store(mut self, store: Store) -> Self { + self.store = store; + self + } + + /// Names the Rust field carrying the document id. + pub fn document_id_field(mut self, field: impl Into) -> Self { + self.document_id_field = Some(field.into()); + self + } + + /// Adds a stored field. + pub fn field(mut self, field: FieldSpec) -> Self { + self.fields.push(field); + self + } + + /// Adds an index. + pub fn index(mut self, index: IndexSpec) -> Self { + self.indexes.push(index); + self + } + + /// Prices an action. + pub fn token_cost(mut self, action: ActionScope, cost: TokenCost) -> Self { + self.token_costs.push(TokenCostSpec { action, cost }); + self + } + + /// Whether two specs describe the same collection apart from origin and + /// indexes. Used to tell a restatement or extension from a conflict. + pub fn same_shape_ignoring_indexes(&self, other: &CollectionSpec) -> bool { + let mut left = self.clone(); + let mut right = other.clone(); + left.origin = right.origin; + left.indexes = Vec::new(); + right.indexes = Vec::new(); + left == right + } +} diff --git a/packages/rs-dash-sdk-contract/src/declare/collections.rs b/packages/rs-dash-sdk-contract/src/declare/collections.rs new file mode 100644 index 00000000000..686beb280e1 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/declare/collections.rs @@ -0,0 +1,118 @@ +//! Typed specialized collections: the manifest slot for native tree families +//! that ordinary document schemas do not expose. +//! +//! Only the declaration shape is specified here. Operation sets, limits, the +//! privacy model and the native adapters are separate capability work; every +//! kind is [`CapabilityStatus::PendingNative`](super::CapabilityStatus). +//! There is no raw path, raw element or database handle anywhere in this +//! model: a typed collection is a declared capability with a key and element +//! type, not an escape hatch. + +use core::fmt; + +use super::entry::ValueType; +use super::DeclarationOrigin; +use crate::identity::CollectionName; + +/// The native tree family behind a typed collection. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TypedCollectionKind { + /// Sum tree. + Sum, + /// Big sum tree (128-bit totals). + BigSum, + /// Count tree. + Count, + /// Count and sum tree. + CountSum, + /// Provable sum tree (range sums with proofs). + ProvableSum, + /// Provable count tree (range counts with proofs). + ProvableCount, + /// Ranked tree. + Ranked, + /// Append-only (Merkle mountain range) collection. + Append, + /// Commitment collection. + Commitment, +} + +impl TypedCollectionKind { + /// Every kind in the catalogue. + pub const ALL: &'static [TypedCollectionKind] = &[ + TypedCollectionKind::Sum, + TypedCollectionKind::BigSum, + TypedCollectionKind::Count, + TypedCollectionKind::CountSum, + TypedCollectionKind::ProvableSum, + TypedCollectionKind::ProvableCount, + TypedCollectionKind::Ranked, + TypedCollectionKind::Append, + TypedCollectionKind::Commitment, + ]; +} + +impl fmt::Display for TypedCollectionKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + TypedCollectionKind::Sum => "sum", + TypedCollectionKind::BigSum => "big sum", + TypedCollectionKind::Count => "count", + TypedCollectionKind::CountSum => "count and sum", + TypedCollectionKind::ProvableSum => "provable sum", + TypedCollectionKind::ProvableCount => "provable count", + TypedCollectionKind::Ranked => "ranked", + TypedCollectionKind::Append => "append", + TypedCollectionKind::Commitment => "commitment", + }) + } +} + +/// A typed specialized collection declaration. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TypedCollectionSpec { + /// Where the spec came from. + pub origin: DeclarationOrigin, + /// The collection's identity, in the same namespace as document + /// collections. + pub id: CollectionName, + /// The tree family. + pub kind: TypedCollectionKind, + /// The key type. + pub key: ValueType, + /// The element type. + pub element: ValueType, + /// Upper bound on the number of elements, when declared. + pub max_elements: Option, +} + +impl TypedCollectionSpec { + /// A builder-declared typed collection. + pub fn new( + id: CollectionName, + kind: TypedCollectionKind, + key: ValueType, + element: ValueType, + ) -> Self { + TypedCollectionSpec { + origin: DeclarationOrigin::Builder, + id, + kind, + key, + element, + max_elements: None, + } + } + + /// Records the origin. + pub fn with_origin(mut self, origin: DeclarationOrigin) -> Self { + self.origin = origin; + self + } + + /// Bounds the element count. + pub fn max_elements(mut self, max_elements: u64) -> Self { + self.max_elements = Some(max_elements); + self + } +} diff --git a/packages/rs-dash-sdk-contract/src/declare/entry.rs b/packages/rs-dash-sdk-contract/src/declare/entry.rs new file mode 100644 index 00000000000..6c31d49647c --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/declare/entry.rs @@ -0,0 +1,195 @@ +//! Entries: the externally callable functions of a contract and their wire +//! types. +//! +//! Only a function marked `#[entry]` is callable; Rust `pub` alone exports +//! nothing. An entry's identity is its [`MethodName`], unique across every +//! module of the contract. Its WASM export symbol is derived from that name +//! (see [`crate::identity::entry_export_symbol`]) and its module is only a +//! binding: moving the function between modules changes the binding and +//! nothing else. + +use alloc::boxed::Box; +use alloc::string::String; +use alloc::vec::Vec; + +use super::field::IntegerWidth; +use super::DeclarationOrigin; +use crate::identity::{CollectionName, MethodName, ModuleName}; + +/// The receiver of an entry. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum Receiver { + /// A free function. + None, + /// `&self` on a persistent struct: the wrapper loads the addressed record + /// and stages nothing. + Ref(CollectionName), + /// `&mut self` on a persistent struct: the wrapper loads the addressed + /// record and stages the receiver update when the method returns + /// successfully. The only implicit staging point in the model. + Mut(CollectionName), +} + +impl Receiver { + /// The receiver's collection, if any. + pub fn collection(&self) -> Option<&CollectionName> { + match self { + Receiver::None => None, + Receiver::Ref(collection) | Receiver::Mut(collection) => Some(collection), + } + } + + /// Whether the receiver is mutable. + pub fn is_mutable(&self) -> bool { + matches!(self, Receiver::Mut(_)) + } +} + +/// A bounded wire value type for entry parameters and returns. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum ValueType { + /// `()` + Unit, + /// `bool` + Bool, + /// A Rust integer. + Integer(IntegerWidth), + /// `f64` + F64, + /// A string with a required character bound. + String { + /// Maximum characters; absent is an `UnboundedField` diagnostic. + max_chars: Option, + }, + /// A byte array with a required length bound. + Bytes { + /// Maximum bytes; absent is an `UnboundedField` diagnostic. + max_len: Option, + }, + /// A 32-byte identifier. + Identifier, + /// A document id. + DocumentId, + /// An optional value. + Option(Box), + /// A list with a required length bound. + List { + /// Maximum items; absent is an `UnboundedField` diagnostic. + max_len: Option, + /// The item type. + item: Box, + }, + /// An embedded bounded value struct. + Struct(Vec<(String, ValueType)>), +} + +impl ValueType { + /// A string of at most `max_chars` characters. + pub fn string(max_chars: u16) -> Self { + ValueType::String { + max_chars: Some(max_chars), + } + } + + /// A byte array of at most `max_len` bytes. + pub fn bytes(max_len: u16) -> Self { + ValueType::Bytes { + max_len: Some(max_len), + } + } + + /// A list of at most `max_len` items. + pub fn list(max_len: u32, item: ValueType) -> Self { + ValueType::List { + max_len: Some(max_len), + item: Box::new(item), + } + } + + /// An optional value. + pub fn option(inner: ValueType) -> Self { + ValueType::Option(Box::new(inner)) + } +} + +/// One entry parameter. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct ParamSpec { + /// The parameter name. + pub name: String, + /// The wire type. + pub ty: ValueType, +} + +/// An entry declaration. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EntrySpec { + /// Where the spec came from. + pub origin: DeclarationOrigin, + /// The entry's identity. + pub name: MethodName, + /// The hosting module; the single module when absent. + pub module: Option, + /// The receiver. + pub receiver: Receiver, + /// The entry stages no writes. + pub read_only: bool, + /// Wire parameters after the receiver's document id, if any. + pub params: Vec, + /// Wire return type. + pub returns: ValueType, +} + +impl EntrySpec { + /// A free entry returning unit. + pub fn new(name: MethodName) -> Self { + EntrySpec { + origin: DeclarationOrigin::Builder, + name, + module: None, + receiver: Receiver::None, + read_only: false, + params: Vec::new(), + returns: ValueType::Unit, + } + } + + /// Records the origin. + pub fn with_origin(mut self, origin: DeclarationOrigin) -> Self { + self.origin = origin; + self + } + + /// Binds the entry to a module. + pub fn module(mut self, module: ModuleName) -> Self { + self.module = Some(module); + self + } + + /// Sets the receiver. + pub fn receiver(mut self, receiver: Receiver) -> Self { + self.receiver = receiver; + self + } + + /// Marks the entry read-only. + pub fn read_only(mut self, read_only: bool) -> Self { + self.read_only = read_only; + self + } + + /// Adds a parameter. + pub fn param(mut self, name: impl Into, ty: ValueType) -> Self { + self.params.push(ParamSpec { + name: name.into(), + ty, + }); + self + } + + /// Sets the return type. + pub fn returns(mut self, returns: ValueType) -> Self { + self.returns = returns; + self + } +} diff --git a/packages/rs-dash-sdk-contract/src/declare/field.rs b/packages/rs-dash-sdk-contract/src/declare/field.rs new file mode 100644 index 00000000000..5d61fd87340 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/declare/field.rs @@ -0,0 +1,291 @@ +//! Stored fields: their stable name and position, their type and their bounds. +//! +//! Every variable-length field declares an upper bound; integer bounds must +//! fit the declared Rust width. The width itself is part of the manifest and +//! is enforced natively by emitting the corresponding schema bounds, so a +//! `u32` field stays a `u32` after native inference. + +use alloc::string::String; +use alloc::vec::Vec; + +use crate::identity::{CollectionName, PropertyName, PropertyPath}; + +/// The Rust integer width of an integer field. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum IntegerWidth { + /// `u8` + U8, + /// `u16` + U16, + /// `u32` + U32, + /// `u64` + U64, + /// `i8` + I8, + /// `i16` + I16, + /// `i32` + I32, + /// `i64` + I64, +} + +impl IntegerWidth { + /// Every width. + pub const ALL: &'static [IntegerWidth] = &[ + IntegerWidth::U8, + IntegerWidth::U16, + IntegerWidth::U32, + IntegerWidth::U64, + IntegerWidth::I8, + IntegerWidth::I16, + IntegerWidth::I32, + IntegerWidth::I64, + ]; + + /// The smallest value of the width. + pub fn min_value(&self) -> i128 { + match self { + IntegerWidth::U8 | IntegerWidth::U16 | IntegerWidth::U32 | IntegerWidth::U64 => 0, + IntegerWidth::I8 => i8::MIN as i128, + IntegerWidth::I16 => i16::MIN as i128, + IntegerWidth::I32 => i32::MIN as i128, + IntegerWidth::I64 => i64::MIN as i128, + } + } + + /// The largest value of the width. + pub fn max_value(&self) -> i128 { + match self { + IntegerWidth::U8 => u8::MAX as i128, + IntegerWidth::U16 => u16::MAX as i128, + IntegerWidth::U32 => u32::MAX as i128, + IntegerWidth::U64 => u64::MAX as i128, + IntegerWidth::I8 => i8::MAX as i128, + IntegerWidth::I16 => i16::MAX as i128, + IntegerWidth::I32 => i32::MAX as i128, + IntegerWidth::I64 => i64::MAX as i128, + } + } + + /// Whether the width is signed. + pub fn is_signed(&self) -> bool { + matches!( + self, + IntegerWidth::I8 | IntegerWidth::I16 | IntegerWidth::I32 | IntegerWidth::I64 + ) + } + + /// The Rust type name. + pub fn rust_name(&self) -> &'static str { + match self { + IntegerWidth::U8 => "u8", + IntegerWidth::U16 => "u16", + IntegerWidth::U32 => "u32", + IntegerWidth::U64 => "u64", + IntegerWidth::I8 => "i8", + IntegerWidth::I16 => "i16", + IntegerWidth::I32 => "i32", + IntegerWidth::I64 => "i64", + } + } +} + +/// Author-declared integer bounds. `i128` so that a bound outside `i64` is +/// representable and can be rejected instead of truncated. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct IntegerBounds { + /// Inclusive lower bound. + pub min: Option, + /// Inclusive upper bound. + pub max: Option, +} + +impl IntegerBounds { + /// No bounds beyond the width's own range. + pub const NONE: IntegerBounds = IntegerBounds { + min: None, + max: None, + }; + + /// Both bounds. + pub fn new(min: i128, max: i128) -> Self { + IntegerBounds { + min: Some(min), + max: Some(max), + } + } +} + +/// What an identifier field refers to. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum ReferenceTarget { + /// An identity. + Identity, + /// A data contract. + Contract, + /// A token. + Token, + /// A document of a type that forbids deletion. + PermanentDocument { + /// The contract holding the referenced type; the declaring contract + /// when absent. + contract: Option<[u8; 32]>, + /// The referenced document type. + document_type: CollectionName, + /// Referring property to referenced property equalities enforced at + /// write time. + agreement: Vec<(PropertyPath, PropertyPath)>, + }, + /// An identity public key; the reference carries the identity id and the + /// named sibling property carries the key id. + IdentityPublicKey { + /// The property of the same collection carrying the key id. + key_id_field: PropertyPath, + }, +} + +/// The type of a stored field. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum FieldType { + /// `bool` + Bool, + /// A Rust integer with optional narrower bounds. + Integer { + /// The Rust width. + width: IntegerWidth, + /// Author bounds inside the width. + bounds: IntegerBounds, + }, + /// `f64` + F64, + /// A string with a required character bound. + String { + /// Minimum characters. + min_chars: Option, + /// Maximum characters; absent is an `UnboundedField` diagnostic. + max_chars: Option, + }, + /// A byte array with a required length bound. + Bytes { + /// Minimum bytes. + min_len: Option, + /// Maximum bytes; absent is an `UnboundedField` diagnostic. + max_len: Option, + }, + /// A 32-byte identifier with no declared target. + Identifier, + /// A 32-byte identifier referring to a native object. + Reference(ReferenceTarget), + /// One of a closed set of strings. + Enum(Vec), + /// A nested object with its own positioned fields. + Object(Vec), +} + +impl FieldType { + /// An integer of the given width with no extra bounds. + pub fn integer(width: IntegerWidth) -> Self { + FieldType::Integer { + width, + bounds: IntegerBounds::NONE, + } + } + + /// An integer of the given width bounded to `min..=max`. + pub fn bounded_integer(width: IntegerWidth, min: i128, max: i128) -> Self { + FieldType::Integer { + width, + bounds: IntegerBounds::new(min, max), + } + } + + /// A string of at most `max_chars` characters. + pub fn string(max_chars: u16) -> Self { + FieldType::String { + min_chars: None, + max_chars: Some(max_chars), + } + } + + /// A byte array of at most `max_len` bytes. + pub fn bytes(max_len: u16) -> Self { + FieldType::Bytes { + min_len: None, + max_len: Some(max_len), + } + } + + /// An identifier referring to an identity. + pub fn identity() -> Self { + FieldType::Reference(ReferenceTarget::Identity) + } +} + +/// A stored field. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct FieldSpec { + /// The serialized property name; part of the field's identity. + pub name: PropertyName, + /// The serialization position; part of the field's identity. Positions are + /// contiguous from 0 at each nesting level and never renumbered. + pub position: u32, + /// The type and bounds. + pub ty: FieldType, + /// Whether the property is required, default true. + pub required: bool, + /// Whether the property is validated but not stored, default false. + pub transient: bool, + /// Human description. + pub description: Option, +} + +impl FieldSpec { + /// A required, stored field. + pub fn new(name: PropertyName, position: u32, ty: FieldType) -> Self { + FieldSpec { + name, + position, + ty, + required: true, + transient: false, + description: None, + } + } + + /// Makes the field optional. + pub fn optional(mut self) -> Self { + self.required = false; + self + } + + /// Makes the field transient. + pub fn transient(mut self) -> Self { + self.transient = true; + self + } + + /// Adds a description. + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_give_each_width_its_rust_range() { + assert_eq!(IntegerWidth::U8.min_value(), 0); + assert_eq!(IntegerWidth::U8.max_value(), 255); + assert_eq!(IntegerWidth::U64.max_value(), u64::MAX as i128); + assert_eq!(IntegerWidth::I8.min_value(), -128); + assert_eq!(IntegerWidth::I64.min_value(), i64::MIN as i128); + assert_eq!(IntegerWidth::I64.max_value(), i64::MAX as i128); + assert!(IntegerWidth::I16.is_signed()); + assert!(!IntegerWidth::U16.is_signed()); + } +} diff --git a/packages/rs-dash-sdk-contract/src/declare/index.rs b/packages/rs-dash-sdk-contract/src/declare/index.rs new file mode 100644 index 00000000000..288490df4f6 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/declare/index.rs @@ -0,0 +1,277 @@ +//! Index declarations covering the full native index catalogue. +//! +//! Combinations that native rejects (a range count without a count, a ranking +//! without the matching range axis, a prefix ranking with a sum axis, a time +//! range on a user property) are not re-validated here: native validation is +//! the authority and the build crate surfaces its errors. + +use alloc::string::String; +use alloc::vec::Vec; + +use super::DeclarationOrigin; +use crate::identity::{IndexName, PropertyName, PropertyPath}; + +/// Count fast path on an index. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Countability { + /// Plain tree, no count. + #[default] + NotCountable, + /// Count tree: totals in constant time. + Countable, + /// Provable count tree: totals plus offset and range queries. + CountableAllowingOffset, +} + +impl Countability { + /// Whether either count form is selected. + pub fn is_countable(&self) -> bool { + !matches!(self, Countability::NotCountable) + } +} + +/// Contested index resolution. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ContestedResolution { + /// Masternode vote, the only native resolution. + #[default] + MasternodeVote, +} + +/// Contested index parameters. The native award is an internal native action; +/// declaring these parameters is the only way a contract influences it. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct ContestedSpec { + /// Index property to regular expression the value must match to be + /// contested. + pub field_matches: Vec<(PropertyPath, String)>, + /// How a contest is resolved. + pub resolution: ContestedResolution, + /// Human description. + pub description: Option, +} + +impl ContestedSpec { + /// A masternode-vote contest with the given field matches. + pub fn masternode_vote(field_matches: Vec<(PropertyPath, String)>) -> Self { + ContestedSpec { + field_matches, + resolution: ContestedResolution::MasternodeVote, + description: None, + } + } + + /// Adds a description. + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } +} + +/// Where count rankings are placed. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub enum RankedCount { + /// No count ranking. + #[default] + None, + /// At the terminal level. + Terminal, + /// At the named index property levels, which may include prefixes. + At(Vec), +} + +/// Ranking axes; each costs its own secondary tree. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct Ranking { + /// Count ranking. + pub count: RankedCount, + /// Sum ranking at the terminal level. + pub sum: bool, + /// Average ranking at the terminal level. + pub average: bool, +} + +/// Bucketing of the index's first property into time windows. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct TimeRangeSpec { + /// The bucketed property; must be the index's first property and a system + /// timestamp. + pub on: PropertyPath, + /// Window length in seconds. + pub range_secs: u64, + /// Interval between window starts in seconds. + pub step_secs: u64, + /// Grid offset in seconds. + pub phase_secs: u64, +} + +/// Index-only options: only meaningful when the collection is index-only. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct IndexOnlySpec { + /// The member key property; `$ownerId` when absent. + pub terminal: Option, + /// Preallocate the index path when the referenced document is created. + pub preallocated: bool, + /// Write no entry when the first property is absent. + pub skip_if_absent: bool, +} + +/// An index declaration. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IndexSpec { + /// Where the spec came from. + pub origin: DeclarationOrigin, + /// The index's identity within its collection. + pub name: IndexName, + /// Indexed property paths in order, all ascending. + pub properties: Vec, + /// Unique index. + pub unique: bool, + /// Null values are searchable, default true. + pub null_searchable: bool, + /// Contested parameters. + pub contested: Option, + /// Count fast path. + pub count: Countability, + /// Range counts. + pub range_count: bool, + /// Integer property summed at the index. + pub sum: Option, + /// Range sums. + pub range_sum: bool, + /// Sugar for `count` plus `sum`; expanded by the validator. + pub average: Option, + /// Sugar for `range_count` plus `range_sum`; expanded by the validator. + pub range_average: bool, + /// Ranking axes. + pub ranked: Ranking, + /// Time range bucketing. + pub time_range: Option, + /// Index-only options. + pub index_only: Option, +} + +impl IndexSpec { + /// A plain ascending index over the given properties. + pub fn new(name: IndexName, properties: Vec) -> Self { + IndexSpec { + origin: DeclarationOrigin::Builder, + name, + properties, + unique: false, + null_searchable: true, + contested: None, + count: Countability::NotCountable, + range_count: false, + sum: None, + range_sum: false, + average: None, + range_average: false, + ranked: Ranking::default(), + time_range: None, + index_only: None, + } + } + + /// Records the origin. + pub fn with_origin(mut self, origin: DeclarationOrigin) -> Self { + self.origin = origin; + self + } + + /// Makes the index unique. + pub fn unique(mut self, unique: bool) -> Self { + self.unique = unique; + self + } + + /// Sets null searchability. + pub fn null_searchable(mut self, null_searchable: bool) -> Self { + self.null_searchable = null_searchable; + self + } + + /// Makes the index contested. + pub fn contested(mut self, contested: ContestedSpec) -> Self { + self.contested = Some(contested); + self + } + + /// Selects a count tree. + pub fn count(mut self) -> Self { + self.count = Countability::Countable; + self + } + + /// Selects a provable count tree. + pub fn count_allowing_offset(mut self) -> Self { + self.count = Countability::CountableAllowingOffset; + self + } + + /// Enables range counts. + pub fn range_count(mut self, range_count: bool) -> Self { + self.range_count = range_count; + self + } + + /// Sums a property at the index. + pub fn sum(mut self, property: PropertyName) -> Self { + self.sum = Some(property); + self + } + + /// Enables range sums. + pub fn range_sum(mut self, range_sum: bool) -> Self { + self.range_sum = range_sum; + self + } + + /// Average sugar: `count` plus `sum` on the property. + pub fn average(mut self, property: PropertyName) -> Self { + self.average = Some(property); + self + } + + /// Range average sugar: `range_count` plus `range_sum`. + pub fn range_average(mut self, range_average: bool) -> Self { + self.range_average = range_average; + self + } + + /// Count ranking at the terminal level. + pub fn ranked_count(mut self) -> Self { + self.ranked.count = RankedCount::Terminal; + self + } + + /// Count ranking at the named levels. + pub fn ranked_count_at(mut self, levels: Vec) -> Self { + self.ranked.count = RankedCount::At(levels); + self + } + + /// Sum ranking. + pub fn ranked_sum(mut self, ranked_sum: bool) -> Self { + self.ranked.sum = ranked_sum; + self + } + + /// Average ranking. + pub fn ranked_average(mut self, ranked_average: bool) -> Self { + self.ranked.average = ranked_average; + self + } + + /// Buckets the first property into time windows. + pub fn time_range(mut self, time_range: TimeRangeSpec) -> Self { + self.time_range = Some(time_range); + self + } + + /// Sets index-only options. + pub fn index_only(mut self, options: IndexOnlySpec) -> Self { + self.index_only = Some(options); + self + } +} diff --git a/packages/rs-dash-sdk-contract/src/declare/mod.rs b/packages/rs-dash-sdk-contract/src/declare/mod.rs new file mode 100644 index 00000000000..f63b0ce275f --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/declare/mod.rs @@ -0,0 +1,143 @@ +//! The typed declaration model. +//! +//! A [`ContractDeclaration`] gathers everything a contract package declares: +//! WASM modules and the interfaces between them, document collections with +//! their fields and indexes, typed specialized collections, entries, rules, +//! explicitly required capabilities and the receipt policy. Attributes and +//! typed builders both produce these specs; each spec records its +//! [`DeclarationOrigin`] so the validator can name both sides of a conflict. +//! +//! Identity is by declared name (see [`crate::identity`]); the order in which +//! specs are added never matters, and a builder may restate an attribute +//! declaration verbatim or extend an attribute-declared collection with more +//! indexes, but may not change what the attribute said. + +pub mod capability; +pub mod collection; +pub mod collections; +pub mod entry; +pub mod field; +pub mod index; +pub mod module; +pub mod rule; + +use alloc::vec::Vec; + +pub use capability::{CapabilityRequirement, CapabilityStatus, ReceiptPolicy}; +pub use collection::{ + BoundedKeyRequirement, CollectionKind, CollectionSpec, GasPaidBy, SecurityLevel, Store, + TokenCost, TokenCostEffect, TokenCostSpec, TradeMode, WritePolicy, +}; +pub use collections::{TypedCollectionKind, TypedCollectionSpec}; +pub use entry::{EntrySpec, ParamSpec, Receiver, ValueType}; +pub use field::{FieldSpec, FieldType, IntegerBounds, IntegerWidth, ReferenceTarget}; +pub use index::{ + ContestedResolution, ContestedSpec, Countability, IndexOnlySpec, IndexSpec, RankedCount, + Ranking, TimeRangeSpec, +}; +pub use module::{InterfaceSpec, InternalFunctionSpec, ModuleSpec, IMPLICIT_MODULE}; +pub use rule::{ActionScope, FieldContext, GuardExpr, Literal, RuleKind, RuleSpec}; + +use crate::manifest::CanonicalManifest; +use crate::validate::{validate, Diagnostic}; + +/// Where a spec came from. Used to tell a harmless restatement from a +/// conflict and to name both sides in a `ConflictingDeclaration` diagnostic. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum DeclarationOrigin { + /// Produced by an attribute on a Rust item. + Attribute, + /// Produced by a typed builder in a declaration module. + Builder, +} + +impl core::fmt::Display for DeclarationOrigin { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(match self { + DeclarationOrigin::Attribute => "attribute", + DeclarationOrigin::Builder => "builder", + }) + } +} + +/// Everything one contract package declares, before validation. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ContractDeclaration { + /// WASM module targets. Empty means one implicit module named `main`. + pub modules: Vec, + /// Interfaces modules provide to each other. + pub interfaces: Vec, + /// Document collections and singletons. + pub collections: Vec, + /// Typed specialized collections. + pub typed_collections: Vec, + /// Externally callable entries. + pub entries: Vec, + /// Rules on ordinary document actions. + pub rules: Vec, + /// Explicitly required capabilities (`requires = [...]`). + pub capabilities: Vec, + /// Receipt policy; stored unless disabled. + pub receipts: ReceiptPolicy, +} + +impl ContractDeclaration { + /// An empty declaration. + pub fn new() -> Self { + Self::default() + } + + /// Adds a module. + pub fn module(mut self, spec: ModuleSpec) -> Self { + self.modules.push(spec); + self + } + + /// Adds an interface. + pub fn interface(mut self, spec: InterfaceSpec) -> Self { + self.interfaces.push(spec); + self + } + + /// Adds a collection or singleton. + pub fn collection(mut self, spec: CollectionSpec) -> Self { + self.collections.push(spec); + self + } + + /// Adds a typed specialized collection. + pub fn typed_collection(mut self, spec: TypedCollectionSpec) -> Self { + self.typed_collections.push(spec); + self + } + + /// Adds an entry. + pub fn entry(mut self, spec: EntrySpec) -> Self { + self.entries.push(spec); + self + } + + /// Adds a rule. + pub fn rule(mut self, spec: RuleSpec) -> Self { + self.rules.push(spec); + self + } + + /// Declares an explicitly required capability. + pub fn require(mut self, capability: CapabilityRequirement) -> Self { + self.capabilities.push(capability); + self + } + + /// Sets the receipt policy. + pub fn receipts(mut self, policy: ReceiptPolicy) -> Self { + self.receipts = policy; + self + } + + /// Validates the declaration and builds its canonical manifest, or returns + /// every diagnostic. Same as [`validate`]. + pub fn validate(&self) -> Result> { + validate(self) + } +} diff --git a/packages/rs-dash-sdk-contract/src/declare/module.rs b/packages/rs-dash-sdk-contract/src/declare/module.rs new file mode 100644 index 00000000000..6674caa31f5 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/declare/module.rs @@ -0,0 +1,110 @@ +//! Named WASM modules and the interfaces between them. +//! +//! A contract package may build several WASM module targets. Each entry binds +//! to one module; interfaces declare the functions one module provides to +//! others. The manifest records the module names, the interfaces and the +//! resulting `(importer, provider)` bindings; the graph must be acyclic. A +//! single-module package needs no module declaration: the implicit module is +//! named `main`. + +use alloc::string::String; +use alloc::vec::Vec; + +use super::entry::{ParamSpec, ValueType}; +use super::DeclarationOrigin; +use crate::identity::{InterfaceName, ModuleName}; + +/// The name of the implicit module of a package that declares none. +/// Provisional. +pub const IMPLICIT_MODULE: &str = "main"; + +/// A WASM module target. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ModuleSpec { + /// Where the spec came from. + pub origin: DeclarationOrigin, + /// The module's identity. + pub name: ModuleName, + /// Interfaces the module imports. + pub uses: Vec, +} + +impl ModuleSpec { + /// A module importing nothing. + pub fn new(name: ModuleName) -> Self { + ModuleSpec { + origin: DeclarationOrigin::Builder, + name, + uses: Vec::new(), + } + } + + /// Records the origin. + pub fn with_origin(mut self, origin: DeclarationOrigin) -> Self { + self.origin = origin; + self + } + + /// Imports an interface. + pub fn uses(mut self, interface: InterfaceName) -> Self { + self.uses.push(interface); + self + } +} + +/// A function of an interface. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct InternalFunctionSpec { + /// The function name, unique within the interface. + pub name: String, + /// Parameters. + pub params: Vec, + /// Return type. + pub returns: ValueType, +} + +/// An interface one module provides to others. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InterfaceSpec { + /// Where the spec came from. + pub origin: DeclarationOrigin, + /// The interface's identity. + pub name: InterfaceName, + /// The module exporting it. + pub provider: ModuleName, + /// Its functions. + pub functions: Vec, +} + +impl InterfaceSpec { + /// An empty interface provided by `provider`. + pub fn new(name: InterfaceName, provider: ModuleName) -> Self { + InterfaceSpec { + origin: DeclarationOrigin::Builder, + name, + provider, + functions: Vec::new(), + } + } + + /// Records the origin. + pub fn with_origin(mut self, origin: DeclarationOrigin) -> Self { + self.origin = origin; + self + } + + /// Adds a function. + pub fn function( + mut self, + name: impl Into, + params: Vec, + returns: ValueType, + ) -> Self { + self.functions.push(InternalFunctionSpec { + name: name.into(), + params, + returns, + }); + self + } +} diff --git a/packages/rs-dash-sdk-contract/src/declare/rule.rs b/packages/rs-dash-sdk-contract/src/declare/rule.rs new file mode 100644 index 00000000000..5d1e2127a22 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/declare/rule.rs @@ -0,0 +1,371 @@ +//! Rules on ordinary document actions: native bounded guards and read-only +//! WASM predicates. +//! +//! [`ActionScope`] lists the ordinary document actions only. A contested-index +//! award is an internal native action that no guest rule, guard or predicate +//! can scope, veto, delay, redirect or retry; it is therefore unrepresentable +//! here, and the grammar rejects `on = "award"` as an invalid option value. +//! Ordinary action rules still apply to ordinary writes on a collection that +//! also declares a contested index. + +use alloc::boxed::Box; +use alloc::string::String; +use alloc::vec::Vec; + +use super::DeclarationOrigin; +use crate::identity::{CollectionName, ModuleName, PropertyPath, RuleName}; + +/// An ordinary document action. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ActionScope { + /// Document creation. + Create, + /// Document replacement. + Replace, + /// Document deletion. + Delete, + /// Document transfer. + Transfer, + /// Document purchase. + Purchase, + /// Price update. + UpdatePrice, +} + +impl ActionScope { + /// Every ordinary action, in canonical order. + pub const ALL: &'static [ActionScope] = &[ + ActionScope::Create, + ActionScope::Replace, + ActionScope::Delete, + ActionScope::Transfer, + ActionScope::Purchase, + ActionScope::UpdatePrice, + ]; + + /// The grammar spelling. + pub fn as_str(&self) -> &'static str { + match self { + ActionScope::Create => "create", + ActionScope::Replace => "replace", + ActionScope::Delete => "delete", + ActionScope::Transfer => "transfer", + ActionScope::Purchase => "purchase", + ActionScope::UpdatePrice => "update_price", + } + } +} + +impl core::fmt::Display for ActionScope { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Which document a guard field reads. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum FieldContext { + /// The target immediately before the action. + Old, + /// The target as the action would leave it. + New, + /// The authenticated action context. + Context, +} + +/// A guard literal. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum Literal { + /// A boolean. + Bool(bool), + /// An integer, typed by the compared field's declared width. + Integer(i128), + /// A string. + Text(String), + /// Bytes. + Bytes(Vec), +} + +/// The author-facing native guard expression. +/// +/// Mirrors the proposed bounded guard node set: no loops, recursion, dynamic +/// traversal or ambient time. Provisional: the canonical guard AST and its +/// evaluator are specified by the guards work; the build tooling translates +/// this form into it. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum GuardExpr { + /// A literal. + Literal(Literal), + /// A field of the old, new or context document. + Field(FieldContext, PropertyPath), + /// Whether the field is present. + Exists(FieldContext, PropertyPath), + /// Whether the field is present and null. + IsNull(FieldContext, PropertyPath), + /// Equality of two typed operands. + Eq(Box, Box), + /// Inequality. + Ne(Box, Box), + /// Less than. + Lt(Box, Box), + /// Less than or equal. + Le(Box, Box), + /// Greater than. + Gt(Box, Box), + /// Greater than or equal. + Ge(Box, Box), + /// Checked addition. + Add(Box, Box), + /// Checked subtraction. + Sub(Box, Box), + /// Checked multiplication. + Mul(Box, Box), + /// Short-circuit conjunction. + And(Box, Box), + /// Short-circuit disjunction. + Or(Box, Box), + /// Negation. + Not(Box), + /// Conditional; one branch is evaluated. + If { + /// The condition. + condition: Box, + /// Evaluated when the condition holds. + then: Box, + /// Evaluated otherwise. + otherwise: Box, + }, +} + +impl GuardExpr { + /// An integer literal. + pub fn integer(value: i128) -> Self { + GuardExpr::Literal(Literal::Integer(value)) + } + + /// A boolean literal. + pub fn boolean(value: bool) -> Self { + GuardExpr::Literal(Literal::Bool(value)) + } + + /// A field read. + pub fn field(context: FieldContext, path: PropertyPath) -> Self { + GuardExpr::Field(context, path) + } + + /// `self == other` + pub fn eq(self, other: GuardExpr) -> Self { + GuardExpr::Eq(Box::new(self), Box::new(other)) + } + + /// `self != other` + pub fn ne(self, other: GuardExpr) -> Self { + GuardExpr::Ne(Box::new(self), Box::new(other)) + } + + /// `self < other` + pub fn lt(self, other: GuardExpr) -> Self { + GuardExpr::Lt(Box::new(self), Box::new(other)) + } + + /// `self <= other` + pub fn le(self, other: GuardExpr) -> Self { + GuardExpr::Le(Box::new(self), Box::new(other)) + } + + /// `self > other` + pub fn gt(self, other: GuardExpr) -> Self { + GuardExpr::Gt(Box::new(self), Box::new(other)) + } + + /// `self >= other` + pub fn ge(self, other: GuardExpr) -> Self { + GuardExpr::Ge(Box::new(self), Box::new(other)) + } + + /// `self + other`, checked. + pub fn plus(self, other: GuardExpr) -> Self { + GuardExpr::Add(Box::new(self), Box::new(other)) + } + + /// `self - other`, checked. + pub fn minus(self, other: GuardExpr) -> Self { + GuardExpr::Sub(Box::new(self), Box::new(other)) + } + + /// `self * other`, checked. + pub fn times(self, other: GuardExpr) -> Self { + GuardExpr::Mul(Box::new(self), Box::new(other)) + } + + /// `self && other` + pub fn and(self, other: GuardExpr) -> Self { + GuardExpr::And(Box::new(self), Box::new(other)) + } + + /// `self || other` + pub fn or(self, other: GuardExpr) -> Self { + GuardExpr::Or(Box::new(self), Box::new(other)) + } + + /// `!self` + pub fn negate(self) -> Self { + GuardExpr::Not(Box::new(self)) + } + + /// `if self { then } else { otherwise }` + pub fn if_else(self, then: GuardExpr, otherwise: GuardExpr) -> Self { + GuardExpr::If { + condition: Box::new(self), + then: Box::new(then), + otherwise: Box::new(otherwise), + } + } + + /// Every field path the expression reads, with its context. + pub fn field_references(&self) -> Vec<(FieldContext, &PropertyPath)> { + let mut references = Vec::new(); + self.collect_field_references(&mut references); + references + } + + fn collect_field_references<'a>(&'a self, into: &mut Vec<(FieldContext, &'a PropertyPath)>) { + match self { + GuardExpr::Literal(_) => {} + GuardExpr::Field(context, path) + | GuardExpr::Exists(context, path) + | GuardExpr::IsNull(context, path) => into.push((*context, path)), + GuardExpr::Eq(a, b) + | GuardExpr::Ne(a, b) + | GuardExpr::Lt(a, b) + | GuardExpr::Le(a, b) + | GuardExpr::Gt(a, b) + | GuardExpr::Ge(a, b) + | GuardExpr::Add(a, b) + | GuardExpr::Sub(a, b) + | GuardExpr::Mul(a, b) + | GuardExpr::And(a, b) + | GuardExpr::Or(a, b) => { + a.collect_field_references(into); + b.collect_field_references(into); + } + GuardExpr::Not(a) => a.collect_field_references(into), + GuardExpr::If { + condition, + then, + otherwise, + } => { + condition.collect_field_references(into); + then.collect_field_references(into); + otherwise.collect_field_references(into); + } + } + } +} + +/// How a rule decides. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum RuleKind { + /// A native bounded guard expression, evaluated by the host. + NativeGuard(GuardExpr), + /// A read-only predicate exported by a WASM module of this contract. + WasmPredicate { + /// The module exporting the predicate. + module: ModuleName, + /// The export symbol, bound against the module's actual exports at + /// build time. + export: String, + }, +} + +/// A rule on ordinary actions of one collection. Its identity is +/// `(collection, name)`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuleSpec { + /// Where the spec came from. + pub origin: DeclarationOrigin, + /// The rule's name, unique within the collection. + pub name: RuleName, + /// The guarded collection. + pub collection: CollectionName, + /// The guarded actions; stored as a sorted set in the manifest. + pub actions: Vec, + /// How the rule decides. + pub kind: RuleKind, +} + +impl RuleSpec { + /// A native guard rule. + pub fn guard( + collection: CollectionName, + name: RuleName, + actions: Vec, + guard: GuardExpr, + ) -> Self { + RuleSpec { + origin: DeclarationOrigin::Builder, + name, + collection, + actions, + kind: RuleKind::NativeGuard(guard), + } + } + + /// A WASM predicate rule. + pub fn predicate( + collection: CollectionName, + name: RuleName, + actions: Vec, + module: ModuleName, + export: impl Into, + ) -> Self { + RuleSpec { + origin: DeclarationOrigin::Builder, + name, + collection, + actions, + kind: RuleKind::WasmPredicate { + module, + export: export.into(), + }, + } + } + + /// Records the origin. + pub fn with_origin(mut self, origin: DeclarationOrigin) -> Self { + self.origin = origin; + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_have_no_award_action() { + assert!(ActionScope::ALL + .iter() + .all(|action| action.as_str() != "award")); + assert_eq!(ActionScope::ALL.len(), 6); + } + + #[test] + fn should_collect_every_field_reference_of_a_guard() { + let points = PropertyPath::new("points").unwrap(); + let guard = GuardExpr::field(FieldContext::New, points.clone()) + .ge(GuardExpr::field(FieldContext::Old, points.clone())) + .and( + GuardExpr::Exists(FieldContext::Old, points.clone()) + .negate() + .if_else( + GuardExpr::boolean(true), + GuardExpr::integer(1).lt(GuardExpr::integer(2)), + ), + ); + let references = guard.field_references(); + assert_eq!(references.len(), 3); + assert_eq!(references[0], (FieldContext::New, &points)); + assert_eq!(references[2], (FieldContext::Old, &points)); + } +} diff --git a/packages/rs-dash-sdk-contract/src/grammar.rs b/packages/rs-dash-sdk-contract/src/grammar.rs new file mode 100644 index 00000000000..3528671dd16 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/grammar.rs @@ -0,0 +1,1561 @@ +//! The attribute grammar as data. +//! +//! [`ATTRIBUTES`] is the single description of every author attribute +//! (`#[persistent]`, `#[index]`, `#[entry]`, ...), the options each one takes +//! and the values those options accept. The proc macros parse against this +//! table, [`check_keys`] reports grammar diagnostics from it, and the book +//! chapter renders it, so the three cannot drift. +//! +//! The grammar is deliberately closed: an option that is not in the table is +//! an [`DiagnosticKind::UnknownOption`] diagnostic, never silently ignored. The +//! index property order admits only `"asc"` because the native document +//! meta-schema admits only ascending index properties; query direction is a +//! per-query choice. The rule action list admits only ordinary document actions +//! because a contested-index award is a native action that no rule can scope. +//! +//! The spellings themselves are provisional under the shared allocation +//! register entry for the Rust macro grammar. + +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +use crate::validate::diagnostic::{DeclarationPath, Diagnostic, DiagnosticKind}; + +/// What a Rust item an attribute may be placed on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AttributeTarget { + /// A struct declaring a collection. + Struct, + /// A field of a persistent struct. + Field, + /// A free function or an inherent method. + Function, + /// A trait declaring an interface between modules. + Trait, + /// A Rust module compiled as one WASM module target. + Module, + /// The crate root: contract-wide declarations. + Crate, +} + +/// A closed set of string values an option accepts, with the reason the set +/// is closed (shown in the diagnostic when a value is outside it). +#[derive(Debug, PartialEq, Eq)] +pub struct Choices { + /// The admitted spellings. + pub allowed: &'static [&'static str], + /// Why nothing else is admitted. + pub explain: &'static str, +} + +/// The shape of an option's value. +#[derive(Debug, PartialEq, Eq)] +pub enum ValueShape { + /// A bare flag (`unique`) or an explicit boolean (`mutable = false`). + Bool, + /// Any string. + Str, + /// One string from a closed set. + Choice(&'static Choices), + /// An integer. + Int, + /// A list of strings. + StrList, + /// A list of strings from a closed set. + ChoiceList(&'static Choices), + /// A bare flag or one string from a closed set (`count` / `count = "offset"`). + BoolOrChoice(&'static Choices), + /// A bare flag or a list of strings (`ranked_count` / `ranked_count = ["a"]`). + BoolOrStrList, + /// A nested option list with its own keys (`contested(...)`). + Nested(&'static [KeySpec]), + /// A nested map whose keys are property paths (`fields(class = "asc")`). + Map(MapValue), +} + +/// The value shape of every entry in a [`ValueShape::Map`]. +#[derive(Debug, PartialEq, Eq)] +pub enum MapValue { + /// Any string. + Str, + /// One string from a closed set. + Choice(&'static Choices), +} + +/// One option of an attribute. +#[derive(Debug, PartialEq, Eq)] +pub struct KeySpec { + /// The option name. + pub name: &'static str, + /// The value shape. + pub value: ValueShape, + /// Whether the option must be present. + pub required: bool, + /// What the option means and its default when absent. + pub doc: &'static str, +} + +/// One attribute of the grammar. +#[derive(Debug, PartialEq, Eq)] +pub struct AttributeSpec { + /// The attribute name as written after `#[`. + pub name: &'static str, + /// Where it may be placed. + pub target: AttributeTarget, + /// Whether the attribute may appear several times on one item. + pub repeatable: bool, + /// The options it takes. + pub keys: &'static [KeySpec], + /// Groups of options of which exactly one must be present. + pub exactly_one_of: &'static [&'static [&'static str]], +} + +/// Index property order: ascending only. +pub const ORDER: Choices = Choices { + allowed: &["asc"], + explain: "native indexes are ascending only; the direction of a query is chosen per query", +}; + +/// Ordinary document actions a rule or a token cost may scope. +pub const ACTIONS: Choices = Choices { + allowed: &[ + "create", + "replace", + "delete", + "transfer", + "purchase", + "update_price", + ], + explain: "only ordinary document actions have a rule scope; a contested-index award is a native action that no guest rule, guard or predicate can scope", +}; + +/// Who may create documents of a collection. +pub const WRITE: Choices = Choices { + allowed: &["any", "owner", "contract"], + explain: "creation restriction modes; `contract` requires the pending native contract-write authority", +}; + +/// Marketplace trade mode. +pub const TRADE: Choices = Choices { + allowed: &["none", "direct_purchase"], + explain: "native trade modes", +}; + +/// Signature security level required to write. +pub const SECURITY_LEVEL: Choices = Choices { + allowed: &["critical", "high", "medium"], + explain: "native signature security levels", +}; + +/// Identity bounded key requirement for encryption or decryption. +pub const KEY_REQUIREMENT: Choices = Choices { + allowed: &["unique", "multiple", "multiple_reference_to_latest"], + explain: "native storage key requirements", +}; + +/// Document store kind. +pub const STORE: Choices = Choices { + allowed: &["public", "private"], + explain: "`private` is catalogued but its interface is disabled until encryption, key control, query visibility and proof behaviour are specified", +}; + +/// Count fast-path form on an index. +pub const COUNT: Choices = Choices { + allowed: &["offset"], + explain: + "`count` alone selects a count tree; `count = \"offset\"` selects a provable count tree", +}; + +/// Reference targets. +pub const REFERS_TO: Choices = Choices { + allowed: &[ + "identity", + "contract", + "token", + "permanent_document", + "identity_public_key", + ], + explain: "native reference targets", +}; + +/// Token cost effect. +pub const TOKEN_EFFECT: Choices = Choices { + allowed: &["transfer_to_contract_owner", "burn"], + explain: "native token cost effects", +}; + +/// Who pays gas for a token-priced action. +pub const GAS_PAID_BY: Choices = Choices { + allowed: &["document_owner", "contract_owner", "prefer_contract_owner"], + explain: "native gas payer options", +}; + +/// Contested index resolution. +pub const RESOLUTION: Choices = Choices { + allowed: &["masternode_vote"], + explain: "the only native contested resolution", +}; + +/// Receipt policy. +pub const RECEIPTS: Choices = Choices { + allowed: &["stored", "disabled"], + explain: "receipts default to stored and may be disabled by the contract", +}; + +/// Declarable capability requirements. +pub const REQUIRES: Choices = Choices { + allowed: &["acl", "randomness"], + explain: + "capabilities a contract declares explicitly; the others are derived from its declarations", +}; + +const CONTESTED_KEYS: &[KeySpec] = &[ + KeySpec { + name: "field_matches", + value: ValueShape::Map(MapValue::Str), + required: false, + doc: "index property to regular expression the value must match to be contested", + }, + KeySpec { + name: "resolution", + value: ValueShape::Choice(&RESOLUTION), + required: true, + doc: "how a contest is resolved", + }, + KeySpec { + name: "description", + value: ValueShape::Str, + required: false, + doc: "human description of the contest", + }, +]; + +const TIME_RANGE_KEYS: &[KeySpec] = &[ + KeySpec { + name: "on", + value: ValueShape::Str, + required: true, + doc: "the index's first property, a system timestamp", + }, + KeySpec { + name: "range_secs", + value: ValueShape::Int, + required: true, + doc: "window length in seconds, a multiple of `step_secs`", + }, + KeySpec { + name: "step_secs", + value: ValueShape::Int, + required: true, + doc: "interval between window starts in seconds", + }, + KeySpec { + name: "phase_secs", + value: ValueShape::Int, + required: false, + doc: "window grid offset in seconds, default 0", + }, +]; + +const COLLECTION_COMMON_KEYS_DOC: &str = "see the persistent attribute"; + +const PERSISTENT_KEYS: &[KeySpec] = &[ + KeySpec { + name: "collection", + value: ValueShape::Str, + required: true, + doc: "collection name (native document type name); the collection's identity", + }, + KeySpec { + name: "schema", + value: ValueShape::Int, + required: false, + doc: "author-declared schema revision, at least 1, default 1", + }, + KeySpec { + name: "write", + value: ValueShape::Choice(&WRITE), + required: false, + doc: "who may create documents, default `any`", + }, + KeySpec { + name: "mutable", + value: ValueShape::Bool, + required: false, + doc: "documents may be replaced, default true", + }, + KeySpec { + name: "deletable", + value: ValueShape::Bool, + required: false, + doc: "documents may be deleted, default true", + }, + KeySpec { + name: "keep_history", + value: ValueShape::Bool, + required: false, + doc: "keep every revision, default false", + }, + KeySpec { + name: "keep_transfer_history", + value: ValueShape::Bool, + required: false, + doc: "keep transfer revisions, default false", + }, + KeySpec { + name: "keep_purchase_history", + value: ValueShape::Bool, + required: false, + doc: "keep purchase revisions, default false", + }, + KeySpec { + name: "keep_pricing_history", + value: ValueShape::Bool, + required: false, + doc: "keep price-update revisions, default false", + }, + KeySpec { + name: "transferable", + value: ValueShape::Bool, + required: false, + doc: "documents may be transferred, default false", + }, + KeySpec { + name: "trade", + value: ValueShape::Choice(&TRADE), + required: false, + doc: "marketplace mode, default `none`", + }, + KeySpec { + name: "security_level", + value: ValueShape::Choice(&SECURITY_LEVEL), + required: false, + doc: "signature security level required to write, default `high`", + }, + KeySpec { + name: "encryption_key", + value: ValueShape::Choice(&KEY_REQUIREMENT), + required: false, + doc: "identity encryption bounded key requirement, default none", + }, + KeySpec { + name: "decryption_key", + value: ValueShape::Choice(&KEY_REQUIREMENT), + required: false, + doc: "identity decryption bounded key requirement, default none", + }, + KeySpec { + name: "count", + value: ValueShape::Bool, + required: false, + doc: "count tree on the primary key, default false", + }, + KeySpec { + name: "range_count", + value: ValueShape::Bool, + required: false, + doc: "provable count on the primary key, needs `count`", + }, + KeySpec { + name: "sum", + value: ValueShape::Str, + required: false, + doc: "integer property summed on the primary key", + }, + KeySpec { + name: "range_sum", + value: ValueShape::Bool, + required: false, + doc: "provable sum on the primary key, needs `sum`", + }, + KeySpec { + name: "average", + value: ValueShape::Str, + required: false, + doc: "sugar for `count` plus `sum = `, expanded before the manifest", + }, + KeySpec { + name: "range_average", + value: ValueShape::Bool, + required: false, + doc: "sugar for `range_count` plus `range_sum`, expanded before the manifest", + }, + KeySpec { + name: "index_only", + value: ValueShape::Bool, + required: false, + doc: "documents live only in their indexes, default false", + }, + KeySpec { + name: "store", + value: ValueShape::Choice(&STORE), + required: false, + doc: "document store, default `public`", + }, +]; + +const SINGLETON_KEYS: &[KeySpec] = &[ + KeySpec { + name: "collection", + value: ValueShape::Str, + required: true, + doc: "collection name; the singleton's identity", + }, + KeySpec { + name: "schema", + value: ValueShape::Int, + required: false, + doc: COLLECTION_COMMON_KEYS_DOC, + }, + KeySpec { + name: "write", + value: ValueShape::Choice(&WRITE), + required: false, + doc: COLLECTION_COMMON_KEYS_DOC, + }, + KeySpec { + name: "security_level", + value: ValueShape::Choice(&SECURITY_LEVEL), + required: false, + doc: COLLECTION_COMMON_KEYS_DOC, + }, + KeySpec { + name: "encryption_key", + value: ValueShape::Choice(&KEY_REQUIREMENT), + required: false, + doc: COLLECTION_COMMON_KEYS_DOC, + }, + KeySpec { + name: "decryption_key", + value: ValueShape::Choice(&KEY_REQUIREMENT), + required: false, + doc: COLLECTION_COMMON_KEYS_DOC, + }, + KeySpec { + name: "store", + value: ValueShape::Choice(&STORE), + required: false, + doc: COLLECTION_COMMON_KEYS_DOC, + }, +]; + +const TOKEN_COST_KEYS: &[KeySpec] = &[ + KeySpec { + name: "on", + value: ValueShape::Choice(&ACTIONS), + required: true, + doc: "the priced action; one token cost per action", + }, + KeySpec { + name: "token_position", + value: ValueShape::Int, + required: true, + doc: "token position in the token contract", + }, + KeySpec { + name: "amount", + value: ValueShape::Int, + required: true, + doc: "token amount charged", + }, + KeySpec { + name: "contract", + value: ValueShape::Str, + required: false, + doc: "token contract id in base58, default the declaring contract", + }, + KeySpec { + name: "effect", + value: ValueShape::Choice(&TOKEN_EFFECT), + required: false, + doc: "default `transfer_to_contract_owner`", + }, + KeySpec { + name: "gas_paid_by", + value: ValueShape::Choice(&GAS_PAID_BY), + required: false, + doc: "default `document_owner`", + }, +]; + +const INDEX_KEYS: &[KeySpec] = &[ + KeySpec { + name: "name", + value: ValueShape::Str, + required: true, + doc: "index name; the index's identity within its collection", + }, + KeySpec { + name: "fields", + value: ValueShape::Map(MapValue::Choice(&ORDER)), + required: true, + doc: "indexed property paths in order; system properties are written as string keys", + }, + KeySpec { + name: "unique", + value: ValueShape::Bool, + required: false, + doc: "default false", + }, + KeySpec { + name: "null_searchable", + value: ValueShape::Bool, + required: false, + doc: "default true", + }, + KeySpec { + name: "contested", + value: ValueShape::Nested(CONTESTED_KEYS), + required: false, + doc: "contested index parameters", + }, + KeySpec { + name: "count", + value: ValueShape::BoolOrChoice(&COUNT), + required: false, + doc: "count tree; `= \"offset\"` for a provable count tree", + }, + KeySpec { + name: "range_count", + value: ValueShape::Bool, + required: false, + doc: "range counts, needs `count`", + }, + KeySpec { + name: "sum", + value: ValueShape::Str, + required: false, + doc: "integer property summed at the index", + }, + KeySpec { + name: "range_sum", + value: ValueShape::Bool, + required: false, + doc: "range sums, needs `sum`", + }, + KeySpec { + name: "average", + value: ValueShape::Str, + required: false, + doc: "sugar for `count` plus `sum = `, expanded before the manifest", + }, + KeySpec { + name: "range_average", + value: ValueShape::Bool, + required: false, + doc: "sugar for `range_count` plus `range_sum`, expanded before the manifest", + }, + KeySpec { + name: "ranked_count", + value: ValueShape::BoolOrStrList, + required: false, + doc: "count ranking at the terminal level, or at the named prefix levels", + }, + KeySpec { + name: "ranked_sum", + value: ValueShape::Bool, + required: false, + doc: "sum ranking at the terminal level", + }, + KeySpec { + name: "ranked_average", + value: ValueShape::Bool, + required: false, + doc: "average ranking at the terminal level", + }, + KeySpec { + name: "time_range", + value: ValueShape::Nested(TIME_RANGE_KEYS), + required: false, + doc: "bucket the first property into time windows", + }, + KeySpec { + name: "terminal", + value: ValueShape::Str, + required: false, + doc: "index-only member key property, default `$ownerId`", + }, + KeySpec { + name: "preallocated", + value: ValueShape::Bool, + required: false, + doc: "index-only path preallocation", + }, + KeySpec { + name: "skip_if_absent", + value: ValueShape::Bool, + required: false, + doc: "index-only conditional participation on the first property", + }, +]; + +const FIELD_KEYS: &[KeySpec] = &[ + KeySpec { + name: "position", + value: ValueShape::Int, + required: true, + doc: "stable serialization position, contiguous from 0 per nesting level", + }, + KeySpec { + name: "max_chars", + value: ValueShape::Int, + required: false, + doc: "string bound, required for strings", + }, + KeySpec { + name: "min_chars", + value: ValueShape::Int, + required: false, + doc: "string lower bound", + }, + KeySpec { + name: "max_len", + value: ValueShape::Int, + required: false, + doc: "byte array bound, required for byte arrays", + }, + KeySpec { + name: "min_len", + value: ValueShape::Int, + required: false, + doc: "byte array lower bound", + }, + KeySpec { + name: "min", + value: ValueShape::Int, + required: false, + doc: "integer lower bound, must fit the Rust type", + }, + KeySpec { + name: "max", + value: ValueShape::Int, + required: false, + doc: "integer upper bound, must fit the Rust type", + }, + KeySpec { + name: "values", + value: ValueShape::StrList, + required: false, + doc: "closed set of string values", + }, + KeySpec { + name: "required", + value: ValueShape::Bool, + required: false, + doc: "default true", + }, + KeySpec { + name: "transient", + value: ValueShape::Bool, + required: false, + doc: "validated but not stored, default false", + }, + KeySpec { + name: "refers_to", + value: ValueShape::Choice(&REFERS_TO), + required: false, + doc: "reference target of an identifier field", + }, + KeySpec { + name: "document_type", + value: ValueShape::Str, + required: false, + doc: "referenced collection for `permanent_document`", + }, + KeySpec { + name: "contract", + value: ValueShape::Str, + required: false, + doc: "referenced contract id in base58 for `permanent_document`, default the declaring contract", + }, + KeySpec { + name: "agreement", + value: ValueShape::Map(MapValue::Str), + required: false, + doc: "referring property to referenced property equalities for `permanent_document`", + }, + KeySpec { + name: "key_id_field", + value: ValueShape::Str, + required: false, + doc: "property carrying the key id for `identity_public_key`", + }, + KeySpec { + name: "description", + value: ValueShape::Str, + required: false, + doc: "human description", + }, +]; + +const ENTRY_KEYS: &[KeySpec] = &[ + KeySpec { + name: "name", + value: ValueShape::Str, + required: true, + doc: "method name; the entry's identity across the whole contract", + }, + KeySpec { + name: "read_only", + value: ValueShape::Bool, + required: false, + doc: "the entry stages no writes, default false", + }, + KeySpec { + name: "module", + value: ValueShape::Str, + required: false, + doc: "hosting WASM module, default the single module", + }, +]; + +const RULE_KEYS: &[KeySpec] = &[ + KeySpec { + name: "name", + value: ValueShape::Str, + required: true, + doc: "rule name, unique within the collection", + }, + KeySpec { + name: "on", + value: ValueShape::ChoiceList(&ACTIONS), + required: true, + doc: "ordinary actions the rule guards", + }, + KeySpec { + name: "guard", + value: ValueShape::Str, + required: false, + doc: "name of a native guard expression constant", + }, + KeySpec { + name: "predicate", + value: ValueShape::Str, + required: false, + doc: "read-only WASM predicate as `module::export`", + }, +]; + +const CONTRACT_KEYS: &[KeySpec] = &[ + KeySpec { + name: "receipts", + value: ValueShape::Choice(&RECEIPTS), + required: false, + doc: "default `stored`", + }, + KeySpec { + name: "requires", + value: ValueShape::ChoiceList(&REQUIRES), + required: false, + doc: "explicitly required capabilities", + }, +]; + +const MODULE_KEYS: &[KeySpec] = &[ + KeySpec { + name: "name", + value: ValueShape::Str, + required: true, + doc: "module name; the module's identity", + }, + KeySpec { + name: "uses", + value: ValueShape::StrList, + required: false, + doc: "interfaces imported from other modules", + }, +]; + +const INTERFACE_KEYS: &[KeySpec] = &[ + KeySpec { + name: "name", + value: ValueShape::Str, + required: true, + doc: "interface name; the interface's identity", + }, + KeySpec { + name: "provider", + value: ValueShape::Str, + required: true, + doc: "the module exporting the interface", + }, +]; + +/// Every attribute of the author grammar. +pub const ATTRIBUTES: &[AttributeSpec] = &[ + AttributeSpec { + name: "persistent", + target: AttributeTarget::Struct, + repeatable: false, + keys: PERSISTENT_KEYS, + exactly_one_of: &[], + }, + AttributeSpec { + name: "singleton", + target: AttributeTarget::Struct, + repeatable: false, + keys: SINGLETON_KEYS, + exactly_one_of: &[], + }, + AttributeSpec { + name: "token_cost", + target: AttributeTarget::Struct, + repeatable: true, + keys: TOKEN_COST_KEYS, + exactly_one_of: &[], + }, + AttributeSpec { + name: "index", + target: AttributeTarget::Struct, + repeatable: true, + keys: INDEX_KEYS, + exactly_one_of: &[], + }, + AttributeSpec { + name: "field", + target: AttributeTarget::Field, + repeatable: false, + keys: FIELD_KEYS, + exactly_one_of: &[], + }, + AttributeSpec { + name: "document_id", + target: AttributeTarget::Field, + repeatable: false, + keys: &[], + exactly_one_of: &[], + }, + AttributeSpec { + name: "entry", + target: AttributeTarget::Function, + repeatable: false, + keys: ENTRY_KEYS, + exactly_one_of: &[], + }, + AttributeSpec { + name: "rule", + target: AttributeTarget::Struct, + repeatable: true, + keys: RULE_KEYS, + exactly_one_of: &[&["guard", "predicate"]], + }, + AttributeSpec { + name: "contract", + target: AttributeTarget::Crate, + repeatable: false, + keys: CONTRACT_KEYS, + exactly_one_of: &[], + }, + AttributeSpec { + name: "module", + target: AttributeTarget::Module, + repeatable: false, + keys: MODULE_KEYS, + exactly_one_of: &[], + }, + AttributeSpec { + name: "interface", + target: AttributeTarget::Trait, + repeatable: false, + keys: INTERFACE_KEYS, + exactly_one_of: &[], + }, +]; + +/// Looks an attribute up by name. +pub fn attribute(name: &str) -> Option<&'static AttributeSpec> { + ATTRIBUTES.iter().find(|spec| spec.name == name) +} + +/// A value as the macro parsed it, before any typing. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GivenValue<'a> { + /// A bare flag or an explicit boolean. + Bool(bool), + /// A string literal. + Str(&'a str), + /// An integer literal. + Int(i128), + /// A list of string literals. + StrList(&'a [&'a str]), + /// A nested option list or map. + Nested(&'a [GivenOption<'a>]), +} + +/// One option as the macro parsed it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GivenOption<'a> { + /// The option name (or the map key). + pub name: &'a str, + /// The value. + pub value: GivenValue<'a>, +} + +/// Checks the options given to `attribute` against the grammar and returns +/// every diagnostic: unknown attribute, unknown option, wrong value shape, +/// repeated option, missing required option, and violated exactly-one groups. +/// +/// The check is structural. Whether a value makes sense against the rest of +/// the declaration (a property path that exists, a bound inside the Rust type) +/// is the validator's job. +pub fn check_keys(attribute: &str, given: &[GivenOption<'_>]) -> Vec { + let mut diagnostics = Vec::new(); + let Some(spec) = self::attribute(attribute) else { + diagnostics.push(Diagnostic::new( + DeclarationPath::attribute(attribute, None), + DiagnosticKind::UnknownAttribute { + attribute: attribute.to_string(), + }, + )); + return diagnostics; + }; + check_options( + attribute, + spec.keys, + spec.exactly_one_of, + given, + &mut diagnostics, + ); + diagnostics +} + +fn check_options( + attribute: &str, + keys: &'static [KeySpec], + exactly_one_of: &'static [&'static [&'static str]], + given: &[GivenOption<'_>], + diagnostics: &mut Vec, +) { + let mut seen: Vec<&str> = Vec::new(); + for option in given { + let path = DeclarationPath::attribute(attribute, Some(option.name)); + let Some(key) = keys.iter().find(|key| key.name == option.name) else { + diagnostics.push(Diagnostic::new( + path, + DiagnosticKind::UnknownOption { + attribute: attribute.to_string(), + option: option.name.to_string(), + }, + )); + continue; + }; + if seen.contains(&option.name) { + diagnostics.push(Diagnostic::new( + path, + DiagnosticKind::DuplicateOption { + attribute: attribute.to_string(), + option: option.name.to_string(), + }, + )); + continue; + } + seen.push(option.name); + if let Err(reason) = check_value(attribute, key, option.value, diagnostics) { + diagnostics.push(Diagnostic::new( + path, + DiagnosticKind::InvalidOptionValue { + attribute: attribute.to_string(), + option: option.name.to_string(), + reason, + }, + )); + } + } + for key in keys.iter().filter(|key| key.required) { + if !seen.contains(&key.name) { + diagnostics.push(Diagnostic::new( + DeclarationPath::attribute(attribute, Some(key.name)), + DiagnosticKind::MissingOption { + attribute: attribute.to_string(), + option: key.name.to_string(), + }, + )); + } + } + for group in exactly_one_of { + let present = group.iter().filter(|name| seen.contains(name)).count(); + if present != 1 { + diagnostics.push(Diagnostic::new( + DeclarationPath::attribute(attribute, None), + DiagnosticKind::ExactlyOneOptionRequired { + attribute: attribute.to_string(), + options: group.iter().map(|name| name.to_string()).collect(), + given: present, + }, + )); + } + } +} + +fn choice_reason(value: &str, choices: &Choices) -> String { + let mut reason = String::new(); + reason.push_str("value "); + reason.push('"'); + reason.push_str(value); + reason.push('"'); + reason.push_str(" is not one of ["); + for (i, allowed) in choices.allowed.iter().enumerate() { + if i > 0 { + reason.push_str(", "); + } + reason.push('"'); + reason.push_str(allowed); + reason.push('"'); + } + reason.push_str("]: "); + reason.push_str(choices.explain); + reason +} + +fn check_choice(value: &str, choices: &Choices) -> Result<(), String> { + if choices.allowed.contains(&value) { + Ok(()) + } else { + Err(choice_reason(value, choices)) + } +} + +fn check_value( + attribute: &str, + key: &'static KeySpec, + value: GivenValue<'_>, + diagnostics: &mut Vec, +) -> Result<(), String> { + match (&key.value, value) { + (ValueShape::Bool, GivenValue::Bool(_)) => Ok(()), + (ValueShape::Bool, _) => Err("expects a bare flag or `= true` / `= false`".to_string()), + (ValueShape::Str, GivenValue::Str(_)) => Ok(()), + (ValueShape::Str, _) => Err("expects a string".to_string()), + (ValueShape::Choice(choices), GivenValue::Str(value)) => check_choice(value, choices), + (ValueShape::Choice(_), _) => Err("expects a string".to_string()), + (ValueShape::Int, GivenValue::Int(_)) => Ok(()), + (ValueShape::Int, _) => Err("expects an integer".to_string()), + (ValueShape::StrList, GivenValue::StrList(_)) => Ok(()), + (ValueShape::StrList, _) => Err("expects a list of strings".to_string()), + (ValueShape::ChoiceList(choices), GivenValue::StrList(values)) => values + .iter() + .try_for_each(|value| check_choice(value, choices)), + (ValueShape::ChoiceList(_), _) => Err("expects a list of strings".to_string()), + (ValueShape::BoolOrChoice(_), GivenValue::Bool(_)) => Ok(()), + (ValueShape::BoolOrChoice(choices), GivenValue::Str(value)) => check_choice(value, choices), + (ValueShape::BoolOrChoice(_), _) => { + Err("expects a bare flag, a boolean or a string".to_string()) + } + (ValueShape::BoolOrStrList, GivenValue::Bool(_) | GivenValue::StrList(_)) => Ok(()), + (ValueShape::BoolOrStrList, _) => { + Err("expects a bare flag, a boolean or a list of strings".to_string()) + } + (ValueShape::Nested(keys), GivenValue::Nested(options)) => { + let mut nested = String::new(); + nested.push_str(attribute); + nested.push('.'); + nested.push_str(key.name); + check_options(&nested, keys, &[], options, diagnostics); + Ok(()) + } + (ValueShape::Nested(_), _) => Err("expects a nested option list".to_string()), + (ValueShape::Map(map_value), GivenValue::Nested(entries)) => { + if entries.is_empty() { + return Err("expects at least one entry".to_string()); + } + let mut seen: Vec<&str> = Vec::new(); + for entry in entries { + if seen.contains(&entry.name) { + let mut reason = String::new(); + reason.push_str("key "); + reason.push_str(entry.name); + reason.push_str(" is repeated"); + return Err(reason); + } + seen.push(entry.name); + match (map_value, entry.value) { + (MapValue::Str, GivenValue::Str(_)) => {} + (MapValue::Choice(choices), GivenValue::Str(value)) => { + check_choice(value, choices)?; + } + _ => { + let mut reason = String::new(); + reason.push_str("key "); + reason.push_str(entry.name); + reason.push_str(" expects a string value"); + return Err(reason); + } + } + } + Ok(()) + } + (ValueShape::Map(_), _) => Err("expects a nested map".to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The rows of the book table, kept literally so a table edit without a + /// grammar edit (or the reverse) fails here. + const BOOK_TABLE: &[(&str, &[&str])] = &[ + ( + "persistent", + &[ + "collection", + "schema", + "write", + "mutable", + "deletable", + "keep_history", + "keep_transfer_history", + "keep_purchase_history", + "keep_pricing_history", + "transferable", + "trade", + "security_level", + "encryption_key", + "decryption_key", + "count", + "range_count", + "sum", + "range_sum", + "average", + "range_average", + "index_only", + "store", + ], + ), + ( + "singleton", + &[ + "collection", + "schema", + "write", + "security_level", + "encryption_key", + "decryption_key", + "store", + ], + ), + ( + "token_cost", + &[ + "on", + "token_position", + "amount", + "contract", + "effect", + "gas_paid_by", + ], + ), + ( + "index", + &[ + "name", + "fields", + "unique", + "null_searchable", + "contested", + "count", + "range_count", + "sum", + "range_sum", + "average", + "range_average", + "ranked_count", + "ranked_sum", + "ranked_average", + "time_range", + "terminal", + "preallocated", + "skip_if_absent", + ], + ), + ( + "field", + &[ + "position", + "max_chars", + "min_chars", + "max_len", + "min_len", + "min", + "max", + "values", + "required", + "transient", + "refers_to", + "document_type", + "contract", + "agreement", + "key_id_field", + "description", + ], + ), + ("document_id", &[]), + ("entry", &["name", "read_only", "module"]), + ("rule", &["name", "on", "guard", "predicate"]), + ("contract", &["receipts", "requires"]), + ("module", &["name", "uses"]), + ("interface", &["name", "provider"]), + ]; + + fn kinds(diagnostics: &[Diagnostic]) -> Vec<&'static str> { + diagnostics.iter().map(|d| d.kind.name()).collect() + } + + #[test] + fn should_contain_every_attribute_and_option_of_the_book_table() { + assert_eq!(ATTRIBUTES.len(), BOOK_TABLE.len()); + for (name, options) in BOOK_TABLE { + let spec = attribute(name).unwrap_or_else(|| panic!("attribute {name} missing")); + let declared: Vec<&str> = spec.keys.iter().map(|key| key.name).collect(); + assert_eq!(&declared, options, "options of {name}"); + } + } + + #[test] + fn should_report_unknown_attribute() { + let diagnostics = check_keys("persisted", &[]); + assert_eq!(kinds(&diagnostics), ["UnknownAttribute"]); + assert_eq!(diagnostics[0].code(), "DSC0001"); + } + + #[test] + fn should_report_unknown_option() { + let diagnostics = check_keys( + "entry", + &[ + GivenOption { + name: "name", + value: GivenValue::Str("score.add"), + }, + GivenOption { + name: "readonly", + value: GivenValue::Bool(true), + }, + ], + ); + assert_eq!(kinds(&diagnostics), ["UnknownOption"]); + } + + #[test] + fn should_report_invalid_option_value_for_wrong_shape() { + let diagnostics = check_keys( + "entry", + &[GivenOption { + name: "name", + value: GivenValue::Int(1), + }], + ); + assert_eq!(kinds(&diagnostics), ["InvalidOptionValue"]); + } + + #[test] + fn should_report_missing_option() { + let diagnostics = check_keys("entry", &[]); + assert_eq!(kinds(&diagnostics), ["MissingOption"]); + } + + #[test] + fn should_report_duplicate_option() { + let diagnostics = check_keys( + "entry", + &[ + GivenOption { + name: "name", + value: GivenValue::Str("a"), + }, + GivenOption { + name: "name", + value: GivenValue::Str("b"), + }, + ], + ); + assert_eq!(kinds(&diagnostics), ["DuplicateOption"]); + } + + #[test] + fn should_reject_descending_index_fields_naming_the_native_rule() { + let fields = [ + GivenOption { + name: "class", + value: GivenValue::Str("asc"), + }, + GivenOption { + name: "points", + value: GivenValue::Str("desc"), + }, + ]; + let diagnostics = check_keys( + "index", + &[ + GivenOption { + name: "name", + value: GivenValue::Str("ranking"), + }, + GivenOption { + name: "fields", + value: GivenValue::Nested(&fields), + }, + ], + ); + assert_eq!(kinds(&diagnostics), ["InvalidOptionValue"]); + let DiagnosticKind::InvalidOptionValue { reason, .. } = &diagnostics[0].kind else { + panic!("expected an invalid option value"); + }; + assert!(reason.contains("ascending only"), "{reason}"); + } + + #[test] + fn should_reject_award_as_a_rule_action() { + let diagnostics = check_keys( + "rule", + &[ + GivenOption { + name: "name", + value: GivenValue::Str("no_award"), + }, + GivenOption { + name: "on", + value: GivenValue::StrList(&["create", "award"]), + }, + GivenOption { + name: "guard", + value: GivenValue::Str("GUARD"), + }, + ], + ); + assert_eq!(kinds(&diagnostics), ["InvalidOptionValue"]); + let DiagnosticKind::InvalidOptionValue { reason, .. } = &diagnostics[0].kind else { + panic!("expected an invalid option value"); + }; + assert!(reason.contains("award"), "{reason}"); + } + + #[test] + fn should_report_exactly_one_option_required_for_rule_kind() { + let both = check_keys( + "rule", + &[ + GivenOption { + name: "name", + value: GivenValue::Str("r"), + }, + GivenOption { + name: "on", + value: GivenValue::StrList(&["create"]), + }, + GivenOption { + name: "guard", + value: GivenValue::Str("GUARD"), + }, + GivenOption { + name: "predicate", + value: GivenValue::Str("main::check"), + }, + ], + ); + assert_eq!(kinds(&both), ["ExactlyOneOptionRequired"]); + let neither = check_keys( + "rule", + &[ + GivenOption { + name: "name", + value: GivenValue::Str("r"), + }, + GivenOption { + name: "on", + value: GivenValue::StrList(&["create"]), + }, + ], + ); + assert_eq!(kinds(&neither), ["ExactlyOneOptionRequired"]); + } + + #[test] + fn should_check_nested_options_under_their_dotted_attribute_name() { + let time_range = [ + GivenOption { + name: "on", + value: GivenValue::Str("$createdAt"), + }, + GivenOption { + name: "range_secs", + value: GivenValue::Int(3600), + }, + GivenOption { + name: "stepp", + value: GivenValue::Int(60), + }, + ]; + let fields = [GivenOption { + name: "$createdAt", + value: GivenValue::Str("asc"), + }]; + let diagnostics = check_keys( + "index", + &[ + GivenOption { + name: "name", + value: GivenValue::Str("recent"), + }, + GivenOption { + name: "fields", + value: GivenValue::Nested(&fields), + }, + GivenOption { + name: "time_range", + value: GivenValue::Nested(&time_range), + }, + ], + ); + assert_eq!(kinds(&diagnostics), ["UnknownOption", "MissingOption"]); + assert_eq!( + diagnostics[0].path().to_string(), + "attribute index.time_range, option stepp" + ); + } + + #[test] + fn should_accept_count_as_flag_and_as_offset_and_ranked_count_as_list() { + let fields = [GivenOption { + name: "class", + value: GivenValue::Str("asc"), + }]; + let diagnostics = check_keys( + "index", + &[ + GivenOption { + name: "name", + value: GivenValue::Str("by_class"), + }, + GivenOption { + name: "fields", + value: GivenValue::Nested(&fields), + }, + GivenOption { + name: "count", + value: GivenValue::Str("offset"), + }, + GivenOption { + name: "ranked_count", + value: GivenValue::StrList(&["class"]), + }, + ], + ); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); + let diagnostics = check_keys( + "index", + &[ + GivenOption { + name: "name", + value: GivenValue::Str("by_class"), + }, + GivenOption { + name: "fields", + value: GivenValue::Nested(&fields), + }, + GivenOption { + name: "count", + value: GivenValue::Str("provable"), + }, + ], + ); + assert_eq!(kinds(&diagnostics), ["InvalidOptionValue"]); + } + + #[test] + fn should_reject_an_empty_or_repeated_fields_map() { + let empty: [GivenOption<'_>; 0] = []; + let diagnostics = check_keys( + "index", + &[ + GivenOption { + name: "name", + value: GivenValue::Str("x"), + }, + GivenOption { + name: "fields", + value: GivenValue::Nested(&empty), + }, + ], + ); + assert_eq!(kinds(&diagnostics), ["InvalidOptionValue"]); + let repeated = [ + GivenOption { + name: "class", + value: GivenValue::Str("asc"), + }, + GivenOption { + name: "class", + value: GivenValue::Str("asc"), + }, + ]; + let diagnostics = check_keys( + "index", + &[ + GivenOption { + name: "name", + value: GivenValue::Str("x"), + }, + GivenOption { + name: "fields", + value: GivenValue::Nested(&repeated), + }, + ], + ); + assert_eq!(kinds(&diagnostics), ["InvalidOptionValue"]); + } + + #[test] + fn should_accept_the_sketch_persistent_attribute() { + let diagnostics = check_keys( + "persistent", + &[ + GivenOption { + name: "collection", + value: GivenValue::Str("scores"), + }, + GivenOption { + name: "schema", + value: GivenValue::Int(1), + }, + GivenOption { + name: "write", + value: GivenValue::Str("contract"), + }, + ], + ); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); + } + + #[test] + fn should_reject_a_private_store_value_outside_the_choice_set_but_accept_private() { + let private = check_keys( + "persistent", + &[ + GivenOption { + name: "collection", + value: GivenValue::Str("secrets"), + }, + GivenOption { + name: "store", + value: GivenValue::Str("private"), + }, + ], + ); + assert!(private.is_empty(), "{private:?}"); + let hidden = check_keys( + "persistent", + &[ + GivenOption { + name: "collection", + value: GivenValue::Str("secrets"), + }, + GivenOption { + name: "store", + value: GivenValue::Str("hidden"), + }, + ], + ); + assert_eq!(kinds(&hidden), ["InvalidOptionValue"]); + } +} diff --git a/packages/rs-dash-sdk-contract/src/identity.rs b/packages/rs-dash-sdk-contract/src/identity.rs new file mode 100644 index 00000000000..f9d601ecfff --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/identity.rs @@ -0,0 +1,525 @@ +//! Stable identities for collections, properties, indexes, methods, modules, +//! interfaces and rules. +//! +//! A declaration is identified by its declared name, never by the Rust path, +//! impl block, source module or declaration order that produced it. The +//! grammars here are the ones the manifest enforces; a name that fails its +//! grammar is reported by the validator as an `InvalidName` diagnostic. +//! +//! | Identity | Grammar | Source of the rule | +//! |---|---|---| +//! | [`CollectionName`] | `^[a-zA-Z0-9_-]{1,64}$` | native document type name rule | +//! | [`PropertyName`] | `^[a-zA-Z0-9_-]{1,64}$` | document meta-schema property names | +//! | [`PropertyPath`] | dotted property names, at most 256 bytes, or a system property | index property name limit | +//! | [`IndexName`] | 1 to 32 characters | document meta-schema index name | +//! | [`MethodName`] | `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$`, at most 64 bytes | provisional SDK rule | +//! | [`ModuleName`] | `^[a-z0-9_]{1,64}$` | provisional, aligned with the bundle validation crate | +//! | [`InterfaceName`] | `^[a-z0-9_]{1,64}$` | provisional SDK rule | +//! | [`RuleName`] | `^[a-z][a-z0-9_]{0,63}$` | provisional SDK rule | +//! +//! The method, module, interface and rule grammars are provisional values under +//! the shared allocation register (Rust macro grammar); the numeric method and +//! type identifiers derived from these names are allocated by the ABI work. + +use alloc::format; +use alloc::string::{String, ToString}; +use core::fmt; + +/// Maximum byte length of a collection or property name (native rule). +pub const MAX_NAME_BYTES: usize = 64; +/// Maximum byte length of an index property path (native meta-schema rule). +pub const MAX_PROPERTY_PATH_BYTES: usize = 256; +/// Maximum character count of an index name (native meta-schema rule). +pub const MAX_INDEX_NAME_CHARS: usize = 32; +/// Maximum byte length of a method name. Provisional. +pub const MAX_METHOD_NAME_BYTES: usize = 64; +/// Maximum byte length of a module or interface name. Provisional, aligned +/// with the bundle validation crate's module name bound. +pub const MAX_MODULE_NAME_BYTES: usize = 64; +/// Maximum byte length of a rule name. Provisional. +pub const MAX_RULE_NAME_BYTES: usize = 64; + +/// Prefix of every entry export symbol. Provisional. +pub const ENTRY_EXPORT_PREFIX: &str = "dash_entry_"; + +/// Document system properties that an index may name without the collection +/// declaring them. Whether a given system property is admissible for a given +/// document type (for example `$creatorId` needs a transferable type) is a +/// native rule and is not repeated here. +pub const SYSTEM_PROPERTIES: &[&str] = &[ + "$id", + "$ownerId", + "$creatorId", + "$createdAt", + "$updatedAt", + "$transferredAt", + "$createdAtBlockHeight", + "$updatedAtBlockHeight", + "$transferredAtBlockHeight", + "$createdAtCoreBlockHeight", + "$updatedAtCoreBlockHeight", + "$transferredAtCoreBlockHeight", +]; + +/// Returns whether `name` is one of the document system properties. +pub fn is_system_property(name: &str) -> bool { + SYSTEM_PROPERTIES.contains(&name) +} + +/// Which identity a name was checked against. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum NameKind { + /// A collection (native document type) name. + Collection, + /// A single property name. + Property, + /// A dotted property path or system property used by an index. + PropertyPath, + /// An index name. + Index, + /// A method (entry) name. + Method, + /// A WASM module name. + Module, + /// An interface name. + Interface, + /// A rule name. + Rule, +} + +impl fmt::Display for NameKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let text = match self { + NameKind::Collection => "collection", + NameKind::Property => "property", + NameKind::PropertyPath => "property path", + NameKind::Index => "index", + NameKind::Method => "method", + NameKind::Module => "module", + NameKind::Interface => "interface", + NameKind::Rule => "rule", + }; + f.write_str(text) + } +} + +/// A name that does not satisfy its identity grammar. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +#[error("invalid {kind} name {name:?}: {reason}")] +pub struct InvalidName { + /// The identity the name was checked against. + pub kind: NameKind, + /// The offending name, verbatim. + pub name: String, + /// Why the grammar rejected it. + pub reason: String, +} + +impl InvalidName { + fn new(kind: NameKind, name: &str, reason: impl Into) -> Self { + InvalidName { + kind, + name: name.to_string(), + reason: reason.into(), + } + } +} + +fn is_name_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '_' || c == '-' +} + +fn check_native_name(kind: NameKind, name: &str) -> Result<(), InvalidName> { + if name.is_empty() { + return Err(InvalidName::new(kind, name, "must not be empty")); + } + if name.len() > MAX_NAME_BYTES { + return Err(InvalidName::new( + kind, + name, + format!("longer than {MAX_NAME_BYTES} bytes"), + )); + } + if !name.chars().all(is_name_char) { + return Err(InvalidName::new( + kind, + name, + "only ASCII letters, digits, `_` and `-` are allowed", + )); + } + Ok(()) +} + +fn check_lower_segment(kind: NameKind, name: &str, segment: &str) -> Result<(), InvalidName> { + let mut chars = segment.chars(); + match chars.next() { + Some(first) if first.is_ascii_lowercase() => {} + _ => { + return Err(InvalidName::new( + kind, + name, + "each segment must start with a lowercase ASCII letter", + )) + } + } + if !chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') { + return Err(InvalidName::new( + kind, + name, + "only lowercase ASCII letters, digits and `_` are allowed after the first character", + )); + } + Ok(()) +} + +fn check_module_style_name(kind: NameKind, name: &str) -> Result<(), InvalidName> { + if name.is_empty() { + return Err(InvalidName::new(kind, name, "must not be empty")); + } + if name.len() > MAX_MODULE_NAME_BYTES { + return Err(InvalidName::new( + kind, + name, + format!("longer than {MAX_MODULE_NAME_BYTES} bytes"), + )); + } + if !name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') + { + return Err(InvalidName::new( + kind, + name, + "only lowercase ASCII letters, digits and `_` are allowed", + )); + } + Ok(()) +} + +macro_rules! name_newtype { + ($(#[$meta:meta])* $name:ident, $kind:expr, $check:expr) => { + $(#[$meta])* + #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct $name(String); + + impl $name { + /// Checks `name` against the grammar and wraps it. + pub fn new(name: impl AsRef) -> Result { + let name = name.as_ref(); + $check($kind, name)?; + Ok($name(name.to_string())) + } + + /// The name as declared. + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl AsRef for $name { + fn as_ref(&self) -> &str { + &self.0 + } + } + + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } + } + + impl TryFrom<&str> for $name { + type Error = InvalidName; + + fn try_from(value: &str) -> Result { + $name::new(value) + } + } + + impl TryFrom for $name { + type Error = InvalidName; + + fn try_from(value: String) -> Result { + $name::new(value) + } + } + }; +} + +name_newtype!( + /// The identity of a collection: its declared name, which is also the native + /// document type name. Grammar `^[a-zA-Z0-9_-]{1,64}$`. + CollectionName, + NameKind::Collection, + check_native_name +); + +name_newtype!( + /// The identity of one property at one nesting level. Grammar + /// `^[a-zA-Z0-9_-]{1,64}$`. Together with its collection and position it + /// identifies a stored field. + PropertyName, + NameKind::Property, + check_native_name +); + +name_newtype!( + /// A dotted path of [`PropertyName`]s (`profile.age`), or one of the + /// [`SYSTEM_PROPERTIES`], as used by index definitions. At most 256 bytes. + PropertyPath, + NameKind::PropertyPath, + check_property_path +); + +name_newtype!( + /// The identity of an index within its collection: 1 to 32 characters, any + /// UTF-8. + IndexName, + NameKind::Index, + check_index_name +); + +name_newtype!( + /// The identity of an entry: unique across the whole contract regardless of + /// which module hosts it. Grammar `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$`, + /// at most 64 bytes, for example `score.add`. Provisional. + MethodName, + NameKind::Method, + check_method_name +); + +name_newtype!( + /// The name of one WASM module in a contract bundle. Grammar + /// `^[a-z0-9_]{1,64}$`. Provisional. + ModuleName, + NameKind::Module, + check_module_style_name +); + +name_newtype!( + /// The name of an interface a module provides to other modules. Grammar + /// `^[a-z0-9_]{1,64}$`. Provisional. + InterfaceName, + NameKind::Interface, + check_module_style_name +); + +name_newtype!( + /// The name of a rule, unique within its collection. Grammar + /// `^[a-z][a-z0-9_]{0,63}$`. Provisional. + RuleName, + NameKind::Rule, + check_rule_name +); + +fn check_property_path(kind: NameKind, path: &str) -> Result<(), InvalidName> { + if is_system_property(path) { + return Ok(()); + } + if path.is_empty() { + return Err(InvalidName::new(kind, path, "must not be empty")); + } + if path.len() > MAX_PROPERTY_PATH_BYTES { + return Err(InvalidName::new( + kind, + path, + format!("longer than {MAX_PROPERTY_PATH_BYTES} bytes"), + )); + } + for segment in path.split('.') { + check_native_name(NameKind::Property, segment).map_err(|error| { + InvalidName::new( + kind, + path, + format!("segment {:?}: {}", segment, error.reason), + ) + })?; + } + Ok(()) +} + +fn check_index_name(kind: NameKind, name: &str) -> Result<(), InvalidName> { + let chars = name.chars().count(); + if chars == 0 { + return Err(InvalidName::new(kind, name, "must not be empty")); + } + if chars > MAX_INDEX_NAME_CHARS { + return Err(InvalidName::new( + kind, + name, + format!("longer than {MAX_INDEX_NAME_CHARS} characters"), + )); + } + Ok(()) +} + +fn check_method_name(kind: NameKind, name: &str) -> Result<(), InvalidName> { + if name.is_empty() { + return Err(InvalidName::new(kind, name, "must not be empty")); + } + if name.len() > MAX_METHOD_NAME_BYTES { + return Err(InvalidName::new( + kind, + name, + format!("longer than {MAX_METHOD_NAME_BYTES} bytes"), + )); + } + for segment in name.split('.') { + check_lower_segment(kind, name, segment)?; + } + Ok(()) +} + +fn check_rule_name(kind: NameKind, name: &str) -> Result<(), InvalidName> { + if name.is_empty() { + return Err(InvalidName::new(kind, name, "must not be empty")); + } + if name.len() > MAX_RULE_NAME_BYTES { + return Err(InvalidName::new( + kind, + name, + format!("longer than {MAX_RULE_NAME_BYTES} bytes"), + )); + } + if name.contains('.') { + return Err(InvalidName::new(kind, name, "`.` is not allowed")); + } + check_lower_segment(kind, name, name) +} + +impl PropertyPath { + /// Returns whether the path names a document system property. + pub fn is_system(&self) -> bool { + is_system_property(&self.0) + } + + /// The path's segments; a system property is a single segment. + pub fn segments(&self) -> impl Iterator { + self.0.split('.') + } +} + +/// The WASM export symbol of an entry: the [`ENTRY_EXPORT_PREFIX`] followed by +/// the method name verbatim, so `score.add` exports as `dash_entry_score.add`. +/// +/// The mapping is a prefix plus the identity, which makes it injective by +/// construction (`a.b` and `a_b` stay distinct). WebAssembly export names are +/// arbitrary UTF-8 and Rust's `#[export_name]` accepts dots. Provisional. +pub fn entry_export_symbol(name: &MethodName) -> String { + format!("{ENTRY_EXPORT_PREFIX}{name}") +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec::Vec; + + #[test] + fn should_accept_collection_names_at_the_boundary() { + let longest: String = core::iter::repeat_n('a', MAX_NAME_BYTES).collect(); + assert!(CollectionName::new(&longest).is_ok()); + assert!(CollectionName::new("scores-v1_2").is_ok()); + assert!(CollectionName::new("A").is_ok()); + } + + #[test] + fn should_reject_collection_names_over_the_boundary_or_with_bad_characters() { + let too_long: String = core::iter::repeat_n('a', MAX_NAME_BYTES + 1).collect(); + let error = CollectionName::new(&too_long).unwrap_err(); + assert_eq!(error.kind, NameKind::Collection); + assert!(CollectionName::new("").is_err()); + assert!(CollectionName::new("scores.v1").is_err()); + assert!(CollectionName::new("scores v1").is_err()); + assert!(CollectionName::new("scörés").is_err()); + } + + #[test] + fn should_accept_property_paths_and_system_properties() { + assert!(PropertyPath::new("class").is_ok()); + assert!(PropertyPath::new("profile.age").is_ok()); + assert!(PropertyPath::new("$ownerId").unwrap().is_system()); + assert!(PropertyPath::new("$createdAt").is_ok()); + let dotted = PropertyPath::new("a.b.c").unwrap(); + let segments: Vec<&str> = dotted.segments().collect(); + assert_eq!(segments, ["a", "b", "c"]); + } + + #[test] + fn should_reject_property_paths_with_empty_segments_or_unknown_system_names() { + assert!(PropertyPath::new("a..b").is_err()); + assert!(PropertyPath::new(".a").is_err()); + assert!(PropertyPath::new("$unknown").is_err()); + assert!(PropertyPath::new("").is_err()); + let too_long: String = + core::iter::repeat_n("abcdefgh.", 29).collect::() + "abcdefgh"; + assert!(too_long.len() > MAX_PROPERTY_PATH_BYTES); + assert!(PropertyPath::new(&too_long).is_err()); + } + + #[test] + fn should_bound_index_names_by_characters_not_bytes() { + let thirty_two: String = core::iter::repeat_n('ü', MAX_INDEX_NAME_CHARS).collect(); + assert!(IndexName::new(&thirty_two).is_ok()); + let thirty_three: String = core::iter::repeat_n('ü', MAX_INDEX_NAME_CHARS + 1).collect(); + assert!(IndexName::new(&thirty_three).is_err()); + assert!(IndexName::new("").is_err()); + } + + #[test] + fn should_accept_dotted_method_names_and_reject_bad_segments() { + assert!(MethodName::new("score.add").is_ok()); + assert!(MethodName::new("a").is_ok()); + assert!(MethodName::new("score.add_pair2").is_ok()); + assert!(MethodName::new("Score.add").is_err()); + assert!(MethodName::new("score..add").is_err()); + assert!(MethodName::new("score.").is_err()); + assert!(MethodName::new("1score").is_err()); + assert!(MethodName::new("score-add").is_err()); + let too_long: String = core::iter::repeat_n('a', MAX_METHOD_NAME_BYTES + 1).collect(); + assert!(MethodName::new(&too_long).is_err()); + } + + #[test] + fn should_accept_module_and_interface_names_and_reject_uppercase() { + assert!(ModuleName::new("main").is_ok()); + assert!(ModuleName::new("helpers_2").is_ok()); + assert!(ModuleName::new("Main").is_err()); + assert!(ModuleName::new("").is_err()); + assert!(InterfaceName::new("math").is_ok()); + assert!(InterfaceName::new("math.v1").is_err()); + } + + #[test] + fn should_accept_rule_names_and_reject_dots() { + assert!(RuleName::new("points_monotonic").is_ok()); + assert!(RuleName::new("p").is_ok()); + assert!(RuleName::new("points.monotonic").is_err()); + assert!(RuleName::new("_points").is_err()); + let too_long: String = core::iter::repeat_n('a', MAX_RULE_NAME_BYTES + 1).collect(); + assert!(RuleName::new(&too_long).is_err()); + } + + #[test] + fn should_export_entries_under_the_prefixed_method_name() { + let name = MethodName::new("score.add").unwrap(); + assert_eq!(entry_export_symbol(&name), "dash_entry_score.add"); + } + + #[test] + fn should_keep_dotted_and_underscored_method_names_distinct_in_exports() { + let dotted = MethodName::new("a.b").unwrap(); + let underscored = MethodName::new("a__b").unwrap(); + assert_ne!( + entry_export_symbol(&dotted), + entry_export_symbol(&underscored) + ); + } + + #[test] + fn should_render_invalid_name_errors_with_kind_and_reason() { + let error = CollectionName::new("bad name").unwrap_err(); + let text = alloc::format!("{error}"); + assert!(text.contains("collection")); + assert!(text.contains("bad name")); + } +} diff --git a/packages/rs-dash-sdk-contract/src/lib.rs b/packages/rs-dash-sdk-contract/src/lib.rs new file mode 100644 index 00000000000..de3767ac9d7 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/lib.rs @@ -0,0 +1,45 @@ +//! Contract-author SDK for DashVM: the declaration model behind +//! `#[persistent]`, `#[index]`, `#[rule]` and `#[entry]`, the attribute +//! grammar those macros implement, the diagnostics they report, and the +//! canonical manifest a contract package publishes. +//! +//! This crate specifies the author-facing model. It carries no proc macros, no +//! host context and no runtime; those are later deliverables of the same +//! workstream and consume the types defined here: +//! +//! - [`grammar`] is the attribute grammar as data: which attributes exist, which +//! options they take and which values are legal. The proc macros, this +//! crate's validator and the book chapter share this single table. +//! - [`declare`] is the typed declaration model: [`declare::ContractDeclaration`] +//! gathers collections, indexes, rules, entries, modules, interfaces, typed +//! collections and capability requirements from attributes and from builders. +//! - [`validate`] turns a declaration into a [`manifest::CanonicalManifest`] or +//! into a complete list of [`validate::Diagnostic`]s. It owns grammar, +//! identity, boundedness, conflict and SDK-semantic checks. Native numeric +//! limits (index counts, name lengths, indexed string sizes) are deliberately +//! not duplicated: Dash Platform Protocol enforces them once and the build +//! crate surfaces them. +//! - [`manifest`] is the sorted, order-independent canonical manifest. It has no +//! wire encoding yet; the encoding and the numeric identifiers are allocated +//! by the ABI work. +//! - [`persistence`] states the persistence semantics as enums so that generated +//! wrappers and the documentation cannot drift from the confirmed policy: +//! detached values never save, explicit operations and successful mutable +//! receivers stage writes, nothing saves on `Drop`. +//! +//! The crate compiles without `std` (`--no-default-features`) on +//! `wasm32v1-none`, which is how guest packages depend on it. + +#![cfg_attr(not(feature = "std"), no_std)] +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +extern crate alloc; + +pub mod declare; +pub mod grammar; +pub mod identity; +pub mod manifest; +pub mod persistence; +pub mod prelude; +pub mod validate; diff --git a/packages/rs-dash-sdk-contract/src/manifest/bundle.rs b/packages/rs-dash-sdk-contract/src/manifest/bundle.rs new file mode 100644 index 00000000000..62fa625dab6 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/manifest/bundle.rs @@ -0,0 +1,56 @@ +//! Module, interface and binding tables. + +use alloc::vec::Vec; + +use crate::declare::InternalFunctionSpec; +use crate::identity::{InterfaceName, ModuleName}; + +/// One module of the bundle. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ModuleEntry { + /// The module's identity. + pub name: ModuleName, + /// Interfaces it imports, sorted. + pub uses: Vec, +} + +/// One interface of the bundle. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InterfaceEntry { + /// The interface's identity. + pub name: InterfaceName, + /// The providing module. + pub provider: ModuleName, + /// Its functions, sorted by name. + pub functions: Vec, +} + +/// An importer-to-provider binding through an interface. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct Binding { + /// The importing module. + pub importer: ModuleName, + /// The providing module. + pub provider: ModuleName, + /// The interface. + pub interface: InterfaceName, +} + +/// The module table. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ModuleTable { + /// Modules, sorted by name. Never empty: a package that declares no + /// module has the implicit `main`. + pub modules: Vec, + /// Interfaces, sorted by name. + pub interfaces: Vec, + /// Bindings, sorted by `(importer, provider, interface)`. + pub bindings: Vec, +} + +impl ModuleTable { + /// The module names. + pub fn names(&self) -> impl Iterator { + self.modules.iter().map(|module| &module.name) + } +} diff --git a/packages/rs-dash-sdk-contract/src/manifest/capability.rs b/packages/rs-dash-sdk-contract/src/manifest/capability.rs new file mode 100644 index 00000000000..c4e344bbd2f --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/manifest/capability.rs @@ -0,0 +1,37 @@ +//! The capability table. + +use alloc::vec::Vec; + +use crate::declare::{CapabilityRequirement, CapabilityStatus}; + +/// One required capability with its catalogue status. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct CapabilityEntry { + /// The requirement. + pub requirement: CapabilityRequirement, + /// Its status in the catalogue. + pub status: CapabilityStatus, +} + +/// Every capability the contract needs, explicit and derived, sorted. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CapabilityTable { + /// The entries. + pub entries: Vec, +} + +impl CapabilityTable { + /// Whether the table contains the requirement. + pub fn requires(&self, requirement: CapabilityRequirement) -> bool { + self.entries + .iter() + .any(|entry| entry.requirement == requirement) + } + + /// The requirements whose status is not [`CapabilityStatus::Native`]. + pub fn pending(&self) -> impl Iterator { + self.entries + .iter() + .filter(|entry| entry.status != CapabilityStatus::Native) + } +} diff --git a/packages/rs-dash-sdk-contract/src/manifest/collection.rs b/packages/rs-dash-sdk-contract/src/manifest/collection.rs new file mode 100644 index 00000000000..97b9bd24580 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/manifest/collection.rs @@ -0,0 +1,134 @@ +//! Collection, index, typed collection and rule manifests. + +use alloc::vec::Vec; + +use crate::declare::{ + ActionScope, BoundedKeyRequirement, CollectionKind, ContestedSpec, Countability, FieldSpec, + IndexOnlySpec, Ranking, RuleKind, SecurityLevel, Store, TimeRangeSpec, TokenCostSpec, + TradeMode, TypedCollectionKind, ValueType, WritePolicy, +}; +use crate::identity::{CollectionName, IndexName, PropertyName, PropertyPath, RuleName}; + +/// One index, sugar expanded. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IndexManifest { + /// The index's identity within its collection. + pub name: IndexName, + /// Indexed property paths in order, all ascending. + pub properties: Vec, + /// Unique index. + pub unique: bool, + /// Null values are searchable. + pub null_searchable: bool, + /// Contested parameters, field matches sorted by property. + pub contested: Option, + /// Count fast path. + pub count: Countability, + /// Range counts. + pub range_count: bool, + /// Integer property summed at the index. + pub sum: Option, + /// Range sums. + pub range_sum: bool, + /// Ranking axes. + pub ranked: Ranking, + /// Time range bucketing. + pub time_range: Option, + /// Index-only options. + pub index_only: Option, +} + +/// One document collection or singleton, sugar expanded. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CollectionManifest { + /// The collection's identity and native document type name. + pub name: CollectionName, + /// Documents or singleton. + pub kind: CollectionKind, + /// Author-declared schema revision. + pub schema_revision: u32, + /// Who may create documents. + pub write: WritePolicy, + /// Documents may be replaced. + pub mutable: bool, + /// Documents may be deleted. + pub deletable: bool, + /// Every revision is kept. + pub keep_history: bool, + /// Transfer revisions are kept. + pub keep_transfer_history: bool, + /// Purchase revisions are kept. + pub keep_purchase_history: bool, + /// Price-update revisions are kept. + pub keep_pricing_history: bool, + /// Documents may be transferred. + pub transferable: bool, + /// Marketplace mode. + pub trade: TradeMode, + /// Signature security level required to write. + pub security_level: SecurityLevel, + /// Identity encryption bounded key requirement. + pub encryption_key: Option, + /// Identity decryption bounded key requirement. + pub decryption_key: Option, + /// Count tree on the primary key. + pub count: bool, + /// Provable count on the primary key. + pub range_count: bool, + /// Integer property summed on the primary key. + pub sum: Option, + /// Provable sum on the primary key. + pub range_sum: bool, + /// Documents live only in their indexes. + pub index_only: bool, + /// Token prices, sorted by action. + pub token_costs: Vec, + /// Document store. + pub store: Store, + /// Stored fields, sorted by position at every level. + pub fields: Vec, + /// Indexes, sorted by name. + pub indexes: Vec, +} + +impl CollectionManifest { + /// Looks an index up by name. + pub fn index(&self, name: &str) -> Option<&IndexManifest> { + self.indexes + .iter() + .find(|index| index.name.as_str() == name) + } + + /// Looks a top-level field up by name. + pub fn field(&self, name: &str) -> Option<&FieldSpec> { + self.fields.iter().find(|field| field.name.as_str() == name) + } +} + +/// One typed specialized collection. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TypedCollectionManifest { + /// The collection's identity. + pub id: CollectionName, + /// The tree family. + pub kind: TypedCollectionKind, + /// The key type. + pub key: ValueType, + /// The element type. + pub element: ValueType, + /// Upper bound on the number of elements, when declared. + pub max_elements: Option, +} + +/// One rule. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuleManifest { + /// The guarded collection. + pub collection: CollectionName, + /// The rule's name within the collection. + pub name: RuleName, + /// The guarded actions, sorted and deduplicated. + pub actions: Vec, + /// How the rule decides. + pub kind: RuleKind, +} diff --git a/packages/rs-dash-sdk-contract/src/manifest/method.rs b/packages/rs-dash-sdk-contract/src/manifest/method.rs new file mode 100644 index 00000000000..f116c7abd5c --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/manifest/method.rs @@ -0,0 +1,58 @@ +//! The method table. + +use alloc::string::String; +use alloc::vec::Vec; + +use crate::declare::{CollectionKind, ParamSpec, Receiver, ValueType}; +use crate::identity::{MethodName, ModuleName}; + +/// One entry of the contract. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MethodEntry { + /// The entry's identity. + pub name: MethodName, + /// The hosting module: a binding, not part of the identity. + pub module: ModuleName, + /// The WASM export symbol, derived from the name. + pub export: String, + /// The receiver. + pub receiver: Receiver, + /// Whether the receiver is addressed by a document id on the wire. True + /// for a receiver on a document collection, false for a free entry or a + /// singleton receiver (the singleton's key is reserved and host known). + pub takes_document_id: bool, + /// Whether the entry stages no writes. + pub read_only: bool, + /// Wire parameters after the document id, if any. + pub params: Vec, + /// Wire return type. + pub returns: ValueType, +} + +impl MethodEntry { + pub(crate) fn takes_document_id(receiver: &Receiver, kind: Option) -> bool { + matches!(receiver, Receiver::Ref(_) | Receiver::Mut(_)) + && kind == Some(CollectionKind::Documents) + } +} + +/// Every entry, sorted by method name. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MethodTable { + /// The entries. + pub entries: Vec, +} + +impl MethodTable { + /// Looks an entry up by method name. + pub fn entry(&self, name: &str) -> Option<&MethodEntry> { + self.entries + .iter() + .find(|entry| entry.name.as_str() == name) + } + + /// The method names, in canonical order. + pub fn names(&self) -> impl Iterator { + self.entries.iter().map(|entry| &entry.name) + } +} diff --git a/packages/rs-dash-sdk-contract/src/manifest/mod.rs b/packages/rs-dash-sdk-contract/src/manifest/mod.rs new file mode 100644 index 00000000000..71bdb8b558c --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/manifest/mod.rs @@ -0,0 +1,61 @@ +//! The canonical manifest: the sorted, order-independent description of a +//! validated contract package. +//! +//! Every table is keyed by a required unique identity (name, position or a +//! tuple of names), so no ordering depends on declaration order, attribute +//! versus builder origin, or which Rust module hosted an item. Two +//! declarations that differ only in those respects produce equal manifests. +//! Sugar (`average`, `range_average`) is expanded before the manifest, which +//! therefore has no average fields. +//! +//! A manifest is constructed only by [`crate::validate::validate`]. It has no +//! wire encoding and no digest here: the encoding and the numeric method and +//! type identifiers are allocated by the ABI work, and this module is the +//! provisional home of the shape until then. + +pub mod bundle; +pub mod capability; +pub mod collection; +pub mod method; + +use alloc::vec::Vec; + +pub use bundle::{Binding, InterfaceEntry, ModuleEntry, ModuleTable}; +pub use capability::{CapabilityEntry, CapabilityTable}; +pub use collection::{CollectionManifest, IndexManifest, RuleManifest, TypedCollectionManifest}; +pub use method::{MethodEntry, MethodTable}; + +use crate::declare::ReceiptPolicy; + +/// The canonical manifest of one contract package. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CanonicalManifest { + /// Modules, interfaces and bindings. + pub modules: ModuleTable, + /// Document collections and singletons, sorted by name. + pub collections: Vec, + /// Typed specialized collections, sorted by id. + pub typed_collections: Vec, + /// Entries, sorted by method name. + pub methods: MethodTable, + /// Rules, sorted by `(collection, name)`. + pub rules: Vec, + /// Required capabilities, sorted. + pub capabilities: CapabilityTable, + /// Receipt policy. + pub receipts: ReceiptPolicy, +} + +impl CanonicalManifest { + /// Looks a collection up by name. + pub fn collection(&self, name: &str) -> Option<&CollectionManifest> { + self.collections + .iter() + .find(|collection| collection.name.as_str() == name) + } + + /// Looks an entry up by method name. + pub fn method(&self, name: &str) -> Option<&MethodEntry> { + self.methods.entry(name) + } +} diff --git a/packages/rs-dash-sdk-contract/src/persistence.rs b/packages/rs-dash-sdk-contract/src/persistence.rs new file mode 100644 index 00000000000..a7ede3c55c7 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/persistence.rs @@ -0,0 +1,114 @@ +//! Persistence semantics, stated as types so that generated wrappers and the +//! documentation cannot drift from the confirmed policy. +//! +//! Detached Rust values do not save automatically. Explicit insert and edit +//! operations, and the successful return of an exported mutable-receiver +//! wrapper, stage writes in the outer transaction. Nothing saves on `Drop`. +//! Document ids, revisions, owners and storage flags are host managed. + +use crate::declare::Receiver; + +/// The only points at which a write is staged. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum StagingPoint { + /// `documents::().insert(value)`: stages a native create, including + /// index maintenance and validation. + ExplicitInsert, + /// `documents::().edit(id, closure)`: loads one record, invokes the + /// closure and stages the update only when the closure returns + /// successfully. + ExplicitEdit, + /// An exported `&mut self` entry returning successfully: the generated + /// wrapper loads exactly the addressed record, invokes the method and + /// stages the receiver update. It never commits independently and never + /// loads the whole collection. + MutReceiverOnOk, +} + +impl StagingPoint { + /// Every staging point. + pub const ALL: &'static [StagingPoint] = &[ + StagingPoint::ExplicitInsert, + StagingPoint::ExplicitEdit, + StagingPoint::MutReceiverOnOk, + ]; + + /// The staging point an entry receiver implies, if any. + pub fn for_receiver(receiver: &Receiver) -> Option { + match receiver { + Receiver::Mut(_) => Some(StagingPoint::MutReceiverOnOk), + Receiver::None | Receiver::Ref(_) => None, + } + } +} + +/// Things that never stage a write. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum NeverStages { + /// Constructing or mutating a detached value. + DetachedValue, + /// Dropping a value, loaded or detached. + Drop, + /// A mutable reference escaping a call. + EscapedReference, + /// Calling an unmarked helper on a local value, whatever it mutates. + UnmarkedHelper, +} + +impl NeverStages { + /// Every case. + pub const ALL: &'static [NeverStages] = &[ + NeverStages::DetachedValue, + NeverStages::Drop, + NeverStages::EscapedReference, + NeverStages::UnmarkedHelper, + ]; +} + +/// Record attributes the host controls; a successful update cannot change +/// them through a mutable field. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum HostManaged { + /// The document id. + DocumentId, + /// The revision. + Revision, + /// The owner. + Owner, + /// Storage flags. + StorageFlags, +} + +impl HostManaged { + /// Every host-managed attribute. + pub const ALL: &'static [HostManaged] = &[ + HostManaged::DocumentId, + HostManaged::Revision, + HostManaged::Owner, + HostManaged::StorageFlags, + ]; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::identity::CollectionName; + + #[test] + fn should_stage_only_on_a_mutable_receiver() { + let scores = CollectionName::new("scores").unwrap(); + assert_eq!( + StagingPoint::for_receiver(&Receiver::Mut(scores.clone())), + Some(StagingPoint::MutReceiverOnOk) + ); + assert_eq!(StagingPoint::for_receiver(&Receiver::Ref(scores)), None); + assert_eq!(StagingPoint::for_receiver(&Receiver::None), None); + } + + #[test] + fn should_list_drop_among_the_cases_that_never_stage() { + assert!(NeverStages::ALL.contains(&NeverStages::Drop)); + assert_eq!(StagingPoint::ALL.len(), 3); + assert_eq!(HostManaged::ALL.len(), 4); + } +} diff --git a/packages/rs-dash-sdk-contract/src/prelude.rs b/packages/rs-dash-sdk-contract/src/prelude.rs new file mode 100644 index 00000000000..d21082a8e4d --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/prelude.rs @@ -0,0 +1,18 @@ +//! Re-exports of the declaration types and builders. + +pub use crate::declare::{ + ActionScope, BoundedKeyRequirement, CapabilityRequirement, CapabilityStatus, CollectionKind, + CollectionSpec, ContestedResolution, ContestedSpec, ContractDeclaration, Countability, + DeclarationOrigin, EntrySpec, FieldContext, FieldSpec, FieldType, GasPaidBy, GuardExpr, + IndexOnlySpec, IndexSpec, IntegerBounds, IntegerWidth, InterfaceSpec, InternalFunctionSpec, + Literal, ModuleSpec, ParamSpec, RankedCount, Ranking, ReceiptPolicy, Receiver, ReferenceTarget, + RuleKind, RuleSpec, SecurityLevel, Store, TimeRangeSpec, TokenCost, TokenCostEffect, + TokenCostSpec, TradeMode, TypedCollectionKind, TypedCollectionSpec, ValueType, WritePolicy, +}; +pub use crate::identity::{ + entry_export_symbol, CollectionName, IndexName, InterfaceName, InvalidName, MethodName, + ModuleName, NameKind, PropertyName, PropertyPath, RuleName, +}; +pub use crate::manifest::CanonicalManifest; +pub use crate::persistence::{HostManaged, NeverStages, StagingPoint}; +pub use crate::validate::{validate, DeclarationPath, Diagnostic, DiagnosticKind}; diff --git a/packages/rs-dash-sdk-contract/src/validate/capabilities.rs b/packages/rs-dash-sdk-contract/src/validate/capabilities.rs new file mode 100644 index 00000000000..6b995752d81 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/validate/capabilities.rs @@ -0,0 +1,88 @@ +//! Capability checks: explicit requirements, derived requirements and the +//! interface-disabled rejection. + +use alloc::vec::Vec; + +use crate::declare::{ + CapabilityRequirement, CapabilityStatus, ContractDeclaration, ReceiptPolicy, RuleKind, Store, + WritePolicy, +}; +use crate::manifest::{ + CapabilityEntry, CapabilityTable, CollectionManifest, MethodTable, ModuleTable, RuleManifest, + TypedCollectionManifest, +}; +use crate::validate::diagnostic::{DeclarationPath, Diagnostic, DiagnosticKind}; + +pub(super) fn validate_capabilities( + declaration: &ContractDeclaration, + modules: &ModuleTable, + collections: &[CollectionManifest], + typed_collections: &[TypedCollectionManifest], + methods: &MethodTable, + rules: &[RuleManifest], + diagnostics: &mut Vec, +) -> CapabilityTable { + let mut requirements: Vec = Vec::new(); + + for requirement in &declaration.capabilities { + if !requirement.is_explicit() { + diagnostics.push(Diagnostic::new( + DeclarationPath::Capability(*requirement), + DiagnosticKind::CapabilityNotDeclarable { + requirement: *requirement, + }, + )); + continue; + } + requirements.push(*requirement); + } + + for collection in collections { + if collection.write == WritePolicy::Contract { + requirements.push(CapabilityRequirement::ContractWrites); + } + if collection.store == Store::Private { + requirements.push(CapabilityRequirement::PrivateStore); + } + } + for typed in typed_collections { + requirements.push(CapabilityRequirement::TypedCollections(typed.kind)); + } + for rule in rules { + requirements.push(match rule.kind { + RuleKind::NativeGuard(_) => CapabilityRequirement::NativeGuards, + RuleKind::WasmPredicate { .. } => CapabilityRequirement::WasmPredicates, + }); + } + if !methods.entries.is_empty() { + requirements.push(CapabilityRequirement::Entries); + } + if modules.modules.len() > 1 { + requirements.push(CapabilityRequirement::Modules); + } + if declaration.receipts == ReceiptPolicy::Stored { + requirements.push(CapabilityRequirement::StoredReceipts); + } + + requirements.sort(); + requirements.dedup(); + + let entries: Vec = requirements + .into_iter() + .map(|requirement| { + let status = requirement.status(); + if status == CapabilityStatus::InterfaceDisabled { + diagnostics.push(Diagnostic::new( + DeclarationPath::Capability(requirement), + DiagnosticKind::CapabilityInterfaceDisabled { requirement }, + )); + } + CapabilityEntry { + requirement, + status, + } + }) + .collect(); + + CapabilityTable { entries } +} diff --git a/packages/rs-dash-sdk-contract/src/validate/collections.rs b/packages/rs-dash-sdk-contract/src/validate/collections.rs new file mode 100644 index 00000000000..533f27dd316 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/validate/collections.rs @@ -0,0 +1,676 @@ +//! Collection checks: identity, fields and positions, bounds, indexes and +//! their cross-references, sugar expansion, singleton rules, typed +//! collections. + +use alloc::string::ToString; +use alloc::vec::Vec; + +use crate::declare::{ + CollectionKind, CollectionSpec, ContractDeclaration, FieldSpec, FieldType, IndexSpec, + IntegerWidth, RankedCount, ReferenceTarget, TypedCollectionSpec, ValueType, +}; +use crate::identity::{CollectionName, PropertyPath}; +use crate::manifest::{CollectionManifest, IndexManifest, TypedCollectionManifest}; +use crate::validate::diagnostic::{DeclarationPath, Diagnostic, DiagnosticKind}; +use crate::validate::merge::dedupe; + +/// The collections a contract declares, by name and kind, for the entry and +/// rule checks. +pub(super) type CollectionKinds = Vec<(CollectionName, CollectionKind)>; + +pub(super) fn validate_collections( + declaration: &ContractDeclaration, + diagnostics: &mut Vec, +) -> (Vec, CollectionKinds) { + let collections = dedupe( + &declaration.collections, + |a, b| a.name == b.name, + |spec| spec.origin, + |a, b| a.same_shape_ignoring_indexes(b), + |kept, next| { + for index in &next.indexes { + kept.indexes.push(index.clone()); + } + }, + |spec| DeclarationPath::collection(&spec.name), + "collection", + || DiagnosticKind::DuplicateCollection, + diagnostics, + ); + + let names: Vec<&CollectionSpec> = collections.iter().collect(); + let mut manifests: Vec = collections + .iter() + .map(|collection| validate_collection(collection, &names, diagnostics)) + .collect(); + manifests.sort_by(|a, b| a.name.cmp(&b.name)); + + let kinds = manifests + .iter() + .map(|manifest| (manifest.name.clone(), manifest.kind)) + .collect(); + (manifests, kinds) +} + +fn validate_collection( + collection: &CollectionSpec, + all: &[&CollectionSpec], + diagnostics: &mut Vec, +) -> CollectionManifest { + let path = DeclarationPath::collection(&collection.name); + + if collection.schema_revision == 0 { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::InvalidSchemaRevision, + )); + } + + match collection.kind { + CollectionKind::Documents => { + if collection.document_id_field.is_none() { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::MissingDocumentIdField, + )); + } + } + CollectionKind::Singleton => { + if collection.document_id_field.is_some() { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::SingletonWithDocumentIdField, + )); + } + if !collection.indexes.is_empty() { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::SingletonWithIndexes, + )); + } + } + } + + let fields = validate_fields(&collection.name, "", &collection.fields, all, diagnostics); + + let mut token_costs = collection.token_costs.clone(); + token_costs.sort_by(|a, b| a.action.cmp(&b.action)); + let mut seen_actions = Vec::new(); + for cost in &token_costs { + if seen_actions.contains(&cost.action) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::DuplicateTokenCostAction { + action: cost.action.to_string(), + }, + )); + } else { + seen_actions.push(cost.action); + } + } + + // Collection-level average sugar: `average = p` is `count` plus `sum = p`; + // `range_average` is `range_count` plus `range_sum`. The same conflict + // rules the native parser applies to `documentsAverageable`. + let mut count = collection.count; + let mut range_count = collection.range_count; + let mut sum = collection.sum.clone(); + let mut range_sum = collection.range_sum; + if let Some(average) = &collection.average { + if let Some(existing) = &sum { + if existing != average { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::ConflictingOption { + options: alloc::vec!["average".to_string(), "sum".to_string()], + reason: "both name the summed property, so they must agree".to_string(), + }, + )); + } + } + count = true; + sum = Some(average.clone()); + if collection.range_average { + range_count = true; + range_sum = true; + } + } else if collection.range_average { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::ConflictingOption { + options: alloc::vec!["range_average".to_string(), "average".to_string()], + reason: "range_average needs average to name the property".to_string(), + }, + )); + } + if let Some(summed) = &sum { + if !has_top_level_property(&fields, summed.as_str()) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::SummablePropertyUnknown { + property: summed.to_string(), + }, + )); + } + } + + let indexes = dedupe( + &collection.indexes, + |a, b| a.name == b.name, + |spec| spec.origin, + |a, b| { + let mut left = a.clone(); + left.origin = b.origin; + left == *b + }, + |_, _| {}, + |spec| DeclarationPath::index(&collection.name, &spec.name), + "index", + || DiagnosticKind::DuplicateIndex, + diagnostics, + ); + let mut index_manifests: Vec = indexes + .iter() + .map(|index| validate_index(collection, &fields, index, diagnostics)) + .collect(); + index_manifests.sort_by(|a, b| a.name.cmp(&b.name)); + + CollectionManifest { + name: collection.name.clone(), + kind: collection.kind, + schema_revision: collection.schema_revision, + write: collection.write, + mutable: collection.mutable, + deletable: collection.deletable, + keep_history: collection.keep_history, + keep_transfer_history: collection.keep_transfer_history, + keep_purchase_history: collection.keep_purchase_history, + keep_pricing_history: collection.keep_pricing_history, + transferable: collection.transferable, + trade: collection.trade, + security_level: collection.security_level, + encryption_key: collection.encryption_key, + decryption_key: collection.decryption_key, + count, + range_count, + sum, + range_sum, + index_only: collection.index_only, + token_costs, + store: collection.store, + fields, + indexes: index_manifests, + } +} + +fn join_path(prefix: &str, name: &str) -> alloc::string::String { + if prefix.is_empty() { + name.to_string() + } else { + let mut path = prefix.to_string(); + path.push('.'); + path.push_str(name); + path + } +} + +/// Validates one nesting level of fields and returns them sorted by position. +fn validate_fields( + collection: &CollectionName, + prefix: &str, + fields: &[FieldSpec], + all: &[&CollectionSpec], + diagnostics: &mut Vec, +) -> Vec { + let mut seen_names: Vec<&str> = Vec::new(); + let mut positions: Vec = Vec::new(); + let mut sorted = Vec::new(); + for field in fields { + let dotted = join_path(prefix, field.name.as_str()); + let path = DeclarationPath::field(collection, &dotted); + if seen_names.contains(&field.name.as_str()) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::DuplicateProperty, + )); + } else { + seen_names.push(field.name.as_str()); + } + if positions.contains(&field.position) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::DuplicatePosition { + position: field.position, + }, + )); + } + positions.push(field.position); + + let ty = validate_field_type(collection, &dotted, fields, &field.ty, all, diagnostics); + let mut validated = field.clone(); + validated.ty = ty; + sorted.push(validated); + } + + let mut unique_positions = positions.clone(); + unique_positions.sort_unstable(); + unique_positions.dedup(); + let contiguous = unique_positions + .iter() + .enumerate() + .all(|(i, &position)| position as usize == i); + if !contiguous { + diagnostics.push(Diagnostic::new( + if prefix.is_empty() { + DeclarationPath::collection(collection) + } else { + DeclarationPath::field(collection, prefix) + }, + DiagnosticKind::NonContiguousPositions { + positions: unique_positions, + }, + )); + } + + sorted.sort_by(|a, b| a.position.cmp(&b.position).then(a.name.cmp(&b.name))); + sorted +} + +fn validate_field_type( + collection: &CollectionName, + dotted: &str, + siblings: &[FieldSpec], + ty: &FieldType, + all: &[&CollectionSpec], + diagnostics: &mut Vec, +) -> FieldType { + let path = DeclarationPath::field(collection, dotted); + match ty { + FieldType::Integer { width, bounds } => { + check_integer_bounds(&path, *width, bounds.min, bounds.max, diagnostics); + ty.clone() + } + FieldType::String { + max_chars: None, .. + } + | FieldType::Bytes { max_len: None, .. } => { + diagnostics.push(Diagnostic::new(path, DiagnosticKind::UnboundedField)); + ty.clone() + } + FieldType::Reference(target) => { + validate_reference(&path, target, siblings, all, diagnostics); + ty.clone() + } + FieldType::Object(nested) => FieldType::Object(validate_fields( + collection, + dotted, + nested, + all, + diagnostics, + )), + FieldType::Bool + | FieldType::F64 + | FieldType::String { .. } + | FieldType::Bytes { .. } + | FieldType::Identifier + | FieldType::Enum(_) => ty.clone(), + } +} + +pub(super) fn check_integer_bounds( + path: &DeclarationPath, + width: IntegerWidth, + min: Option, + max: Option, + diagnostics: &mut Vec, +) { + let inside = |bound: i128| bound >= width.min_value() && bound <= width.max_value(); + let mut outside = false; + for bound in [min, max].into_iter().flatten() { + if !inside(bound) { + outside = true; + } else if bound > i64::MAX as i128 { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::IntegerBoundNotNativelyRepresentable { bound }, + )); + } + } + if let (Some(min), Some(max)) = (min, max) { + if min > max { + outside = true; + } + } + if outside { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::IntegerBoundsOutsideType { + width: width.rust_name().to_string(), + }, + )); + } +} + +fn validate_reference( + path: &DeclarationPath, + target: &ReferenceTarget, + siblings: &[FieldSpec], + all: &[&CollectionSpec], + diagnostics: &mut Vec, +) { + match target { + ReferenceTarget::Identity | ReferenceTarget::Contract | ReferenceTarget::Token => {} + ReferenceTarget::PermanentDocument { + contract, + document_type, + agreement, + } => { + // Only same-contract references can be checked here; another + // contract's types are validated natively at registration. + let referenced = all + .iter() + .find(|candidate| candidate.name == *document_type); + if contract.is_none() { + match referenced { + None => diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::ReferenceCollectionUnknown { + collection: document_type.to_string(), + }, + )), + Some(referenced) => { + for (_, referenced_property) in agreement { + if !has_property_path(&referenced.fields, referenced_property) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::ReferencePropertyUnknown { + property: referenced_property.to_string(), + }, + )); + } + } + } + } + } + for (referring_property, _) in agreement { + if !has_property_path(siblings, referring_property) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::ReferencePropertyUnknown { + property: referring_property.to_string(), + }, + )); + } + } + } + ReferenceTarget::IdentityPublicKey { key_id_field } => { + if !has_property_path(siblings, key_id_field) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::ReferencePropertyUnknown { + property: key_id_field.to_string(), + }, + )); + } + } + } +} + +fn has_top_level_property(fields: &[FieldSpec], name: &str) -> bool { + fields.iter().any(|field| field.name.as_str() == name) +} + +/// Resolves a dotted path through nested objects. +pub(super) fn has_property_path(fields: &[FieldSpec], path: &PropertyPath) -> bool { + if path.is_system() { + return true; + } + let mut current = fields; + let mut segments = path.segments().peekable(); + while let Some(segment) = segments.next() { + let Some(field) = current.iter().find(|field| field.name.as_str() == segment) else { + return false; + }; + if segments.peek().is_none() { + return true; + } + match &field.ty { + FieldType::Object(nested) => current = nested, + _ => return false, + } + } + false +} + +fn validate_index( + collection: &CollectionSpec, + fields: &[FieldSpec], + index: &IndexSpec, + diagnostics: &mut Vec, +) -> IndexManifest { + let path = DeclarationPath::index(&collection.name, &index.name); + + for property in &index.properties { + if !has_property_path(fields, property) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::IndexPropertyUnknown { + property: property.to_string(), + }, + )); + } + } + + // Index-level average sugar, expanded the way the native parser expands + // `averageable` / `rangeAverageable`. + let mut count = index.count; + let mut range_count = index.range_count; + let mut sum = index.sum.clone(); + let mut range_sum = index.range_sum; + if let Some(average) = &index.average { + if let Some(existing) = &sum { + if existing != average { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::ConflictingOption { + options: alloc::vec!["average".to_string(), "sum".to_string()], + reason: "both name the summed property, so they must agree".to_string(), + }, + )); + } + } + if !count.is_countable() { + count = crate::declare::Countability::Countable; + } + sum = Some(average.clone()); + if index.range_average { + range_count = true; + range_sum = true; + } + } else if index.range_average { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::ConflictingOption { + options: alloc::vec!["range_average".to_string(), "average".to_string()], + reason: "range_average needs average to name the property".to_string(), + }, + )); + } + if let Some(summed) = &sum { + if !has_top_level_property(fields, summed.as_str()) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::SummablePropertyUnknown { + property: summed.to_string(), + }, + )); + } + } + + let mut contested = index.contested.clone(); + if let Some(contested) = contested.as_mut() { + for (property, _) in &contested.field_matches { + if !index.properties.contains(property) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::ContestedFieldNotIndexed { + property: property.to_string(), + }, + )); + } + } + contested.field_matches.sort_by(|a, b| a.0.cmp(&b.0)); + } + + if let RankedCount::At(levels) = &index.ranked.count { + for level in levels { + if !index.properties.contains(level) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::RankedLevelNotIndexed { + property: level.to_string(), + }, + )); + } + } + } + + if let Some(time_range) = &index.time_range { + if index.properties.first() != Some(&time_range.on) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::TimeRangeSourceNotFirst { + property: time_range.on.to_string(), + }, + )); + } + } + + if let Some(options) = &index.index_only { + if !collection.index_only { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::IndexOnlyOptionOnStoredCollection, + )); + } + if let Some(terminal) = &options.terminal { + let is_owner = terminal.as_str() == "$ownerId"; + if !is_owner && (terminal.is_system() || !has_property_path(fields, terminal)) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::TerminalNotAProperty { + property: terminal.to_string(), + }, + )); + } + } + } + + let mut ranked = index.ranked.clone(); + if let RankedCount::At(levels) = &mut ranked.count { + levels.sort(); + levels.dedup(); + } + + IndexManifest { + name: index.name.clone(), + properties: index.properties.clone(), + unique: index.unique, + null_searchable: index.null_searchable, + contested, + count, + range_count, + sum, + range_sum, + ranked, + time_range: index.time_range.clone(), + index_only: index.index_only.clone(), + } +} + +pub(super) fn validate_typed_collections( + declaration: &ContractDeclaration, + collections: &[CollectionManifest], + diagnostics: &mut Vec, +) -> Vec { + let typed = dedupe( + &declaration.typed_collections, + |a, b| a.id == b.id, + |spec| spec.origin, + |a, b| { + let mut left = a.clone(); + left.origin = b.origin; + left == *b + }, + |_, _| {}, + |spec| DeclarationPath::typed_collection(&spec.id), + "typed collection", + || DiagnosticKind::DuplicateCollection, + diagnostics, + ); + let mut manifests: Vec = typed + .iter() + .map(|spec: &TypedCollectionSpec| { + let path = DeclarationPath::typed_collection(&spec.id); + if collections + .iter() + .any(|collection| collection.name == spec.id) + { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::DuplicateCollection, + )); + } + check_value_type(&path, &spec.key, diagnostics); + check_value_type(&path, &spec.element, diagnostics); + TypedCollectionManifest { + id: spec.id.clone(), + kind: spec.kind, + key: spec.key.clone(), + element: spec.element.clone(), + max_elements: spec.max_elements, + } + }) + .collect(); + manifests.sort_by(|a, b| a.id.cmp(&b.id)); + manifests +} + +/// Every string, byte array and list in a wire type declares a maximum. +pub(super) fn check_value_type( + path: &DeclarationPath, + ty: &ValueType, + diagnostics: &mut Vec, +) { + match ty { + ValueType::String { max_chars: None } | ValueType::Bytes { max_len: None } => { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::UnboundedField, + )); + } + ValueType::List { max_len, item } => { + if max_len.is_none() { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::UnboundedField, + )); + } + check_value_type(path, item, diagnostics); + } + ValueType::Option(inner) => check_value_type(path, inner, diagnostics), + ValueType::Struct(members) => { + for (_, member) in members { + check_value_type(path, member, diagnostics); + } + } + ValueType::Unit + | ValueType::Bool + | ValueType::Integer(_) + | ValueType::F64 + | ValueType::String { .. } + | ValueType::Bytes { .. } + | ValueType::Identifier + | ValueType::DocumentId => {} + } +} diff --git a/packages/rs-dash-sdk-contract/src/validate/diagnostic.rs b/packages/rs-dash-sdk-contract/src/validate/diagnostic.rs new file mode 100644 index 00000000000..823d98c4646 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/validate/diagnostic.rs @@ -0,0 +1,763 @@ +//! Diagnostics: typed, append-only, with stable codes. + +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +use core::fmt; + +use crate::declare::{CapabilityRequirement, DeclarationOrigin}; +use crate::identity::InvalidName; + +/// Where in the declaration a diagnostic points. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum DeclarationPath { + /// The contract as a whole. + Contract, + /// An attribute, optionally one of its options. Nested option lists use + /// dotted attribute names (`index.time_range`). + Attribute { + /// The attribute name. + attribute: String, + /// The option name, when the diagnostic is about one option. + option: Option, + }, + /// A module. + Module(String), + /// An interface. + Interface(String), + /// A document collection or singleton. + Collection(String), + /// A stored field, by dotted path. + Field { + /// The collection. + collection: String, + /// The dotted field path. + field: String, + }, + /// An index. + Index { + /// The collection. + collection: String, + /// The index name. + index: String, + }, + /// A typed specialized collection. + TypedCollection(String), + /// An entry. + Entry(String), + /// An entry parameter or its return value. + EntryParam { + /// The entry. + entry: String, + /// The parameter name, or `return`. + param: String, + }, + /// A rule. + Rule { + /// The collection. + collection: String, + /// The rule name. + rule: String, + }, + /// A capability requirement. + Capability(CapabilityRequirement), +} + +impl DeclarationPath { + /// An attribute path. + pub fn attribute(attribute: &str, option: Option<&str>) -> Self { + DeclarationPath::Attribute { + attribute: attribute.to_string(), + option: option.map(|option| option.to_string()), + } + } + + /// A collection path. + pub fn collection(name: impl AsRef) -> Self { + DeclarationPath::Collection(name.as_ref().to_string()) + } + + /// A field path. + pub fn field(collection: impl AsRef, field: impl AsRef) -> Self { + DeclarationPath::Field { + collection: collection.as_ref().to_string(), + field: field.as_ref().to_string(), + } + } + + /// An index path. + pub fn index(collection: impl AsRef, index: impl AsRef) -> Self { + DeclarationPath::Index { + collection: collection.as_ref().to_string(), + index: index.as_ref().to_string(), + } + } + + /// An entry path. + pub fn entry(name: impl AsRef) -> Self { + DeclarationPath::Entry(name.as_ref().to_string()) + } + + /// An entry parameter path. + pub fn entry_param(entry: impl AsRef, param: impl AsRef) -> Self { + DeclarationPath::EntryParam { + entry: entry.as_ref().to_string(), + param: param.as_ref().to_string(), + } + } + + /// A rule path. + pub fn rule(collection: impl AsRef, rule: impl AsRef) -> Self { + DeclarationPath::Rule { + collection: collection.as_ref().to_string(), + rule: rule.as_ref().to_string(), + } + } + + /// A module path. + pub fn module(name: impl AsRef) -> Self { + DeclarationPath::Module(name.as_ref().to_string()) + } + + /// An interface path. + pub fn interface(name: impl AsRef) -> Self { + DeclarationPath::Interface(name.as_ref().to_string()) + } + + /// A typed collection path. + pub fn typed_collection(name: impl AsRef) -> Self { + DeclarationPath::TypedCollection(name.as_ref().to_string()) + } +} + +impl fmt::Display for DeclarationPath { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DeclarationPath::Contract => f.write_str("contract"), + DeclarationPath::Attribute { attribute, option } => match option { + Some(option) => write!(f, "attribute {attribute}, option {option}"), + None => write!(f, "attribute {attribute}"), + }, + DeclarationPath::Module(name) => write!(f, "module {name}"), + DeclarationPath::Interface(name) => write!(f, "interface {name}"), + DeclarationPath::Collection(name) => write!(f, "collection {name}"), + DeclarationPath::Field { collection, field } => { + write!(f, "collection {collection}, field {field}") + } + DeclarationPath::Index { collection, index } => { + write!(f, "collection {collection}, index {index}") + } + DeclarationPath::TypedCollection(name) => write!(f, "typed collection {name}"), + DeclarationPath::Entry(name) => write!(f, "entry {name}"), + DeclarationPath::EntryParam { entry, param } => { + write!(f, "entry {entry}, parameter {param}") + } + DeclarationPath::Rule { collection, rule } => { + write!(f, "collection {collection}, rule {rule}") + } + DeclarationPath::Capability(requirement) => write!(f, "capability {requirement}"), + } + } +} + +/// What went wrong. +/// +/// Variants are appended, never reordered or removed: their position is +/// their stable code (`DSC0001` onwards, provisional). The rule each one +/// enforces is on the variant. +// @append_only +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DiagnosticKind { + /// The attribute is not in the grammar. + UnknownAttribute { + /// The attribute as written. + attribute: String, + }, + /// The option is not in the attribute's grammar. + UnknownOption { + /// The attribute. + attribute: String, + /// The option as written. + option: String, + }, + /// The option's value has the wrong shape or is outside its closed set. + InvalidOptionValue { + /// The attribute. + attribute: String, + /// The option. + option: String, + /// What was expected. + reason: String, + }, + /// A required option is absent. + MissingOption { + /// The attribute. + attribute: String, + /// The option. + option: String, + }, + /// An option is given twice. + DuplicateOption { + /// The attribute. + attribute: String, + /// The option. + option: String, + }, + /// Exactly one option of a group must be present (`guard` or + /// `predicate` on a rule). + ExactlyOneOptionRequired { + /// The attribute. + attribute: String, + /// The group. + options: Vec, + /// How many were given. + given: usize, + }, + /// Two options describe the same thing differently (`average` naming a + /// different property than `sum`, `range_average` without `average`). + ConflictingOption { + /// The two options. + options: Vec, + /// Why they conflict. + reason: String, + }, + /// An attribute and a builder declare the same item differently. + ConflictingDeclaration { + /// What kind of item. + what: String, + /// The first declaration's origin. + first: DeclarationOrigin, + /// The second declaration's origin. + second: DeclarationOrigin, + }, + /// Two declarations of the same origin use one collection name. + DuplicateCollection, + /// Two declarations of the same origin use one index name in one + /// collection. + DuplicateIndex, + /// Two declarations of the same origin use one method name. + DuplicateMethod, + /// Two entries produce the same export symbol. + DuplicateExportSymbol { + /// The symbol. + export: String, + }, + /// Two declarations of the same origin use one rule name in one + /// collection. + DuplicateRule, + /// Two declarations of the same origin use one module name. + DuplicateModule, + /// Two declarations of the same origin use one interface name. + DuplicateInterface, + /// Two fields at one nesting level share a name. + DuplicateProperty, + /// Two fields at one nesting level share a position. + DuplicatePosition { + /// The position. + position: u32, + }, + /// Positions at one nesting level are not `0..n`. + NonContiguousPositions { + /// The positions as declared, sorted. + positions: Vec, + }, + /// A token cost is declared twice for one action. + DuplicateTokenCostAction { + /// The action. + action: String, + }, + /// A name fails its identity grammar. + InvalidName(InvalidName), + /// A string, byte array or list declares no maximum. + UnboundedField, + /// Integer bounds fall outside the declared Rust width, or `min > max`. + IntegerBoundsOutsideType { + /// The Rust width. + width: String, + }, + /// A bound cannot be expressed in the native schema, which reads bounds + /// as signed 64-bit integers; omit the bound to get the full width. + IntegerBoundNotNativelyRepresentable { + /// The bound. + bound: i128, + }, + /// An index names a property the collection does not declare. + IndexPropertyUnknown { + /// The property path. + property: String, + }, + /// A sum names a property the collection does not declare. + SummablePropertyUnknown { + /// The property. + property: String, + }, + /// A contested field match names a property the index does not cover. + ContestedFieldNotIndexed { + /// The property path. + property: String, + }, + /// A time range buckets a property that is not the index's first. + TimeRangeSourceNotFirst { + /// The property path. + property: String, + }, + /// An index-only terminal is neither `$ownerId` nor a declared property. + TerminalNotAProperty { + /// The property path. + property: String, + }, + /// A ranked level names a property the index does not cover. + RankedLevelNotIndexed { + /// The property path. + property: String, + }, + /// `terminal`, `preallocated` or `skip_if_absent` on a collection that is + /// not index-only. + IndexOnlyOptionOnStoredCollection, + /// A singleton declares indexes. + SingletonWithIndexes, + /// A singleton marks a field `#[document_id]`. + SingletonWithDocumentIdField, + /// A persistent struct has no `#[document_id]` field. + MissingDocumentIdField, + /// The schema revision is 0. + InvalidSchemaRevision, + /// A reference names a collection this contract does not declare. + ReferenceCollectionUnknown { + /// The collection. + collection: String, + }, + /// A reference names a property that does not exist. + ReferencePropertyUnknown { + /// The property path. + property: String, + }, + /// An entry receiver names a collection this contract does not declare. + ReceiverCollectionUnknown { + /// The collection. + collection: String, + }, + /// A `&mut self` entry on a collection whose documents cannot be + /// replaced: there is no write for the wrapper to stage. + MutableReceiverOnImmutableCollection { + /// The collection. + collection: String, + }, + /// A read-only entry with a `&mut self` receiver. + ReadOnlyEntryWithMutableReceiver, + /// Two parameters of one entry or interface function share a name. + DuplicateParameter { + /// The parameter. + param: String, + }, + /// An entry binds to a module this contract does not declare. + EntryModuleUnknown { + /// The module. + module: String, + }, + /// An entry names no module and the contract declares several. + EntryModuleRequired, + /// An interface's provider is not a declared module. + InterfaceProviderUnknown { + /// The module. + module: String, + }, + /// A module uses an interface no declaration provides. + UsedInterfaceUnknown { + /// The interface. + interface: String, + }, + /// A module uses an interface it provides itself. + ModuleSelfImport { + /// The interface. + interface: String, + }, + /// The module import graph has a cycle. + ModuleGraphCycle { + /// The modules on the cycle, in import order. + modules: Vec, + }, + /// Two functions of one interface share a name. + DuplicateInterfaceFunction { + /// The function. + function: String, + }, + /// A predicate names a module this contract does not declare. + PredicateModuleUnknown { + /// The module. + module: String, + }, + /// A rule guards a collection this contract does not declare. + RuleCollectionUnknown { + /// The collection. + collection: String, + }, + /// A rule names no action. + RuleWithoutActions, + /// A guard reads a property the guarded collection does not declare. + GuardFieldUnknown { + /// The property path. + property: String, + }, + /// A capability whose interface is disabled is required (the private + /// document store). + CapabilityInterfaceDisabled { + /// The requirement. + requirement: CapabilityRequirement, + }, + /// A derived capability is listed as an explicit requirement. + CapabilityNotDeclarable { + /// The requirement. + requirement: CapabilityRequirement, + }, + /// Reserved and never produced: the model has no raw path, raw element or + /// database handle grammar, so there is nothing to reject. The variant + /// documents that absence. + RawPathDeclaration, +} + +impl DiagnosticKind { + fn code_and_name(&self) -> (&'static str, &'static str) { + match self { + DiagnosticKind::UnknownAttribute { .. } => ("DSC0001", "UnknownAttribute"), + DiagnosticKind::UnknownOption { .. } => ("DSC0002", "UnknownOption"), + DiagnosticKind::InvalidOptionValue { .. } => ("DSC0003", "InvalidOptionValue"), + DiagnosticKind::MissingOption { .. } => ("DSC0004", "MissingOption"), + DiagnosticKind::DuplicateOption { .. } => ("DSC0005", "DuplicateOption"), + DiagnosticKind::ExactlyOneOptionRequired { .. } => { + ("DSC0006", "ExactlyOneOptionRequired") + } + DiagnosticKind::ConflictingOption { .. } => ("DSC0007", "ConflictingOption"), + DiagnosticKind::ConflictingDeclaration { .. } => ("DSC0008", "ConflictingDeclaration"), + DiagnosticKind::DuplicateCollection => ("DSC0009", "DuplicateCollection"), + DiagnosticKind::DuplicateIndex => ("DSC0010", "DuplicateIndex"), + DiagnosticKind::DuplicateMethod => ("DSC0011", "DuplicateMethod"), + DiagnosticKind::DuplicateExportSymbol { .. } => ("DSC0012", "DuplicateExportSymbol"), + DiagnosticKind::DuplicateRule => ("DSC0013", "DuplicateRule"), + DiagnosticKind::DuplicateModule => ("DSC0014", "DuplicateModule"), + DiagnosticKind::DuplicateInterface => ("DSC0015", "DuplicateInterface"), + DiagnosticKind::DuplicateProperty => ("DSC0016", "DuplicateProperty"), + DiagnosticKind::DuplicatePosition { .. } => ("DSC0017", "DuplicatePosition"), + DiagnosticKind::NonContiguousPositions { .. } => ("DSC0018", "NonContiguousPositions"), + DiagnosticKind::DuplicateTokenCostAction { .. } => { + ("DSC0019", "DuplicateTokenCostAction") + } + DiagnosticKind::InvalidName(_) => ("DSC0020", "InvalidName"), + DiagnosticKind::UnboundedField => ("DSC0021", "UnboundedField"), + DiagnosticKind::IntegerBoundsOutsideType { .. } => { + ("DSC0022", "IntegerBoundsOutsideType") + } + DiagnosticKind::IntegerBoundNotNativelyRepresentable { .. } => { + ("DSC0023", "IntegerBoundNotNativelyRepresentable") + } + DiagnosticKind::IndexPropertyUnknown { .. } => ("DSC0024", "IndexPropertyUnknown"), + DiagnosticKind::SummablePropertyUnknown { .. } => { + ("DSC0025", "SummablePropertyUnknown") + } + DiagnosticKind::ContestedFieldNotIndexed { .. } => { + ("DSC0026", "ContestedFieldNotIndexed") + } + DiagnosticKind::TimeRangeSourceNotFirst { .. } => { + ("DSC0027", "TimeRangeSourceNotFirst") + } + DiagnosticKind::TerminalNotAProperty { .. } => ("DSC0028", "TerminalNotAProperty"), + DiagnosticKind::RankedLevelNotIndexed { .. } => ("DSC0029", "RankedLevelNotIndexed"), + DiagnosticKind::IndexOnlyOptionOnStoredCollection => { + ("DSC0030", "IndexOnlyOptionOnStoredCollection") + } + DiagnosticKind::SingletonWithIndexes => ("DSC0031", "SingletonWithIndexes"), + DiagnosticKind::SingletonWithDocumentIdField => { + ("DSC0032", "SingletonWithDocumentIdField") + } + DiagnosticKind::MissingDocumentIdField => ("DSC0033", "MissingDocumentIdField"), + DiagnosticKind::InvalidSchemaRevision => ("DSC0034", "InvalidSchemaRevision"), + DiagnosticKind::ReferenceCollectionUnknown { .. } => { + ("DSC0035", "ReferenceCollectionUnknown") + } + DiagnosticKind::ReferencePropertyUnknown { .. } => { + ("DSC0036", "ReferencePropertyUnknown") + } + DiagnosticKind::ReceiverCollectionUnknown { .. } => { + ("DSC0037", "ReceiverCollectionUnknown") + } + DiagnosticKind::MutableReceiverOnImmutableCollection { .. } => { + ("DSC0038", "MutableReceiverOnImmutableCollection") + } + DiagnosticKind::ReadOnlyEntryWithMutableReceiver => { + ("DSC0039", "ReadOnlyEntryWithMutableReceiver") + } + DiagnosticKind::DuplicateParameter { .. } => ("DSC0040", "DuplicateParameter"), + DiagnosticKind::EntryModuleUnknown { .. } => ("DSC0041", "EntryModuleUnknown"), + DiagnosticKind::EntryModuleRequired => ("DSC0042", "EntryModuleRequired"), + DiagnosticKind::InterfaceProviderUnknown { .. } => { + ("DSC0043", "InterfaceProviderUnknown") + } + DiagnosticKind::UsedInterfaceUnknown { .. } => ("DSC0044", "UsedInterfaceUnknown"), + DiagnosticKind::ModuleSelfImport { .. } => ("DSC0045", "ModuleSelfImport"), + DiagnosticKind::ModuleGraphCycle { .. } => ("DSC0046", "ModuleGraphCycle"), + DiagnosticKind::DuplicateInterfaceFunction { .. } => { + ("DSC0047", "DuplicateInterfaceFunction") + } + DiagnosticKind::PredicateModuleUnknown { .. } => ("DSC0048", "PredicateModuleUnknown"), + DiagnosticKind::RuleCollectionUnknown { .. } => ("DSC0049", "RuleCollectionUnknown"), + DiagnosticKind::RuleWithoutActions => ("DSC0050", "RuleWithoutActions"), + DiagnosticKind::GuardFieldUnknown { .. } => ("DSC0051", "GuardFieldUnknown"), + DiagnosticKind::CapabilityInterfaceDisabled { .. } => { + ("DSC0052", "CapabilityInterfaceDisabled") + } + DiagnosticKind::CapabilityNotDeclarable { .. } => { + ("DSC0053", "CapabilityNotDeclarable") + } + DiagnosticKind::RawPathDeclaration => ("DSC0054", "RawPathDeclaration"), + } + } + + /// The stable code, `DSC` followed by four digits. Provisional. + pub fn code(&self) -> &'static str { + self.code_and_name().0 + } + + /// The variant name. + pub fn name(&self) -> &'static str { + self.code_and_name().1 + } +} + +impl fmt::Display for DiagnosticKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DiagnosticKind::UnknownAttribute { attribute } => { + write!(f, "unknown attribute `{attribute}`") + } + DiagnosticKind::UnknownOption { attribute, option } => { + write!(f, "unknown option `{option}` on `{attribute}`") + } + DiagnosticKind::InvalidOptionValue { + attribute, + option, + reason, + } => write!(f, "invalid value for `{option}` on `{attribute}`: {reason}"), + DiagnosticKind::MissingOption { attribute, option } => { + write!(f, "`{attribute}` requires option `{option}`") + } + DiagnosticKind::DuplicateOption { attribute, option } => { + write!(f, "option `{option}` on `{attribute}` is given twice") + } + DiagnosticKind::ExactlyOneOptionRequired { + attribute, + options, + given, + } => write!( + f, + "`{attribute}` requires exactly one of {options:?}, {given} given" + ), + DiagnosticKind::ConflictingOption { options, reason } => { + write!(f, "options {options:?} conflict: {reason}") + } + DiagnosticKind::ConflictingDeclaration { + what, + first, + second, + } => write!( + f, + "the {what} is declared differently by an {first} and a {second}" + ), + DiagnosticKind::DuplicateCollection => f.write_str("collection declared twice"), + DiagnosticKind::DuplicateIndex => f.write_str("index declared twice"), + DiagnosticKind::DuplicateMethod => f.write_str("method declared twice"), + DiagnosticKind::DuplicateExportSymbol { export } => { + write!(f, "export symbol `{export}` produced twice") + } + DiagnosticKind::DuplicateRule => f.write_str("rule declared twice"), + DiagnosticKind::DuplicateModule => f.write_str("module declared twice"), + DiagnosticKind::DuplicateInterface => f.write_str("interface declared twice"), + DiagnosticKind::DuplicateProperty => f.write_str("property declared twice"), + DiagnosticKind::DuplicatePosition { position } => { + write!(f, "position {position} used twice") + } + DiagnosticKind::NonContiguousPositions { positions } => { + write!(f, "positions {positions:?} are not contiguous from 0") + } + DiagnosticKind::DuplicateTokenCostAction { action } => { + write!(f, "token cost declared twice for action `{action}`") + } + DiagnosticKind::InvalidName(error) => write!(f, "{error}"), + DiagnosticKind::UnboundedField => { + f.write_str("variable-length value declares no maximum") + } + DiagnosticKind::IntegerBoundsOutsideType { width } => { + write!(f, "integer bounds do not fit `{width}` or are inverted") + } + DiagnosticKind::IntegerBoundNotNativelyRepresentable { bound } => write!( + f, + "bound {bound} cannot be expressed natively (bounds are signed 64-bit); omit it to keep the full width" + ), + DiagnosticKind::IndexPropertyUnknown { property } => { + write!(f, "index property `{property}` is not declared") + } + DiagnosticKind::SummablePropertyUnknown { property } => { + write!(f, "summed property `{property}` is not declared") + } + DiagnosticKind::ContestedFieldNotIndexed { property } => { + write!(f, "contested field `{property}` is not an index property") + } + DiagnosticKind::TimeRangeSourceNotFirst { property } => { + write!(f, "time range source `{property}` is not the index's first property") + } + DiagnosticKind::TerminalNotAProperty { property } => { + write!(f, "terminal `{property}` is neither `$ownerId` nor a declared property") + } + DiagnosticKind::RankedLevelNotIndexed { property } => { + write!(f, "ranked level `{property}` is not an index property") + } + DiagnosticKind::IndexOnlyOptionOnStoredCollection => { + f.write_str("terminal, preallocated and skip_if_absent need an index-only collection") + } + DiagnosticKind::SingletonWithIndexes => f.write_str("a singleton has no indexes"), + DiagnosticKind::SingletonWithDocumentIdField => { + f.write_str("a singleton has no document id field") + } + DiagnosticKind::MissingDocumentIdField => { + f.write_str("a persistent struct needs a `#[document_id]` field") + } + DiagnosticKind::InvalidSchemaRevision => f.write_str("schema revision must be at least 1"), + DiagnosticKind::ReferenceCollectionUnknown { collection } => { + write!(f, "referenced collection `{collection}` is not declared") + } + DiagnosticKind::ReferencePropertyUnknown { property } => { + write!(f, "referenced property `{property}` is not declared") + } + DiagnosticKind::ReceiverCollectionUnknown { collection } => { + write!(f, "receiver collection `{collection}` is not declared") + } + DiagnosticKind::MutableReceiverOnImmutableCollection { collection } => write!( + f, + "`&mut self` on collection `{collection}` whose documents cannot be replaced" + ), + DiagnosticKind::ReadOnlyEntryWithMutableReceiver => { + f.write_str("a read-only entry cannot take `&mut self`") + } + DiagnosticKind::DuplicateParameter { param } => { + write!(f, "parameter `{param}` declared twice") + } + DiagnosticKind::EntryModuleUnknown { module } => { + write!(f, "module `{module}` is not declared") + } + DiagnosticKind::EntryModuleRequired => { + f.write_str("the contract declares several modules; name the entry's module") + } + DiagnosticKind::InterfaceProviderUnknown { module } => { + write!(f, "provider module `{module}` is not declared") + } + DiagnosticKind::UsedInterfaceUnknown { interface } => { + write!(f, "interface `{interface}` is not declared") + } + DiagnosticKind::ModuleSelfImport { interface } => { + write!(f, "module uses interface `{interface}` that it provides itself") + } + DiagnosticKind::ModuleGraphCycle { modules } => { + write!(f, "module imports form a cycle: {modules:?}") + } + DiagnosticKind::DuplicateInterfaceFunction { function } => { + write!(f, "interface function `{function}` declared twice") + } + DiagnosticKind::PredicateModuleUnknown { module } => { + write!(f, "predicate module `{module}` is not declared") + } + DiagnosticKind::RuleCollectionUnknown { collection } => { + write!(f, "rule collection `{collection}` is not declared") + } + DiagnosticKind::RuleWithoutActions => f.write_str("a rule needs at least one action"), + DiagnosticKind::GuardFieldUnknown { property } => { + write!(f, "guard reads property `{property}` that is not declared") + } + DiagnosticKind::CapabilityInterfaceDisabled { requirement } => write!( + f, + "capability `{requirement}` is catalogued but its interface is disabled" + ), + DiagnosticKind::CapabilityNotDeclarable { requirement } => write!( + f, + "capability `{requirement}` is derived from declarations and cannot be required explicitly" + ), + DiagnosticKind::RawPathDeclaration => { + f.write_str("raw database paths are not declarable") + } + } + } +} + +/// One diagnostic: where and what. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Diagnostic { + /// Where the diagnostic points. + pub path: DeclarationPath, + /// What went wrong. + pub kind: DiagnosticKind, +} + +impl Diagnostic { + /// A diagnostic at `path`. + pub fn new(path: DeclarationPath, kind: DiagnosticKind) -> Self { + Diagnostic { path, kind } + } + + /// The stable code of the kind. + pub fn code(&self) -> &'static str { + self.kind.code() + } + + /// Where the diagnostic points. + pub fn path(&self) -> &DeclarationPath { + &self.path + } + + /// Wraps a name grammar failure. + pub fn invalid_name(path: DeclarationPath, error: InvalidName) -> Self { + Diagnostic::new(path, DiagnosticKind::InvalidName(error)) + } +} + +impl fmt::Display for Diagnostic { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} at {}: {}", self.kind.code(), self.path, self.kind) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_render_code_path_and_message() { + let diagnostic = Diagnostic::new( + DeclarationPath::collection("scores"), + DiagnosticKind::DuplicateCollection, + ); + assert_eq!( + diagnostic.to_string(), + "DSC0009 at collection scores: collection declared twice" + ); + assert_eq!(diagnostic.kind.name(), "DuplicateCollection"); + } + + #[test] + fn should_render_every_path_shape() { + let paths = [ + DeclarationPath::Contract, + DeclarationPath::attribute("index", Some("fields")), + DeclarationPath::attribute("index", None), + DeclarationPath::module("main"), + DeclarationPath::interface("math"), + DeclarationPath::collection("scores"), + DeclarationPath::field("scores", "profile.age"), + DeclarationPath::index("scores", "by_class"), + DeclarationPath::typed_collection("totals"), + DeclarationPath::entry("score.add"), + DeclarationPath::entry_param("score.add", "delta"), + DeclarationPath::rule("scores", "monotonic"), + DeclarationPath::Capability(CapabilityRequirement::PrivateStore), + ]; + for path in paths { + assert!(!path.to_string().is_empty()); + } + } +} diff --git a/packages/rs-dash-sdk-contract/src/validate/entries.rs b/packages/rs-dash-sdk-contract/src/validate/entries.rs new file mode 100644 index 00000000000..c43b92a98f7 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/validate/entries.rs @@ -0,0 +1,172 @@ +//! Entry checks: identity, export symbols, receivers, module bindings and +//! bounded wire types. + +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +use crate::declare::{ContractDeclaration, EntrySpec, Receiver, IMPLICIT_MODULE}; +use crate::identity::{entry_export_symbol, ModuleName}; +use crate::manifest::{MethodEntry, MethodTable, ModuleTable}; +use crate::validate::collections::{check_value_type, CollectionKinds}; +use crate::validate::diagnostic::{DeclarationPath, Diagnostic, DiagnosticKind}; +use crate::validate::merge::dedupe; + +pub(super) fn validate_entries( + declaration: &ContractDeclaration, + modules: &ModuleTable, + collections: &CollectionKinds, + diagnostics: &mut Vec, +) -> MethodTable { + let entries = dedupe( + &declaration.entries, + |a, b| a.name == b.name, + |spec| spec.origin, + |a, b| { + let mut left = a.clone(); + left.origin = b.origin; + left == *b + }, + |_, _| {}, + |spec| DeclarationPath::entry(&spec.name), + "entry", + || DiagnosticKind::DuplicateMethod, + diagnostics, + ); + + let single_module: Option<&ModuleName> = match modules.modules.as_slice() { + [only] => Some(&only.name), + _ => None, + }; + + let mut exports: Vec = Vec::new(); + let mut table: Vec = Vec::new(); + for entry in &entries { + let path = DeclarationPath::entry(&entry.name); + + let export = entry_export_symbol(&entry.name); + if exports.contains(&export) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::DuplicateExportSymbol { + export: export.clone(), + }, + )); + } else { + exports.push(export.clone()); + } + + let module = resolve_module(entry, modules, single_module, &path, diagnostics); + + let kind = entry.receiver.collection().and_then(|name| { + collections + .iter() + .find(|(candidate, _)| candidate == name) + .map(|(_, kind)| *kind) + }); + if let Some(name) = entry.receiver.collection() { + if kind.is_none() { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::ReceiverCollectionUnknown { + collection: name.to_string(), + }, + )); + } + } + if entry.receiver.is_mutable() { + if entry.read_only { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::ReadOnlyEntryWithMutableReceiver, + )); + } + if let Receiver::Mut(name) = &entry.receiver { + let immutable = declaration + .collections + .iter() + .find(|collection| &collection.name == name) + .map(|collection| !collection.mutable) + .unwrap_or(false); + if immutable { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::MutableReceiverOnImmutableCollection { + collection: name.to_string(), + }, + )); + } + } + } + + let mut params: Vec<&str> = Vec::new(); + for param in &entry.params { + let param_path = DeclarationPath::entry_param(&entry.name, ¶m.name); + if params.contains(¶m.name.as_str()) { + diagnostics.push(Diagnostic::new( + param_path.clone(), + DiagnosticKind::DuplicateParameter { + param: param.name.clone(), + }, + )); + } else { + params.push(¶m.name); + } + check_value_type(¶m_path, ¶m.ty, diagnostics); + } + check_value_type( + &DeclarationPath::entry_param(&entry.name, "return"), + &entry.returns, + diagnostics, + ); + + table.push(MethodEntry { + name: entry.name.clone(), + module, + export, + receiver: entry.receiver.clone(), + takes_document_id: MethodEntry::takes_document_id(&entry.receiver, kind), + read_only: entry.read_only, + params: entry.params.clone(), + returns: entry.returns.clone(), + }); + } + table.sort_by(|a, b| a.name.cmp(&b.name)); + MethodTable { entries: table } +} + +fn resolve_module( + entry: &EntrySpec, + modules: &ModuleTable, + single_module: Option<&ModuleName>, + path: &DeclarationPath, + diagnostics: &mut Vec, +) -> ModuleName { + match (&entry.module, single_module) { + (Some(module), _) => { + if !modules.names().any(|name| name == module) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::EntryModuleUnknown { + module: module.to_string(), + }, + )); + } + module.clone() + } + (None, Some(single)) => single.clone(), + (None, None) => { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::EntryModuleRequired, + )); + modules + .modules + .first() + .map(|module| module.name.clone()) + .unwrap_or_else(|| { + ModuleName::new(IMPLICIT_MODULE) + .expect("the implicit module name satisfies the grammar") + }) + } + } +} diff --git a/packages/rs-dash-sdk-contract/src/validate/merge.rs b/packages/rs-dash-sdk-contract/src/validate/merge.rs new file mode 100644 index 00000000000..334d688bbbe --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/validate/merge.rs @@ -0,0 +1,81 @@ +//! Merging attribute and builder declarations of one item. +//! +//! Two specs with the same identity are one item when they come from +//! different origins and agree; a builder may restate what an attribute +//! declared. Two specs of the same origin with one identity are a duplicate, +//! and two specs of different origins that disagree are a conflict. + +use alloc::string::ToString; +use alloc::vec::Vec; + +use crate::declare::DeclarationOrigin; +use crate::validate::diagnostic::{DeclarationPath, Diagnostic, DiagnosticKind}; + +/// How two specs sharing an identity relate. +pub(super) enum Overlap { + /// Same origin: a duplicate. + Duplicate, + /// Different origins, same content: keep the first. + Restatement, + /// Different origins, different content. + Conflict, +} + +/// Classifies `kept` against `next`. +pub(super) fn classify( + kept_origin: DeclarationOrigin, + next_origin: DeclarationOrigin, + equivalent: bool, +) -> Overlap { + if kept_origin == next_origin { + Overlap::Duplicate + } else if equivalent { + Overlap::Restatement + } else { + Overlap::Conflict + } +} + +/// Deduplicates `items` by identity, reporting duplicates and conflicts, and +/// returns the kept specs in first-seen order. `merge` is called on a +/// restatement so the caller can union what may legitimately extend (the +/// indexes of a collection). +#[allow(clippy::too_many_arguments)] +pub(super) fn dedupe( + items: &[T], + same_identity: impl Fn(&T, &T) -> bool, + origin: impl Fn(&T) -> DeclarationOrigin, + equivalent: impl Fn(&T, &T) -> bool, + merge: impl Fn(&mut T, &T), + path: impl Fn(&T) -> DeclarationPath, + what: &str, + duplicate: impl Fn() -> DiagnosticKind, + diagnostics: &mut Vec, +) -> Vec { + let mut kept: Vec = Vec::new(); + for item in items { + match kept + .iter_mut() + .find(|existing| same_identity(existing, item)) + { + None => kept.push(item.clone()), + Some(existing) => { + match classify(origin(existing), origin(item), equivalent(existing, item)) { + Overlap::Duplicate => { + diagnostics.push(Diagnostic::new(path(item), duplicate())) + } + Overlap::Restatement => merge(existing, item), + Overlap::Conflict => diagnostics.push(Diagnostic::new( + path(item), + DiagnosticKind::ConflictingDeclaration { + what: what.to_string(), + first: origin(existing), + second: origin(item), + }, + )), + } + } + } + } + kept +} diff --git a/packages/rs-dash-sdk-contract/src/validate/mod.rs b/packages/rs-dash-sdk-contract/src/validate/mod.rs new file mode 100644 index 00000000000..467fc8c61c9 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/validate/mod.rs @@ -0,0 +1,83 @@ +//! Validation: from a [`ContractDeclaration`] to a [`CanonicalManifest`] or a +//! complete list of [`Diagnostic`]s. +//! +//! The validator owns what the native host cannot know: attribute grammar, +//! identity uniqueness, conflicts between an attribute and a builder, +//! boundedness of fields and wire types, cross-references between +//! declarations, module graph shape and the SDK's own semantic rules (a +//! mutable receiver on an immutable collection, a singleton with indexes). +//! It deliberately does not mirror native numeric limits (index counts, name +//! lengths, indexed string sizes, time-range overlap caps) or native schema +//! dependencies (a range count needs a count): those are enforced once, by +//! Dash Platform Protocol, and the build crate surfaces them. +//! +//! Every check runs and every diagnostic is collected; there is no +//! first-error return. + +pub mod diagnostic; + +mod capabilities; +mod collections; +mod entries; +mod merge; +mod modules; +mod rules; +#[cfg(test)] +mod tests; + +use alloc::vec::Vec; + +pub use diagnostic::{DeclarationPath, Diagnostic, DiagnosticKind}; + +use crate::declare::ContractDeclaration; +use crate::manifest::CanonicalManifest; + +/// Validates the declaration and builds its canonical manifest, or returns +/// every diagnostic found. +pub fn validate(declaration: &ContractDeclaration) -> Result> { + let mut diagnostics = Vec::new(); + + let module_table = modules::validate_modules(declaration, &mut diagnostics); + let (collection_manifests, collection_kinds) = + collections::validate_collections(declaration, &mut diagnostics); + let typed_collections = collections::validate_typed_collections( + declaration, + &collection_manifests, + &mut diagnostics, + ); + let method_table = entries::validate_entries( + declaration, + &module_table, + &collection_kinds, + &mut diagnostics, + ); + let rule_manifests = rules::validate_rules( + declaration, + &module_table, + &collection_manifests, + &mut diagnostics, + ); + let capability_table = capabilities::validate_capabilities( + declaration, + &module_table, + &collection_manifests, + &typed_collections, + &method_table, + &rule_manifests, + &mut diagnostics, + ); + + if !diagnostics.is_empty() { + return Err(diagnostics); + } + + Ok(CanonicalManifest { + modules: module_table, + collections: collection_manifests, + typed_collections, + methods: method_table, + rules: rule_manifests, + capabilities: capability_table, + receipts: declaration.receipts, + }) +} diff --git a/packages/rs-dash-sdk-contract/src/validate/modules.rs b/packages/rs-dash-sdk-contract/src/validate/modules.rs new file mode 100644 index 00000000000..0d6e34d32c4 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/validate/modules.rs @@ -0,0 +1,238 @@ +//! Module and interface checks: identity, providers, imports and the acyclic +//! import graph. + +use alloc::string::ToString; +use alloc::vec::Vec; + +use crate::declare::{ContractDeclaration, InterfaceSpec, ModuleSpec, IMPLICIT_MODULE}; +use crate::identity::{InterfaceName, ModuleName}; +use crate::manifest::{Binding, InterfaceEntry, ModuleEntry, ModuleTable}; +use crate::validate::diagnostic::{DeclarationPath, Diagnostic, DiagnosticKind}; +use crate::validate::merge::dedupe; + +pub(super) fn validate_modules( + declaration: &ContractDeclaration, + diagnostics: &mut Vec, +) -> ModuleTable { + let mut modules = dedupe( + &declaration.modules, + |a, b| a.name == b.name, + |spec| spec.origin, + |a, b| sorted_uses(a) == sorted_uses(b), + |kept, next| { + for interface in &next.uses { + if !kept.uses.contains(interface) { + kept.uses.push(interface.clone()); + } + } + }, + |spec| DeclarationPath::module(&spec.name), + "module", + || DiagnosticKind::DuplicateModule, + diagnostics, + ); + if modules.is_empty() { + modules.push(ModuleSpec::new( + ModuleName::new(IMPLICIT_MODULE) + .expect("the implicit module name satisfies the grammar"), + )); + } + + let interfaces = dedupe( + &declaration.interfaces, + |a, b| a.name == b.name, + |spec| spec.origin, + |a, b| a.provider == b.provider && sorted_functions(a) == sorted_functions(b), + |_, _| {}, + |spec| DeclarationPath::interface(&spec.name), + "interface", + || DiagnosticKind::DuplicateInterface, + diagnostics, + ); + + let module_names: Vec<&ModuleName> = modules.iter().map(|module| &module.name).collect(); + + for interface in &interfaces { + if !module_names.contains(&&interface.provider) { + diagnostics.push(Diagnostic::new( + DeclarationPath::interface(&interface.name), + DiagnosticKind::InterfaceProviderUnknown { + module: interface.provider.to_string(), + }, + )); + } + let mut seen: Vec<&str> = Vec::new(); + for function in &interface.functions { + if seen.contains(&function.name.as_str()) { + diagnostics.push(Diagnostic::new( + DeclarationPath::interface(&interface.name), + DiagnosticKind::DuplicateInterfaceFunction { + function: function.name.clone(), + }, + )); + } else { + seen.push(&function.name); + } + let mut params: Vec<&str> = Vec::new(); + for param in &function.params { + if params.contains(¶m.name.as_str()) { + diagnostics.push(Diagnostic::new( + DeclarationPath::interface(&interface.name), + DiagnosticKind::DuplicateParameter { + param: param.name.clone(), + }, + )); + } else { + params.push(¶m.name); + } + } + } + } + + let mut bindings: Vec = Vec::new(); + for module in &modules { + for used in &module.uses { + let Some(interface) = interfaces.iter().find(|interface| &interface.name == used) + else { + diagnostics.push(Diagnostic::new( + DeclarationPath::module(&module.name), + DiagnosticKind::UsedInterfaceUnknown { + interface: used.to_string(), + }, + )); + continue; + }; + if interface.provider == module.name { + diagnostics.push(Diagnostic::new( + DeclarationPath::module(&module.name), + DiagnosticKind::ModuleSelfImport { + interface: used.to_string(), + }, + )); + continue; + } + let binding = Binding { + importer: module.name.clone(), + provider: interface.provider.clone(), + interface: used.clone(), + }; + if !bindings.contains(&binding) { + bindings.push(binding); + } + } + } + + if let Some(cycle) = find_cycle(&module_names, &bindings) { + diagnostics.push(Diagnostic::new( + DeclarationPath::module(&cycle[0]), + DiagnosticKind::ModuleGraphCycle { + modules: cycle.iter().map(|name| name.to_string()).collect(), + }, + )); + } + + let mut module_entries: Vec = modules + .iter() + .map(|module| ModuleEntry { + name: module.name.clone(), + uses: sorted_uses(module), + }) + .collect(); + module_entries.sort_by(|a, b| a.name.cmp(&b.name)); + + let mut interface_entries: Vec = interfaces + .iter() + .map(|interface| InterfaceEntry { + name: interface.name.clone(), + provider: interface.provider.clone(), + functions: sorted_functions(interface), + }) + .collect(); + interface_entries.sort_by(|a, b| a.name.cmp(&b.name)); + + bindings.sort(); + + ModuleTable { + modules: module_entries, + interfaces: interface_entries, + bindings, + } +} + +fn sorted_uses(module: &ModuleSpec) -> Vec { + let mut uses = module.uses.clone(); + uses.sort(); + uses.dedup(); + uses +} + +fn sorted_functions(interface: &InterfaceSpec) -> Vec { + let mut functions = interface.functions.clone(); + functions.sort_by(|a, b| a.name.cmp(&b.name)); + functions +} + +/// Depth-first search over the importer-to-provider edges; returns the +/// modules on the first cycle found, starting and ending at the same module +/// omitted. +fn find_cycle(modules: &[&ModuleName], bindings: &[Binding]) -> Option> { + #[derive(Clone, Copy, PartialEq)] + enum Mark { + Unvisited, + Active, + Done, + } + + fn visit( + node: usize, + modules: &[&ModuleName], + bindings: &[Binding], + marks: &mut [Mark], + stack: &mut Vec, + ) -> Option> { + marks[node] = Mark::Active; + stack.push(node); + for binding in bindings + .iter() + .filter(|binding| &binding.importer == modules[node]) + { + let Some(next) = modules + .iter() + .position(|module| **module == binding.provider) + else { + continue; + }; + match marks[next] { + Mark::Active => { + let start = stack.iter().position(|&index| index == next).unwrap_or(0); + return Some( + stack[start..] + .iter() + .map(|&index| modules[index].clone()) + .collect(), + ); + } + Mark::Unvisited => { + if let Some(cycle) = visit(next, modules, bindings, marks, stack) { + return Some(cycle); + } + } + Mark::Done => {} + } + } + stack.pop(); + marks[node] = Mark::Done; + None + } + + let mut marks = alloc::vec![Mark::Unvisited; modules.len()]; + let mut stack = Vec::new(); + for node in 0..modules.len() { + if marks[node] == Mark::Unvisited { + if let Some(cycle) = visit(node, modules, bindings, &mut marks, &mut stack) { + return Some(cycle); + } + } + } + None +} diff --git a/packages/rs-dash-sdk-contract/src/validate/rules.rs b/packages/rs-dash-sdk-contract/src/validate/rules.rs new file mode 100644 index 00000000000..d0f8fb618dd --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/validate/rules.rs @@ -0,0 +1,106 @@ +//! Rule checks: identity, scope, kind and guard field references. + +use alloc::string::ToString; +use alloc::vec::Vec; + +use crate::declare::{ContractDeclaration, FieldContext, RuleKind}; +use crate::manifest::{CollectionManifest, ModuleTable, RuleManifest}; +use crate::validate::collections::has_property_path; +use crate::validate::diagnostic::{DeclarationPath, Diagnostic, DiagnosticKind}; +use crate::validate::merge::dedupe; + +pub(super) fn validate_rules( + declaration: &ContractDeclaration, + modules: &ModuleTable, + collections: &[CollectionManifest], + diagnostics: &mut Vec, +) -> Vec { + let rules = dedupe( + &declaration.rules, + |a, b| a.collection == b.collection && a.name == b.name, + |spec| spec.origin, + |a, b| { + let mut left = a.clone(); + left.origin = b.origin; + left.actions.sort(); + left.actions.dedup(); + let mut right = b.clone(); + right.actions.sort(); + right.actions.dedup(); + left == right + }, + |_, _| {}, + |spec| DeclarationPath::rule(&spec.collection, &spec.name), + "rule", + || DiagnosticKind::DuplicateRule, + diagnostics, + ); + + let mut manifests: Vec = rules + .iter() + .map(|rule| { + let path = DeclarationPath::rule(&rule.collection, &rule.name); + let collection = collections + .iter() + .find(|collection| collection.name == rule.collection); + if collection.is_none() { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::RuleCollectionUnknown { + collection: rule.collection.to_string(), + }, + )); + } + let mut actions = rule.actions.clone(); + actions.sort(); + actions.dedup(); + if actions.is_empty() { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::RuleWithoutActions, + )); + } + match &rule.kind { + RuleKind::NativeGuard(guard) => { + if let Some(collection) = collection { + for (context, property) in guard.field_references() { + let known = match context { + FieldContext::Old | FieldContext::New => { + has_property_path(&collection.fields, property) + } + // The action context's fields are host defined. + FieldContext::Context => true, + }; + if !known { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::GuardFieldUnknown { + property: property.to_string(), + }, + )); + } + } + } + } + RuleKind::WasmPredicate { module, .. } => { + if !modules.names().any(|name| name == module) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::PredicateModuleUnknown { + module: module.to_string(), + }, + )); + } + } + } + RuleManifest { + collection: rule.collection.clone(), + name: rule.name.clone(), + actions, + kind: rule.kind.clone(), + } + }) + .collect(); + manifests.sort_by(|a, b| (&a.collection, &a.name).cmp(&(&b.collection, &b.name))); + manifests +} diff --git a/packages/rs-dash-sdk-contract/src/validate/tests.rs b/packages/rs-dash-sdk-contract/src/validate/tests.rs new file mode 100644 index 00000000000..aa9360857c0 --- /dev/null +++ b/packages/rs-dash-sdk-contract/src/validate/tests.rs @@ -0,0 +1,1386 @@ +//! Validator tests: the sketch, one `should_report_*` test per producible +//! diagnostic, sugar expansion and manifest order independence. + +use alloc::string::ToString; +use alloc::vec; +use alloc::vec::Vec; + +use crate::declare::*; +use crate::identity::*; +use crate::manifest::CanonicalManifest; +use crate::validate::{validate, Diagnostic, DiagnosticKind}; + +fn collection(name: &str) -> CollectionName { + CollectionName::new(name).unwrap() +} + +fn property(name: &str) -> PropertyName { + PropertyName::new(name).unwrap() +} + +fn path(name: &str) -> PropertyPath { + PropertyPath::new(name).unwrap() +} + +fn index_name(name: &str) -> IndexName { + IndexName::new(name).unwrap() +} + +fn method(name: &str) -> MethodName { + MethodName::new(name).unwrap() +} + +fn module(name: &str) -> ModuleName { + ModuleName::new(name).unwrap() +} + +fn interface(name: &str) -> InterfaceName { + InterfaceName::new(name).unwrap() +} + +fn rule(name: &str) -> RuleName { + RuleName::new(name).unwrap() +} + +/// The `Score` collection of the issue's API sketch, with the ascending-only +/// correction (`points = "desc"` is not expressible natively) and the given +/// write policy. +pub(crate) fn score_collection(write: WritePolicy) -> CollectionSpec { + CollectionSpec::documents(collection("scores")) + .with_origin(DeclarationOrigin::Attribute) + .schema_revision(1) + .write(write) + .document_id_field("id") + .field(FieldSpec::new(property("class"), 0, FieldType::string(64))) + .field(FieldSpec::new( + property("points"), + 1, + FieldType::bounded_integer(IntegerWidth::I64, 0, 1_000_000), + )) + .field(FieldSpec::new(property("owner"), 2, FieldType::identity())) + .index( + IndexSpec::new(index_name("by_class"), vec![path("class")]) + .with_origin(DeclarationOrigin::Attribute) + .count() + .sum(property("points")), + ) + .index( + IndexSpec::new(index_name("by_owner"), vec![path("owner")]) + .with_origin(DeclarationOrigin::Attribute), + ) + .index( + IndexSpec::new(index_name("ranking"), vec![path("class"), path("points")]) + .with_origin(DeclarationOrigin::Attribute) + .ranked_count(), + ) +} + +/// The sketch's entries: a mutable receiver, a free creator, a free pair edit +/// and a read-only aggregate. +pub(crate) fn score_entries() -> Vec { + vec![ + EntrySpec::new(method("score.add")) + .with_origin(DeclarationOrigin::Attribute) + .receiver(Receiver::Mut(collection("scores"))) + .param("delta", ValueType::Integer(IntegerWidth::I64)), + EntrySpec::new(method("score.create")) + .with_origin(DeclarationOrigin::Attribute) + .param("class", ValueType::string(64)) + .param("points", ValueType::Integer(IntegerWidth::I64)) + .returns(ValueType::DocumentId), + EntrySpec::new(method("score.add_pair")) + .with_origin(DeclarationOrigin::Attribute) + .param("first", ValueType::DocumentId) + .param("second", ValueType::DocumentId), + EntrySpec::new(method("score.total")) + .with_origin(DeclarationOrigin::Attribute) + .read_only(true) + .param("class", ValueType::string(64)) + .returns(ValueType::Struct(vec![ + ("count".to_string(), ValueType::Integer(IntegerWidth::U64)), + ("sum".to_string(), ValueType::Integer(IntegerWidth::I64)), + ])), + ] +} + +pub(crate) fn score_sketch(write: WritePolicy) -> ContractDeclaration { + let mut declaration = ContractDeclaration::new().collection(score_collection(write)); + for entry in score_entries() { + declaration = declaration.entry(entry); + } + declaration.require(CapabilityRequirement::Acl) +} + +fn kinds(diagnostics: &[Diagnostic]) -> Vec<&'static str> { + diagnostics.iter().map(|d| d.kind.name()).collect() +} + +fn expect_diagnostics(declaration: &ContractDeclaration) -> Vec { + match validate(declaration) { + Ok(_) => panic!("expected diagnostics"), + Err(diagnostics) => diagnostics, + } +} + +fn expect_manifest(declaration: &ContractDeclaration) -> CanonicalManifest { + match validate(declaration) { + Ok(manifest) => manifest, + Err(diagnostics) => panic!("unexpected diagnostics: {diagnostics:#?}"), + } +} + +fn assert_reports(declaration: &ContractDeclaration, kind: &str) -> Vec { + let diagnostics = expect_diagnostics(declaration); + assert!( + kinds(&diagnostics).contains(&kind), + "expected {kind}, got {:?}", + kinds(&diagnostics) + ); + diagnostics +} + +fn minimal(name: &str) -> CollectionSpec { + CollectionSpec::documents(collection(name)) + .document_id_field("id") + .field(FieldSpec::new(property("a"), 0, FieldType::Bool)) +} + +#[test] +fn should_validate_the_sketch_with_owner_writes() { + let manifest = expect_manifest(&score_sketch(WritePolicy::Owner)); + let scores = manifest.collection("scores").unwrap(); + assert_eq!(scores.indexes.len(), 3); + let by_class = scores.index("by_class").unwrap(); + assert_eq!(by_class.count, Countability::Countable); + assert_eq!(by_class.sum, Some(property("points"))); + assert_eq!( + scores.index("ranking").unwrap().ranked.count, + RankedCount::Terminal + ); + let names: Vec<&str> = manifest.methods.names().map(|n| n.as_str()).collect(); + assert_eq!( + names, + ["score.add", "score.add_pair", "score.create", "score.total"] + ); + let add = manifest.method("score.add").unwrap(); + assert_eq!(add.module.as_str(), "main"); + assert_eq!(add.export, "dash_entry_score.add"); + assert!(add.takes_document_id); + assert!(!manifest.method("score.create").unwrap().takes_document_id); + assert!(manifest.methods.entry("score.total").unwrap().read_only); + assert!(manifest.capabilities.requires(CapabilityRequirement::Acl)); + assert!(manifest + .capabilities + .requires(CapabilityRequirement::Entries)); + assert!(manifest + .capabilities + .requires(CapabilityRequirement::StoredReceipts)); + assert!(!manifest + .capabilities + .requires(CapabilityRequirement::ContractWrites)); + assert!(!manifest + .capabilities + .requires(CapabilityRequirement::Modules)); + assert_eq!(manifest.receipts, ReceiptPolicy::Stored); +} + +#[test] +fn should_validate_the_sketch_with_contract_writes_as_a_derived_requirement() { + let manifest = expect_manifest(&score_sketch(WritePolicy::Contract)); + assert!(manifest + .capabilities + .requires(CapabilityRequirement::ContractWrites)); + assert!(manifest + .capabilities + .pending() + .any(|entry| entry.requirement == CapabilityRequirement::ContractWrites)); +} + +#[test] +fn should_validate_an_empty_declaration_to_the_implicit_module() { + let manifest = expect_manifest(&ContractDeclaration::new()); + let names: Vec<&str> = manifest.modules.names().map(|n| n.as_str()).collect(); + assert_eq!(names, ["main"]); + assert!(manifest.methods.entries.is_empty()); + assert!(!manifest + .capabilities + .requires(CapabilityRequirement::Entries)); +} + +// Conflicts and duplicates + +#[test] +fn should_report_conflicting_declaration_between_attribute_and_builder() { + let attribute = minimal("scores").with_origin(DeclarationOrigin::Attribute); + let builder = minimal("scores") + .with_origin(DeclarationOrigin::Builder) + .mutable(false); + let declaration = ContractDeclaration::new() + .collection(attribute) + .collection(builder); + let diagnostics = assert_reports(&declaration, "ConflictingDeclaration"); + let DiagnosticKind::ConflictingDeclaration { first, second, .. } = &diagnostics[0].kind else { + panic!() + }; + assert_eq!(*first, DeclarationOrigin::Attribute); + assert_eq!(*second, DeclarationOrigin::Builder); +} + +#[test] +fn should_let_a_builder_extend_an_attribute_collection_with_indexes() { + let attribute = minimal("scores").with_origin(DeclarationOrigin::Attribute); + let builder = minimal("scores") + .with_origin(DeclarationOrigin::Builder) + .index(IndexSpec::new(index_name("by_a"), vec![path("a")])); + let declaration = ContractDeclaration::new() + .collection(attribute) + .collection(builder); + let manifest = expect_manifest(&declaration); + assert_eq!(manifest.collection("scores").unwrap().indexes.len(), 1); +} + +#[test] +fn should_report_duplicate_collection() { + let declaration = ContractDeclaration::new() + .collection(minimal("scores")) + .collection(minimal("scores")); + assert_reports(&declaration, "DuplicateCollection"); +} + +#[test] +fn should_report_duplicate_collection_between_typed_and_document_collections() { + let declaration = ContractDeclaration::new() + .collection(minimal("totals")) + .typed_collection(TypedCollectionSpec::new( + collection("totals"), + TypedCollectionKind::Sum, + ValueType::Identifier, + ValueType::Integer(IntegerWidth::I64), + )); + assert_reports(&declaration, "DuplicateCollection"); +} + +#[test] +fn should_report_duplicate_index() { + let spec = minimal("scores") + .index(IndexSpec::new(index_name("by_a"), vec![path("a")])) + .index(IndexSpec::new(index_name("by_a"), vec![path("a")]).unique(true)); + assert_reports( + &ContractDeclaration::new().collection(spec), + "DuplicateIndex", + ); +} + +#[test] +fn should_report_duplicate_method() { + let declaration = ContractDeclaration::new() + .entry(EntrySpec::new(method("a"))) + .entry(EntrySpec::new(method("a")).read_only(true)); + assert_reports(&declaration, "DuplicateMethod"); +} + +#[test] +fn should_report_duplicate_rule() { + let guard = GuardExpr::boolean(true); + let declaration = ContractDeclaration::new() + .collection(minimal("scores")) + .rule(RuleSpec::guard( + collection("scores"), + rule("r"), + vec![ActionScope::Create], + guard.clone(), + )) + .rule(RuleSpec::guard( + collection("scores"), + rule("r"), + vec![ActionScope::Delete], + guard, + )); + assert_reports(&declaration, "DuplicateRule"); +} + +#[test] +fn should_report_duplicate_module() { + let declaration = ContractDeclaration::new() + .module(ModuleSpec::new(module("main"))) + .module(ModuleSpec::new(module("main"))); + assert_reports(&declaration, "DuplicateModule"); +} + +#[test] +fn should_report_duplicate_interface() { + let declaration = ContractDeclaration::new() + .module(ModuleSpec::new(module("main"))) + .interface(InterfaceSpec::new(interface("math"), module("main"))) + .interface(InterfaceSpec::new(interface("math"), module("main"))); + assert_reports(&declaration, "DuplicateInterface"); +} + +#[test] +fn should_report_duplicate_property() { + let spec = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new(property("a"), 0, FieldType::Bool)) + .field(FieldSpec::new(property("a"), 1, FieldType::Bool)); + assert_reports( + &ContractDeclaration::new().collection(spec), + "DuplicateProperty", + ); +} + +#[test] +fn should_report_duplicate_position() { + let spec = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new(property("a"), 0, FieldType::Bool)) + .field(FieldSpec::new(property("b"), 0, FieldType::Bool)); + assert_reports( + &ContractDeclaration::new().collection(spec), + "DuplicatePosition", + ); +} + +#[test] +fn should_report_non_contiguous_positions_at_nested_levels() { + let spec = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new( + property("profile"), + 0, + FieldType::Object(vec![ + FieldSpec::new(property("age"), 0, FieldType::integer(IntegerWidth::U8)), + FieldSpec::new(property("name"), 2, FieldType::string(10)), + ]), + )); + let diagnostics = assert_reports( + &ContractDeclaration::new().collection(spec), + "NonContiguousPositions", + ); + assert_eq!( + diagnostics[0].path().to_string(), + "collection c, field profile" + ); +} + +#[test] +fn should_report_duplicate_token_cost_action() { + let spec = minimal("c") + .token_cost(ActionScope::Create, TokenCost::new(0, 1)) + .token_cost(ActionScope::Create, TokenCost::new(0, 2)); + assert_reports( + &ContractDeclaration::new().collection(spec), + "DuplicateTokenCostAction", + ); +} + +#[test] +fn should_report_duplicate_export_symbol_when_the_scheme_collides() { + // The scheme is injective today, so the diagnostic is reached through the + // method identity check: identical names are `DuplicateMethod`, and the + // export check sees the same symbol twice. + let declaration = ContractDeclaration::new() + .entry(EntrySpec::new(method("a.b")).with_origin(DeclarationOrigin::Attribute)) + .entry( + EntrySpec::new(method("a.b")) + .with_origin(DeclarationOrigin::Builder) + .read_only(true), + ); + let diagnostics = expect_diagnostics(&declaration); + assert_eq!(kinds(&diagnostics), ["ConflictingDeclaration"]); + let declaration = ContractDeclaration::new() + .entry(EntrySpec::new(method("a.b"))) + .entry(EntrySpec::new(method("a__b"))); + let manifest = expect_manifest(&declaration); + assert_eq!(manifest.methods.entries.len(), 2); + assert_ne!( + manifest.method("a.b").unwrap().export, + manifest.method("a__b").unwrap().export + ); +} + +// Names and bounds + +#[test] +fn should_report_invalid_name_through_the_newtypes() { + let error = CollectionName::new("bad name").unwrap_err(); + let diagnostic = Diagnostic::invalid_name( + crate::validate::DeclarationPath::collection("bad name"), + error, + ); + assert_eq!(diagnostic.kind.name(), "InvalidName"); + assert_eq!(diagnostic.code(), "DSC0020"); +} + +#[test] +fn should_report_unbounded_field_for_strings_bytes_and_lists() { + let spec = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new( + property("s"), + 0, + FieldType::String { + min_chars: None, + max_chars: None, + }, + )) + .field(FieldSpec::new( + property("b"), + 1, + FieldType::Bytes { + min_len: None, + max_len: None, + }, + )); + let declaration = ContractDeclaration::new().collection(spec).entry( + EntrySpec::new(method("f")) + .param( + "items", + ValueType::List { + max_len: None, + item: alloc::boxed::Box::new(ValueType::Bool), + }, + ) + .returns(ValueType::String { max_chars: None }), + ); + let diagnostics = expect_diagnostics(&declaration); + assert_eq!( + kinds(&diagnostics), + [ + "UnboundedField", + "UnboundedField", + "UnboundedField", + "UnboundedField" + ] + ); +} + +#[test] +fn should_report_integer_bounds_outside_type() { + let spec = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new( + property("a"), + 0, + FieldType::bounded_integer(IntegerWidth::U8, 0, 256), + )) + .field(FieldSpec::new( + property("b"), + 1, + FieldType::bounded_integer(IntegerWidth::I32, 10, 1), + )) + .field(FieldSpec::new( + property("c"), + 2, + FieldType::bounded_integer(IntegerWidth::U16, -1, 5), + )); + let diagnostics = expect_diagnostics(&ContractDeclaration::new().collection(spec)); + assert_eq!( + kinds(&diagnostics), + [ + "IntegerBoundsOutsideType", + "IntegerBoundsOutsideType", + "IntegerBoundsOutsideType" + ] + ); +} + +#[test] +fn should_report_integer_bound_not_natively_representable() { + let spec = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new( + property("a"), + 0, + FieldType::bounded_integer(IntegerWidth::U64, 0, i64::MAX as i128 + 1), + )); + let diagnostics = expect_diagnostics(&ContractDeclaration::new().collection(spec)); + assert_eq!( + kinds(&diagnostics), + ["IntegerBoundNotNativelyRepresentable"] + ); + let spec = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new( + property("a"), + 0, + FieldType::integer(IntegerWidth::U64), + )); + expect_manifest(&ContractDeclaration::new().collection(spec)); +} + +#[test] +fn should_report_invalid_schema_revision() { + let declaration = ContractDeclaration::new().collection(minimal("c").schema_revision(0)); + assert_reports(&declaration, "InvalidSchemaRevision"); +} + +// Cross-references + +#[test] +fn should_report_index_property_unknown() { + let spec = minimal("c").index(IndexSpec::new(index_name("i"), vec![path("missing")])); + assert_reports( + &ContractDeclaration::new().collection(spec), + "IndexPropertyUnknown", + ); +} + +#[test] +fn should_accept_system_properties_and_nested_paths_in_indexes() { + let spec = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new( + property("profile"), + 0, + FieldType::Object(vec![FieldSpec::new( + property("age"), + 0, + FieldType::integer(IntegerWidth::U8), + )]), + )) + .index(IndexSpec::new( + index_name("i"), + vec![path("$ownerId"), path("profile.age"), path("$createdAt")], + )); + expect_manifest(&ContractDeclaration::new().collection(spec)); +} + +#[test] +fn should_report_summable_property_unknown_at_index_and_collection_level() { + let spec = minimal("c") + .sum(property("missing")) + .index(IndexSpec::new(index_name("i"), vec![path("a")]).sum(property("gone"))); + let diagnostics = expect_diagnostics(&ContractDeclaration::new().collection(spec)); + assert_eq!( + kinds(&diagnostics), + ["SummablePropertyUnknown", "SummablePropertyUnknown"] + ); +} + +#[test] +fn should_report_contested_field_not_indexed() { + let spec = minimal("c").index( + IndexSpec::new(index_name("i"), vec![path("a")]) + .unique(true) + .contested(ContestedSpec::masternode_vote(vec![( + path("other"), + "^.*$".to_string(), + )])), + ); + assert_reports( + &ContractDeclaration::new().collection(spec), + "ContestedFieldNotIndexed", + ); +} + +#[test] +fn should_accept_a_contested_index_next_to_a_create_rule() { + let spec = CollectionSpec::documents(collection("names")) + .document_id_field("id") + .field(FieldSpec::new(property("label"), 0, FieldType::string(63))) + .index( + IndexSpec::new(index_name("by_label"), vec![path("label")]) + .unique(true) + .contested( + ContestedSpec::masternode_vote(vec![( + path("label"), + "^[a-z]{3,19}$".to_string(), + )]) + .description("short names are contested"), + ), + ); + let declaration = ContractDeclaration::new() + .collection(spec) + .rule(RuleSpec::guard( + collection("names"), + rule("label_present"), + vec![ActionScope::Create], + GuardExpr::Exists(FieldContext::New, path("label")), + )); + let manifest = expect_manifest(&declaration); + assert_eq!(manifest.rules.len(), 1); + assert!(manifest + .collection("names") + .unwrap() + .index("by_label") + .unwrap() + .contested + .is_some()); +} + +#[test] +fn should_report_time_range_source_not_first() { + let spec = minimal("c").index( + IndexSpec::new(index_name("i"), vec![path("a"), path("$createdAt")]).time_range( + TimeRangeSpec { + on: path("$createdAt"), + range_secs: 3600, + step_secs: 60, + phase_secs: 0, + }, + ), + ); + assert_reports( + &ContractDeclaration::new().collection(spec), + "TimeRangeSourceNotFirst", + ); +} + +#[test] +fn should_report_terminal_not_a_property() { + let spec = CollectionSpec::documents(collection("likes")) + .document_id_field("id") + .mutable(false) + .index_only(true) + .field(FieldSpec::new(property("post"), 0, FieldType::identity())) + .index( + IndexSpec::new(index_name("by_post"), vec![path("post")]).index_only(IndexOnlySpec { + terminal: Some(path("$createdAt")), + preallocated: false, + skip_if_absent: false, + }), + ) + .index( + IndexSpec::new(index_name("by_post2"), vec![path("post")]).index_only(IndexOnlySpec { + terminal: Some(path("liker")), + preallocated: false, + skip_if_absent: false, + }), + ); + let diagnostics = expect_diagnostics(&ContractDeclaration::new().collection(spec)); + assert_eq!( + kinds(&diagnostics), + ["TerminalNotAProperty", "TerminalNotAProperty"] + ); +} + +#[test] +fn should_report_ranked_level_not_indexed() { + let spec = minimal("c").index( + IndexSpec::new(index_name("i"), vec![path("a")]) + .count() + .range_count(true) + .ranked_count_at(vec![path("missing")]), + ); + assert_reports( + &ContractDeclaration::new().collection(spec), + "RankedLevelNotIndexed", + ); +} + +#[test] +fn should_report_index_only_option_on_stored_collection() { + let spec = minimal("c").index(IndexSpec::new(index_name("i"), vec![path("a")]).index_only( + IndexOnlySpec { + terminal: None, + preallocated: true, + skip_if_absent: false, + }, + )); + assert_reports( + &ContractDeclaration::new().collection(spec), + "IndexOnlyOptionOnStoredCollection", + ); +} + +#[test] +fn should_report_reference_collection_unknown_for_same_contract_permanent_documents() { + let spec = CollectionSpec::documents(collection("likes")) + .document_id_field("id") + .field(FieldSpec::new( + property("post"), + 0, + FieldType::Reference(ReferenceTarget::PermanentDocument { + contract: None, + document_type: collection("posts"), + agreement: vec![], + }), + )); + assert_reports( + &ContractDeclaration::new().collection(spec), + "ReferenceCollectionUnknown", + ); +} + +#[test] +fn should_report_reference_property_unknown_for_agreements_and_key_ids() { + let posts = CollectionSpec::documents(collection("posts")) + .document_id_field("id") + .deletable(false) + .field(FieldSpec::new(property("author"), 0, FieldType::identity())); + let likes = CollectionSpec::documents(collection("likes")) + .document_id_field("id") + .field(FieldSpec::new( + property("post"), + 0, + FieldType::Reference(ReferenceTarget::PermanentDocument { + contract: None, + document_type: collection("posts"), + agreement: vec![(path("missing_here"), path("missing_there"))], + }), + )) + .field(FieldSpec::new( + property("signer"), + 1, + FieldType::Reference(ReferenceTarget::IdentityPublicKey { + key_id_field: path("key_id"), + }), + )); + let diagnostics = expect_diagnostics( + &ContractDeclaration::new() + .collection(posts) + .collection(likes), + ); + assert_eq!( + kinds(&diagnostics), + [ + "ReferencePropertyUnknown", + "ReferencePropertyUnknown", + "ReferencePropertyUnknown" + ] + ); +} + +#[test] +fn should_accept_a_cross_contract_permanent_document_reference_without_checking_it() { + let likes = CollectionSpec::documents(collection("likes")) + .document_id_field("id") + .field(FieldSpec::new( + property("post"), + 0, + FieldType::Reference(ReferenceTarget::PermanentDocument { + contract: Some([7; 32]), + document_type: collection("posts"), + agreement: vec![], + }), + )); + expect_manifest(&ContractDeclaration::new().collection(likes)); +} + +// Collection kinds + +#[test] +fn should_report_singleton_with_indexes() { + let spec = CollectionSpec::singleton(collection("config")) + .field(FieldSpec::new(property("a"), 0, FieldType::Bool)) + .index(IndexSpec::new(index_name("i"), vec![path("a")])); + assert_reports( + &ContractDeclaration::new().collection(spec), + "SingletonWithIndexes", + ); +} + +#[test] +fn should_report_singleton_with_document_id_field() { + let spec = CollectionSpec::singleton(collection("config")) + .document_id_field("id") + .field(FieldSpec::new(property("a"), 0, FieldType::Bool)); + assert_reports( + &ContractDeclaration::new().collection(spec), + "SingletonWithDocumentIdField", + ); +} + +#[test] +fn should_report_missing_document_id_field() { + let spec = CollectionSpec::documents(collection("c")).field(FieldSpec::new( + property("a"), + 0, + FieldType::Bool, + )); + assert_reports( + &ContractDeclaration::new().collection(spec), + "MissingDocumentIdField", + ); +} + +#[test] +fn should_accept_a_singleton_receiver_without_a_document_id_on_the_wire() { + let config = CollectionSpec::singleton(collection("config")).field(FieldSpec::new( + property("a"), + 0, + FieldType::Bool, + )); + let declaration = ContractDeclaration::new().collection(config).entry( + EntrySpec::new(method("config.set")) + .receiver(Receiver::Mut(collection("config"))) + .param("a", ValueType::Bool), + ); + let manifest = expect_manifest(&declaration); + assert!(!manifest.method("config.set").unwrap().takes_document_id); +} + +// Entries + +#[test] +fn should_report_receiver_collection_unknown() { + let declaration = ContractDeclaration::new() + .entry(EntrySpec::new(method("f")).receiver(Receiver::Ref(collection("missing")))); + assert_reports(&declaration, "ReceiverCollectionUnknown"); +} + +#[test] +fn should_report_mutable_receiver_on_immutable_collection() { + let declaration = ContractDeclaration::new() + .collection(minimal("frozen").mutable(false)) + .entry(EntrySpec::new(method("f")).receiver(Receiver::Mut(collection("frozen")))); + assert_reports(&declaration, "MutableReceiverOnImmutableCollection"); + let declaration = ContractDeclaration::new() + .collection(minimal("frozen").mutable(false)) + .entry(EntrySpec::new(method("f")).receiver(Receiver::Ref(collection("frozen")))); + expect_manifest(&declaration); +} + +#[test] +fn should_report_read_only_entry_with_mutable_receiver() { + let declaration = ContractDeclaration::new().collection(minimal("c")).entry( + EntrySpec::new(method("f")) + .receiver(Receiver::Mut(collection("c"))) + .read_only(true), + ); + assert_reports(&declaration, "ReadOnlyEntryWithMutableReceiver"); +} + +#[test] +fn should_report_duplicate_parameter() { + let declaration = ContractDeclaration::new().entry( + EntrySpec::new(method("f")) + .param("a", ValueType::Bool) + .param("a", ValueType::Bool), + ); + assert_reports(&declaration, "DuplicateParameter"); +} + +#[test] +fn should_report_entry_module_unknown() { + let declaration = ContractDeclaration::new() + .module(ModuleSpec::new(module("main"))) + .entry(EntrySpec::new(method("f")).module(module("helpers"))); + assert_reports(&declaration, "EntryModuleUnknown"); +} + +#[test] +fn should_report_entry_module_required_with_several_modules() { + let declaration = ContractDeclaration::new() + .module(ModuleSpec::new(module("main"))) + .module(ModuleSpec::new(module("helpers"))) + .entry(EntrySpec::new(method("f"))); + assert_reports(&declaration, "EntryModuleRequired"); +} + +// Modules + +#[test] +fn should_report_interface_provider_unknown() { + let declaration = ContractDeclaration::new() + .module(ModuleSpec::new(module("main"))) + .interface(InterfaceSpec::new(interface("math"), module("ghost"))); + assert_reports(&declaration, "InterfaceProviderUnknown"); +} + +#[test] +fn should_report_used_interface_unknown() { + let declaration = + ContractDeclaration::new().module(ModuleSpec::new(module("main")).uses(interface("math"))); + assert_reports(&declaration, "UsedInterfaceUnknown"); +} + +#[test] +fn should_report_module_self_import() { + let declaration = ContractDeclaration::new() + .module(ModuleSpec::new(module("main")).uses(interface("math"))) + .interface(InterfaceSpec::new(interface("math"), module("main"))); + assert_reports(&declaration, "ModuleSelfImport"); +} + +#[test] +fn should_report_module_graph_cycle() { + let declaration = ContractDeclaration::new() + .module(ModuleSpec::new(module("a")).uses(interface("from_b"))) + .module(ModuleSpec::new(module("b")).uses(interface("from_c"))) + .module(ModuleSpec::new(module("c")).uses(interface("from_a"))) + .interface(InterfaceSpec::new(interface("from_a"), module("a"))) + .interface(InterfaceSpec::new(interface("from_b"), module("b"))) + .interface(InterfaceSpec::new(interface("from_c"), module("c"))); + let diagnostics = assert_reports(&declaration, "ModuleGraphCycle"); + let DiagnosticKind::ModuleGraphCycle { modules } = &diagnostics[0].kind else { + panic!() + }; + assert_eq!(modules.len(), 3); +} + +#[test] +fn should_report_duplicate_interface_function() { + let declaration = ContractDeclaration::new() + .module(ModuleSpec::new(module("main"))) + .interface( + InterfaceSpec::new(interface("math"), module("main")) + .function("add", vec![], ValueType::Unit) + .function("add", vec![], ValueType::Bool), + ); + assert_reports(&declaration, "DuplicateInterfaceFunction"); +} + +#[test] +fn should_bind_a_dag_of_modules_and_sort_bindings() { + let declaration = ContractDeclaration::new() + .module( + ModuleSpec::new(module("main")) + .uses(interface("math")) + .uses(interface("text")), + ) + .module(ModuleSpec::new(module("helpers")).uses(interface("text"))) + .module(ModuleSpec::new(module("core"))) + .interface(InterfaceSpec::new(interface("math"), module("helpers"))) + .interface(InterfaceSpec::new(interface("text"), module("core"))) + .entry(EntrySpec::new(method("f")).module(module("main"))); + let manifest = expect_manifest(&declaration); + let bindings: Vec<(&str, &str, &str)> = manifest + .modules + .bindings + .iter() + .map(|b| { + ( + b.importer.as_str(), + b.provider.as_str(), + b.interface.as_str(), + ) + }) + .collect(); + assert_eq!( + bindings, + [ + ("helpers", "core", "text"), + ("main", "core", "text"), + ("main", "helpers", "math") + ] + ); + assert!(manifest + .capabilities + .requires(CapabilityRequirement::Modules)); +} + +// Rules + +#[test] +fn should_report_predicate_module_unknown() { + let declaration = + ContractDeclaration::new() + .collection(minimal("c")) + .rule(RuleSpec::predicate( + collection("c"), + rule("p"), + vec![ActionScope::Create], + module("ghost"), + "check", + )); + assert_reports(&declaration, "PredicateModuleUnknown"); +} + +#[test] +fn should_report_rule_collection_unknown() { + let declaration = ContractDeclaration::new().rule(RuleSpec::guard( + collection("ghost"), + rule("r"), + vec![ActionScope::Create], + GuardExpr::boolean(true), + )); + assert_reports(&declaration, "RuleCollectionUnknown"); +} + +#[test] +fn should_report_rule_without_actions() { + let declaration = ContractDeclaration::new() + .collection(minimal("c")) + .rule(RuleSpec::guard( + collection("c"), + rule("r"), + vec![], + GuardExpr::boolean(true), + )); + assert_reports(&declaration, "RuleWithoutActions"); +} + +#[test] +fn should_report_guard_field_unknown() { + let declaration = ContractDeclaration::new() + .collection(minimal("c")) + .rule(RuleSpec::guard( + collection("c"), + rule("r"), + vec![ActionScope::Replace], + GuardExpr::field(FieldContext::New, path("points")) + .ge(GuardExpr::field(FieldContext::Old, path("a"))), + )); + let diagnostics = assert_reports(&declaration, "GuardFieldUnknown"); + assert_eq!(diagnostics.len(), 1); +} + +#[test] +fn should_sort_and_dedupe_rule_actions() { + let declaration = ContractDeclaration::new() + .collection(minimal("c")) + .rule(RuleSpec::guard( + collection("c"), + rule("r"), + vec![ + ActionScope::Delete, + ActionScope::Create, + ActionScope::Delete, + ], + GuardExpr::boolean(true), + )); + let manifest = expect_manifest(&declaration); + assert_eq!( + manifest.rules[0].actions, + [ActionScope::Create, ActionScope::Delete] + ); + assert!(manifest + .capabilities + .requires(CapabilityRequirement::NativeGuards)); +} + +// Capabilities + +#[test] +fn should_report_capability_interface_disabled_for_the_private_store() { + let declaration = + ContractDeclaration::new().collection(minimal("secrets").store(Store::Private)); + let diagnostics = expect_diagnostics(&declaration); + assert_eq!(kinds(&diagnostics), ["CapabilityInterfaceDisabled"]); + let DiagnosticKind::CapabilityInterfaceDisabled { requirement } = &diagnostics[0].kind else { + panic!() + }; + assert_eq!(*requirement, CapabilityRequirement::PrivateStore); +} + +#[test] +fn should_report_capability_not_declarable_for_derived_requirements() { + let declaration = ContractDeclaration::new().require(CapabilityRequirement::ContractWrites); + assert_reports(&declaration, "CapabilityNotDeclarable"); +} + +#[test] +fn should_derive_typed_collection_and_predicate_requirements() { + let declaration = ContractDeclaration::new() + .collection(minimal("c")) + .typed_collection(TypedCollectionSpec::new( + collection("totals"), + TypedCollectionKind::Sum, + ValueType::Identifier, + ValueType::Integer(IntegerWidth::I64), + )) + .rule(RuleSpec::predicate( + collection("c"), + rule("p"), + vec![ActionScope::Create], + module("main"), + "check", + )) + .receipts(ReceiptPolicy::Disabled); + let manifest = expect_manifest(&declaration); + assert!(manifest + .capabilities + .requires(CapabilityRequirement::TypedCollections( + TypedCollectionKind::Sum + ))); + assert!(manifest + .capabilities + .requires(CapabilityRequirement::WasmPredicates)); + assert!(!manifest + .capabilities + .requires(CapabilityRequirement::StoredReceipts)); + assert_eq!(manifest.typed_collections.len(), 1); +} + +// Sugar + +#[test] +fn should_expand_average_sugar_into_count_and_sum_at_both_levels() { + let longhand = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new(property("class"), 0, FieldType::string(8))) + .field(FieldSpec::new( + property("points"), + 1, + FieldType::integer(IntegerWidth::I64), + )) + .count(true) + .range_count(true) + .sum(property("points")) + .range_sum(true) + .index( + IndexSpec::new(index_name("i"), vec![path("class")]) + .count() + .range_count(true) + .sum(property("points")) + .range_sum(true), + ); + let sugar = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new(property("class"), 0, FieldType::string(8))) + .field(FieldSpec::new( + property("points"), + 1, + FieldType::integer(IntegerWidth::I64), + )) + .average(property("points")) + .range_average(true) + .index( + IndexSpec::new(index_name("i"), vec![path("class")]) + .average(property("points")) + .range_average(true), + ); + let a = expect_manifest(&ContractDeclaration::new().collection(longhand)); + let b = expect_manifest(&ContractDeclaration::new().collection(sugar)); + assert_eq!(a, b); + let c = a.collection("c").unwrap(); + assert!(c.count && c.range_count && c.range_sum); + assert_eq!(c.sum, Some(property("points"))); +} + +#[test] +fn should_keep_offset_count_when_average_sugar_is_added() { + let spec = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new(property("class"), 0, FieldType::string(8))) + .field(FieldSpec::new( + property("points"), + 1, + FieldType::integer(IntegerWidth::I64), + )) + .index( + IndexSpec::new(index_name("i"), vec![path("class")]) + .count_allowing_offset() + .average(property("points")), + ); + let manifest = expect_manifest(&ContractDeclaration::new().collection(spec)); + assert_eq!( + manifest.collection("c").unwrap().index("i").unwrap().count, + Countability::CountableAllowingOffset + ); +} + +#[test] +fn should_report_conflicting_option_for_average_against_a_different_sum() { + let spec = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new( + property("a"), + 0, + FieldType::integer(IntegerWidth::I64), + )) + .field(FieldSpec::new( + property("b"), + 1, + FieldType::integer(IntegerWidth::I64), + )) + .sum(property("a")) + .average(property("b")) + .index( + IndexSpec::new(index_name("i"), vec![path("a")]) + .sum(property("a")) + .average(property("b")), + ); + let diagnostics = expect_diagnostics(&ContractDeclaration::new().collection(spec)); + assert_eq!( + kinds(&diagnostics), + ["ConflictingOption", "ConflictingOption"] + ); +} + +#[test] +fn should_report_conflicting_option_for_range_average_without_average() { + let spec = minimal("c") + .range_average(true) + .index(IndexSpec::new(index_name("i"), vec![path("a")]).range_average(true)); + let diagnostics = expect_diagnostics(&ContractDeclaration::new().collection(spec)); + assert_eq!( + kinds(&diagnostics), + ["ConflictingOption", "ConflictingOption"] + ); +} + +// Order independence and identity stability + +fn reversed(declaration: &ContractDeclaration) -> ContractDeclaration { + let mut reversed = declaration.clone(); + reversed.modules.reverse(); + reversed.interfaces.reverse(); + reversed.collections.reverse(); + reversed.typed_collections.reverse(); + reversed.entries.reverse(); + reversed.rules.reverse(); + reversed.capabilities.reverse(); + for collection in &mut reversed.collections { + collection.fields.reverse(); + collection.indexes.reverse(); + collection.token_costs.reverse(); + for index in &mut collection.indexes { + if let RankedCount::At(levels) = &mut index.ranked.count { + levels.reverse(); + } + if let Some(contested) = &mut index.contested { + contested.field_matches.reverse(); + } + } + } + for rule in &mut reversed.rules { + rule.actions.reverse(); + } + for module in &mut reversed.modules { + module.uses.reverse(); + } + reversed +} + +fn rich_declaration() -> ContractDeclaration { + let scores = score_collection(WritePolicy::Owner) + .token_cost(ActionScope::Create, TokenCost::new(0, 5)) + .token_cost(ActionScope::Delete, TokenCost::new(1, 3)) + .index( + IndexSpec::new(index_name("prefix"), vec![path("class"), path("owner")]) + .count() + .range_count(true) + .ranked_count_at(vec![path("owner"), path("class")]) + .contested(ContestedSpec::masternode_vote(vec![ + (path("owner"), "^a".to_string()), + (path("class"), "^b".to_string()), + ])), + ); + let mut declaration = ContractDeclaration::new() + .module( + ModuleSpec::new(module("main")) + .uses(interface("math")) + .uses(interface("text")), + ) + .module(ModuleSpec::new(module("helpers"))) + .interface(InterfaceSpec::new(interface("math"), module("helpers"))) + .interface(InterfaceSpec::new(interface("text"), module("helpers"))) + .collection(scores) + .collection(minimal("audit")) + .typed_collection(TypedCollectionSpec::new( + collection("totals"), + TypedCollectionKind::Sum, + ValueType::Identifier, + ValueType::Integer(IntegerWidth::I64), + )) + .rule(RuleSpec::guard( + collection("scores"), + rule("monotonic"), + vec![ActionScope::Replace, ActionScope::Create], + GuardExpr::field(FieldContext::New, path("points")) + .ge(GuardExpr::field(FieldContext::Old, path("points"))), + )) + .rule(RuleSpec::guard( + collection("scores"), + rule("bounded"), + vec![ActionScope::Create], + GuardExpr::field(FieldContext::New, path("points")).le(GuardExpr::integer(100)), + )) + .rule(RuleSpec::predicate( + collection("scores"), + rule("checked"), + vec![ActionScope::Delete], + module("helpers"), + "may_delete", + )) + .require(CapabilityRequirement::Randomness) + .require(CapabilityRequirement::Acl); + for entry in score_entries() { + declaration = declaration.entry(entry.module(module("main"))); + } + declaration +} + +#[test] +fn should_produce_the_same_manifest_regardless_of_declaration_order() { + let declaration = rich_declaration(); + let forward = expect_manifest(&declaration); + let backward = expect_manifest(&reversed(&declaration)); + assert_eq!(forward, backward); + let rules: Vec<&str> = forward.rules.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(rules, ["bounded", "checked", "monotonic"]); + let costs: Vec = forward + .collection("scores") + .unwrap() + .token_costs + .iter() + .map(|c| c.action) + .collect(); + assert_eq!(costs, [ActionScope::Create, ActionScope::Delete]); +} + +#[test] +fn should_produce_the_same_manifest_regardless_of_attribute_or_builder_origin() { + let declaration = rich_declaration(); + let mut swapped = declaration.clone(); + for collection in &mut swapped.collections { + collection.origin = DeclarationOrigin::Builder; + for index in &mut collection.indexes { + index.origin = DeclarationOrigin::Builder; + } + } + for entry in &mut swapped.entries { + entry.origin = DeclarationOrigin::Builder; + } + assert_eq!(expect_manifest(&declaration), expect_manifest(&swapped)); +} + +#[test] +fn should_keep_method_identity_when_an_entry_moves_between_modules() { + let declaration = rich_declaration(); + let before = expect_manifest(&declaration); + let mut moved = declaration.clone(); + for entry in &mut moved.entries { + if entry.name.as_str() == "score.total" { + entry.module = Some(module("helpers")); + } + } + let after = expect_manifest(&moved); + let names_before: Vec<&MethodName> = before.methods.names().collect(); + let names_after: Vec<&MethodName> = after.methods.names().collect(); + assert_eq!(names_before, names_after); + for (a, b) in before.methods.entries.iter().zip(&after.methods.entries) { + assert_eq!(a.name, b.name); + assert_eq!(a.export, b.export); + assert_eq!(a.params, b.params); + assert_eq!(a.returns, b.returns); + if a.name.as_str() == "score.total" { + assert_eq!(a.module.as_str(), "main"); + assert_eq!(b.module.as_str(), "helpers"); + } else { + assert_eq!(a.module, b.module); + } + } + let mut after_rebound = after.clone(); + for entry in &mut after_rebound.methods.entries { + if entry.name.as_str() == "score.total" { + entry.module = module("main"); + } + } + assert_eq!(before, after_rebound); +} + +#[test] +fn should_collect_every_diagnostic_instead_of_stopping_at_the_first() { + let declaration = ContractDeclaration::new() + .collection(minimal("c").schema_revision(0)) + .entry(EntrySpec::new(method("f")).receiver(Receiver::Ref(collection("ghost")))) + .rule(RuleSpec::guard( + collection("ghost"), + rule("r"), + vec![], + GuardExpr::boolean(true), + )); + let diagnostics = expect_diagnostics(&declaration); + assert_eq!( + kinds(&diagnostics), + [ + "InvalidSchemaRevision", + "ReceiverCollectionUnknown", + "RuleCollectionUnknown", + "RuleWithoutActions" + ] + ); +} diff --git a/packages/rs-dash-sdk-contract/tests/alloc_profile.rs b/packages/rs-dash-sdk-contract/tests/alloc_profile.rs new file mode 100644 index 00000000000..882f1c06173 --- /dev/null +++ b/packages/rs-dash-sdk-contract/tests/alloc_profile.rs @@ -0,0 +1,63 @@ +//! The declaration model on the guest profile: this test is compiled by the +//! CI guest cut with `--no-default-features`, so it exercises the builders, +//! the validator and the manifest without `std`. Under default features it +//! runs the same code with `std` present. + +use dash_sdk_contract::prelude::*; + +fn scores() -> CollectionSpec { + CollectionSpec::documents(CollectionName::new("scores").unwrap()) + .document_id_field("id") + .field(FieldSpec::new( + PropertyName::new("class").unwrap(), + 0, + FieldType::string(64), + )) + .field(FieldSpec::new( + PropertyName::new("points").unwrap(), + 1, + FieldType::bounded_integer(IntegerWidth::I64, 0, 1_000_000), + )) + .index( + IndexSpec::new( + IndexName::new("by_class").unwrap(), + vec![PropertyPath::new("class").unwrap()], + ) + .count() + .sum(PropertyName::new("points").unwrap()), + ) +} + +#[test] +fn should_build_the_sketch_manifest_through_builders() { + let declaration = ContractDeclaration::new().collection(scores()).entry( + EntrySpec::new(MethodName::new("score.add").unwrap()) + .receiver(Receiver::Mut(CollectionName::new("scores").unwrap())) + .param("delta", ValueType::Integer(IntegerWidth::I64)), + ); + let manifest = validate(&declaration).expect("the sketch validates"); + assert_eq!(manifest.collections.len(), 1); + let add = manifest.method("score.add").expect("entry present"); + assert_eq!(add.export, entry_export_symbol(&add.name)); + assert_eq!( + StagingPoint::for_receiver(&add.receiver), + Some(StagingPoint::MutReceiverOnOk) + ); +} + +#[test] +fn should_report_diagnostics_with_stable_codes() { + let declaration = ContractDeclaration::new() + .collection(scores().mutable(false)) + .entry( + EntrySpec::new(MethodName::new("score.add").unwrap()) + .receiver(Receiver::Mut(CollectionName::new("scores").unwrap())), + ); + let diagnostics = validate(&declaration).expect_err("immutable receiver is rejected"); + assert_eq!(diagnostics.len(), 1); + assert_eq!( + diagnostics[0].kind.name(), + "MutableReceiverOnImmutableCollection" + ); + assert!(diagnostics[0].code().starts_with("DSC")); +} From 6f6e12db075c7abdca9c20a65f17ac83d995cd48 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 20:25:38 -0500 Subject: [PATCH 2/9] build: wire dash-sdk-contract into CI and document the author API Add the wasm32v1-none target to the toolchain, the package filters and the nextest package list for the new crate, a guest declaration cut step that checks the crate without std on wasm32v1-none and runs its tests without default features, and the check-features entry. Add the DashVM book section with the contract declarations chapter and the coding-conventions table row for author declarations. Refs #4680 Co-Authored-By: Claude Fable 5.1 --- .../rs-packages-no-workflows.yml | 3 + .github/package-filters/rs-packages.yml | 4 + .github/workflows/tests-rs-workspace.yml | 12 + book/src/SUMMARY.md | 4 + book/src/contributing/coding-conventions.md | 1 + book/src/dashvm/contract-declarations.md | 323 ++++++++++++++++++ packages/check-features/src/main.rs | 1 + packages/rs-dash-sdk-contract/README.md | 24 ++ packages/rs-dash-sdk-contract/src/grammar.rs | 19 ++ rust-toolchain.toml | 2 +- 10 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 book/src/dashvm/contract-declarations.md create mode 100644 packages/rs-dash-sdk-contract/README.md diff --git a/.github/package-filters/rs-packages-no-workflows.yml b/.github/package-filters/rs-packages-no-workflows.yml index b0c596ec3a8..ceeaaf603a7 100644 --- a/.github/package-filters/rs-packages-no-workflows.yml +++ b/.github/package-filters/rs-packages-no-workflows.yml @@ -112,6 +112,9 @@ rs-sdk-trusted-context-provider: &sdk_trusted_context_provider - *context_provider - *dpp +dash-sdk-contract: + - packages/rs-dash-sdk-contract/** + dapi-grpc: &dapi_grpc - packages/rs-platform-version/** - packages/rs-dash-platform-macros/** diff --git a/.github/package-filters/rs-packages.yml b/.github/package-filters/rs-packages.yml index 1174da0c998..4ba0d6dae86 100644 --- a/.github/package-filters/rs-packages.yml +++ b/.github/package-filters/rs-packages.yml @@ -133,6 +133,10 @@ rs-sdk-trusted-context-provider: &sdk_trusted_context_provider - *context_provider - *dpp +dash-sdk-contract: + - .github/workflows/tests* + - packages/rs-dash-sdk-contract/** + dapi-grpc: &dapi_grpc - .github/workflows/tests* - packages/rs-platform-version/** diff --git a/.github/workflows/tests-rs-workspace.yml b/.github/workflows/tests-rs-workspace.yml index ac2c782301c..f05a1ad1f3f 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -255,6 +255,17 @@ jobs: done done + # The contract-author declaration crate is a guest dependency: contract + # packages compile it for WebAssembly without std. Whole-workspace + # builds unify features and would hide a std leak, so check the + # standalone no-std graph on the no-std-library wasm target and run the + # crate's tests without default features. + - name: Check guest declaration cut + run: | + rustup target add wasm32v1-none + cargo check -p dash-sdk-contract --no-default-features --target wasm32v1-none --locked + cargo test -p dash-sdk-contract --no-default-features --locked + - name: Detect immutable structure changes if: github.event_name == 'pull_request' run: | @@ -453,6 +464,7 @@ jobs: --package wallet-utils-contract \ --package keyword-search-contract \ --package app-connect-contract \ + --package dash-sdk-contract \ --all-features \ --locked \ -E 'not test(~shield) and (not binary_id(=drive-abci::strategy_tests) or test(~comprehensive_mixed_operations) or test(~process_proposal_collision))' diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index c41f5122fa5..b552db5f28c 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -114,6 +114,10 @@ - [Binding Patterns](wasm/binding-patterns.md) - [Error Macros](wasm/error-macros.md) +# DashVM + +- [Contract Declarations and the Author API](dashvm/contract-declarations.md) + --- # Appendix diff --git a/book/src/contributing/coding-conventions.md b/book/src/contributing/coding-conventions.md index 65d7ae9c579..4503ca42449 100644 --- a/book/src/contributing/coding-conventions.md +++ b/book/src/contributing/coding-conventions.md @@ -47,6 +47,7 @@ crate that has what the change needs, and no lower. | A client API | `packages/rs-sdk` | Follow the query checklist in `packages/rs-sdk/README.md`. | | A JavaScript binding | `packages/wasm-dpp2`, `packages/wasm-sdk` | Mirror the Rust shape; never validate. See `packages/wasm-dpp2/CONVENTIONS.md`. | | Mobile orchestration (sync, identity registration, DashPay) | `packages/rs-platform-wallet` | The FFI crates and the Swift and Kotlin SDKs marshal; they do not decide. | +| A contract-author declaration or diagnostic | `packages/rs-dash-sdk-contract` | Builds for `wasm32v1-none` with `--no-default-features`; native limits are not duplicated there, they surface through `dash-contract-build`. See [Contract Declarations](../dashvm/contract-declarations.md). | Three boundaries are enforced by CI and worth knowing by name: diff --git a/book/src/dashvm/contract-declarations.md b/book/src/dashvm/contract-declarations.md new file mode 100644 index 00000000000..6c0fa222d0e --- /dev/null +++ b/book/src/dashvm/contract-declarations.md @@ -0,0 +1,323 @@ +# Contract Declarations and the Author API + +> **Status:** specification landed on the development branch as the crate +> `dash-sdk-contract` (`packages/rs-dash-sdk-contract`, import +> `dash_sdk_contract`). The attribute spellings, the export symbol scheme, the +> method, module, interface and rule name grammars and the diagnostic codes are +> provisional under the shared allocation register for the Rust macro grammar. +> The proc macros, generated persistence wrappers, host context and runtime are +> later deliverables of the same workstream and consume the types described +> here. The native translation and host validation crate, +> `dash-contract-build`, lands separately. + +A DashVM contract is ordinary Rust. A struct marked `#[persistent]` is stored +as Platform documents; indexes are declared on that struct; only functions +marked `#[entry]` are callable from outside, and `pub` alone exports nothing. +This chapter describes the declaration model behind those attributes: what +the grammar admits, how declarations are identified, what the validator +checks and what it leaves to the native host, and what the canonical manifest +looks like. + +The confirmed policy the model implements: + +- Detached Rust values do not save automatically. Explicit insert and edit + operations and successful exported mutable-receiver wrappers stage writes in + the outer transaction. No save on `Drop`. +- Simple persistence, index and rule attributes plus typed builders for + advanced native features produce one canonical manifest. Unsupported or + conflicting declarations are rejected; native validation remains the + authority. No unrestricted database paths. +- Existing versioned DPP and Drive index-update rules are reused exactly. + There is no index backfill or migration subsystem. +- Private Rust fields and access-control checks are not encryption. The + private document store stays in the capability catalogue with its interface + disabled until encryption, key control, query visibility and proof behaviour + are specified. +- A contested-index award is a native action. Contracts parameterize + contested indexes only through the supported native declarations; no guest + code, guard or predicate runs on the award. + +## The sketch + +The issue's `Score` example, in the shape the model accepts: + +```rust +use dash_sdk_contract::prelude::*; + +#[persistent(collection = "scores", schema = 1, write = "contract")] +#[index(name = "by_class", fields(class = "asc"), count, sum = "points")] +#[index(name = "by_owner", fields(owner = "asc"))] +#[index(name = "ranking", fields(class = "asc", points = "asc"), ranked_count)] +pub struct Score { + #[document_id] + pub id: DocumentId, + #[field(position = 0, max_chars = 64)] + pub class: String, + #[field(position = 1, min = 0, max = 1_000_000)] + pub points: i64, + #[field(position = 2, refers_to = "identity")] + pub owner: IdentityId, +} + +impl Score { + #[entry(name = "score.add")] + pub fn add(&mut self, ctx: &mut Context, delta: i64) -> Result<()> { /* ... */ } +} + +#[entry(name = "score.total", read_only)] +pub fn class_total(ctx: &Context, class: String) -> Result { /* ... */ } +``` + +One correction to the original sketch: the `ranking` index is declared with +`points = "asc"`, not `"desc"`. The native document meta-schema admits only +ascending index properties; a descending walk is a per-query choice, not an +index property. `fields(points = "desc")` is rejected with +`InvalidOptionValue`, and the diagnostic says why. + +Until the macros ship, the same declaration is written through builders: + +```rust +let scores = CollectionSpec::documents(CollectionName::new("scores")?) + .write(WritePolicy::Contract) + .document_id_field("id") + .field(FieldSpec::new(PropertyName::new("class")?, 0, FieldType::string(64))) + .field(FieldSpec::new( + PropertyName::new("points")?, + 1, + FieldType::bounded_integer(IntegerWidth::I64, 0, 1_000_000), + )) + .field(FieldSpec::new(PropertyName::new("owner")?, 2, FieldType::identity())) + .index( + IndexSpec::new(IndexName::new("by_class")?, vec![PropertyPath::new("class")?]) + .count() + .sum(PropertyName::new("points")?), + ); + +let manifest = ContractDeclaration::new() + .collection(scores) + .entry( + EntrySpec::new(MethodName::new("score.add")?) + .receiver(Receiver::Mut(CollectionName::new("scores")?)) + .param("delta", ValueType::Integer(IntegerWidth::I64)), + ) + .validate()?; +``` + +Attributes and builders feed the same `ContractDeclaration`. A builder may +restate an attribute declaration verbatim, or add indexes to an +attribute-declared collection, but a builder that changes what an attribute +said is a `ConflictingDeclaration` naming both origins. + +## The attribute grammar + +The grammar is data: `dash_sdk_contract::grammar::ATTRIBUTES` lists every +attribute, its options and the value each option accepts. The proc macros +parse against that table, `grammar::check_keys` reports grammar diagnostics +from it, and a test pins this chapter's table against it, so the three cannot +drift. An option not in the table is `UnknownOption`; a value outside a closed +set is `InvalidOptionValue`; a repeated option is `DuplicateOption`; a +missing required option is `MissingOption`. Nothing is ignored. + +| Attribute | On | Options | +|---|---|---| +| `persistent` | struct | `collection` (required), `schema` (integer, default 1), `write` (`any` / `owner` / `contract`), `mutable`, `deletable`, `keep_history`, `keep_transfer_history`, `keep_purchase_history`, `keep_pricing_history`, `transferable`, `trade` (`none` / `direct_purchase`), `security_level` (`critical` / `high` / `medium`), `encryption_key` and `decryption_key` (`unique` / `multiple` / `multiple_reference_to_latest`), `count`, `range_count`, `sum = ""`, `range_sum`, `average = ""`, `range_average`, `index_only`, `store` (`public` / `private`) | +| `singleton` | struct | `collection` (required), `schema`, `write`, `security_level`, `encryption_key`, `decryption_key`, `store` | +| `token_cost` | struct, repeatable | `on` (an ordinary action, required), `token_position` (required), `amount` (required), `contract` (base58), `effect` (`transfer_to_contract_owner` / `burn`), `gas_paid_by` (`document_owner` / `contract_owner` / `prefer_contract_owner`) | +| `index` | struct, repeatable | `name` (required), `fields( = "asc", ...)` (required), `unique`, `null_searchable` (default true), `contested(field_matches( = ""), resolution = "masternode_vote", description)`, `count` or `count = "offset"`, `range_count`, `sum = ""`, `range_sum`, `average = ""`, `range_average`, `ranked_count` or `ranked_count = ["", ...]`, `ranked_sum`, `ranked_average`, `time_range(on, range_secs, step_secs, phase_secs)`, `terminal = ""`, `preallocated`, `skip_if_absent` | +| `field` | field | `position` (required), `max_chars`, `min_chars`, `max_len`, `min_len`, `min`, `max`, `values = [...]`, `required` (default true), `transient`, `refers_to` (`identity` / `contract` / `token` / `permanent_document` / `identity_public_key`), `document_type`, `contract`, `agreement( = "")`, `key_id_field`, `description` | +| `document_id` | field | none | +| `entry` | fn | `name` (required), `read_only`, `module` | +| `rule` | struct, repeatable | `name` (required), `on = [...]` (ordinary actions, required), exactly one of `guard = ""` or `predicate = "::"` | +| `contract` | crate | `receipts` (`stored` / `disabled`, default stored), `requires = [...]` (`acl`, `randomness`) | +| `module` | module | `name` (required), `uses = [...]` | +| `interface` | trait | `name` (required), `provider` (required) | + +The ordinary actions are `create`, `replace`, `delete`, `transfer`, +`purchase` and `update_price`. There is no `award`: `on = "award"` is +`InvalidOptionValue` and the reason names the native award rule. + +## Identity + +A declaration is identified by its declared name, never by the Rust path, +impl block, source file or declaration order that produced it. + +| Identity | Grammar | Where the rule comes from | +|---|---|---| +| Collection | `^[a-zA-Z0-9_-]{1,64}$` | native document type name | +| Property | `^[a-zA-Z0-9_-]{1,64}$`, plus its position | meta-schema property names | +| Index | 1 to 32 characters, unique within its collection | meta-schema index name | +| Method | `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$`, at most 64 bytes, unique contract-wide | provisional | +| Module, interface | `^[a-z0-9_]{1,64}$` | provisional, aligned with the bundle validation crate | +| Rule | `^[a-z][a-z0-9_]{0,63}$`, unique within its collection | provisional | + +An entry's WASM export symbol is `dash_entry_` followed by the method name +verbatim (`dash_entry_score.add`). The mapping is a prefix plus the identity, +so it is injective; WebAssembly export names are arbitrary UTF-8 and Rust's +`#[export_name]` accepts dots. The numeric method and type identifiers derived +from these names are allocated by the ABI work, not here. + +## Bounded fields + +Every string, byte array and list declares a maximum, in stored fields and in +entry parameters and returns alike; a missing maximum is `UnboundedField`, +never a default. Integer fields carry their Rust width (`u8` to `u64`, `i8` to +`i64`) and optional narrower bounds; bounds outside the width or inverted are +`IntegerBoundsOutsideType`. Bounds are stored as `i128` so that a `u64` bound +above `i64::MAX` is representable and rejected as +`IntegerBoundNotNativelyRepresentable` (the native schema reads bounds as +signed 64-bit) instead of being truncated. Positions are contiguous from 0 at +every nesting level and are never renumbered. + +The width is part of the manifest. The build crate emits the width's own +range as the native `minimum` and `maximum` when the author gives no bounds, +so native sized-integer inference reproduces the declared width; an author +bound narrower than the width yields the narrower native storage type, which +generated codecs accept. + +## What the validator checks, and what it leaves to native + +`dash_sdk_contract::validate` runs every check and returns every diagnostic; +there is no first-error return. Diagnostics are typed, append-only and carry +stable codes (`DSC0001` onwards, provisional). + +The validator owns what the native host cannot know: + +- grammar (unknown attributes, options and values; missing, duplicate and + mutually exclusive options; sugar conflicts); +- identity (duplicate collections, indexes, methods, export symbols, rules, + modules, interfaces, properties and positions; non-contiguous positions; + invalid names); +- conflicts between an attribute and a builder describing one item; +- boundedness of every variable-length field and wire value; +- cross-references (an index, sum, contested field match, ranked level, time + range source, terminal, reference agreement, receiver, rule or guard naming + something the declaration does not have); +- collection kinds (a singleton has no indexes and no document id field; a + persistent struct needs one); +- entry semantics (a `&mut self` entry on a collection whose documents cannot + be replaced has nothing to stage; a read-only entry cannot take `&mut + self`); +- the module graph (unknown providers and interfaces, self-imports, cycles, + an entry with no module when several exist); +- rule scope and kind; +- the capability catalogue (the private store is `CapabilityInterfaceDisabled`; + derived capabilities cannot be required explicitly). + +It deliberately does not mirror native numeric limits (ten indexes per type, +32-character index names, 63-character indexed strings, one hundred +properties, time-range caps) or native schema dependencies (a range count +needs a count, a ranking needs its range axis, a prefix ranking excludes sum +axes, a time range needs a system timestamp). Those are enforced once, by Dash +Platform Protocol, and the build crate surfaces them by running the real +contract validation. Two definitions of "valid" would drift the day one of +them changed. + +## Persistence + +`dash_sdk_contract::persistence` states the persistence semantics as enums so +generated wrappers and this chapter cannot drift: + +| | Result | +|---|---| +| Construct or mutate a detached value | nothing is written | +| `documents::().insert(value)` | stages a native create | +| `documents::().edit(id, closure)` | loads one record and stages the update when the closure returns successfully | +| An exported `&mut self` entry returns successfully | its wrapper loads exactly the addressed record and stages the receiver update | +| `Drop`, an escaping reference, an unmarked helper | nothing is written | + +Document ids, revisions, owners and storage flags are host managed. A receiver +entry on a document collection takes the target document id as its first wire +argument; a singleton receiver takes none, its key being reserved. + +## Indexes and the native catalogue + +`IndexSpec` expresses every index feature the native catalogue supports: +unique, null searchability, contested parameters (field matches, masternode +vote resolution, description), count and offset count, range count, sum and +range sum, average sugar, count ranking at the terminal level or at named +prefix levels, sum and average ranking, time-range buckets, and the index-only +options terminal, preallocated and skip-if-absent. Collection-level count, +sum, average and index-only flags, the document type switches, the bounded key +requirements and per-action token costs are on `CollectionSpec`. Every +reference target (identity, contract, token, permanent document with property +agreement, identity public key) is a `FieldType::Reference`. + +Average sugar (`average = "p"`, `range_average`) is expanded into `count` +plus `sum = "p"` and `range_count` plus `range_sum` before the manifest, +with the same conflict rules the native parser applies to `averageable`; the +manifest has no average fields. + +Index definitions on existing document types are frozen by the existing +versioned native update validation: adding, removing or changing an index on +an existing type is rejected, and a new type may declare indexes through +normal validation. The SDK adds no migration or backfill. + +## Rules and native awards + +A rule guards ordinary actions of one collection with either a native bounded +guard expression (`GuardExpr`, the author-facing form of the proposed guard +node set: literals, old, new and context field reads, existence and null +tests, comparisons, checked arithmetic, boolean operators and a conditional) +or a read-only WASM predicate exported by one of the contract's modules. The +rule's identity is its collection and name; its action set is stored sorted. + +The contested award is unrepresentable. `ActionScope` has no award variant, a +contested index is declared only through its supported parameters, and a +contested unique index may sit next to rules on ordinary actions of the same +collection; those rules apply to ordinary writes and never to the award. + +## Capabilities + +The manifest's capability table lists everything the contract needs, explicit +(`requires = ["acl", "randomness"]`) or derived (`write = "contract"`, native +guards, WASM predicates, typed collections, the private store, stored +receipts, entries, several modules), each with its catalogue status: + +| Status | Meaning | +|---|---| +| `Native` | supported by the native host today | +| `PendingNative` | declarable and specified, no native implementation yet; the build crate reports it as a gap and refuses to call the manifest deployable | +| `InterfaceDisabled` | catalogued, rejected by the validator until specified (the private store) | + +Typed specialized collections (`TypedCollectionSpec`: sum, big sum, count, +count-and-sum, provable sum, provable count, ranked, append and commitment +families) are a manifest slot with a key type, an element type and an optional +element bound. Their operation sets, limits, privacy model and native adapters +are separate capability work; there is no raw path, raw element or database +handle anywhere in the model. + +## Named modules + +A package may build several WASM module targets. `ModuleSpec` names each and +lists the interfaces it imports; `InterfaceSpec` names a provider module and +its functions. The manifest records the sorted modules and interfaces and the +`(importer, provider, interface)` bindings, and rejects cycles and +self-imports. A package that declares no module has one implicit module named +`main`. An entry binds to one module; moving it between modules changes the +binding and nothing else, which a test pins by comparing the method table +before and after a move. + +## The canonical manifest + +`CanonicalManifest` is sorted throughout: modules and interfaces by name, +bindings by tuple, collections by name with fields by position and indexes by +name, typed collections by id, methods by name, rules by collection and name, +capabilities by requirement. Every table is keyed by a required unique +identity, so two declarations that differ only in source order, in attribute +versus builder origin, or in which module hosts an entry (beyond the binding +itself) produce equal manifests. The manifest is built only by the validator +and has no wire encoding or digest here. + +## What is provisional + +- The attribute spellings and option names. +- The export symbol scheme `dash_entry_`. +- The method, module, interface and rule name grammars and the implicit + module name `main`. +- The meaning of `schema = N` (an author-declared schema revision recorded for + compatibility reports). +- The diagnostic codes. +- The `GuardExpr` node set, which mirrors the proposed native guard grammar; + the canonical guard AST is specified by the guards work. +- The home of the manifest shape in this crate, until the ABI work allocates + its encoding. diff --git a/packages/check-features/src/main.rs b/packages/check-features/src/main.rs index 8cba6485410..ba94c7bbafa 100644 --- a/packages/check-features/src/main.rs +++ b/packages/check-features/src/main.rs @@ -11,6 +11,7 @@ fn main() { ("rs-drive-proof-verifier", vec![]), ("rs-platform-wallet", vec![]), ("dash-platform-queries", vec![]), + ("rs-dash-sdk-contract", vec![]), ]; for (specific_crate, to_ignore) in crates { diff --git a/packages/rs-dash-sdk-contract/README.md b/packages/rs-dash-sdk-contract/README.md new file mode 100644 index 00000000000..d9f105fedf9 --- /dev/null +++ b/packages/rs-dash-sdk-contract/README.md @@ -0,0 +1,24 @@ +# dash-sdk-contract + +Contract-author SDK for DashVM: the declaration model behind `#[persistent]`, +`#[index]`, `#[rule]` and `#[entry]`, the attribute grammar those macros +implement, the diagnostics they report, and the canonical manifest a contract +package publishes. + +```rust +use dash_sdk_contract::prelude::*; +``` + +This crate specifies the author-facing model. It carries no proc macros, no +host context and no runtime. It compiles without `std` on `wasm32v1-none`: + +```bash +cargo check -p dash-sdk-contract --no-default-features --target wasm32v1-none +cargo test -p dash-sdk-contract +cargo test -p dash-sdk-contract --no-default-features +``` + +The chapter [Contract Declarations and the Author API](../../book/src/dashvm/contract-declarations.md) +in the Dash Platform Book describes the grammar, the identity rules, the +persistence semantics and what the validator checks versus what native +validation enforces. diff --git a/packages/rs-dash-sdk-contract/src/grammar.rs b/packages/rs-dash-sdk-contract/src/grammar.rs index 3528671dd16..8543af82ccb 100644 --- a/packages/rs-dash-sdk-contract/src/grammar.rs +++ b/packages/rs-dash-sdk-contract/src/grammar.rs @@ -1211,6 +1211,25 @@ mod tests { } } + #[test] + fn should_keep_attribute_and_option_names_unique_and_groups_resolvable() { + let mut names: Vec<&str> = ATTRIBUTES.iter().map(|spec| spec.name).collect(); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), ATTRIBUTES.len()); + for spec in ATTRIBUTES { + let mut keys: Vec<&str> = spec.keys.iter().map(|key| key.name).collect(); + keys.sort_unstable(); + keys.dedup(); + assert_eq!(keys.len(), spec.keys.len(), "options of {}", spec.name); + for group in spec.exactly_one_of { + for option in *group { + assert!(keys.contains(option), "{option} of {}", spec.name); + } + } + } + } + #[test] fn should_report_unknown_attribute() { let diagnostics = check_keys("persisted", &[]); diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 0835736b9de..e5fb32b4eb5 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -2,4 +2,4 @@ # Rust version the same as in /README.md channel = "1.98.1" -targets = ["wasm32-unknown-unknown"] +targets = ["wasm32-unknown-unknown", "wasm32v1-none"] From 4dd0b9a323a72bdd0024aa55b18129ee7b1397bf Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 20:30:20 -0500 Subject: [PATCH 3/9] refactor: merge declarations through an identified-spec trait Replace the closure-parameter dedupe with an Identified trait implemented per spec type, use validated collection summaries in the entry checks, and build grammar diagnostics with format. No behaviour change. Refs #4680 Co-Authored-By: Claude Fable 5.1 --- .../rs-dash-sdk-contract/src/declare/mod.rs | 4 +- .../src/declare/module.rs | 15 + packages/rs-dash-sdk-contract/src/grammar.rs | 59 ++-- .../src/manifest/method.rs | 7 +- .../src/validate/collections.rs | 57 ++-- .../src/validate/entries.rs | 58 ++-- .../src/validate/merge.rs | 304 +++++++++++++++--- .../src/validate/modules.rs | 36 +-- .../src/validate/rules.rs | 21 +- .../src/validate/tests.rs | 15 +- 10 files changed, 349 insertions(+), 227 deletions(-) diff --git a/packages/rs-dash-sdk-contract/src/declare/mod.rs b/packages/rs-dash-sdk-contract/src/declare/mod.rs index f63b0ce275f..a0901d73ba0 100644 --- a/packages/rs-dash-sdk-contract/src/declare/mod.rs +++ b/packages/rs-dash-sdk-contract/src/declare/mod.rs @@ -35,7 +35,9 @@ pub use index::{ ContestedResolution, ContestedSpec, Countability, IndexOnlySpec, IndexSpec, RankedCount, Ranking, TimeRangeSpec, }; -pub use module::{InterfaceSpec, InternalFunctionSpec, ModuleSpec, IMPLICIT_MODULE}; +pub use module::{ + implicit_module, InterfaceSpec, InternalFunctionSpec, ModuleSpec, IMPLICIT_MODULE, +}; pub use rule::{ActionScope, FieldContext, GuardExpr, Literal, RuleKind, RuleSpec}; use crate::manifest::CanonicalManifest; diff --git a/packages/rs-dash-sdk-contract/src/declare/module.rs b/packages/rs-dash-sdk-contract/src/declare/module.rs index 6674caa31f5..c8c711dc733 100644 --- a/packages/rs-dash-sdk-contract/src/declare/module.rs +++ b/packages/rs-dash-sdk-contract/src/declare/module.rs @@ -18,6 +18,11 @@ use crate::identity::{InterfaceName, ModuleName}; /// Provisional. pub const IMPLICIT_MODULE: &str = "main"; +/// The implicit module as a [`ModuleName`]. +pub fn implicit_module() -> ModuleName { + ModuleName::new(IMPLICIT_MODULE).expect("the implicit module name satisfies the module grammar") +} + /// A WASM module target. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ModuleSpec { @@ -108,3 +113,13 @@ impl InterfaceSpec { self } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_name_the_implicit_module_main() { + assert_eq!(implicit_module().as_str(), IMPLICIT_MODULE); + } +} diff --git a/packages/rs-dash-sdk-contract/src/grammar.rs b/packages/rs-dash-sdk-contract/src/grammar.rs index 8543af82ccb..3a101848816 100644 --- a/packages/rs-dash-sdk-contract/src/grammar.rs +++ b/packages/rs-dash-sdk-contract/src/grammar.rs @@ -16,6 +16,7 @@ //! The spellings themselves are provisional under the shared allocation //! register entry for the Rust macro grammar. +use alloc::format; use alloc::string::{String, ToString}; use alloc::vec::Vec; @@ -257,8 +258,6 @@ const TIME_RANGE_KEYS: &[KeySpec] = &[ }, ]; -const COLLECTION_COMMON_KEYS_DOC: &str = "see the persistent attribute"; - const PERSISTENT_KEYS: &[KeySpec] = &[ KeySpec { name: "collection", @@ -405,37 +404,37 @@ const SINGLETON_KEYS: &[KeySpec] = &[ name: "schema", value: ValueShape::Int, required: false, - doc: COLLECTION_COMMON_KEYS_DOC, + doc: "author-declared schema revision, at least 1, default 1", }, KeySpec { name: "write", value: ValueShape::Choice(&WRITE), required: false, - doc: COLLECTION_COMMON_KEYS_DOC, + doc: "who may write the singleton, default `any`", }, KeySpec { name: "security_level", value: ValueShape::Choice(&SECURITY_LEVEL), required: false, - doc: COLLECTION_COMMON_KEYS_DOC, + doc: "signature security level required to write, default `high`", }, KeySpec { name: "encryption_key", value: ValueShape::Choice(&KEY_REQUIREMENT), required: false, - doc: COLLECTION_COMMON_KEYS_DOC, + doc: "identity encryption bounded key requirement, default none", }, KeySpec { name: "decryption_key", value: ValueShape::Choice(&KEY_REQUIREMENT), required: false, - doc: COLLECTION_COMMON_KEYS_DOC, + doc: "identity decryption bounded key requirement, default none", }, KeySpec { name: "store", value: ValueShape::Choice(&STORE), required: false, - doc: COLLECTION_COMMON_KEYS_DOC, + doc: "document store, default `public`", }, ]; @@ -988,23 +987,16 @@ fn check_options( } fn choice_reason(value: &str, choices: &Choices) -> String { - let mut reason = String::new(); - reason.push_str("value "); - reason.push('"'); - reason.push_str(value); - reason.push('"'); - reason.push_str(" is not one of ["); - for (i, allowed) in choices.allowed.iter().enumerate() { - if i > 0 { - reason.push_str(", "); - } - reason.push('"'); - reason.push_str(allowed); - reason.push('"'); - } - reason.push_str("]: "); - reason.push_str(choices.explain); - reason + let allowed: Vec = choices + .allowed + .iter() + .map(|allowed| format!("\"{allowed}\"")) + .collect(); + format!( + "value \"{value}\" is not one of [{}]: {}", + allowed.join(", "), + choices.explain + ) } fn check_choice(value: &str, choices: &Choices) -> Result<(), String> { @@ -1046,10 +1038,7 @@ fn check_value( Err("expects a bare flag, a boolean or a list of strings".to_string()) } (ValueShape::Nested(keys), GivenValue::Nested(options)) => { - let mut nested = String::new(); - nested.push_str(attribute); - nested.push('.'); - nested.push_str(key.name); + let nested = format!("{attribute}.{}", key.name); check_options(&nested, keys, &[], options, diagnostics); Ok(()) } @@ -1061,11 +1050,7 @@ fn check_value( let mut seen: Vec<&str> = Vec::new(); for entry in entries { if seen.contains(&entry.name) { - let mut reason = String::new(); - reason.push_str("key "); - reason.push_str(entry.name); - reason.push_str(" is repeated"); - return Err(reason); + return Err(format!("key {} is repeated", entry.name)); } seen.push(entry.name); match (map_value, entry.value) { @@ -1074,11 +1059,7 @@ fn check_value( check_choice(value, choices)?; } _ => { - let mut reason = String::new(); - reason.push_str("key "); - reason.push_str(entry.name); - reason.push_str(" expects a string value"); - return Err(reason); + return Err(format!("key {} expects a string value", entry.name)); } } } diff --git a/packages/rs-dash-sdk-contract/src/manifest/method.rs b/packages/rs-dash-sdk-contract/src/manifest/method.rs index f116c7abd5c..2dd1ddef555 100644 --- a/packages/rs-dash-sdk-contract/src/manifest/method.rs +++ b/packages/rs-dash-sdk-contract/src/manifest/method.rs @@ -30,7 +30,12 @@ pub struct MethodEntry { } impl MethodEntry { - pub(crate) fn takes_document_id(receiver: &Receiver, kind: Option) -> bool { + /// Whether a receiver on a collection of the given kind is addressed by a + /// document id on the wire. + pub(crate) fn receiver_takes_document_id( + receiver: &Receiver, + kind: Option, + ) -> bool { matches!(receiver, Receiver::Ref(_) | Receiver::Mut(_)) && kind == Some(CollectionKind::Documents) } diff --git a/packages/rs-dash-sdk-contract/src/validate/collections.rs b/packages/rs-dash-sdk-contract/src/validate/collections.rs index 533f27dd316..8453ad44196 100644 --- a/packages/rs-dash-sdk-contract/src/validate/collections.rs +++ b/packages/rs-dash-sdk-contract/src/validate/collections.rs @@ -6,17 +6,26 @@ use alloc::string::ToString; use alloc::vec::Vec; use crate::declare::{ - CollectionKind, CollectionSpec, ContractDeclaration, FieldSpec, FieldType, IndexSpec, - IntegerWidth, RankedCount, ReferenceTarget, TypedCollectionSpec, ValueType, + CollectionKind, CollectionSpec, ContractDeclaration, Countability, FieldSpec, FieldType, + IndexSpec, IntegerWidth, RankedCount, ReferenceTarget, TypedCollectionSpec, ValueType, }; use crate::identity::{CollectionName, PropertyPath}; use crate::manifest::{CollectionManifest, IndexManifest, TypedCollectionManifest}; use crate::validate::diagnostic::{DeclarationPath, Diagnostic, DiagnosticKind}; use crate::validate::merge::dedupe; -/// The collections a contract declares, by name and kind, for the entry and -/// rule checks. -pub(super) type CollectionKinds = Vec<(CollectionName, CollectionKind)>; +/// What the entry checks need to know about each validated collection. +pub(super) struct CollectionSummary { + /// The collection's identity. + pub(super) name: CollectionName, + /// Documents or singleton. + pub(super) kind: CollectionKind, + /// Whether documents may be replaced. + pub(super) mutable: bool, +} + +/// The validated collections, for the entry checks. +pub(super) type CollectionKinds = Vec; pub(super) fn validate_collections( declaration: &ContractDeclaration, @@ -24,17 +33,7 @@ pub(super) fn validate_collections( ) -> (Vec, CollectionKinds) { let collections = dedupe( &declaration.collections, - |a, b| a.name == b.name, - |spec| spec.origin, - |a, b| a.same_shape_ignoring_indexes(b), - |kept, next| { - for index in &next.indexes { - kept.indexes.push(index.clone()); - } - }, |spec| DeclarationPath::collection(&spec.name), - "collection", - || DiagnosticKind::DuplicateCollection, diagnostics, ); @@ -47,7 +46,11 @@ pub(super) fn validate_collections( let kinds = manifests .iter() - .map(|manifest| (manifest.name.clone(), manifest.kind)) + .map(|manifest| CollectionSummary { + name: manifest.name.clone(), + kind: manifest.kind, + mutable: manifest.mutable, + }) .collect(); (manifests, kinds) } @@ -156,17 +159,7 @@ fn validate_collection( let indexes = dedupe( &collection.indexes, - |a, b| a.name == b.name, - |spec| spec.origin, - |a, b| { - let mut left = a.clone(); - left.origin = b.origin; - left == *b - }, - |_, _| {}, |spec| DeclarationPath::index(&collection.name, &spec.name), - "index", - || DiagnosticKind::DuplicateIndex, diagnostics, ); let mut index_manifests: Vec = indexes @@ -480,7 +473,7 @@ fn validate_index( } } if !count.is_countable() { - count = crate::declare::Countability::Countable; + count = Countability::Countable; } sum = Some(average.clone()); if index.range_average { @@ -595,17 +588,7 @@ pub(super) fn validate_typed_collections( ) -> Vec { let typed = dedupe( &declaration.typed_collections, - |a, b| a.id == b.id, - |spec| spec.origin, - |a, b| { - let mut left = a.clone(); - left.origin = b.origin; - left == *b - }, - |_, _| {}, |spec| DeclarationPath::typed_collection(&spec.id), - "typed collection", - || DiagnosticKind::DuplicateCollection, diagnostics, ); let mut manifests: Vec = typed diff --git a/packages/rs-dash-sdk-contract/src/validate/entries.rs b/packages/rs-dash-sdk-contract/src/validate/entries.rs index c43b92a98f7..74fabecacf2 100644 --- a/packages/rs-dash-sdk-contract/src/validate/entries.rs +++ b/packages/rs-dash-sdk-contract/src/validate/entries.rs @@ -4,7 +4,7 @@ use alloc::string::{String, ToString}; use alloc::vec::Vec; -use crate::declare::{ContractDeclaration, EntrySpec, Receiver, IMPLICIT_MODULE}; +use crate::declare::{implicit_module, ContractDeclaration, EntrySpec, Receiver}; use crate::identity::{entry_export_symbol, ModuleName}; use crate::manifest::{MethodEntry, MethodTable, ModuleTable}; use crate::validate::collections::{check_value_type, CollectionKinds}; @@ -19,17 +19,7 @@ pub(super) fn validate_entries( ) -> MethodTable { let entries = dedupe( &declaration.entries, - |a, b| a.name == b.name, - |spec| spec.origin, - |a, b| { - let mut left = a.clone(); - left.origin = b.origin; - left == *b - }, - |_, _| {}, |spec| DeclarationPath::entry(&spec.name), - "entry", - || DiagnosticKind::DuplicateMethod, diagnostics, ); @@ -57,14 +47,12 @@ pub(super) fn validate_entries( let module = resolve_module(entry, modules, single_module, &path, diagnostics); - let kind = entry.receiver.collection().and_then(|name| { - collections - .iter() - .find(|(candidate, _)| candidate == name) - .map(|(_, kind)| *kind) - }); + let summary = entry + .receiver + .collection() + .and_then(|name| collections.iter().find(|summary| &summary.name == name)); if let Some(name) = entry.receiver.collection() { - if kind.is_none() { + if summary.is_none() { diagnostics.push(Diagnostic::new( path.clone(), DiagnosticKind::ReceiverCollectionUnknown { @@ -73,28 +61,20 @@ pub(super) fn validate_entries( )); } } - if entry.receiver.is_mutable() { + if let Receiver::Mut(name) = &entry.receiver { if entry.read_only { diagnostics.push(Diagnostic::new( path.clone(), DiagnosticKind::ReadOnlyEntryWithMutableReceiver, )); } - if let Receiver::Mut(name) = &entry.receiver { - let immutable = declaration - .collections - .iter() - .find(|collection| &collection.name == name) - .map(|collection| !collection.mutable) - .unwrap_or(false); - if immutable { - diagnostics.push(Diagnostic::new( - path.clone(), - DiagnosticKind::MutableReceiverOnImmutableCollection { - collection: name.to_string(), - }, - )); - } + if summary.is_some_and(|summary| !summary.mutable) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::MutableReceiverOnImmutableCollection { + collection: name.to_string(), + }, + )); } } @@ -124,7 +104,10 @@ pub(super) fn validate_entries( module, export, receiver: entry.receiver.clone(), - takes_document_id: MethodEntry::takes_document_id(&entry.receiver, kind), + takes_document_id: MethodEntry::receiver_takes_document_id( + &entry.receiver, + summary.map(|summary| summary.kind), + ), read_only: entry.read_only, params: entry.params.clone(), returns: entry.returns.clone(), @@ -163,10 +146,7 @@ fn resolve_module( .modules .first() .map(|module| module.name.clone()) - .unwrap_or_else(|| { - ModuleName::new(IMPLICIT_MODULE) - .expect("the implicit module name satisfies the grammar") - }) + .unwrap_or_else(implicit_module) } } } diff --git a/packages/rs-dash-sdk-contract/src/validate/merge.rs b/packages/rs-dash-sdk-contract/src/validate/merge.rs index 334d688bbbe..d2905c4c61a 100644 --- a/packages/rs-dash-sdk-contract/src/validate/merge.rs +++ b/packages/rs-dash-sdk-contract/src/validate/merge.rs @@ -8,73 +8,271 @@ use alloc::string::ToString; use alloc::vec::Vec; -use crate::declare::DeclarationOrigin; +use crate::declare::{ + CollectionSpec, DeclarationOrigin, EntrySpec, IndexSpec, InterfaceSpec, ModuleSpec, RuleSpec, + TypedCollectionSpec, +}; use crate::validate::diagnostic::{DeclarationPath, Diagnostic, DiagnosticKind}; -/// How two specs sharing an identity relate. -pub(super) enum Overlap { - /// Same origin: a duplicate. - Duplicate, - /// Different origins, same content: keep the first. - Restatement, - /// Different origins, different content. - Conflict, +/// A spec with a declared identity and an origin. +pub(super) trait Identified: Clone { + /// What the item is called in a conflict diagnostic. + const WHAT: &'static str; + + /// Whether the two specs declare the same item. + fn same_identity(&self, other: &Self) -> bool; + + /// Where the spec came from. + fn origin(&self) -> DeclarationOrigin; + + /// Whether the two specs agree apart from origin (and whatever `merge` + /// may legitimately union). + fn equivalent(&self, other: &Self) -> bool; + + /// Unions what a restatement may add; nothing by default. + fn merge(&mut self, _other: &Self) {} + + /// The diagnostic for two specs of one origin sharing an identity. + fn duplicate() -> DiagnosticKind; +} + +/// Same content apart from origin. +fn equal_ignoring_origin(a: &T, b: &T) -> bool { + let mut left = a.clone(); + left.set_origin(b.origin()); + left == *b +} + +/// Origin access shared by the spec types. +trait HasOrigin { + fn origin(&self) -> DeclarationOrigin; + fn set_origin(&mut self, origin: DeclarationOrigin); +} + +macro_rules! has_origin { + ($($ty:ty),* $(,)?) => { + $( + impl HasOrigin for $ty { + fn origin(&self) -> DeclarationOrigin { + self.origin + } + + fn set_origin(&mut self, origin: DeclarationOrigin) { + self.origin = origin; + } + } + )* + }; } -/// Classifies `kept` against `next`. -pub(super) fn classify( - kept_origin: DeclarationOrigin, - next_origin: DeclarationOrigin, - equivalent: bool, -) -> Overlap { - if kept_origin == next_origin { - Overlap::Duplicate - } else if equivalent { - Overlap::Restatement - } else { - Overlap::Conflict +has_origin!( + ModuleSpec, + InterfaceSpec, + CollectionSpec, + IndexSpec, + TypedCollectionSpec, + EntrySpec, + RuleSpec, +); + +impl Identified for ModuleSpec { + const WHAT: &'static str = "module"; + + fn same_identity(&self, other: &Self) -> bool { + self.name == other.name + } + + fn origin(&self) -> DeclarationOrigin { + self.origin + } + + fn equivalent(&self, other: &Self) -> bool { + sorted(&self.uses) == sorted(&other.uses) + } + + fn merge(&mut self, other: &Self) { + for interface in &other.uses { + if !self.uses.contains(interface) { + self.uses.push(interface.clone()); + } + } + } + + fn duplicate() -> DiagnosticKind { + DiagnosticKind::DuplicateModule } } -/// Deduplicates `items` by identity, reporting duplicates and conflicts, and -/// returns the kept specs in first-seen order. `merge` is called on a -/// restatement so the caller can union what may legitimately extend (the -/// indexes of a collection). -#[allow(clippy::too_many_arguments)] -pub(super) fn dedupe( +impl Identified for InterfaceSpec { + const WHAT: &'static str = "interface"; + + fn same_identity(&self, other: &Self) -> bool { + self.name == other.name + } + + fn origin(&self) -> DeclarationOrigin { + self.origin + } + + fn equivalent(&self, other: &Self) -> bool { + let mut left = self.functions.clone(); + left.sort_by(|a, b| a.name.cmp(&b.name)); + let mut right = other.functions.clone(); + right.sort_by(|a, b| a.name.cmp(&b.name)); + self.provider == other.provider && left == right + } + + fn duplicate() -> DiagnosticKind { + DiagnosticKind::DuplicateInterface + } +} + +impl Identified for CollectionSpec { + const WHAT: &'static str = "collection"; + + fn same_identity(&self, other: &Self) -> bool { + self.name == other.name + } + + fn origin(&self) -> DeclarationOrigin { + self.origin + } + + fn equivalent(&self, other: &Self) -> bool { + self.same_shape_ignoring_indexes(other) + } + + fn merge(&mut self, other: &Self) { + self.indexes.extend(other.indexes.iter().cloned()); + } + + fn duplicate() -> DiagnosticKind { + DiagnosticKind::DuplicateCollection + } +} + +impl Identified for IndexSpec { + const WHAT: &'static str = "index"; + + fn same_identity(&self, other: &Self) -> bool { + self.name == other.name + } + + fn origin(&self) -> DeclarationOrigin { + self.origin + } + + fn equivalent(&self, other: &Self) -> bool { + equal_ignoring_origin(self, other) + } + + fn duplicate() -> DiagnosticKind { + DiagnosticKind::DuplicateIndex + } +} + +impl Identified for TypedCollectionSpec { + const WHAT: &'static str = "typed collection"; + + fn same_identity(&self, other: &Self) -> bool { + self.id == other.id + } + + fn origin(&self) -> DeclarationOrigin { + self.origin + } + + fn equivalent(&self, other: &Self) -> bool { + equal_ignoring_origin(self, other) + } + + fn duplicate() -> DiagnosticKind { + DiagnosticKind::DuplicateCollection + } +} + +impl Identified for EntrySpec { + const WHAT: &'static str = "entry"; + + fn same_identity(&self, other: &Self) -> bool { + self.name == other.name + } + + fn origin(&self) -> DeclarationOrigin { + self.origin + } + + fn equivalent(&self, other: &Self) -> bool { + equal_ignoring_origin(self, other) + } + + fn duplicate() -> DiagnosticKind { + DiagnosticKind::DuplicateMethod + } +} + +impl Identified for RuleSpec { + const WHAT: &'static str = "rule"; + + fn same_identity(&self, other: &Self) -> bool { + self.collection == other.collection && self.name == other.name + } + + fn origin(&self) -> DeclarationOrigin { + self.origin + } + + fn equivalent(&self, other: &Self) -> bool { + let mut left = self.clone(); + left.origin = other.origin; + left.actions = sorted(&left.actions); + let mut right = other.clone(); + right.actions = sorted(&right.actions); + left == right + } + + fn duplicate() -> DiagnosticKind { + DiagnosticKind::DuplicateRule + } +} + +/// Sorted and deduplicated copy. +pub(super) fn sorted(items: &[T]) -> Vec { + let mut sorted = items.to_vec(); + sorted.sort(); + sorted.dedup(); + sorted +} + +/// Deduplicates `items` by identity, reporting duplicates and conflicts at +/// `path`, and returns the kept specs in first-seen order. +pub(super) fn dedupe( items: &[T], - same_identity: impl Fn(&T, &T) -> bool, - origin: impl Fn(&T) -> DeclarationOrigin, - equivalent: impl Fn(&T, &T) -> bool, - merge: impl Fn(&mut T, &T), path: impl Fn(&T) -> DeclarationPath, - what: &str, - duplicate: impl Fn() -> DiagnosticKind, diagnostics: &mut Vec, ) -> Vec { let mut kept: Vec = Vec::new(); for item in items { - match kept + let Some(existing) = kept .iter_mut() - .find(|existing| same_identity(existing, item)) - { - None => kept.push(item.clone()), - Some(existing) => { - match classify(origin(existing), origin(item), equivalent(existing, item)) { - Overlap::Duplicate => { - diagnostics.push(Diagnostic::new(path(item), duplicate())) - } - Overlap::Restatement => merge(existing, item), - Overlap::Conflict => diagnostics.push(Diagnostic::new( - path(item), - DiagnosticKind::ConflictingDeclaration { - what: what.to_string(), - first: origin(existing), - second: origin(item), - }, - )), - } - } + .find(|existing| existing.same_identity(item)) + else { + kept.push(item.clone()); + continue; + }; + if existing.origin() == item.origin() { + diagnostics.push(Diagnostic::new(path(item), T::duplicate())); + } else if existing.equivalent(item) { + existing.merge(item); + } else { + diagnostics.push(Diagnostic::new( + path(item), + DiagnosticKind::ConflictingDeclaration { + what: T::WHAT.to_string(), + first: existing.origin(), + second: item.origin(), + }, + )); } } kept diff --git a/packages/rs-dash-sdk-contract/src/validate/modules.rs b/packages/rs-dash-sdk-contract/src/validate/modules.rs index 0d6e34d32c4..e1f2175468b 100644 --- a/packages/rs-dash-sdk-contract/src/validate/modules.rs +++ b/packages/rs-dash-sdk-contract/src/validate/modules.rs @@ -4,11 +4,13 @@ use alloc::string::ToString; use alloc::vec::Vec; -use crate::declare::{ContractDeclaration, InterfaceSpec, ModuleSpec, IMPLICIT_MODULE}; +use crate::declare::{ + implicit_module, ContractDeclaration, InterfaceSpec, InternalFunctionSpec, ModuleSpec, +}; use crate::identity::{InterfaceName, ModuleName}; use crate::manifest::{Binding, InterfaceEntry, ModuleEntry, ModuleTable}; use crate::validate::diagnostic::{DeclarationPath, Diagnostic, DiagnosticKind}; -use crate::validate::merge::dedupe; +use crate::validate::merge::{dedupe, sorted}; pub(super) fn validate_modules( declaration: &ContractDeclaration, @@ -16,37 +18,16 @@ pub(super) fn validate_modules( ) -> ModuleTable { let mut modules = dedupe( &declaration.modules, - |a, b| a.name == b.name, - |spec| spec.origin, - |a, b| sorted_uses(a) == sorted_uses(b), - |kept, next| { - for interface in &next.uses { - if !kept.uses.contains(interface) { - kept.uses.push(interface.clone()); - } - } - }, |spec| DeclarationPath::module(&spec.name), - "module", - || DiagnosticKind::DuplicateModule, diagnostics, ); if modules.is_empty() { - modules.push(ModuleSpec::new( - ModuleName::new(IMPLICIT_MODULE) - .expect("the implicit module name satisfies the grammar"), - )); + modules.push(ModuleSpec::new(implicit_module())); } let interfaces = dedupe( &declaration.interfaces, - |a, b| a.name == b.name, - |spec| spec.origin, - |a, b| a.provider == b.provider && sorted_functions(a) == sorted_functions(b), - |_, _| {}, |spec| DeclarationPath::interface(&spec.name), - "interface", - || DiagnosticKind::DuplicateInterface, diagnostics, ); @@ -160,13 +141,10 @@ pub(super) fn validate_modules( } fn sorted_uses(module: &ModuleSpec) -> Vec { - let mut uses = module.uses.clone(); - uses.sort(); - uses.dedup(); - uses + sorted(&module.uses) } -fn sorted_functions(interface: &InterfaceSpec) -> Vec { +fn sorted_functions(interface: &InterfaceSpec) -> Vec { let mut functions = interface.functions.clone(); functions.sort_by(|a, b| a.name.cmp(&b.name)); functions diff --git a/packages/rs-dash-sdk-contract/src/validate/rules.rs b/packages/rs-dash-sdk-contract/src/validate/rules.rs index d0f8fb618dd..fb8575f5f46 100644 --- a/packages/rs-dash-sdk-contract/src/validate/rules.rs +++ b/packages/rs-dash-sdk-contract/src/validate/rules.rs @@ -7,7 +7,7 @@ use crate::declare::{ContractDeclaration, FieldContext, RuleKind}; use crate::manifest::{CollectionManifest, ModuleTable, RuleManifest}; use crate::validate::collections::has_property_path; use crate::validate::diagnostic::{DeclarationPath, Diagnostic, DiagnosticKind}; -use crate::validate::merge::dedupe; +use crate::validate::merge::{dedupe, sorted}; pub(super) fn validate_rules( declaration: &ContractDeclaration, @@ -17,22 +17,7 @@ pub(super) fn validate_rules( ) -> Vec { let rules = dedupe( &declaration.rules, - |a, b| a.collection == b.collection && a.name == b.name, - |spec| spec.origin, - |a, b| { - let mut left = a.clone(); - left.origin = b.origin; - left.actions.sort(); - left.actions.dedup(); - let mut right = b.clone(); - right.actions.sort(); - right.actions.dedup(); - left == right - }, - |_, _| {}, |spec| DeclarationPath::rule(&spec.collection, &spec.name), - "rule", - || DiagnosticKind::DuplicateRule, diagnostics, ); @@ -51,9 +36,7 @@ pub(super) fn validate_rules( }, )); } - let mut actions = rule.actions.clone(); - actions.sort(); - actions.dedup(); + let actions = sorted(&rule.actions); if actions.is_empty() { diagnostics.push(Diagnostic::new( path.clone(), diff --git a/packages/rs-dash-sdk-contract/src/validate/tests.rs b/packages/rs-dash-sdk-contract/src/validate/tests.rs index aa9360857c0..d68623e7b75 100644 --- a/packages/rs-dash-sdk-contract/src/validate/tests.rs +++ b/packages/rs-dash-sdk-contract/src/validate/tests.rs @@ -8,7 +8,7 @@ use alloc::vec::Vec; use crate::declare::*; use crate::identity::*; use crate::manifest::CanonicalManifest; -use crate::validate::{validate, Diagnostic, DiagnosticKind}; +use crate::validate::{validate, DeclarationPath, Diagnostic, DiagnosticKind}; fn collection(name: &str) -> CollectionName { CollectionName::new(name).unwrap() @@ -374,10 +374,10 @@ fn should_report_duplicate_token_cost_action() { } #[test] -fn should_report_duplicate_export_symbol_when_the_scheme_collides() { - // The scheme is injective today, so the diagnostic is reached through the - // method identity check: identical names are `DuplicateMethod`, and the - // export check sees the same symbol twice. +fn should_keep_export_symbols_distinct_for_distinct_method_names() { + // The export scheme is injective, so `DuplicateExportSymbol` is a defence + // against a future scheme change and is not producible today: identical + // names are caught as a duplicate or conflicting method first. let declaration = ContractDeclaration::new() .entry(EntrySpec::new(method("a.b")).with_origin(DeclarationOrigin::Attribute)) .entry( @@ -403,10 +403,7 @@ fn should_report_duplicate_export_symbol_when_the_scheme_collides() { #[test] fn should_report_invalid_name_through_the_newtypes() { let error = CollectionName::new("bad name").unwrap_err(); - let diagnostic = Diagnostic::invalid_name( - crate::validate::DeclarationPath::collection("bad name"), - error, - ); + let diagnostic = Diagnostic::invalid_name(DeclarationPath::collection("bad name"), error); assert_eq!(diagnostic.kind.name(), "InvalidName"); assert_eq!(diagnostic.code(), "DSC0020"); } From 0cd825916c4409e12f8567314afe227232c559c3 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Sat, 12 Sep 2026 20:42:11 -0500 Subject: [PATCH 4/9] fix: make declaration merging order independent and bound interface wire types Group declarations by identity before judging them, so a repeated declaration of one origin is a duplicate and a disagreeing attribute and builder pair is a conflict whatever the declaration order. Run the bounded-value check on every interface function parameter and return under an interface parameter path. Refs #4680 Co-Authored-By: Claude Fable 5.1 --- .../src/validate/diagnostic.rs | 39 ++++++- .../src/validate/merge.rs | 83 ++++++++------ .../src/validate/modules.rs | 11 +- .../src/validate/tests.rs | 105 ++++++++++++++++++ 4 files changed, 202 insertions(+), 36 deletions(-) diff --git a/packages/rs-dash-sdk-contract/src/validate/diagnostic.rs b/packages/rs-dash-sdk-contract/src/validate/diagnostic.rs index 823d98c4646..a45774b0100 100644 --- a/packages/rs-dash-sdk-contract/src/validate/diagnostic.rs +++ b/packages/rs-dash-sdk-contract/src/validate/diagnostic.rs @@ -60,6 +60,15 @@ pub enum DeclarationPath { }, /// A capability requirement. Capability(CapabilityRequirement), + /// An interface function parameter or its return value. + InterfaceParam { + /// The interface. + interface: String, + /// The function. + function: String, + /// The parameter name, or `return`. + param: String, + }, } impl DeclarationPath { @@ -127,6 +136,19 @@ impl DeclarationPath { pub fn typed_collection(name: impl AsRef) -> Self { DeclarationPath::TypedCollection(name.as_ref().to_string()) } + + /// An interface function parameter path. + pub fn interface_param( + interface: impl AsRef, + function: impl AsRef, + param: impl AsRef, + ) -> Self { + DeclarationPath::InterfaceParam { + interface: interface.as_ref().to_string(), + function: function.as_ref().to_string(), + param: param.as_ref().to_string(), + } + } } impl fmt::Display for DeclarationPath { @@ -155,6 +177,14 @@ impl fmt::Display for DeclarationPath { write!(f, "collection {collection}, rule {rule}") } DeclarationPath::Capability(requirement) => write!(f, "capability {requirement}"), + DeclarationPath::InterfaceParam { + interface, + function, + param, + } => write!( + f, + "interface {interface}, function {function}, parameter {param}" + ), } } } @@ -220,13 +250,15 @@ pub enum DiagnosticKind { /// Why they conflict. reason: String, }, - /// An attribute and a builder declare the same item differently. + /// An attribute and a builder declare the same item differently. The + /// origins are reported attribute first and builder second whatever the + /// declaration order. ConflictingDeclaration { /// What kind of item. what: String, - /// The first declaration's origin. + /// The attribute origin. first: DeclarationOrigin, - /// The second declaration's origin. + /// The builder origin. second: DeclarationOrigin, }, /// Two declarations of the same origin use one collection name. @@ -755,6 +787,7 @@ mod tests { DeclarationPath::entry_param("score.add", "delta"), DeclarationPath::rule("scores", "monotonic"), DeclarationPath::Capability(CapabilityRequirement::PrivateStore), + DeclarationPath::interface_param("math", "add", "return"), ]; for path in paths { assert!(!path.to_string().is_empty()); diff --git a/packages/rs-dash-sdk-contract/src/validate/merge.rs b/packages/rs-dash-sdk-contract/src/validate/merge.rs index d2905c4c61a..057e353c5b7 100644 --- a/packages/rs-dash-sdk-contract/src/validate/merge.rs +++ b/packages/rs-dash-sdk-contract/src/validate/merge.rs @@ -90,14 +90,6 @@ impl Identified for ModuleSpec { sorted(&self.uses) == sorted(&other.uses) } - fn merge(&mut self, other: &Self) { - for interface in &other.uses { - if !self.uses.contains(interface) { - self.uses.push(interface.clone()); - } - } - } - fn duplicate() -> DiagnosticKind { DiagnosticKind::DuplicateModule } @@ -244,36 +236,63 @@ pub(super) fn sorted(items: &[T]) -> Vec { sorted } -/// Deduplicates `items` by identity, reporting duplicates and conflicts at -/// `path`, and returns the kept specs in first-seen order. +/// Deduplicates `items` by identity and returns one spec per identity in +/// first-seen order. +/// +/// Items are grouped by identity before anything is judged, so the outcome +/// does not depend on declaration order: every repeated declaration of one +/// origin is a duplicate whatever its content, every attribute-versus-builder +/// pair that disagrees is a conflict, and a builder that agrees with the +/// attribute is merged into it (which, for a collection, adds its indexes). pub(super) fn dedupe( items: &[T], path: impl Fn(&T) -> DeclarationPath, diagnostics: &mut Vec, ) -> Vec { - let mut kept: Vec = Vec::new(); + let mut groups: Vec> = Vec::new(); for item in items { - let Some(existing) = kept - .iter_mut() - .find(|existing| existing.same_identity(item)) - else { - kept.push(item.clone()); - continue; - }; - if existing.origin() == item.origin() { - diagnostics.push(Diagnostic::new(path(item), T::duplicate())); - } else if existing.equivalent(item) { - existing.merge(item); - } else { - diagnostics.push(Diagnostic::new( - path(item), - DiagnosticKind::ConflictingDeclaration { - what: T::WHAT.to_string(), - first: existing.origin(), - second: item.origin(), - }, - )); + match groups.iter_mut().find(|group| group[0].same_identity(item)) { + Some(group) => group.push(item), + None => groups.push(alloc::vec![item]), } } - kept + + groups + .into_iter() + .map(|group| { + let path = path(group[0]); + for origin in [DeclarationOrigin::Attribute, DeclarationOrigin::Builder] { + let repeats = group.iter().filter(|item| item.origin() == origin).count(); + for _ in 1..repeats { + diagnostics.push(Diagnostic::new(path.clone(), T::duplicate())); + } + } + let of = |origin: DeclarationOrigin| { + group + .iter() + .copied() + .filter(move |item| item.origin() == origin) + }; + let conflict = of(DeclarationOrigin::Attribute).any(|attribute| { + of(DeclarationOrigin::Builder).any(|builder| !attribute.equivalent(builder)) + }); + if conflict { + diagnostics.push(Diagnostic::new( + path, + DiagnosticKind::ConflictingDeclaration { + what: T::WHAT.to_string(), + first: DeclarationOrigin::Attribute, + second: DeclarationOrigin::Builder, + }, + )); + } + let mut kept = group[0].clone(); + for item in &group[1..] { + if kept.equivalent(item) { + kept.merge(item); + } + } + kept + }) + .collect() } diff --git a/packages/rs-dash-sdk-contract/src/validate/modules.rs b/packages/rs-dash-sdk-contract/src/validate/modules.rs index e1f2175468b..b79182fda5a 100644 --- a/packages/rs-dash-sdk-contract/src/validate/modules.rs +++ b/packages/rs-dash-sdk-contract/src/validate/modules.rs @@ -9,6 +9,7 @@ use crate::declare::{ }; use crate::identity::{InterfaceName, ModuleName}; use crate::manifest::{Binding, InterfaceEntry, ModuleEntry, ModuleTable}; +use crate::validate::collections::check_value_type; use crate::validate::diagnostic::{DeclarationPath, Diagnostic, DiagnosticKind}; use crate::validate::merge::{dedupe, sorted}; @@ -56,9 +57,11 @@ pub(super) fn validate_modules( } let mut params: Vec<&str> = Vec::new(); for param in &function.params { + let param_path = + DeclarationPath::interface_param(&interface.name, &function.name, ¶m.name); if params.contains(¶m.name.as_str()) { diagnostics.push(Diagnostic::new( - DeclarationPath::interface(&interface.name), + param_path.clone(), DiagnosticKind::DuplicateParameter { param: param.name.clone(), }, @@ -66,7 +69,13 @@ pub(super) fn validate_modules( } else { params.push(¶m.name); } + check_value_type(¶m_path, ¶m.ty, diagnostics); } + check_value_type( + &DeclarationPath::interface_param(&interface.name, &function.name, "return"), + &function.returns, + diagnostics, + ); } } diff --git a/packages/rs-dash-sdk-contract/src/validate/tests.rs b/packages/rs-dash-sdk-contract/src/validate/tests.rs index d68623e7b75..3099abade81 100644 --- a/packages/rs-dash-sdk-contract/src/validate/tests.rs +++ b/packages/rs-dash-sdk-contract/src/validate/tests.rs @@ -226,6 +226,73 @@ fn should_report_conflicting_declaration_between_attribute_and_builder() { assert_eq!(*second, DeclarationOrigin::Builder); } +#[test] +fn should_report_conflicting_declaration_attribute_first_whatever_the_order() { + let attribute = minimal("scores").with_origin(DeclarationOrigin::Attribute); + let builder = minimal("scores") + .with_origin(DeclarationOrigin::Builder) + .mutable(false); + let declaration = ContractDeclaration::new() + .collection(builder) + .collection(attribute); + let diagnostics = expect_diagnostics(&declaration); + assert_eq!(kinds(&diagnostics), ["ConflictingDeclaration"]); + let DiagnosticKind::ConflictingDeclaration { first, second, .. } = &diagnostics[0].kind else { + panic!() + }; + assert_eq!(*first, DeclarationOrigin::Attribute); + assert_eq!(*second, DeclarationOrigin::Builder); +} + +#[test] +fn should_report_duplicate_builder_declarations_whatever_the_order() { + let attribute = minimal("scores").with_origin(DeclarationOrigin::Attribute); + let first = minimal("scores") + .with_origin(DeclarationOrigin::Builder) + .index(IndexSpec::new(index_name("by_a"), vec![path("a")])); + let second = minimal("scores") + .with_origin(DeclarationOrigin::Builder) + .index(IndexSpec::new(index_name("by_a_too"), vec![path("a")])); + let orders = [ + [attribute.clone(), first.clone(), second.clone()], + [first.clone(), attribute.clone(), second.clone()], + [first, second, attribute], + ]; + for order in orders { + let mut declaration = ContractDeclaration::new(); + for collection in order { + declaration = declaration.collection(collection); + } + let diagnostics = expect_diagnostics(&declaration); + assert_eq!(kinds(&diagnostics), ["DuplicateCollection"]); + } +} + +#[test] +fn should_report_both_a_duplicate_and_a_conflict_whatever_the_order() { + let attribute = minimal("scores").with_origin(DeclarationOrigin::Attribute); + let agreeing = minimal("scores").with_origin(DeclarationOrigin::Builder); + let disagreeing = minimal("scores") + .with_origin(DeclarationOrigin::Builder) + .mutable(false); + let orders = [ + [attribute.clone(), agreeing.clone(), disagreeing.clone()], + [attribute.clone(), disagreeing.clone(), agreeing.clone()], + [disagreeing, agreeing, attribute], + ]; + for order in orders { + let mut declaration = ContractDeclaration::new(); + for collection in order { + declaration = declaration.collection(collection); + } + let diagnostics = expect_diagnostics(&declaration); + assert_eq!( + kinds(&diagnostics), + ["DuplicateCollection", "ConflictingDeclaration"] + ); + } +} + #[test] fn should_let_a_builder_extend_an_attribute_collection_with_indexes() { let attribute = minimal("scores").with_origin(DeclarationOrigin::Attribute); @@ -918,6 +985,44 @@ fn should_report_duplicate_interface_function() { assert_reports(&declaration, "DuplicateInterfaceFunction"); } +#[test] +fn should_report_unbounded_field_in_interface_parameters_and_returns() { + let declaration = ContractDeclaration::new() + .module(ModuleSpec::new(module("main"))) + .interface( + InterfaceSpec::new(interface("text"), module("main")).function( + "join", + vec![ + ParamSpec { + name: "parts".to_string(), + ty: ValueType::list( + 8, + ValueType::Struct(vec![( + "text".to_string(), + ValueType::String { max_chars: None }, + )]), + ), + }, + ParamSpec { + name: "separator".to_string(), + ty: ValueType::string(4), + }, + ], + ValueType::option(ValueType::Bytes { max_len: None }), + ), + ); + let diagnostics = expect_diagnostics(&declaration); + assert_eq!(kinds(&diagnostics), ["UnboundedField", "UnboundedField"]); + assert_eq!( + diagnostics[0].path().to_string(), + "interface text, function join, parameter parts" + ); + assert_eq!( + diagnostics[1].path().to_string(), + "interface text, function join, parameter return" + ); +} + #[test] fn should_bind_a_dag_of_modules_and_sort_bindings() { let declaration = ContractDeclaration::new() From 50e137420410dd291310cc64678af917ecb0c4c5 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Tue, 15 Sep 2026 16:30:40 -0500 Subject: [PATCH 5/9] fix: keep aggregate options explicit and canonicalize order-insensitive members Count, range count and range sum on collections and indexes are now optional so that average sugar promotes only what the author omitted and reports an explicit false as a conflicting option, matching the native parser. Reference paths resolve from the document root at every nesting level, permanent-document agreements are sorted and reject duplicate referring keys, and a restated collection compares fields, token costs, agreements, contested matches and ranked levels in canonical order so a reordering is never a conflict. Strings and byte arrays reject inverted length bounds, wire structs and contested matches reject duplicate keys, the cycle search states its stack invariant, and the canonical manifest exposes its tables through accessors only. Refs #4680 Co-Authored-By: Claude Fable 5.1 --- book/src/dashvm/contract-declarations.md | 22 +- .../src/declare/collection.rs | 63 +++- .../rs-dash-sdk-contract/src/declare/index.rs | 51 ++- .../rs-dash-sdk-contract/src/manifest/mod.rs | 53 +++- .../src/validate/collections.rs | 228 +++++++++++--- .../src/validate/diagnostic.rs | 42 +++ .../src/validate/merge.rs | 2 +- .../src/validate/modules.rs | 7 +- .../src/validate/tests.rs | 297 +++++++++++++++++- .../tests/alloc_profile.rs | 2 +- 10 files changed, 665 insertions(+), 102 deletions(-) diff --git a/book/src/dashvm/contract-declarations.md b/book/src/dashvm/contract-declarations.md index 6c0fa222d0e..3d5b99da628 100644 --- a/book/src/dashvm/contract-declarations.md +++ b/book/src/dashvm/contract-declarations.md @@ -204,13 +204,15 @@ The validator owns what the native host cannot know: derived capabilities cannot be required explicitly). It deliberately does not mirror native numeric limits (ten indexes per type, -32-character index names, 63-character indexed strings, one hundred -properties, time-range caps) or native schema dependencies (a range count -needs a count, a ranking needs its range axis, a prefix ranking excludes sum -axes, a time range needs a system timestamp). Those are enforced once, by Dash -Platform Protocol, and the build crate surfaces them by running the real -contract validation. Two definitions of "valid" would drift the day one of -them changed. +63-character indexed strings, one hundred properties, time-range caps) or +native schema dependencies (a range count needs a count, a ranking needs its +range axis, a prefix ranking excludes sum axes, a time range needs a system +timestamp). Those are enforced once, by Dash Platform Protocol, and the build +crate surfaces them by running the real contract validation. Two definitions +of "valid" would drift the day one of them changed. The exception is the +identity grammar: collection, property, property path and index name limits +intentionally match the native rules because on-chain identity must agree; +see the identity table above. ## Persistence @@ -244,8 +246,10 @@ agreement, identity public key) is a `FieldType::Reference`. Average sugar (`average = "p"`, `range_average`) is expanded into `count` plus `sum = "p"` and `range_count` plus `range_sum` before the manifest, -with the same conflict rules the native parser applies to `averageable`; the -manifest has no average fields. +with the same conflict rules the native parser applies to `averageable`: an +omitted option is promoted, an explicit `count = false`, `range_count = +false` or `range_sum = false` next to the sugar is a `ConflictingOption`. +The manifest has no average fields. Index definitions on existing document types are frozen by the existing versioned native update validation: adding, removing or changing an index on diff --git a/packages/rs-dash-sdk-contract/src/declare/collection.rs b/packages/rs-dash-sdk-contract/src/declare/collection.rs index 5d85f4ef6c8..34ed4dd2e16 100644 --- a/packages/rs-dash-sdk-contract/src/declare/collection.rs +++ b/packages/rs-dash-sdk-contract/src/declare/collection.rs @@ -4,7 +4,7 @@ use alloc::string::String; use alloc::vec::Vec; -use super::field::FieldSpec; +use super::field::{FieldSpec, FieldType, ReferenceTarget}; use super::index::IndexSpec; use super::rule::ActionScope; use super::DeclarationOrigin; @@ -192,14 +192,16 @@ pub struct CollectionSpec { pub encryption_key: Option, /// Identity decryption bounded key requirement. pub decryption_key: Option, - /// Count tree on the primary key. - pub count: bool, - /// Provable count on the primary key. - pub range_count: bool, + /// Count tree on the primary key. `None` when the author said nothing, so + /// that average sugar may promote it; an explicit `Some(false)` next to + /// `average` is a conflict, as it is natively. + pub count: Option, + /// Provable count on the primary key; explicitness as for `count`. + pub range_count: Option, /// Integer property summed on the primary key. pub sum: Option, - /// Provable sum on the primary key. - pub range_sum: bool, + /// Provable sum on the primary key; explicitness as for `count`. + pub range_sum: Option, /// Sugar for `count` plus `sum`; expanded by the validator, never stored in /// the manifest. pub average: Option, @@ -240,10 +242,10 @@ impl CollectionSpec { security_level: SecurityLevel::default(), encryption_key: None, decryption_key: None, - count: false, - range_count: false, + count: None, + range_count: None, sum: None, - range_sum: false, + range_sum: None, average: None, range_average: false, index_only: false, @@ -351,13 +353,13 @@ impl CollectionSpec { /// Counts documents on the primary key. pub fn count(mut self, count: bool) -> Self { - self.count = count; + self.count = Some(count); self } /// Provable counts on the primary key. pub fn range_count(mut self, range_count: bool) -> Self { - self.range_count = range_count; + self.range_count = Some(range_count); self } @@ -369,7 +371,7 @@ impl CollectionSpec { /// Provable sums on the primary key. pub fn range_sum(mut self, range_sum: bool) -> Self { - self.range_sum = range_sum; + self.range_sum = Some(range_sum); self } @@ -421,14 +423,41 @@ impl CollectionSpec { self } - /// Whether two specs describe the same collection apart from origin and - /// indexes. Used to tell a restatement or extension from a conflict. + /// Whether two specs describe the same collection apart from origin, + /// indexes and the order of order-insensitive members (fields at every + /// nesting level, token costs, reference agreements). Used to tell a + /// restatement or extension from a conflict. pub fn same_shape_ignoring_indexes(&self, other: &CollectionSpec) -> bool { - let mut left = self.clone(); - let mut right = other.clone(); + let mut left = self.normalized(); + let mut right = other.normalized(); left.origin = right.origin; left.indexes = Vec::new(); right.indexes = Vec::new(); left == right } + + /// A copy with every order-insensitive member in canonical order: fields + /// by position at every nesting level, token costs by action, reference + /// agreements by referring property. + pub fn normalized(&self) -> CollectionSpec { + let mut normalized = self.clone(); + normalize_fields(&mut normalized.fields); + normalized + .token_costs + .sort_by(|a, b| a.action.cmp(&b.action)); + normalized + } +} + +fn normalize_fields(fields: &mut [FieldSpec]) { + for field in fields.iter_mut() { + match &mut field.ty { + FieldType::Object(nested) => normalize_fields(nested), + FieldType::Reference(ReferenceTarget::PermanentDocument { agreement, .. }) => { + agreement.sort(); + } + _ => {} + } + } + fields.sort_by(|a, b| a.position.cmp(&b.position).then(a.name.cmp(&b.name))); } diff --git a/packages/rs-dash-sdk-contract/src/declare/index.rs b/packages/rs-dash-sdk-contract/src/declare/index.rs index 288490df4f6..1c34689ec1b 100644 --- a/packages/rs-dash-sdk-contract/src/declare/index.rs +++ b/packages/rs-dash-sdk-contract/src/declare/index.rs @@ -131,14 +131,16 @@ pub struct IndexSpec { pub null_searchable: bool, /// Contested parameters. pub contested: Option, - /// Count fast path. - pub count: Countability, - /// Range counts. - pub range_count: bool, + /// Count fast path. `None` when the author said nothing, so that average + /// sugar may promote it; an explicit `Some(NotCountable)` next to + /// `average` is a conflict, as it is natively. + pub count: Option, + /// Range counts; explicitness as for `count`. + pub range_count: Option, /// Integer property summed at the index. pub sum: Option, - /// Range sums. - pub range_sum: bool, + /// Range sums; explicitness as for `count`. + pub range_sum: Option, /// Sugar for `count` plus `sum`; expanded by the validator. pub average: Option, /// Sugar for `range_count` plus `range_sum`; expanded by the validator. @@ -161,10 +163,10 @@ impl IndexSpec { unique: false, null_searchable: true, contested: None, - count: Countability::NotCountable, - range_count: false, + count: None, + range_count: None, sum: None, - range_sum: false, + range_sum: None, average: None, range_average: false, ranked: Ranking::default(), @@ -198,20 +200,24 @@ impl IndexSpec { } /// Selects a count tree. - pub fn count(mut self) -> Self { - self.count = Countability::Countable; - self + pub fn count(self) -> Self { + self.countability(Countability::Countable) } /// Selects a provable count tree. - pub fn count_allowing_offset(mut self) -> Self { - self.count = Countability::CountableAllowingOffset; + pub fn count_allowing_offset(self) -> Self { + self.countability(Countability::CountableAllowingOffset) + } + + /// Sets the count fast path explicitly, including `NotCountable`. + pub fn countability(mut self, count: Countability) -> Self { + self.count = Some(count); self } /// Enables range counts. pub fn range_count(mut self, range_count: bool) -> Self { - self.range_count = range_count; + self.range_count = Some(range_count); self } @@ -223,7 +229,7 @@ impl IndexSpec { /// Enables range sums. pub fn range_sum(mut self, range_sum: bool) -> Self { - self.range_sum = range_sum; + self.range_sum = Some(range_sum); self } @@ -274,4 +280,17 @@ impl IndexSpec { self.index_only = Some(options); self } + + /// A copy with every order-insensitive member in canonical order: + /// contested field matches by property and ranked levels by property. + pub fn normalized(&self) -> IndexSpec { + let mut normalized = self.clone(); + if let Some(contested) = normalized.contested.as_mut() { + contested.field_matches.sort(); + } + if let RankedCount::At(levels) = &mut normalized.ranked.count { + levels.sort(); + } + normalized + } } diff --git a/packages/rs-dash-sdk-contract/src/manifest/mod.rs b/packages/rs-dash-sdk-contract/src/manifest/mod.rs index 71bdb8b558c..beb747f720b 100644 --- a/packages/rs-dash-sdk-contract/src/manifest/mod.rs +++ b/packages/rs-dash-sdk-contract/src/manifest/mod.rs @@ -28,25 +28,58 @@ pub use method::{MethodEntry, MethodTable}; use crate::declare::ReceiptPolicy; /// The canonical manifest of one contract package. +/// +/// The tables are read through accessors only: a manifest exists solely as +/// the output of validation, and its invariants (a non-empty module table, +/// derived capabilities matching the declarations) hold because nothing +/// outside the validator can construct or edit one. #[derive(Clone, Debug, PartialEq, Eq)] pub struct CanonicalManifest { + pub(crate) modules: ModuleTable, + pub(crate) collections: Vec, + pub(crate) typed_collections: Vec, + pub(crate) methods: MethodTable, + pub(crate) rules: Vec, + pub(crate) capabilities: CapabilityTable, + pub(crate) receipts: ReceiptPolicy, +} + +impl CanonicalManifest { /// Modules, interfaces and bindings. - pub modules: ModuleTable, + pub fn modules(&self) -> &ModuleTable { + &self.modules + } + /// Document collections and singletons, sorted by name. - pub collections: Vec, + pub fn collections(&self) -> &[CollectionManifest] { + &self.collections + } + /// Typed specialized collections, sorted by id. - pub typed_collections: Vec, + pub fn typed_collections(&self) -> &[TypedCollectionManifest] { + &self.typed_collections + } + /// Entries, sorted by method name. - pub methods: MethodTable, + pub fn methods(&self) -> &MethodTable { + &self.methods + } + /// Rules, sorted by `(collection, name)`. - pub rules: Vec, - /// Required capabilities, sorted. - pub capabilities: CapabilityTable, + pub fn rules(&self) -> &[RuleManifest] { + &self.rules + } + + /// Required capabilities, explicit and derived, sorted. + pub fn capabilities(&self) -> &CapabilityTable { + &self.capabilities + } + /// Receipt policy. - pub receipts: ReceiptPolicy, -} + pub fn receipts(&self) -> ReceiptPolicy { + self.receipts + } -impl CanonicalManifest { /// Looks a collection up by name. pub fn collection(&self, name: &str) -> Option<&CollectionManifest> { self.collections diff --git a/packages/rs-dash-sdk-contract/src/validate/collections.rs b/packages/rs-dash-sdk-contract/src/validate/collections.rs index 8453ad44196..5e4bc634aa9 100644 --- a/packages/rs-dash-sdk-contract/src/validate/collections.rs +++ b/packages/rs-dash-sdk-contract/src/validate/collections.rs @@ -94,7 +94,14 @@ fn validate_collection( } } - let fields = validate_fields(&collection.name, "", &collection.fields, all, diagnostics); + let fields = validate_fields( + &collection.name, + "", + &collection.fields, + &collection.fields, + all, + diagnostics, + ); let mut token_costs = collection.token_costs.clone(); token_costs.sort_by(|a, b| a.action.cmp(&b.action)); @@ -114,36 +121,60 @@ fn validate_collection( // Collection-level average sugar: `average = p` is `count` plus `sum = p`; // `range_average` is `range_count` plus `range_sum`. The same conflict - // rules the native parser applies to `documentsAverageable`. - let mut count = collection.count; - let mut range_count = collection.range_count; + // rules the native parser applies to `documentsAverageable`: an omitted + // option is promoted silently, an explicit `false` next to the sugar is a + // contradiction the author must resolve. + let mut count = collection.count.unwrap_or(false); + let mut range_count = collection.range_count.unwrap_or(false); let mut sum = collection.sum.clone(); - let mut range_sum = collection.range_sum; + let mut range_sum = collection.range_sum.unwrap_or(false); if let Some(average) = &collection.average { if let Some(existing) = &sum { if existing != average { - diagnostics.push(Diagnostic::new( - path.clone(), - DiagnosticKind::ConflictingOption { - options: alloc::vec!["average".to_string(), "sum".to_string()], - reason: "both name the summed property, so they must agree".to_string(), - }, + diagnostics.push(conflicting_option( + &path, + "average", + "sum", + "both name the summed property, so they must agree", )); } } + if collection.count == Some(false) { + diagnostics.push(conflicting_option( + &path, + "average", + "count", + "average implies count; remove the explicit `count = false`", + )); + } count = true; sum = Some(average.clone()); if collection.range_average { + if collection.range_count == Some(false) { + diagnostics.push(conflicting_option( + &path, + "range_average", + "range_count", + "range_average implies range_count; remove the explicit `range_count = false`", + )); + } + if collection.range_sum == Some(false) { + diagnostics.push(conflicting_option( + &path, + "range_average", + "range_sum", + "range_average implies range_sum; remove the explicit `range_sum = false`", + )); + } range_count = true; range_sum = true; } } else if collection.range_average { - diagnostics.push(Diagnostic::new( - path.clone(), - DiagnosticKind::ConflictingOption { - options: alloc::vec!["range_average".to_string(), "average".to_string()], - reason: "range_average needs average to name the property".to_string(), - }, + diagnostics.push(conflicting_option( + &path, + "range_average", + "average", + "range_average needs average to name the property", )); } if let Some(summed) = &sum { @@ -196,6 +227,21 @@ fn validate_collection( } } +fn conflicting_option( + path: &DeclarationPath, + first: &str, + second: &str, + reason: &str, +) -> Diagnostic { + Diagnostic::new( + path.clone(), + DiagnosticKind::ConflictingOption { + options: alloc::vec![first.to_string(), second.to_string()], + reason: reason.to_string(), + }, + ) +} + fn join_path(prefix: &str, name: &str) -> alloc::string::String { if prefix.is_empty() { name.to_string() @@ -208,10 +254,14 @@ fn join_path(prefix: &str, name: &str) -> alloc::string::String { } /// Validates one nesting level of fields and returns them sorted by position. +/// `root` is the collection's top-level fields: reference paths resolve from +/// the document root, the way native registration resolves them through the +/// flattened properties, whatever the nesting level of the referring field. fn validate_fields( collection: &CollectionName, prefix: &str, fields: &[FieldSpec], + root: &[FieldSpec], all: &[&CollectionSpec], diagnostics: &mut Vec, ) -> Vec { @@ -239,7 +289,7 @@ fn validate_fields( } positions.push(field.position); - let ty = validate_field_type(collection, &dotted, fields, &field.ty, all, diagnostics); + let ty = validate_field_type(collection, &dotted, root, &field.ty, all, diagnostics); let mut validated = field.clone(); validated.ty = ty; sorted.push(validated); @@ -272,7 +322,7 @@ fn validate_fields( fn validate_field_type( collection: &CollectionName, dotted: &str, - siblings: &[FieldSpec], + root: &[FieldSpec], ty: &FieldType, all: &[&CollectionSpec], diagnostics: &mut Vec, @@ -290,14 +340,29 @@ fn validate_field_type( diagnostics.push(Diagnostic::new(path, DiagnosticKind::UnboundedField)); ty.clone() } - FieldType::Reference(target) => { - validate_reference(&path, target, siblings, all, diagnostics); + FieldType::String { + min_chars: Some(min), + max_chars: Some(max), + } + | FieldType::Bytes { + min_len: Some(min), + max_len: Some(max), + } if min > max => { + diagnostics.push(Diagnostic::new( + path, + DiagnosticKind::LengthBoundsInverted { + min: *min, + max: *max, + }, + )); ty.clone() } + FieldType::Reference(target) => validate_reference(&path, target, root, all, diagnostics), FieldType::Object(nested) => FieldType::Object(validate_fields( collection, dotted, nested, + root, all, diagnostics, )), @@ -344,15 +409,19 @@ pub(super) fn check_integer_bounds( } } +/// Validates a reference target against the declaring collection's root +/// fields and returns the type with its agreement in canonical order. fn validate_reference( path: &DeclarationPath, target: &ReferenceTarget, - siblings: &[FieldSpec], + root: &[FieldSpec], all: &[&CollectionSpec], diagnostics: &mut Vec, -) { +) -> FieldType { match target { - ReferenceTarget::Identity | ReferenceTarget::Contract | ReferenceTarget::Token => {} + ReferenceTarget::Identity | ReferenceTarget::Contract | ReferenceTarget::Token => { + FieldType::Reference(target.clone()) + } ReferenceTarget::PermanentDocument { contract, document_type, @@ -385,8 +454,9 @@ fn validate_reference( } } } + let mut seen: Vec<&PropertyPath> = Vec::new(); for (referring_property, _) in agreement { - if !has_property_path(siblings, referring_property) { + if !has_property_path(root, referring_property) { diagnostics.push(Diagnostic::new( path.clone(), DiagnosticKind::ReferencePropertyUnknown { @@ -394,10 +464,29 @@ fn validate_reference( }, )); } + if seen.contains(&referring_property) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::DuplicateAgreementProperty { + property: referring_property.to_string(), + }, + )); + } else { + seen.push(referring_property); + } } + // The agreement is a map keyed by the referring property; the + // manifest stores it sorted so declaration order never leaks in. + let mut agreement = agreement.clone(); + agreement.sort(); + FieldType::Reference(ReferenceTarget::PermanentDocument { + contract: *contract, + document_type: document_type.clone(), + agreement, + }) } ReferenceTarget::IdentityPublicKey { key_id_field } => { - if !has_property_path(siblings, key_id_field) { + if !has_property_path(root, key_id_field) { diagnostics.push(Diagnostic::new( path.clone(), DiagnosticKind::ReferencePropertyUnknown { @@ -405,6 +494,7 @@ fn validate_reference( }, )); } + FieldType::Reference(target.clone()) } } } @@ -455,38 +545,62 @@ fn validate_index( } // Index-level average sugar, expanded the way the native parser expands - // `averageable` / `rangeAverageable`. - let mut count = index.count; - let mut range_count = index.range_count; + // `averageable` / `rangeAverageable`: an omitted option is promoted, an + // explicit non-countable or `false` next to the sugar is a contradiction. + let mut count = index.count.unwrap_or_default(); + let mut range_count = index.range_count.unwrap_or(false); let mut sum = index.sum.clone(); - let mut range_sum = index.range_sum; + let mut range_sum = index.range_sum.unwrap_or(false); if let Some(average) = &index.average { if let Some(existing) = &sum { if existing != average { - diagnostics.push(Diagnostic::new( - path.clone(), - DiagnosticKind::ConflictingOption { - options: alloc::vec!["average".to_string(), "sum".to_string()], - reason: "both name the summed property, so they must agree".to_string(), - }, + diagnostics.push(conflicting_option( + &path, + "average", + "sum", + "both name the summed property, so they must agree", )); } } - if !count.is_countable() { - count = Countability::Countable; + match index.count { + None => count = Countability::Countable, + Some(explicit) if !explicit.is_countable() => { + diagnostics.push(conflicting_option( + &path, + "average", + "count", + "average implies a countable index; remove the explicit not-countable setting", + )); + } + Some(_) => {} } sum = Some(average.clone()); if index.range_average { + if index.range_count == Some(false) { + diagnostics.push(conflicting_option( + &path, + "range_average", + "range_count", + "range_average implies range_count; remove the explicit `range_count = false`", + )); + } + if index.range_sum == Some(false) { + diagnostics.push(conflicting_option( + &path, + "range_average", + "range_sum", + "range_average implies range_sum; remove the explicit `range_sum = false`", + )); + } range_count = true; range_sum = true; } } else if index.range_average { - diagnostics.push(Diagnostic::new( - path.clone(), - DiagnosticKind::ConflictingOption { - options: alloc::vec!["range_average".to_string(), "average".to_string()], - reason: "range_average needs average to name the property".to_string(), - }, + diagnostics.push(conflicting_option( + &path, + "range_average", + "average", + "range_average needs average to name the property", )); } if let Some(summed) = &sum { @@ -502,6 +616,7 @@ fn validate_index( let mut contested = index.contested.clone(); if let Some(contested) = contested.as_mut() { + let mut seen: Vec<&PropertyPath> = Vec::new(); for (property, _) in &contested.field_matches { if !index.properties.contains(property) { diagnostics.push(Diagnostic::new( @@ -511,8 +626,18 @@ fn validate_index( }, )); } + if seen.contains(&property) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::DuplicateContestedField { + property: property.to_string(), + }, + )); + } else { + seen.push(property); + } } - contested.field_matches.sort_by(|a, b| a.0.cmp(&b.0)); + contested.field_matches.sort(); } if let RankedCount::At(levels) = &index.ranked.count { @@ -643,7 +768,18 @@ pub(super) fn check_value_type( } ValueType::Option(inner) => check_value_type(path, inner, diagnostics), ValueType::Struct(members) => { - for (_, member) in members { + let mut seen: Vec<&str> = Vec::new(); + for (name, member) in members { + if seen.contains(&name.as_str()) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::DuplicateStructMember { + member: name.clone(), + }, + )); + } else { + seen.push(name); + } check_value_type(path, member, diagnostics); } } diff --git a/packages/rs-dash-sdk-contract/src/validate/diagnostic.rs b/packages/rs-dash-sdk-contract/src/validate/diagnostic.rs index a45774b0100..6202cc66d52 100644 --- a/packages/rs-dash-sdk-contract/src/validate/diagnostic.rs +++ b/packages/rs-dash-sdk-contract/src/validate/diagnostic.rs @@ -445,6 +445,28 @@ pub enum DiagnosticKind { /// database handle grammar, so there is nothing to reject. The variant /// documents that absence. RawPathDeclaration, + /// A string or byte array declares a minimum above its maximum. + LengthBoundsInverted { + /// The minimum. + min: u16, + /// The maximum. + max: u16, + }, + /// Two members of one wire struct share a name. + DuplicateStructMember { + /// The member. + member: String, + }, + /// A contested index names one field match property twice. + DuplicateContestedField { + /// The property path. + property: String, + }, + /// A reference agreement names one referring property twice. + DuplicateAgreementProperty { + /// The property path. + property: String, + }, } impl DiagnosticKind { @@ -540,6 +562,14 @@ impl DiagnosticKind { ("DSC0053", "CapabilityNotDeclarable") } DiagnosticKind::RawPathDeclaration => ("DSC0054", "RawPathDeclaration"), + DiagnosticKind::LengthBoundsInverted { .. } => ("DSC0055", "LengthBoundsInverted"), + DiagnosticKind::DuplicateStructMember { .. } => ("DSC0056", "DuplicateStructMember"), + DiagnosticKind::DuplicateContestedField { .. } => { + ("DSC0057", "DuplicateContestedField") + } + DiagnosticKind::DuplicateAgreementProperty { .. } => { + ("DSC0058", "DuplicateAgreementProperty") + } } } @@ -713,6 +743,18 @@ impl fmt::Display for DiagnosticKind { DiagnosticKind::RawPathDeclaration => { f.write_str("raw database paths are not declarable") } + DiagnosticKind::LengthBoundsInverted { min, max } => { + write!(f, "minimum length {min} is above maximum length {max}") + } + DiagnosticKind::DuplicateStructMember { member } => { + write!(f, "struct member `{member}` declared twice") + } + DiagnosticKind::DuplicateContestedField { property } => { + write!(f, "contested field match `{property}` declared twice") + } + DiagnosticKind::DuplicateAgreementProperty { property } => { + write!(f, "agreement property `{property}` declared twice") + } } } } diff --git a/packages/rs-dash-sdk-contract/src/validate/merge.rs b/packages/rs-dash-sdk-contract/src/validate/merge.rs index 057e353c5b7..bf18bb74573 100644 --- a/packages/rs-dash-sdk-contract/src/validate/merge.rs +++ b/packages/rs-dash-sdk-contract/src/validate/merge.rs @@ -155,7 +155,7 @@ impl Identified for IndexSpec { } fn equivalent(&self, other: &Self) -> bool { - equal_ignoring_origin(self, other) + equal_ignoring_origin(&self.normalized(), &other.normalized()) } fn duplicate() -> DiagnosticKind { diff --git a/packages/rs-dash-sdk-contract/src/validate/modules.rs b/packages/rs-dash-sdk-contract/src/validate/modules.rs index b79182fda5a..652d8651769 100644 --- a/packages/rs-dash-sdk-contract/src/validate/modules.rs +++ b/packages/rs-dash-sdk-contract/src/validate/modules.rs @@ -191,7 +191,12 @@ fn find_cycle(modules: &[&ModuleName], bindings: &[Binding]) -> Option { - let start = stack.iter().position(|&index| index == next).unwrap_or(0); + // An active module is on the stack by construction: it was + // pushed when marked and is popped only when marked done. + let start = stack + .iter() + .position(|&index| index == next) + .expect("an active module is on the search stack"); return Some( stack[start..] .iter() diff --git a/packages/rs-dash-sdk-contract/src/validate/tests.rs b/packages/rs-dash-sdk-contract/src/validate/tests.rs index 3099abade81..7db5764bdeb 100644 --- a/packages/rs-dash-sdk-contract/src/validate/tests.rs +++ b/packages/rs-dash-sdk-contract/src/validate/tests.rs @@ -1,7 +1,7 @@ //! Validator tests: the sketch, one `should_report_*` test per producible //! diagnostic, sugar expansion and manifest order independence. -use alloc::string::ToString; +use alloc::string::{String, ToString}; use alloc::vec; use alloc::vec::Vec; @@ -293,6 +293,69 @@ fn should_report_both_a_duplicate_and_a_conflict_whatever_the_order() { } } +#[test] +fn should_treat_a_reordered_restatement_as_equivalent() { + let ordered = CollectionSpec::documents(collection("c")) + .with_origin(DeclarationOrigin::Attribute) + .document_id_field("id") + .field(FieldSpec::new(property("a"), 0, FieldType::Bool)) + .field(FieldSpec::new( + property("nested"), + 1, + FieldType::Object(vec![ + FieldSpec::new(property("x"), 0, FieldType::Bool), + FieldSpec::new(property("y"), 1, FieldType::Bool), + ]), + )) + .token_cost(ActionScope::Create, TokenCost::new(0, 5)) + .token_cost(ActionScope::Delete, TokenCost::new(1, 3)) + .index( + IndexSpec::new(index_name("i"), vec![path("a")]) + .with_origin(DeclarationOrigin::Attribute) + .count() + .range_count(true) + .ranked_count_at(vec![path("a")]) + .contested(ContestedSpec::masternode_vote(vec![( + path("a"), + "^x".to_string(), + )])), + ); + let reordered = CollectionSpec::documents(collection("c")) + .with_origin(DeclarationOrigin::Builder) + .document_id_field("id") + .field(FieldSpec::new( + property("nested"), + 1, + FieldType::Object(vec![ + FieldSpec::new(property("y"), 1, FieldType::Bool), + FieldSpec::new(property("x"), 0, FieldType::Bool), + ]), + )) + .field(FieldSpec::new(property("a"), 0, FieldType::Bool)) + .token_cost(ActionScope::Delete, TokenCost::new(1, 3)) + .token_cost(ActionScope::Create, TokenCost::new(0, 5)) + .index( + IndexSpec::new(index_name("i"), vec![path("a")]) + .with_origin(DeclarationOrigin::Builder) + .count() + .range_count(true) + .ranked_count_at(vec![path("a")]) + .contested(ContestedSpec::masternode_vote(vec![( + path("a"), + "^x".to_string(), + )])), + ); + let merged = expect_manifest( + &ContractDeclaration::new() + .collection(ordered.clone()) + .collection(reordered), + ); + assert_eq!( + merged, + expect_manifest(&ContractDeclaration::new().collection(ordered)) + ); +} + #[test] fn should_let_a_builder_extend_an_attribute_collection_with_indexes() { let attribute = minimal("scores").with_origin(DeclarationOrigin::Attribute); @@ -518,6 +581,60 @@ fn should_report_unbounded_field_for_strings_bytes_and_lists() { ); } +#[test] +fn should_report_length_bounds_inverted() { + let spec = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new( + property("s"), + 0, + FieldType::String { + min_chars: Some(9), + max_chars: Some(8), + }, + )) + .field(FieldSpec::new( + property("b"), + 1, + FieldType::Bytes { + min_len: Some(2), + max_len: Some(1), + }, + )); + let diagnostics = expect_diagnostics(&ContractDeclaration::new().collection(spec)); + assert_eq!( + kinds(&diagnostics), + ["LengthBoundsInverted", "LengthBoundsInverted"] + ); +} + +#[test] +fn should_report_duplicate_struct_member() { + let declaration = ContractDeclaration::new().entry(EntrySpec::new(method("f")).returns( + ValueType::Struct(vec![ + ("count".to_string(), ValueType::Bool), + ("count".to_string(), ValueType::Bool), + ]), + )); + assert_reports(&declaration, "DuplicateStructMember"); +} + +#[test] +fn should_report_duplicate_contested_field() { + let spec = minimal("c").index( + IndexSpec::new(index_name("i"), vec![path("a")]) + .unique(true) + .contested(ContestedSpec::masternode_vote(vec![ + (path("a"), "^x".to_string()), + (path("a"), "^y".to_string()), + ])), + ); + assert_reports( + &ContractDeclaration::new().collection(spec), + "DuplicateContestedField", + ); +} + #[test] fn should_report_integer_bounds_outside_type() { let spec = CollectionSpec::documents(collection("c")) @@ -805,6 +922,110 @@ fn should_report_reference_property_unknown_for_agreements_and_key_ids() { ); } +#[test] +fn should_resolve_nested_reference_paths_from_the_document_root() { + let posts = CollectionSpec::documents(collection("posts")) + .document_id_field("id") + .deletable(false) + .field(FieldSpec::new(property("author"), 0, FieldType::identity())); + let likes = CollectionSpec::documents(collection("likes")) + .document_id_field("id") + .field(FieldSpec::new(property("author"), 0, FieldType::identity())) + .field(FieldSpec::new( + property("nested"), + 1, + FieldType::Object(vec![ + FieldSpec::new(property("key_id"), 0, FieldType::integer(IntegerWidth::U32)), + FieldSpec::new( + property("signer"), + 1, + FieldType::Reference(ReferenceTarget::IdentityPublicKey { + key_id_field: path("nested.key_id"), + }), + ), + FieldSpec::new( + property("post"), + 2, + FieldType::Reference(ReferenceTarget::PermanentDocument { + contract: None, + document_type: collection("posts"), + agreement: vec![(path("author"), path("author"))], + }), + ), + ]), + )); + expect_manifest( + &ContractDeclaration::new() + .collection(posts) + .collection(likes), + ); +} + +#[test] +fn should_report_reference_property_unknown_for_a_sibling_relative_path() { + // Native resolves reference paths from the document root, so a nested + // field naming its sibling without the object prefix is unknown. + let spec = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new( + property("nested"), + 0, + FieldType::Object(vec![ + FieldSpec::new(property("key_id"), 0, FieldType::integer(IntegerWidth::U32)), + FieldSpec::new( + property("signer"), + 1, + FieldType::Reference(ReferenceTarget::IdentityPublicKey { + key_id_field: path("key_id"), + }), + ), + ]), + )); + let diagnostics = expect_diagnostics(&ContractDeclaration::new().collection(spec)); + assert_eq!(kinds(&diagnostics), ["ReferencePropertyUnknown"]); +} + +#[test] +fn should_canonicalize_agreement_order_and_report_duplicate_agreement_property() { + let posts = CollectionSpec::documents(collection("posts")) + .document_id_field("id") + .deletable(false) + .field(FieldSpec::new(property("a"), 0, FieldType::identity())) + .field(FieldSpec::new(property("b"), 1, FieldType::identity())); + let likes = |agreement: Vec<(PropertyPath, PropertyPath)>| { + CollectionSpec::documents(collection("likes")) + .document_id_field("id") + .field(FieldSpec::new(property("a"), 0, FieldType::identity())) + .field(FieldSpec::new(property("b"), 1, FieldType::identity())) + .field(FieldSpec::new( + property("post"), + 2, + FieldType::Reference(ReferenceTarget::PermanentDocument { + contract: None, + document_type: collection("posts"), + agreement, + }), + )) + }; + let forward = expect_manifest( + &ContractDeclaration::new() + .collection(posts.clone()) + .collection(likes(vec![(path("a"), path("a")), (path("b"), path("b"))])), + ); + let backward = expect_manifest( + &ContractDeclaration::new() + .collection(posts.clone()) + .collection(likes(vec![(path("b"), path("b")), (path("a"), path("a"))])), + ); + assert_eq!(forward, backward); + let diagnostics = expect_diagnostics( + &ContractDeclaration::new() + .collection(posts) + .collection(likes(vec![(path("a"), path("a")), (path("a"), path("b"))])), + ); + assert_eq!(kinds(&diagnostics), ["DuplicateAgreementProperty"]); +} + #[test] fn should_accept_a_cross_contract_permanent_document_reference_without_checking_it() { let likes = CollectionSpec::documents(collection("likes")) @@ -1291,6 +1512,80 @@ fn should_report_conflicting_option_for_average_against_a_different_sum() { ); } +#[test] +fn should_report_conflicting_option_for_explicit_false_next_to_average_sugar() { + let spec = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new(property("class"), 0, FieldType::string(8))) + .field(FieldSpec::new( + property("points"), + 1, + FieldType::integer(IntegerWidth::I64), + )) + .count(false) + .range_count(false) + .range_sum(false) + .average(property("points")) + .range_average(true) + .index( + IndexSpec::new(index_name("i"), vec![path("class")]) + .countability(Countability::NotCountable) + .range_count(false) + .range_sum(false) + .average(property("points")) + .range_average(true), + ); + let diagnostics = expect_diagnostics(&ContractDeclaration::new().collection(spec)); + assert_eq!( + kinds(&diagnostics), + [ + "ConflictingOption", + "ConflictingOption", + "ConflictingOption", + "ConflictingOption", + "ConflictingOption", + "ConflictingOption" + ] + ); + let pairs: Vec> = diagnostics + .iter() + .map(|d| match &d.kind { + DiagnosticKind::ConflictingOption { options, .. } => options.clone(), + _ => panic!(), + }) + .collect(); + assert_eq!(pairs[0], ["average", "count"]); + assert_eq!(pairs[1], ["range_average", "range_count"]); + assert_eq!(pairs[2], ["range_average", "range_sum"]); + assert_eq!(pairs[3], ["average", "count"]); +} + +#[test] +fn should_promote_omitted_options_but_keep_explicit_true_next_to_average_sugar() { + let spec = CollectionSpec::documents(collection("c")) + .document_id_field("id") + .field(FieldSpec::new(property("class"), 0, FieldType::string(8))) + .field(FieldSpec::new( + property("points"), + 1, + FieldType::integer(IntegerWidth::I64), + )) + .count(true) + .average(property("points")) + .index( + IndexSpec::new(index_name("i"), vec![path("class")]) + .range_count(true) + .average(property("points")) + .range_average(true), + ); + let manifest = expect_manifest(&ContractDeclaration::new().collection(spec)); + let c = manifest.collection("c").unwrap(); + assert!(c.count && !c.range_count && !c.range_sum); + let i = c.index("i").unwrap(); + assert_eq!(i.count, Countability::Countable); + assert!(i.range_count && i.range_sum); +} + #[test] fn should_report_conflicting_option_for_range_average_without_average() { let spec = minimal("c") diff --git a/packages/rs-dash-sdk-contract/tests/alloc_profile.rs b/packages/rs-dash-sdk-contract/tests/alloc_profile.rs index 882f1c06173..495ce41fed4 100644 --- a/packages/rs-dash-sdk-contract/tests/alloc_profile.rs +++ b/packages/rs-dash-sdk-contract/tests/alloc_profile.rs @@ -36,7 +36,7 @@ fn should_build_the_sketch_manifest_through_builders() { .param("delta", ValueType::Integer(IntegerWidth::I64)), ); let manifest = validate(&declaration).expect("the sketch validates"); - assert_eq!(manifest.collections.len(), 1); + assert_eq!(manifest.collections().len(), 1); let add = manifest.method("score.add").expect("entry present"); assert_eq!(add.export, entry_export_symbol(&add.name)); assert_eq!( From 373363c4ed264a2502e364a0cca63ceb96eb535c Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Tue, 15 Sep 2026 16:40:21 -0500 Subject: [PATCH 6/9] build: refresh the lock after rebasing onto v4.3-dev and satisfy clippy 1.98 The base bumped the workspace version to 4.2.0-dev.11 and the toolchain to 1.98.1, so the locked lock entry for dash-sdk-contract was stale and two token cost sorts tripped the unnecessary_sort_by lint on the newer clippy. Refs #4680 Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 2 +- packages/rs-dash-sdk-contract/src/declare/collection.rs | 4 +--- packages/rs-dash-sdk-contract/src/validate/collections.rs | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9c771e160a7..dd5f0c1d093 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1759,7 +1759,7 @@ dependencies = [ [[package]] name = "dash-sdk-contract" -version = "4.2.0-dev.8" +version = "4.2.0-dev.11" dependencies = [ "thiserror 2.0.18", ] diff --git a/packages/rs-dash-sdk-contract/src/declare/collection.rs b/packages/rs-dash-sdk-contract/src/declare/collection.rs index 34ed4dd2e16..840f0f50f89 100644 --- a/packages/rs-dash-sdk-contract/src/declare/collection.rs +++ b/packages/rs-dash-sdk-contract/src/declare/collection.rs @@ -442,9 +442,7 @@ impl CollectionSpec { pub fn normalized(&self) -> CollectionSpec { let mut normalized = self.clone(); normalize_fields(&mut normalized.fields); - normalized - .token_costs - .sort_by(|a, b| a.action.cmp(&b.action)); + normalized.token_costs.sort_by_key(|cost| cost.action); normalized } } diff --git a/packages/rs-dash-sdk-contract/src/validate/collections.rs b/packages/rs-dash-sdk-contract/src/validate/collections.rs index 5e4bc634aa9..e7d69171679 100644 --- a/packages/rs-dash-sdk-contract/src/validate/collections.rs +++ b/packages/rs-dash-sdk-contract/src/validate/collections.rs @@ -104,7 +104,7 @@ fn validate_collection( ); let mut token_costs = collection.token_costs.clone(); - token_costs.sort_by(|a, b| a.action.cmp(&b.action)); + token_costs.sort_by_key(|cost| cost.action); let mut seen_actions = Vec::new(); for cost in &token_costs { if seen_actions.contains(&cost.action) { From be98a7058e864c94d5381c40bc4c5700edecfc39 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Tue, 15 Sep 2026 19:31:30 -0500 Subject: [PATCH 7/9] feat: declare required system properties and sharpen wire and ranked diagnostics Collections gain a requires list of system properties (grammar key requires on persistent and singleton), carried sorted in the manifest, so a time-range index on a system timestamp can state the requirement the native parser demands instead of leaving the translator to invent it; a time range on an unrequired system timestamp is a diagnostic. Duplicate ranked levels are reported instead of silently deduplicated. Wire type diagnostics inside structs and lists point at the member through a new Member path segment. The book grammar table is now read from the chapter by an integration test and compared with ATTRIBUTES, and the in-crate table becomes an explicit grammar snapshot. Refs #4680 Co-Authored-By: Claude Fable 5.1 --- book/src/dashvm/contract-declarations.md | 15 ++- .../src/declare/collection.rs | 16 ++- packages/rs-dash-sdk-contract/src/grammar.rs | 27 +++- .../src/manifest/collection.rs | 2 + .../src/validate/collections.rs | 50 ++++++- .../src/validate/diagnostic.rs | 74 +++++++++++ .../src/validate/tests.rs | 124 ++++++++++++++++-- .../rs-dash-sdk-contract/tests/book_table.rs | 100 ++++++++++++++ 8 files changed, 383 insertions(+), 25 deletions(-) create mode 100644 packages/rs-dash-sdk-contract/tests/book_table.rs diff --git a/book/src/dashvm/contract-declarations.md b/book/src/dashvm/contract-declarations.md index 3d5b99da628..f79ebf4eafd 100644 --- a/book/src/dashvm/contract-declarations.md +++ b/book/src/dashvm/contract-declarations.md @@ -113,15 +113,15 @@ said is a `ConflictingDeclaration` naming both origins. The grammar is data: `dash_sdk_contract::grammar::ATTRIBUTES` lists every attribute, its options and the value each option accepts. The proc macros parse against that table, `grammar::check_keys` reports grammar diagnostics -from it, and a test pins this chapter's table against it, so the three cannot -drift. An option not in the table is `UnknownOption`; a value outside a closed +from it, and an integration test reads this chapter and checks the table +below against it, so the three cannot drift. An option not in the table is `UnknownOption`; a value outside a closed set is `InvalidOptionValue`; a repeated option is `DuplicateOption`; a missing required option is `MissingOption`. Nothing is ignored. | Attribute | On | Options | |---|---|---| -| `persistent` | struct | `collection` (required), `schema` (integer, default 1), `write` (`any` / `owner` / `contract`), `mutable`, `deletable`, `keep_history`, `keep_transfer_history`, `keep_purchase_history`, `keep_pricing_history`, `transferable`, `trade` (`none` / `direct_purchase`), `security_level` (`critical` / `high` / `medium`), `encryption_key` and `decryption_key` (`unique` / `multiple` / `multiple_reference_to_latest`), `count`, `range_count`, `sum = ""`, `range_sum`, `average = ""`, `range_average`, `index_only`, `store` (`public` / `private`) | -| `singleton` | struct | `collection` (required), `schema`, `write`, `security_level`, `encryption_key`, `decryption_key`, `store` | +| `persistent` | struct | `collection` (required), `schema` (integer, default 1), `write` (`any` / `owner` / `contract`), `mutable`, `deletable`, `keep_history`, `keep_transfer_history`, `keep_purchase_history`, `keep_pricing_history`, `transferable`, `trade` (`none` / `direct_purchase`), `security_level` (`critical` / `high` / `medium`), `encryption_key` and `decryption_key` (`unique` / `multiple` / `multiple_reference_to_latest`), `count`, `range_count`, `sum = ""`, `range_sum`, `average = ""`, `range_average`, `index_only`, `requires = ["$createdAt", ...]` (system properties every document carries), `store` (`public` / `private`) | +| `singleton` | struct | `collection` (required), `schema`, `write`, `security_level`, `encryption_key`, `decryption_key`, `requires = [...]`, `store` | | `token_cost` | struct, repeatable | `on` (an ordinary action, required), `token_position` (required), `amount` (required), `contract` (base58), `effect` (`transfer_to_contract_owner` / `burn`), `gas_paid_by` (`document_owner` / `contract_owner` / `prefer_contract_owner`) | | `index` | struct, repeatable | `name` (required), `fields( = "asc", ...)` (required), `unique`, `null_searchable` (default true), `contested(field_matches( = ""), resolution = "masternode_vote", description)`, `count` or `count = "offset"`, `range_count`, `sum = ""`, `range_sum`, `average = ""`, `range_average`, `ranked_count` or `ranked_count = ["", ...]`, `ranked_sum`, `ranked_average`, `time_range(on, range_secs, step_secs, phase_secs)`, `terminal = ""`, `preallocated`, `skip_if_absent` | | `field` | field | `position` (required), `max_chars`, `min_chars`, `max_len`, `min_len`, `min`, `max`, `values = [...]`, `required` (default true), `transient`, `refers_to` (`identity` / `contract` / `token` / `permanent_document` / `identity_public_key`), `document_type`, `contract`, `agreement( = "")`, `key_id_field`, `description` | @@ -244,6 +244,13 @@ requirements and per-action token costs are on `CollectionSpec`. Every reference target (identity, contract, token, permanent document with property agreement, identity public key) is a `FieldType::Reference`. +System timestamps and block height stamps (`$createdAt`, `$updatedAt`, +`$transferredAt` and their height variants) exist on a document only when the +collection requires them, so `requires = ["$createdAt"]` on the collection is +what makes them present; a time-range index on a system timestamp needs the +collection to require it (`TimeRangeSourceNotRequired` otherwise), which is +the native rule. User fields are required on the field itself. + Average sugar (`average = "p"`, `range_average`) is expanded into `count` plus `sum = "p"` and `range_count` plus `range_sum` before the manifest, with the same conflict rules the native parser applies to `averageable`: an diff --git a/packages/rs-dash-sdk-contract/src/declare/collection.rs b/packages/rs-dash-sdk-contract/src/declare/collection.rs index 840f0f50f89..889f36a9e24 100644 --- a/packages/rs-dash-sdk-contract/src/declare/collection.rs +++ b/packages/rs-dash-sdk-contract/src/declare/collection.rs @@ -8,7 +8,7 @@ use super::field::{FieldSpec, FieldType, ReferenceTarget}; use super::index::IndexSpec; use super::rule::ActionScope; use super::DeclarationOrigin; -use crate::identity::{CollectionName, PropertyName}; +use crate::identity::{CollectionName, PropertyName, PropertyPath}; /// Whether a collection holds many documents or one reserved record. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -209,6 +209,12 @@ pub struct CollectionSpec { pub range_average: bool, /// Documents live only in their indexes. pub index_only: bool, + /// System properties every document must carry (`$createdAt`, + /// `$updatedAt`, `$transferredAt` and the block height stamps). A + /// system timestamp is populated only when required, so a time-range + /// index on one needs it listed here. User fields are required through + /// [`FieldSpec::required`]. + pub requires: Vec, /// Token prices per action. pub token_costs: Vec, /// Document store. @@ -249,6 +255,7 @@ impl CollectionSpec { average: None, range_average: false, index_only: false, + requires: Vec::new(), token_costs: Vec::new(), store: Store::default(), document_id_field: None, @@ -417,6 +424,12 @@ impl CollectionSpec { self } + /// Requires a system property on every document. + pub fn requires(mut self, property: PropertyPath) -> Self { + self.requires.push(property); + self + } + /// Prices an action. pub fn token_cost(mut self, action: ActionScope, cost: TokenCost) -> Self { self.token_costs.push(TokenCostSpec { action, cost }); @@ -443,6 +456,7 @@ impl CollectionSpec { let mut normalized = self.clone(); normalize_fields(&mut normalized.fields); normalized.token_costs.sort_by_key(|cost| cost.action); + normalized.requires.sort(); normalized } } diff --git a/packages/rs-dash-sdk-contract/src/grammar.rs b/packages/rs-dash-sdk-contract/src/grammar.rs index 3a101848816..e689535db40 100644 --- a/packages/rs-dash-sdk-contract/src/grammar.rs +++ b/packages/rs-dash-sdk-contract/src/grammar.rs @@ -385,6 +385,12 @@ const PERSISTENT_KEYS: &[KeySpec] = &[ required: false, doc: "documents live only in their indexes, default false", }, + KeySpec { + name: "requires", + value: ValueShape::StrList, + required: false, + doc: "system properties every document carries, such as `$createdAt`; a time-range index on a system timestamp needs it here", + }, KeySpec { name: "store", value: ValueShape::Choice(&STORE), @@ -430,6 +436,12 @@ const SINGLETON_KEYS: &[KeySpec] = &[ required: false, doc: "identity decryption bounded key requirement, default none", }, + KeySpec { + name: "requires", + value: ValueShape::StrList, + required: false, + doc: "system properties the record carries, such as `$updatedAt`", + }, KeySpec { name: "store", value: ValueShape::Choice(&STORE), @@ -1073,9 +1085,10 @@ fn check_value( mod tests { use super::*; - /// The rows of the book table, kept literally so a table edit without a - /// grammar edit (or the reverse) fails here. - const BOOK_TABLE: &[(&str, &[&str])] = &[ + /// A snapshot of the grammar, attribute by attribute, so an unintended + /// option change fails here. The book chapter's table is checked against + /// `ATTRIBUTES` by the `book_table` integration test. + const GRAMMAR_SNAPSHOT: &[(&str, &[&str])] = &[ ( "persistent", &[ @@ -1100,6 +1113,7 @@ mod tests { "average", "range_average", "index_only", + "requires", "store", ], ), @@ -1112,6 +1126,7 @@ mod tests { "security_level", "encryption_key", "decryption_key", + "requires", "store", ], ), @@ -1183,9 +1198,9 @@ mod tests { } #[test] - fn should_contain_every_attribute_and_option_of_the_book_table() { - assert_eq!(ATTRIBUTES.len(), BOOK_TABLE.len()); - for (name, options) in BOOK_TABLE { + fn should_match_the_grammar_snapshot() { + assert_eq!(ATTRIBUTES.len(), GRAMMAR_SNAPSHOT.len()); + for (name, options) in GRAMMAR_SNAPSHOT { let spec = attribute(name).unwrap_or_else(|| panic!("attribute {name} missing")); let declared: Vec<&str> = spec.keys.iter().map(|key| key.name).collect(); assert_eq!(&declared, options, "options of {name}"); diff --git a/packages/rs-dash-sdk-contract/src/manifest/collection.rs b/packages/rs-dash-sdk-contract/src/manifest/collection.rs index 97b9bd24580..67ce33049f4 100644 --- a/packages/rs-dash-sdk-contract/src/manifest/collection.rs +++ b/packages/rs-dash-sdk-contract/src/manifest/collection.rs @@ -81,6 +81,8 @@ pub struct CollectionManifest { pub range_sum: bool, /// Documents live only in their indexes. pub index_only: bool, + /// Required system properties, sorted. + pub requires: Vec, /// Token prices, sorted by action. pub token_costs: Vec, /// Document store. diff --git a/packages/rs-dash-sdk-contract/src/validate/collections.rs b/packages/rs-dash-sdk-contract/src/validate/collections.rs index e7d69171679..8a343b2cd5b 100644 --- a/packages/rs-dash-sdk-contract/src/validate/collections.rs +++ b/packages/rs-dash-sdk-contract/src/validate/collections.rs @@ -103,6 +103,29 @@ fn validate_collection( diagnostics, ); + let mut requires: Vec = Vec::new(); + for property in &collection.requires { + if !property.is_system() { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::RequiredPropertyNotSystem { + property: property.to_string(), + }, + )); + } + if requires.contains(property) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::DuplicateRequiredProperty { + property: property.to_string(), + }, + )); + } else { + requires.push(property.clone()); + } + } + requires.sort(); + let mut token_costs = collection.token_costs.clone(); token_costs.sort_by_key(|cost| cost.action); let mut seen_actions = Vec::new(); @@ -220,6 +243,7 @@ fn validate_collection( sum, range_sum, index_only: collection.index_only, + requires, token_costs, store: collection.store, fields, @@ -641,6 +665,7 @@ fn validate_index( } if let RankedCount::At(levels) = &index.ranked.count { + let mut seen: Vec<&PropertyPath> = Vec::new(); for level in levels { if !index.properties.contains(level) { diagnostics.push(Diagnostic::new( @@ -650,6 +675,16 @@ fn validate_index( }, )); } + if seen.contains(&level) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::DuplicateRankedLevel { + property: level.to_string(), + }, + )); + } else { + seen.push(level); + } } } @@ -662,6 +697,14 @@ fn validate_index( }, )); } + if time_range.on.is_system() && !collection.requires.contains(&time_range.on) { + diagnostics.push(Diagnostic::new( + path.clone(), + DiagnosticKind::TimeRangeSourceNotRequired { + property: time_range.on.to_string(), + }, + )); + } } if let Some(options) = &index.index_only { @@ -687,7 +730,6 @@ fn validate_index( let mut ranked = index.ranked.clone(); if let RankedCount::At(levels) = &mut ranked.count { levels.sort(); - levels.dedup(); } IndexManifest { @@ -745,6 +787,8 @@ pub(super) fn validate_typed_collections( } /// Every string, byte array and list in a wire type declares a maximum. +/// Diagnostics inside a struct or list point at the member through +/// [`DeclarationPath::member`], so two unbounded members are distinguishable. pub(super) fn check_value_type( path: &DeclarationPath, ty: &ValueType, @@ -764,7 +808,7 @@ pub(super) fn check_value_type( DiagnosticKind::UnboundedField, )); } - check_value_type(path, item, diagnostics); + check_value_type(&path.member("[]"), item, diagnostics); } ValueType::Option(inner) => check_value_type(path, inner, diagnostics), ValueType::Struct(members) => { @@ -780,7 +824,7 @@ pub(super) fn check_value_type( } else { seen.push(name); } - check_value_type(path, member, diagnostics); + check_value_type(&path.member(name), member, diagnostics); } } ValueType::Unit diff --git a/packages/rs-dash-sdk-contract/src/validate/diagnostic.rs b/packages/rs-dash-sdk-contract/src/validate/diagnostic.rs index 6202cc66d52..6bc6531f59d 100644 --- a/packages/rs-dash-sdk-contract/src/validate/diagnostic.rs +++ b/packages/rs-dash-sdk-contract/src/validate/diagnostic.rs @@ -1,5 +1,6 @@ //! Diagnostics: typed, append-only, with stable codes. +use alloc::boxed::Box; use alloc::string::{String, ToString}; use alloc::vec::Vec; use core::fmt; @@ -69,9 +70,32 @@ pub enum DeclarationPath { /// The parameter name, or `return`. param: String, }, + /// A member inside a wire value: struct members by name, list items as + /// `[]`, joined with `.` (`profile.avatars.[]`). + Member { + /// The parameter, return value or typed collection slot holding the + /// value. + parent: Box, + /// The dotted member path. + member: String, + }, } impl DeclarationPath { + /// The path of a member inside this wire value. + pub fn member(&self, segment: &str) -> Self { + match self { + DeclarationPath::Member { parent, member } => DeclarationPath::Member { + parent: parent.clone(), + member: alloc::format!("{member}.{segment}"), + }, + other => DeclarationPath::Member { + parent: Box::new(other.clone()), + member: segment.to_string(), + }, + } + } + /// An attribute path. pub fn attribute(attribute: &str, option: Option<&str>) -> Self { DeclarationPath::Attribute { @@ -185,6 +209,7 @@ impl fmt::Display for DeclarationPath { f, "interface {interface}, function {function}, parameter {param}" ), + DeclarationPath::Member { parent, member } => write!(f, "{parent}, member {member}"), } } } @@ -467,6 +492,28 @@ pub enum DiagnosticKind { /// The property path. property: String, }, + /// A ranked count names one index level twice. + DuplicateRankedLevel { + /// The property path. + property: String, + }, + /// A required system property is not a system property; user fields are + /// required on the field itself. + RequiredPropertyNotSystem { + /// The property path. + property: String, + }, + /// A collection lists one required system property twice. + DuplicateRequiredProperty { + /// The property path. + property: String, + }, + /// A time range buckets a system timestamp the collection does not + /// require; the timestamp is populated only when required. + TimeRangeSourceNotRequired { + /// The property path. + property: String, + }, } impl DiagnosticKind { @@ -570,6 +617,16 @@ impl DiagnosticKind { DiagnosticKind::DuplicateAgreementProperty { .. } => { ("DSC0058", "DuplicateAgreementProperty") } + DiagnosticKind::DuplicateRankedLevel { .. } => ("DSC0059", "DuplicateRankedLevel"), + DiagnosticKind::RequiredPropertyNotSystem { .. } => { + ("DSC0060", "RequiredPropertyNotSystem") + } + DiagnosticKind::DuplicateRequiredProperty { .. } => { + ("DSC0061", "DuplicateRequiredProperty") + } + DiagnosticKind::TimeRangeSourceNotRequired { .. } => { + ("DSC0062", "TimeRangeSourceNotRequired") + } } } @@ -755,6 +812,20 @@ impl fmt::Display for DiagnosticKind { DiagnosticKind::DuplicateAgreementProperty { property } => { write!(f, "agreement property `{property}` declared twice") } + DiagnosticKind::DuplicateRankedLevel { property } => { + write!(f, "ranked level `{property}` named twice; each level is one ranking") + } + DiagnosticKind::RequiredPropertyNotSystem { property } => write!( + f, + "`{property}` is not a system property; require a user field on the field itself" + ), + DiagnosticKind::DuplicateRequiredProperty { property } => { + write!(f, "required system property `{property}` listed twice") + } + DiagnosticKind::TimeRangeSourceNotRequired { property } => write!( + f, + "time range buckets `{property}`, which the collection does not require; a system timestamp is populated only when required" + ), } } } @@ -830,6 +901,9 @@ mod tests { DeclarationPath::rule("scores", "monotonic"), DeclarationPath::Capability(CapabilityRequirement::PrivateStore), DeclarationPath::interface_param("math", "add", "return"), + DeclarationPath::entry_param("f", "profile") + .member("avatars") + .member("[]"), ]; for path in paths { assert!(!path.to_string().is_empty()); diff --git a/packages/rs-dash-sdk-contract/src/validate/tests.rs b/packages/rs-dash-sdk-contract/src/validate/tests.rs index 7db5764bdeb..70b5da4a06c 100644 --- a/packages/rs-dash-sdk-contract/src/validate/tests.rs +++ b/packages/rs-dash-sdk-contract/src/validate/tests.rs @@ -789,22 +789,67 @@ fn should_accept_a_contested_index_next_to_a_create_rule() { .is_some()); } +fn created_at_window() -> TimeRangeSpec { + TimeRangeSpec { + on: path("$createdAt"), + range_secs: 3600, + step_secs: 60, + phase_secs: 0, + } +} + #[test] fn should_report_time_range_source_not_first() { - let spec = minimal("c").index( - IndexSpec::new(index_name("i"), vec![path("a"), path("$createdAt")]).time_range( - TimeRangeSpec { - on: path("$createdAt"), - range_secs: 3600, - step_secs: 60, - phase_secs: 0, - }, - ), + let spec = minimal("c").requires(path("$createdAt")).index( + IndexSpec::new(index_name("i"), vec![path("a"), path("$createdAt")]) + .time_range(created_at_window()), ); - assert_reports( + let diagnostics = assert_reports( &ContractDeclaration::new().collection(spec), "TimeRangeSourceNotFirst", ); + assert_eq!(diagnostics.len(), 1); +} + +#[test] +fn should_report_time_range_source_not_required() { + let spec = minimal("c").index( + IndexSpec::new(index_name("i"), vec![path("$createdAt")]).time_range(created_at_window()), + ); + let diagnostics = assert_reports( + &ContractDeclaration::new().collection(spec), + "TimeRangeSourceNotRequired", + ); + assert_eq!(diagnostics.len(), 1); +} + +#[test] +fn should_carry_required_system_properties_sorted_in_the_manifest() { + let spec = minimal("c") + .requires(path("$updatedAt")) + .requires(path("$createdAt")) + .index( + IndexSpec::new(index_name("i"), vec![path("$createdAt")]) + .time_range(created_at_window()), + ); + let manifest = expect_manifest(&ContractDeclaration::new().collection(spec)); + assert_eq!( + manifest.collection("c").unwrap().requires, + [path("$createdAt"), path("$updatedAt")] + ); +} + +#[test] +fn should_report_required_property_not_system_and_duplicate_required_property() { + let spec = minimal("c") + .requires(path("a")) + .requires(path("$createdAt")) + .requires(path("$createdAt")); + let diagnostics = expect_diagnostics(&ContractDeclaration::new().collection(spec)); + assert_eq!( + kinds(&diagnostics), + ["RequiredPropertyNotSystem", "DuplicateRequiredProperty"] + ); } #[test] @@ -849,6 +894,21 @@ fn should_report_ranked_level_not_indexed() { ); } +#[test] +fn should_report_duplicate_ranked_level() { + let spec = minimal("c").index( + IndexSpec::new(index_name("i"), vec![path("a")]) + .count() + .range_count(true) + .ranked_count_at(vec![path("a"), path("a")]), + ); + let diagnostics = assert_reports( + &ContractDeclaration::new().collection(spec), + "DuplicateRankedLevel", + ); + assert_eq!(diagnostics.len(), 1); +} + #[test] fn should_report_index_only_option_on_stored_collection() { let spec = minimal("c").index(IndexSpec::new(index_name("i"), vec![path("a")]).index_only( @@ -1236,7 +1296,7 @@ fn should_report_unbounded_field_in_interface_parameters_and_returns() { assert_eq!(kinds(&diagnostics), ["UnboundedField", "UnboundedField"]); assert_eq!( diagnostics[0].path().to_string(), - "interface text, function join, parameter parts" + "interface text, function join, parameter parts, member [].text" ); assert_eq!( diagnostics[1].path().to_string(), @@ -1244,6 +1304,48 @@ fn should_report_unbounded_field_in_interface_parameters_and_returns() { ); } +#[test] +fn should_point_nested_wire_diagnostics_at_the_member() { + let profile = ValueType::Struct(vec![ + ("name".to_string(), ValueType::String { max_chars: None }), + ("avatar".to_string(), ValueType::Bytes { max_len: None }), + ( + "tags".to_string(), + ValueType::list(4, ValueType::String { max_chars: None }), + ), + ]); + let declaration = ContractDeclaration::new() + .module(ModuleSpec::new(module("main"))) + .interface( + InterfaceSpec::new(interface("text"), module("main")).function( + "describe", + vec![], + ValueType::option(profile.clone()), + ), + ) + .entry( + EntrySpec::new(method("f")) + .module(module("main")) + .param("profile", profile), + ); + let diagnostics = expect_diagnostics(&declaration); + let paths: Vec = diagnostics.iter().map(|d| d.path().to_string()).collect(); + assert_eq!( + paths, + [ + "interface text, function describe, parameter return, member name", + "interface text, function describe, parameter return, member avatar", + "interface text, function describe, parameter return, member tags.[]", + "entry f, parameter profile, member name", + "entry f, parameter profile, member avatar", + "entry f, parameter profile, member tags.[]", + ] + ); + assert!(diagnostics + .iter() + .all(|d| d.kind.name() == "UnboundedField")); +} + #[test] fn should_bind_a_dag_of_modules_and_sort_bindings() { let declaration = ContractDeclaration::new() diff --git a/packages/rs-dash-sdk-contract/tests/book_table.rs b/packages/rs-dash-sdk-contract/tests/book_table.rs new file mode 100644 index 00000000000..bc697054813 --- /dev/null +++ b/packages/rs-dash-sdk-contract/tests/book_table.rs @@ -0,0 +1,100 @@ +//! The grammar table in the book chapter is checked against `ATTRIBUTES`: +//! every attribute row names exactly the options the grammar declares, in +//! grammar order, so an edit to either side without the other fails here. +//! +//! The chapter is read from the repository at test time, so this test runs +//! only where the repository layout exists (it is skipped when the book file +//! is absent, for example in a published crate). + +#![cfg(feature = "std")] + +use dash_sdk_contract::grammar::ATTRIBUTES; + +const CHAPTER: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../book/src/dashvm/contract-declarations.md" +); + +/// Option names as they appear in a table cell: every backtick span at +/// parenthesis depth zero whose first character starts an identifier, keeping +/// the identifier only (`sum = "

"` yields `sum`, `fields( = "asc", +/// ...)` yields `fields`). Spans inside parentheses are the values an option +/// admits (`(`any` / `owner`)`), not options, which is the table's convention. +fn options_in_cell(cell: &str) -> Vec { + let mut options = Vec::new(); + let mut depth = 0usize; + let mut chars = cell.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '(' => depth += 1, + ')' => depth = depth.saturating_sub(1), + '`' => { + let mut span = String::new(); + for c in chars.by_ref() { + if c == '`' { + break; + } + span.push(c); + } + let identifier: String = span + .chars() + .take_while(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '_') + .collect(); + let starts_identifier = span.chars().next().is_some_and(|c| c.is_ascii_lowercase()); + if depth == 0 && starts_identifier && !options.contains(&identifier) { + options.push(identifier); + } + } + _ => {} + } + } + options +} + +fn table_rows(chapter: &str) -> Vec<(String, Vec)> { + chapter + .lines() + .filter(|line| line.starts_with("| `")) + .filter_map(|line| { + let cells: Vec<&str> = line.trim_matches('|').split(" | ").collect(); + let [name, _target, options] = cells.as_slice() else { + return None; + }; + let name = name.trim().trim_matches('`').to_string(); + Some((name, options_in_cell(options))) + }) + .collect() +} + +#[test] +fn should_keep_the_book_grammar_table_equal_to_the_grammar() { + let Ok(chapter) = std::fs::read_to_string(CHAPTER) else { + eprintln!("book chapter not found at {CHAPTER}; skipping"); + return; + }; + let rows = table_rows(&chapter); + let attribute_rows: Vec<&(String, Vec)> = rows + .iter() + .filter(|(name, _)| ATTRIBUTES.iter().any(|spec| spec.name == name)) + .collect(); + assert_eq!( + attribute_rows.len(), + ATTRIBUTES.len(), + "the book table has {} attribute rows, the grammar {}", + attribute_rows.len(), + ATTRIBUTES.len() + ); + for (spec, (name, options)) in ATTRIBUTES.iter().zip(attribute_rows) { + assert_eq!( + spec.name, name, + "attribute row order differs from the grammar" + ); + let declared: Vec<&str> = spec.keys.iter().map(|key| key.name).collect(); + // `rule` spells its exclusive pair as prose ("exactly one of"), the + // identifiers still appear in grammar order. + assert_eq!( + options, &declared, + "options of `{name}` in the book differ from the grammar" + ); + } +} From a88026cc5187e7d6532e802b1f8bbc63a916856a Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Tue, 15 Sep 2026 22:01:23 -0500 Subject: [PATCH 8/9] feat: carry the time-range ttl and canonicalize enum values TimeRangeSpec gains an optional ttl_secs (grammar key ttl_secs under time_range) so the author's retention choice reaches the manifest; the native cap and grid rules stay native. Enum values are stored sorted in the manifest and compared sorted when merging a restatement, and typed collection diagnostics point at the key or element member. Refs #4680 Co-Authored-By: Claude Fable 5.1 --- book/src/dashvm/contract-declarations.md | 5 +- .../src/declare/collection.rs | 4 +- .../rs-dash-sdk-contract/src/declare/index.rs | 5 ++ packages/rs-dash-sdk-contract/src/grammar.rs | 6 ++ .../src/validate/collections.rs | 15 +++- .../src/validate/tests.rs | 80 +++++++++++++++++++ 6 files changed, 108 insertions(+), 7 deletions(-) diff --git a/book/src/dashvm/contract-declarations.md b/book/src/dashvm/contract-declarations.md index f79ebf4eafd..59efcaea73e 100644 --- a/book/src/dashvm/contract-declarations.md +++ b/book/src/dashvm/contract-declarations.md @@ -123,7 +123,7 @@ missing required option is `MissingOption`. Nothing is ignored. | `persistent` | struct | `collection` (required), `schema` (integer, default 1), `write` (`any` / `owner` / `contract`), `mutable`, `deletable`, `keep_history`, `keep_transfer_history`, `keep_purchase_history`, `keep_pricing_history`, `transferable`, `trade` (`none` / `direct_purchase`), `security_level` (`critical` / `high` / `medium`), `encryption_key` and `decryption_key` (`unique` / `multiple` / `multiple_reference_to_latest`), `count`, `range_count`, `sum = ""`, `range_sum`, `average = ""`, `range_average`, `index_only`, `requires = ["$createdAt", ...]` (system properties every document carries), `store` (`public` / `private`) | | `singleton` | struct | `collection` (required), `schema`, `write`, `security_level`, `encryption_key`, `decryption_key`, `requires = [...]`, `store` | | `token_cost` | struct, repeatable | `on` (an ordinary action, required), `token_position` (required), `amount` (required), `contract` (base58), `effect` (`transfer_to_contract_owner` / `burn`), `gas_paid_by` (`document_owner` / `contract_owner` / `prefer_contract_owner`) | -| `index` | struct, repeatable | `name` (required), `fields( = "asc", ...)` (required), `unique`, `null_searchable` (default true), `contested(field_matches( = ""), resolution = "masternode_vote", description)`, `count` or `count = "offset"`, `range_count`, `sum = ""`, `range_sum`, `average = ""`, `range_average`, `ranked_count` or `ranked_count = ["", ...]`, `ranked_sum`, `ranked_average`, `time_range(on, range_secs, step_secs, phase_secs)`, `terminal = ""`, `preallocated`, `skip_if_absent` | +| `index` | struct, repeatable | `name` (required), `fields( = "asc", ...)` (required), `unique`, `null_searchable` (default true), `contested(field_matches( = ""), resolution = "masternode_vote", description)`, `count` or `count = "offset"`, `range_count`, `sum = ""`, `range_sum`, `average = ""`, `range_average`, `ranked_count` or `ranked_count = ["", ...]`, `ranked_sum`, `ranked_average`, `time_range(on, range_secs, step_secs, phase_secs, ttl_secs)`, `terminal = ""`, `preallocated`, `skip_if_absent` | | `field` | field | `position` (required), `max_chars`, `min_chars`, `max_len`, `min_len`, `min`, `max`, `values = [...]`, `required` (default true), `transient`, `refers_to` (`identity` / `contract` / `token` / `permanent_document` / `identity_public_key`), `document_type`, `contract`, `agreement( = "")`, `key_id_field`, `description` | | `document_id` | field | none | | `entry` | fn | `name` (required), `read_only`, `module` | @@ -237,7 +237,8 @@ argument; a singleton receiver takes none, its key being reserved. unique, null searchability, contested parameters (field matches, masternode vote resolution, description), count and offset count, range count, sum and range sum, average sugar, count ranking at the terminal level or at named -prefix levels, sum and average ranking, time-range buckets, and the index-only +prefix levels, sum and average ranking, time-range buckets with an optional +time to live, and the index-only options terminal, preallocated and skip-if-absent. Collection-level count, sum, average and index-only flags, the document type switches, the bounded key requirements and per-action token costs are on `CollectionSpec`. Every diff --git a/packages/rs-dash-sdk-contract/src/declare/collection.rs b/packages/rs-dash-sdk-contract/src/declare/collection.rs index 889f36a9e24..8668f1f4ab0 100644 --- a/packages/rs-dash-sdk-contract/src/declare/collection.rs +++ b/packages/rs-dash-sdk-contract/src/declare/collection.rs @@ -451,7 +451,8 @@ impl CollectionSpec { /// A copy with every order-insensitive member in canonical order: fields /// by position at every nesting level, token costs by action, reference - /// agreements by referring property. + /// agreements by referring property, enum values and required system + /// properties sorted. pub fn normalized(&self) -> CollectionSpec { let mut normalized = self.clone(); normalize_fields(&mut normalized.fields); @@ -468,6 +469,7 @@ fn normalize_fields(fields: &mut [FieldSpec]) { FieldType::Reference(ReferenceTarget::PermanentDocument { agreement, .. }) => { agreement.sort(); } + FieldType::Enum(values) => values.sort(), _ => {} } } diff --git a/packages/rs-dash-sdk-contract/src/declare/index.rs b/packages/rs-dash-sdk-contract/src/declare/index.rs index 1c34689ec1b..69c11b0d852 100644 --- a/packages/rs-dash-sdk-contract/src/declare/index.rs +++ b/packages/rs-dash-sdk-contract/src/declare/index.rs @@ -103,6 +103,11 @@ pub struct TimeRangeSpec { pub step_secs: u64, /// Grid offset in seconds. pub phase_secs: u64, + /// Time to live in seconds: entries expire this long after their bucket + /// starts and expired windows are not queryable. `None` keeps entries + /// indefinitely. The native cap and the shared-grid rules are native + /// validation. + pub ttl_secs: Option, } /// Index-only options: only meaningful when the collection is index-only. diff --git a/packages/rs-dash-sdk-contract/src/grammar.rs b/packages/rs-dash-sdk-contract/src/grammar.rs index e689535db40..5e86e9916fa 100644 --- a/packages/rs-dash-sdk-contract/src/grammar.rs +++ b/packages/rs-dash-sdk-contract/src/grammar.rs @@ -256,6 +256,12 @@ const TIME_RANGE_KEYS: &[KeySpec] = &[ required: false, doc: "window grid offset in seconds, default 0", }, + KeySpec { + name: "ttl_secs", + value: ValueShape::Int, + required: false, + doc: "time to live in seconds; entries expire this long after their bucket starts, default indefinite", + }, ]; const PERSISTENT_KEYS: &[KeySpec] = &[ diff --git a/packages/rs-dash-sdk-contract/src/validate/collections.rs b/packages/rs-dash-sdk-contract/src/validate/collections.rs index 8a343b2cd5b..135dee632cb 100644 --- a/packages/rs-dash-sdk-contract/src/validate/collections.rs +++ b/packages/rs-dash-sdk-contract/src/validate/collections.rs @@ -390,12 +390,19 @@ fn validate_field_type( all, diagnostics, )), + FieldType::Enum(values) => { + // A closed set: the manifest stores it sorted so declaration order + // never leaks in. Repeated values stay for native validation to + // reject. + let mut values = values.clone(); + values.sort(); + FieldType::Enum(values) + } FieldType::Bool | FieldType::F64 | FieldType::String { .. } | FieldType::Bytes { .. } - | FieldType::Identifier - | FieldType::Enum(_) => ty.clone(), + | FieldType::Identifier => ty.clone(), } } @@ -771,8 +778,8 @@ pub(super) fn validate_typed_collections( DiagnosticKind::DuplicateCollection, )); } - check_value_type(&path, &spec.key, diagnostics); - check_value_type(&path, &spec.element, diagnostics); + check_value_type(&path.member("key"), &spec.key, diagnostics); + check_value_type(&path.member("element"), &spec.element, diagnostics); TypedCollectionManifest { id: spec.id.clone(), kind: spec.kind, diff --git a/packages/rs-dash-sdk-contract/src/validate/tests.rs b/packages/rs-dash-sdk-contract/src/validate/tests.rs index 70b5da4a06c..3db04e08505 100644 --- a/packages/rs-dash-sdk-contract/src/validate/tests.rs +++ b/packages/rs-dash-sdk-contract/src/validate/tests.rs @@ -377,6 +377,68 @@ fn should_report_duplicate_collection() { assert_reports(&declaration, "DuplicateCollection"); } +#[test] +fn should_canonicalize_enum_value_order_and_merge_a_reordered_enum_restatement() { + let with = |values: &[&str], origin| { + CollectionSpec::documents(collection("c")) + .with_origin(origin) + .document_id_field("id") + .field(FieldSpec::new( + property("nested"), + 0, + FieldType::Object(vec![FieldSpec::new( + property("state"), + 0, + FieldType::Enum(values.iter().map(|v| v.to_string()).collect()), + )]), + )) + }; + let forward = expect_manifest( + &ContractDeclaration::new() + .collection(with(&["open", "closed"], DeclarationOrigin::Attribute)), + ); + let backward = expect_manifest( + &ContractDeclaration::new() + .collection(with(&["closed", "open"], DeclarationOrigin::Attribute)), + ); + assert_eq!(forward, backward); + let merged = expect_manifest( + &ContractDeclaration::new() + .collection(with(&["open", "closed"], DeclarationOrigin::Attribute)) + .collection(with(&["closed", "open"], DeclarationOrigin::Builder)), + ); + assert_eq!(merged, forward); + let FieldType::Object(nested) = &forward.collection("c").unwrap().fields[0].ty else { + panic!() + }; + assert_eq!( + nested[0].ty, + FieldType::Enum(vec!["closed".to_string(), "open".to_string()]) + ); +} + +#[test] +fn should_point_typed_collection_diagnostics_at_the_key_or_element() { + let declaration = ContractDeclaration::new().typed_collection(TypedCollectionSpec::new( + collection("lookup"), + TypedCollectionKind::Sum, + ValueType::String { max_chars: None }, + ValueType::Struct(vec![( + "blob".to_string(), + ValueType::Bytes { max_len: None }, + )]), + )); + let diagnostics = expect_diagnostics(&declaration); + let paths: Vec = diagnostics.iter().map(|d| d.path().to_string()).collect(); + assert_eq!( + paths, + [ + "typed collection lookup, member key", + "typed collection lookup, member element.blob", + ] + ); +} + #[test] fn should_report_duplicate_collection_between_typed_and_document_collections() { let declaration = ContractDeclaration::new() @@ -795,9 +857,27 @@ fn created_at_window() -> TimeRangeSpec { range_secs: 3600, step_secs: 60, phase_secs: 0, + ttl_secs: None, } } +#[test] +fn should_carry_the_time_range_ttl_into_the_manifest() { + let window = TimeRangeSpec { + ttl_secs: Some(86_400), + ..created_at_window() + }; + let spec = minimal("c") + .requires(path("$createdAt")) + .index(IndexSpec::new(index_name("i"), vec![path("$createdAt")]).time_range(window)); + let manifest = expect_manifest(&ContractDeclaration::new().collection(spec)); + let index = manifest.collection("c").unwrap().index("i").unwrap(); + assert_eq!( + index.time_range.as_ref().and_then(|t| t.ttl_secs), + Some(86_400) + ); +} + #[test] fn should_report_time_range_source_not_first() { let spec = minimal("c").requires(path("$createdAt")).index( From 2427ca714ea981badc224ed5c086d9289bd757f1 Mon Sep 17 00:00:00 2001 From: DCG-Claude Date: Tue, 22 Sep 2026 15:12:51 -0500 Subject: [PATCH 9/9] build: refresh the lock for the 4.2.0-beta.3 workspace version The base moved the workspace version from 4.2.0-dev.11 to 4.2.0-beta.3 after the previous rebase, so the locked entry for dash-sdk-contract was stale and every --locked build failed before compiling anything. Refs #4680 Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index dd5f0c1d093..812f58a01ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1759,7 +1759,7 @@ dependencies = [ [[package]] name = "dash-sdk-contract" -version = "4.2.0-dev.11" +version = "4.2.0-beta.3" dependencies = [ "thiserror 2.0.18", ]