diff --git a/README.md b/README.md index f503f32..88c3bd2 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,9 @@ value-encoding rules, and the no-real-user-data guarantee. - [SDK Integration Fixtures](docs/sdk-fixtures.md) — deterministic example outputs for compliance, minting, transfer, event, error, and capability scenarios, for cross-repo testing +- [Contract Capability Flags](docs/capabilities.md) — read-only descriptor of which modules and protocol behaviours a deployment supports, for SDK/dashboard feature gating + +- [Public Interface Compatibility Checks](docs/interface-compatibility.md) — how SDK/dashboard clients verify their required capabilities and schema version against a deployment before integrating - [Investor Holding Restriction Checks](docs/investor-holding-restrictions.md) — per-investor holding cap workflow and enforcement diff --git a/docs/capabilities.md b/docs/capabilities.md index 11c1b1a..bfebc8e 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -244,6 +244,14 @@ enumerate the registry rather than hardcode it — and detect at runtime that a deployment is older or newer than the keys it knows about. Order is stable within a schema version. +### `check_interface_compatibility(client_schema_version, required_capabilities) -> InterfaceCompatibilityReport` + +Checks a client's required capability keys against this deployment in one +call and reports the schema-version relationship, so an SDK or dashboard can +answer "can I safely integrate with this deployment?" without hand-rolling +the comparison. See [`docs/interface-compatibility.md`](interface-compatibility.md) +for the full field reference and usage guidance. + ## Versioning `capability_version` is the schema version of the response diff --git a/docs/contract-spec.md b/docs/contract-spec.md index db7841f..670aa04 100644 --- a/docs/contract-spec.md +++ b/docs/contract-spec.md @@ -115,6 +115,7 @@ key registry, and versioning rules. * `get_capabilities(env)`: Returns a `ContractCapabilities` struct describing compliance, minting, transfer, pause, metadata, and event support, plus `capability_version` / `contract_version`. Each behaviour is a `CapabilityStatus` — `Supported`, `Planned`, or `Unsupported` — alongside runtime switches (`paused`, `operations_enabled`, `supply_cap_enforced`, `holding_cap_enforced`, `metadata_configured`, `initialized`). * `supports_capability(env, capability)`: Returns the `CapabilityStatus` for a single capability key. Unknown keys return `Unsupported` instead of reverting, so newer clients fail safe against older deployments. * `get_capability_keys(env)`: Returns every capability key understood by this contract version. +* `check_interface_compatibility(env, client_schema_version, required_capabilities)`: Returns an `InterfaceCompatibilityReport` — whether every key in `required_capabilities` resolves to `Supported`, plus how `client_schema_version` relates to this deployment's schema version. See [`docs/interface-compatibility.md`](interface-compatibility.md). > A capability indicates the protocol *implements* a behaviour — not that the > caller is authorized to perform it, nor that it will succeed against current diff --git a/docs/interface-compatibility.md b/docs/interface-compatibility.md new file mode 100644 index 0000000..d4b2487 --- /dev/null +++ b/docs/interface-compatibility.md @@ -0,0 +1,122 @@ +# Public Interface Compatibility Checks + +This document describes `check_interface_compatibility`, a read-only entrypoint +that lets an SDK or dashboard client verify its required capabilities against +a specific Aegis deployment **before** it starts building transactions +against it. + +It builds directly on [`docs/capabilities.md`](capabilities.md) — read that +first if you are not already familiar with `CapabilityStatus`, +`get_capabilities`, and the append-only versioning rules. This document only +covers the compatibility check itself. + +> **Not a permission or compliance check.** Like the capability flags it is +> built on, this only reports what the *protocol* implements. It is not +> legal, financial, or compliance advice, and it never determines whether a +> specific caller is authorized to do anything — see +> [`docs/admin-roles.md`](admin-roles.md) and +> [`docs/investor-eligibility.md`](investor-eligibility.md) for that. + +## Why + +`get_capabilities` and `supports_capability` already let a client *ask* what +a deployment supports. What they don't do is give a client a single, +actionable **yes/no plus a reason** for "can I safely integrate with this +deployment at all?" Without that, every integrator re-implements the same +comparison logic — or skips it, and discovers a gap only when a transaction +it assumed would succeed reverts. That is a worse failure mode for +RWA/compliance tooling than for a typical dApp: a dashboard that silently +renders a "supported" control for a capability the deployment doesn't +actually have can walk an investor into building a transaction that reverts, +or worse, mask a compliance-relevant feature gap. + +`check_interface_compatibility` answers the question directly, in one call, +from the deployment itself — the same design principle as the capability +flags it depends on. + +## API + +Pure read: **no storage writes, no events, no authorization required, and it +never panics** — including before `initialize` has been called and while the +contract is paused. Safe to call from a read-only RPC simulation at any time. + +### `check_interface_compatibility(client_schema_version: u32, required_capabilities: Vec) -> InterfaceCompatibilityReport` + +```rust +pub struct InterfaceCompatibilityReport { + pub contract_schema_version: u32, // this deployment's CAPABILITY_SCHEMA_VERSION + pub client_schema_version: u32, // echoed back from the call + pub schema_relation: SchemaVersionRelation, + pub unsupported_required: Vec, // subset of the input not Supported + pub compatible: bool, // true iff unsupported_required is empty +} + +pub enum SchemaVersionRelation { + Matching, // client_schema_version == contract_schema_version + ClientOlder, // client_schema_version < contract_schema_version + ClientNewer, // client_schema_version > contract_schema_version +} +``` + +* `client_schema_version` — the [`CAPABILITY_SCHEMA_VERSION`](capabilities.md#versioning) + the calling SDK/dashboard build was written against. Pass the constant your + generated client was built with. +* `required_capabilities` — the capability keys (see the + [key registry](capabilities.md#supports_capabilitycapability-symbol---capabilitystatus)) + your client build cannot function without. Pass only what is actually + required for the feature set you are about to enable — not every key in the + registry. +* `unsupported_required` is derived by calling `supports_capability` for each + requested key, so it can never disagree with `get_capabilities` / + `supports_capability`. A key resolves into this list if it is + `Planned`, `Unsupported`, **or unknown to this deployment** — an unknown key + fails safe exactly like `supports_capability` does. +* `compatible` is `true` **iff `unsupported_required` is empty.** A schema + version mismatch alone never makes a client incompatible: schema fields and + keys are append-only (see [Versioning](capabilities.md#versioning)), so the + only thing that can actually break a client is a *specific capability it + depends on* not being `Supported`. + +## Reading `schema_relation` + +| Relation | Meaning | What to do | +| --- | --- | --- | +| `Matching` | Client and deployment were built against the same schema. | Nothing extra — the two evolved together. | +| `ClientOlder` | The deployment may advertise fields/keys the client predates. | Safe on its own. Fields are append-only, so nothing the client already understands has moved or been repurposed. | +| `ClientNewer` | The client may expect fields/keys this deployment predates. | Not automatically fatal — check `unsupported_required`. If it's empty, everything the client actually asked for is present; the client simply also knows about capabilities this deployment hasn't shipped yet. | + +`schema_relation` is a diagnostic signal, not a pass/fail gate by itself — +`compatible` is the field to branch on. + +## SDK and dashboard usage + +* **Call once per deployment, before first use.** Build `required_capabilities` + from the feature set your build actually depends on (e.g. `whitelist`, + `transfers`, `holding_cap`), not the full registry. +* **Branch only on `compatible`.** If `false`, block the affected flows and + surface `unsupported_required` to the integrator/operator — it is the exact + list to act on, not a hint to go re-derive. +* **Treat `ClientNewer` with an otherwise-empty `unsupported_required` as + fine.** It only means the client's build knows about capabilities this + particular deployment hasn't shipped — none of which the client currently + requires. +* **Re-check after a contract upgrade**, the same way you would re-read + `get_capabilities` — static capabilities are fixed per build, so cache + results for the lifetime of a deployment, not across upgrades. + +## Compatibility + +* **Purely additive.** No existing function, error code, event, or storage + key changed. The check re-derives every answer from the existing + `supports_capability` helper, so it cannot disagree with `get_capabilities` + or the key registry. +* **No new storage keys and no new error codes.** The function is a pure + computation over its inputs and existing capability state. +* **Not a state-changing call**, exempt from the pause guard by design, + consistent with the other read helpers in + [`contract-spec.md`](contract-spec.md#read-functions). +* Tests covering matching/older/newer schema relations, aggregation of + multiple unsupported keys, agreement with `supports_capability`, the + empty-requirements case, and the no-mutation/pre-`initialize` guarantee + live in [`src/test.rs`](../src/test.rs) under + "Public interface compatibility checks (#37)". diff --git a/src/capabilities.rs b/src/capabilities.rs index 1212d71..a3eb5a2 100644 --- a/src/capabilities.rs +++ b/src/capabilities.rs @@ -574,6 +574,98 @@ pub fn get_capability_keys(env: &Env) -> Vec { ] } +// ─── Interface compatibility checks ──────────────────────────────────────────── + +/// How a client's known schema version relates to this deployment's. +/// +/// Derived purely from comparing two `u32`s against the append-only +/// versioning contract described in `docs/capabilities.md`. +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SchemaVersionRelation { + /// The client was built against exactly this schema version. + Matching, + /// The client is older than this deployment: the contract may advertise + /// fields the client has never heard of. Safe — schema fields are + /// append-only, so nothing the client already understands has moved. + ClientOlder, + /// The client is newer than this deployment: the client may expect + /// fields or keys this deployment predates. Check `unsupported_required` + /// rather than assuming the mismatch alone is fatal. + ClientNewer, +} + +/// Result of checking an SDK/dashboard's expected interface against this +/// deployment's actual capability surface. +/// +/// See [`check_interface_compatibility`]. This is a diagnostic, not a +/// permission check — like [`ContractCapabilities`], it never gates +/// authorization, only feature availability. +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InterfaceCompatibilityReport { + /// This deployment's [`CAPABILITY_SCHEMA_VERSION`]. + pub contract_schema_version: u32, + /// The schema version the calling client was built against. + pub client_schema_version: u32, + /// How the two versions relate. + pub schema_relation: SchemaVersionRelation, + /// The subset of `required_capabilities` (from the call) that this + /// deployment does **not** resolve to `Supported` — including any key + /// the deployment has never heard of, per the fail-safe rule in + /// `supports_capability`. Empty means every requirement is met. + pub unsupported_required: Vec, + /// `true` iff `unsupported_required` is empty. A schema-version mismatch + /// alone does not make a client incompatible — only a missing required + /// capability does, since fields are append-only. + pub compatible: bool, +} + +/// Checks whether a client's required capabilities are all `Supported` by +/// this deployment, and reports how the client's schema version compares. +/// +/// `required_capabilities` is the set of capability keys (see +/// `get_capability_keys`) the calling SDK/dashboard build cannot function +/// without. This lets integrators — including RWA/compliance tooling that +/// must not silently degrade — fail fast with a precise, actionable list +/// instead of discovering a gap mid-transaction. +/// +/// Pure read: no storage writes, no events, no authorization, never panics. +/// Always available, including before `initialize`. +pub fn check_interface_compatibility( + env: &Env, + client_schema_version: u32, + required_capabilities: &Vec, +) -> InterfaceCompatibilityReport { + let contract_schema_version = CAPABILITY_SCHEMA_VERSION; + + let schema_relation = if client_schema_version == contract_schema_version { + SchemaVersionRelation::Matching + } else if client_schema_version < contract_schema_version { + SchemaVersionRelation::ClientOlder + } else { + SchemaVersionRelation::ClientNewer + }; + + // Re-derive each requirement from the single source of truth so this + // can never disagree with `supports_capability` / `get_capabilities`. + let mut unsupported_required: Vec = vec![env]; + for i in 0..required_capabilities.len() { + let key = required_capabilities.get(i).unwrap(); + if supports_capability(env, &key) != CapabilityStatus::Supported { + unsupported_required.push_back(key); + } + } + + InterfaceCompatibilityReport { + contract_schema_version, + client_schema_version, + schema_relation, + compatible: unsupported_required.is_empty(), + unsupported_required, + } +} + // ─── Public API ─────────────────────────────────────────────────────────────── #[contractimpl] @@ -605,4 +697,19 @@ impl AegisContract { pub fn get_capability_keys(env: Env) -> Vec { get_capability_keys(&env) } + + /// Checks a client's required capability keys against this deployment + /// and reports the schema-version relationship, for public-interface + /// compatibility checks ahead of integration. See + /// `docs/interface-compatibility.md`. + /// + /// Never mutates state, emits no events, requires no authorization, and + /// remains callable before `initialize` and while paused. + pub fn check_interface_compatibility( + env: Env, + client_schema_version: u32, + required_capabilities: Vec, + ) -> InterfaceCompatibilityReport { + check_interface_compatibility(&env, client_schema_version, &required_capabilities) + } } diff --git a/src/test.rs b/src/test.rs index 66b37e9..183b84e 100644 --- a/src/test.rs +++ b/src/test.rs @@ -8,8 +8,8 @@ use crate::admin::{ use crate::asset::{AssetMintedEvent, TransferEvent, YieldDistributedEvent}; use crate::capabilities::{ CapabilityStatus, ComplianceCapabilities, ContractCapabilities, EventCapabilities, - MetadataCapabilities, MintingCapabilities, PauseCapabilities, TransferCapabilities, - CAPABILITY_SCHEMA_VERSION, + MetadataCapabilities, MintingCapabilities, PauseCapabilities, + SchemaVersionRelation, TransferCapabilities, CAPABILITY_SCHEMA_VERSION, }; use crate::compliance::{ @@ -4279,6 +4279,139 @@ fn test_capabilities_advertise_the_compliance_lifecycle() { assert!(keys.contains(Symbol::new(&env, "compliance_lifecycle_events"))); } +// ─── Public interface compatibility checks (#37) ───────────────────────────── + +#[test] +fn test_interface_compatibility_matching_schema_and_supported_keys_is_compatible() { + let (env, client, admin, _user1, _user2) = setup(); + env.mock_all_auths(); + client.initialize(&admin); + + let required = vec![ + &env, + Symbol::new(&env, "whitelist"), + Symbol::new(&env, "transfers"), + ]; + let report = + client.check_interface_compatibility(&CAPABILITY_SCHEMA_VERSION, &required); + + assert_eq!(report.contract_schema_version, CAPABILITY_SCHEMA_VERSION); + assert_eq!(report.client_schema_version, CAPABILITY_SCHEMA_VERSION); + assert_eq!(report.schema_relation, SchemaVersionRelation::Matching); + assert_eq!(report.unsupported_required.len(), 0); + assert!(report.compatible); +} + +#[test] +fn test_interface_compatibility_older_client_schema_is_still_compatible() { + let (env, client, admin, _user1, _user2) = setup(); + env.mock_all_auths(); + client.initialize(&admin); + + // A client built against an earlier schema is forward-compatible as long + // as everything it actually asks for is still Supported. + let required = vec![&env, Symbol::new(&env, "whitelist")]; + let report = client.check_interface_compatibility(&1u32, &required); + + assert_eq!(report.schema_relation, SchemaVersionRelation::ClientOlder); + assert!(report.compatible); +} + +#[test] +fn test_interface_compatibility_newer_client_schema_flags_gap_when_relevant() { + let (env, client, admin, _user1, _user2) = setup(); + env.mock_all_auths(); + client.initialize(&admin); + + // A client from a future schema version claiming a key this deployment + // never heard of must be reported, not silently treated as fine. + let required = vec![&env, Symbol::new(&env, "some_future_capability")]; + let newer_version = CAPABILITY_SCHEMA_VERSION + 1; + let report = client.check_interface_compatibility(&newer_version, &required); + + assert_eq!(report.schema_relation, SchemaVersionRelation::ClientNewer); + assert_eq!(report.unsupported_required.len(), 1); + assert!(report + .unsupported_required + .contains(Symbol::new(&env, "some_future_capability"))); + assert!(!report.compatible); +} + +#[test] +fn test_interface_compatibility_reports_every_unsupported_required_key() { + let (env, client, admin, _user1, _user2) = setup(); + env.mock_all_auths(); + client.initialize(&admin); + + // `burning` is a real, permanently Unsupported key (no burn entrypoint). + // `allowances` is Planned, which also does not count as Supported. + let required = vec![ + &env, + Symbol::new(&env, "whitelist"), + Symbol::new(&env, "burning"), + Symbol::new(&env, "allowances"), + ]; + let report = + client.check_interface_compatibility(&CAPABILITY_SCHEMA_VERSION, &required); + + assert_eq!(report.unsupported_required.len(), 2); + assert!(report + .unsupported_required + .contains(Symbol::new(&env, "burning"))); + assert!(report + .unsupported_required + .contains(Symbol::new(&env, "allowances"))); + assert!(!report.compatible); +} + +#[test] +fn test_interface_compatibility_empty_requirements_always_compatible() { + let (env, client, admin, _user1, _user2) = setup(); + env.mock_all_auths(); + client.initialize(&admin); + + // No requirements means nothing to fail on, regardless of schema drift. + let required = vec![&env]; + let newer_version = CAPABILITY_SCHEMA_VERSION + 5; + let report = client.check_interface_compatibility(&newer_version, &required); + + assert!(report.unsupported_required.is_empty()); + assert!(report.compatible); +} + +#[test] +fn test_interface_compatibility_agrees_with_supports_capability() { + let (env, client, admin, _user1, _user2) = setup(); + env.mock_all_auths(); + client.initialize(&admin); + + // Cross-check against the independent single-key resolver so the two + // entrypoints can never silently disagree. + let key = Symbol::new(&env, "decimals"); // Planned, not Supported. + let required = vec![&env, key.clone()]; + let report = + client.check_interface_compatibility(&CAPABILITY_SCHEMA_VERSION, &required); + + let direct_status = client.supports_capability(&key); + assert_ne!(direct_status, CapabilityStatus::Supported); + assert!(report.unsupported_required.contains(key)); +} + +#[test] +fn test_interface_compatibility_never_mutates_and_works_before_initialize() { + let (env, client, _admin, _user1, _user2) = setup(); + + // No auth mocked and no initialize() call — must still answer safely. + let required = vec![&env, Symbol::new(&env, "whitelist")]; + let report = client.check_interface_compatibility(&CAPABILITY_SCHEMA_VERSION, &required); + assert!(report.compatible); + + // Re-running it changes nothing about contract state. + let total_supply_before = client.get_total_supply(); + let _ = client.check_interface_compatibility(&CAPABILITY_SCHEMA_VERSION, &required); + assert_eq!(client.get_total_supply(), total_supply_before); +} + #[test] fn test_lifecycle_reads_never_revert_and_never_mutate() { let (env, client, _admin, user1, _user2) = setup();