From cb6b8ad0d8669be5b310c9425d83b53f05155fc4 Mon Sep 17 00:00:00 2001 From: Fury03 Date: Wed, 29 Jul 2026 14:30:22 +0100 Subject: [PATCH] feat(compliance): add status transition guards with pre-flight reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a guard layer over the compliance lifecycle so a status change can be evaluated — and explained — before it is submitted, using the same code the write path enforces. - src/compliance_guards.rs: ordered guard chain (initialized, not paused, caller authority, no-op, target-not-Unknown, transition matrix) evaluated by a function that never panics and never writes, plus the typed `TransitionGuard` reasons and their error-code mapping. - compliance.rs now reaches its verdict through the same evaluation, so a pre-flight verdict and enforcement cannot drift. Failure shapes (panic vs. typed Err) and the tolerant legacy wrappers are unchanged. - New reads: check_compliance_transition, get_compliance_transition_guard, check_compliance_batch. All are pure, stay callable while paused, and emit no events. - BlockedRequiresAdmin is reported separately from CallerUnauthorized: both map to 3000, but one needs an escalation and the other a role. - 17 tests asserting the guard and the contract agree across all 25 edges for officer, admin, and unauthorized callers, plus pause ordering, uninitialized reads, role revocation, batch atomicity, and duplicate detection. - docs/compliance-transition-guards.md with the guard chain, reason codes, security assumptions, and client guidance; README, capabilities registry (schema v4), and SDK fixtures updated. --- README.md | 2 + docs/capabilities.md | 13 +- docs/compliance-lifecycle.md | 9 + docs/compliance-status-transitions.md | 8 + docs/compliance-transition-guards.md | 241 ++++++++++++ fixtures/sdk/01-compliance.json | 118 ++++++ fixtures/sdk/05-errors.json | 24 +- src/capabilities.rs | 13 +- src/compliance.rs | 44 +-- src/compliance_guards.rs | 438 ++++++++++++++++++++++ src/config.rs | 34 +- src/config_test.rs | 9 +- src/lib.rs | 5 +- src/test.rs | 504 +++++++++++++++++++++++++- tests/sdk_fixtures.rs | 88 ++++- 15 files changed, 1470 insertions(+), 80 deletions(-) create mode 100644 docs/compliance-transition-guards.md create mode 100644 src/compliance_guards.rs diff --git a/README.md b/README.md index bedb26b..e98edae 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ value-encoding rules, and the no-real-user-data guarantee. - [Compliance Status Transitions](docs/compliance-status-transitions.md) — the approved/revoked/blocked/pending/unknown state machine, its transition matrix under authorised and unauthorised callers, and the invariant transition tests that guard it (audit readiness) +- [Compliance Status Transition Guards](docs/compliance-transition-guards.md) — the ordered guard chain every status change must clear, the typed refusal reasons (`BlockedRequiresAdmin`, `TransitionForbidden`, …), the pre-flight reads (`check_compliance_transition` / `check_compliance_batch`) that share one evaluation with enforcement, and the documented security assumptions + - [Compliance Batch Updates](docs/compliance-batch-updates.md) - atomic multi-address lifecycle updates, edge cases, event ordering, and SDK/dashboard guidance ## Errors diff --git a/docs/capabilities.md b/docs/capabilities.md index 11c1b1a..f123d04 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -126,6 +126,7 @@ pub struct ContractCapabilities { | `investor_tiers` | status | `Unsupported` | Jurisdiction/accreditation tiers. | | `lifecycle_states` | status | `Supported` | Five-state compliance lifecycle + `get_compliance_status`. See [`compliance-lifecycle.md`](compliance-lifecycle.md). | | `lifecycle_transitions` | status | `Supported` | Enforced transition matrix on `set_compliance_status`, plus the pre-flight transition reads. | +| `transition_guards` | status | `Supported` | Pre-flight transition guards (`check_compliance_transition`, `get_compliance_transition_guard`, `check_compliance_batch`) returning a typed refusal reason. See [`compliance-transition-guards.md`](compliance-transition-guards.md). | | `eligibility_reads` | status | `Supported` | `get_investor_eligibility`, `check_transfer_eligibility`. | | `enforced_on_mint` | `bool` | `true` | Every mint checks the receiver's lifecycle status. | | `enforced_on_transfer` | `bool` | `true` | Every transfer checks both parties' lifecycle statuses. | @@ -215,6 +216,7 @@ Registry (also returned by `get_capability_keys()`): | `investor_tiers` | `compliance.investor_tiers` | | `compliance_lifecycle` | `compliance.lifecycle_states` | | `compliance_transitions` | `compliance.lifecycle_transitions` | +| `compliance_transition_guards` | `compliance.transition_guards` | | `eligibility_reads` | `compliance.eligibility_reads` | | `minting` | `minting.minting` | | `burning` | `minting.burning` | @@ -247,13 +249,10 @@ within a schema version. ## Versioning `capability_version` is the schema version of the response - -(`CAPABILITY_SCHEMA_VERSION`, currently `2`); `contract_version` is the - -(`CAPABILITY_SCHEMA_VERSION`, currently `2` — bumped when the compliance -lifecycle fields and keys were added); `contract_version` is the - -deployed crate's semantic version. +(`CAPABILITY_SCHEMA_VERSION`, currently `4` — last bumped when +`compliance.transition_guards` and the `compliance_transition_guards` +registry key were added); `contract_version` is the deployed crate's +semantic version. Bump `capability_version` whenever a field is **added** to any capability struct or a key is added to the registry, so an SDK pinned to an older schema diff --git a/docs/compliance-lifecycle.md b/docs/compliance-lifecycle.md index a71810c..ce10086 100644 --- a/docs/compliance-lifecycle.md +++ b/docs/compliance-lifecycle.md @@ -324,3 +324,12 @@ Lifecycle tests live in [`src/test.rs`](../src/test.rs): `test_check_transfer_eligibility_tracks_lifecycle_changes`, `test_capabilities_advertise_the_compliance_lifecycle`, `test_lifecycle_reads_never_revert_and_never_mutate` + +## Pre-flight guards + +Every precondition above — the pause, the caller's authority, the no-op rule, +and the matrix itself — is evaluated by one shared guard chain that clients can +read *before* submitting a transaction, and that the write path enforces. See +[`compliance-transition-guards.md`](compliance-transition-guards.md) for the +guard order, the typed refusal reasons, and the +`check_compliance_transition` / `check_compliance_batch` entrypoints. diff --git a/docs/compliance-status-transitions.md b/docs/compliance-status-transitions.md index b99a0cc..82870d7 100644 --- a/docs/compliance-status-transitions.md +++ b/docs/compliance-status-transitions.md @@ -1,5 +1,13 @@ # Compliance status transitions +> This document describes the **legacy two-transition model** +> (`whitelist_user` / `revoke_whitelist`) and its invariant tests. The current +> five-state model is specified in +> [`compliance-lifecycle.md`](compliance-lifecycle.md), and the guards that +> gate every status change — with the typed refusal reasons and the pre-flight +> reads that share them — in +> [`compliance-transition-guards.md`](compliance-transition-guards.md). + This document defines the compliance status state machine for investor addresses — the **approved**, **revoked**, **blocked**, **pending**, and **unknown** statuses — the transitions that are valid under authorised and diff --git a/docs/compliance-transition-guards.md b/docs/compliance-transition-guards.md new file mode 100644 index 0000000..70122bf --- /dev/null +++ b/docs/compliance-transition-guards.md @@ -0,0 +1,241 @@ +# Compliance Status Transition Guards + +This document specifies the **guards** that every compliance status change +must clear, the typed reason each refusal produces, and the pre-flight reads +that let an SDK or dashboard obtain that reason *before* an officer signs a +transaction. + +> **Not legal or financial advice.** These guards are protocol-level access +> and state-machine controls only. Whether an investor may be approved, +> revoked, or frozen under a real-world regulatory regime is decided off-chain +> by the issuer's compliance and legal functions — see +> [`legal-boundary-disclaimer.md`](legal-boundary-disclaimer.md). The contract +> enforces that a recorded decision is *well-formed and authorized*; it does +> not make the decision. + +Related: [`compliance-lifecycle.md`](compliance-lifecycle.md) defines the +states and the transition matrix; this document covers the guards layered on +top of it. [`compliance-status-transitions.md`](compliance-status-transitions.md) +covers the legacy two-transition model and its invariant tests. + +## Why + +The lifecycle already rejects illegal status changes. What it could not do was +*explain* a rejection ahead of time. A client had three bad options: + +| Option | Problem | +| --- | --- | +| Re-implement the rules client-side | Two copies of a compliance-critical rule set, free to drift silently. The dashboard would confidently offer an action the contract refuses — or hide one it permits. | +| Submit and translate the revert | Burns a transaction, and a bare `Unauthorized` (3000) cannot distinguish "you need a role" from "this address is frozen and only the admin can act". | +| Read `is_compliance_transition_allowed` | Matrix-only. It ignores the caller, the pause, and the admin-only exit from `Blocked`, so `true` did not mean "this call will succeed". | + +The guards close this by making one evaluation serve both purposes: the read +path and the write path call the *same function*. A pre-flight verdict cannot +disagree with enforcement, because there is nothing to disagree with. + +## The guard chain + +Every precondition is evaluated in a fixed order, and the **first** failure is +returned. The order matters and is itself a security property. + +| # | Guard | Reason on failure | Error | +| --- | --- | --- | --- | +| 1 | Contract is initialized | `NotInitialized` | `NotInitialized` (2000) | +| 2 | Contract is not paused | `ContractPaused` | `ContractPaused` (3004) | +| 3 | Caller may act on the *current* status | `CallerUnauthorized` / `BlockedRequiresAdmin` | `Unauthorized` (3000) | +| 4 | Requested status differs from current | `StatusUnchanged` | `ComplianceStatusUnchanged` (4007) | +| 5 | Target is not `Unknown` | `TargetUnknownForbidden` | `InvalidComplianceTransition` (4006) | +| 6 | `from -> to` is in the transition matrix | `TransitionForbidden` | `InvalidComplianceTransition` (4006) | +| — | all pass | `Allowed` | — | +| batch only | Address appears once per batch | `DuplicateUserInBatch` | `InvalidComplianceTransition` (4006) | + +Two ordering choices are deliberate: + +- **Pause before authority.** A paused contract reports the pause to everyone. + It never leaks whether the caller *would* have qualified, so the pre-flight + read cannot be used to probe the role table during an incident. +- **Authority before the matrix.** An unauthorized caller learns nothing about + which edges are legal for an address they may not touch. + +### Authority: who may move which status + +| Current status | ComplianceOfficer | EmergencyOfficer | Admin | Anyone else | +| --- | :---: | :---: | :---: | :---: | +| `Unknown` / `Pending` / `Approved` / `Revoked` | ✓ | ✓ | ✓ | ✗ | +| `Blocked` | ✗ | ✗ | ✓ | ✗ | + +Leaving `Blocked` is admin-only, mirroring the pause/unpause asymmetry in +[`admin-roles.md`](admin-roles.md): a compromised or coerced compliance officer +must not be able to lift a sanctions freeze. *Entering* `Blocked` stays +available to any compliance role — freezing fast is the safe direction. + +`BlockedRequiresAdmin` and `CallerUnauthorized` both map to `Unauthorized` +(3000) on-chain, but they are separate *reasons* because the remediation +differs: "escalate to the admin" versus "request a role". A client that +collapses them will tell a properly-credentialed officer to ask for a +permission they already hold. + +## Reason codes + +```rust +pub enum TransitionGuard { + Allowed, + NotInitialized, + ContractPaused, + CallerUnauthorized, + BlockedRequiresAdmin, + StatusUnchanged, + TargetUnknownForbidden, + TransitionForbidden, + DuplicateUserInBatch, +} +``` + +Variants are **append-only**: never reorder or repurpose one. The variant order +is part of the contract ABI, under the same stability contract as the +[`error-codes.md`](error-codes.md) numeric codes and the +[`events.md`](events.md) topics. + +`TargetUnknownForbidden` is separated from `TransitionForbidden` because +`Unknown` is unreachable from *every* source status — compliance history is +never erased, and offboarding is `Revoked`. A client should drop `Unknown` from +its target picker entirely rather than render an edge that can never succeed. + +## Read entrypoints + +All three are pure reads: no authorization, no writes, no events, and they +remain callable while the contract is paused. + +### `check_compliance_transition(caller, user, new_status) -> ComplianceTransitionCheck` + +```rust +pub struct ComplianceTransitionCheck { + pub user: Address, + pub caller: Address, + pub current_status: ComplianceStatus, + pub requested_status: ComplianceStatus, + pub allowed: bool, + pub reason: TransitionGuard, + pub error_code: Option, +} +``` + +`current_status` is included so a client cannot race a separate +`get_compliance_status` read against this one and render an inconsistent pair. +`error_code` is pre-resolved to the numeric code a rejected submission would +revert with, so clients reuse their existing +[`error-codes.md`](error-codes.md) mapping instead of maintaining a second +reason table. + +### `get_compliance_transition_guard(caller, user, new_status) -> TransitionGuard` + +The reason alone, for clients that only branch on it. + +### `check_compliance_batch(caller, updates) -> Vec` + +One verdict per entry, in input order. Entries are evaluated independently — +which is sound precisely because duplicate addresses are rejected, so no entry +can change the status another is judged against. + +**The batch is atomic.** A single `allowed == false` row means the whole +submission fails and *no* address is updated. Treat a rejected row as "this +batch will not commit", never as "this row will be skipped". This is the one +place where per-row `allowed == true` must not be read as "this row commits" — +the batch pre-flight tells you which row to fix, not what will partially apply. + +## Enforcement path + +`set_compliance_status`, `batch_set_compliance_status`, `whitelist_user`, and +`revoke_whitelist` all reach their verdict through the same evaluation. Two +details preserve backwards compatibility exactly: + +- **Failure shape is unchanged.** Availability and authorization failures + (guards 1–3) abort by panicking, as they always have; rule violations that + are the caller's choice of edge (guards 4–6) return a typed `Err`. Existing + SDK error handling, tests, and fixtures see identical behaviour. +- **Legacy wrappers stay tolerant.** `whitelist_user` and `revoke_whitelist` + enforce only the *authority* half of the chain, keeping their documented + idempotent no-op behaviour (re-approving an approved address succeeds; + revoking an `Unknown` address is a no-op). They still cannot lift a freeze: + `Blocked -> Approved` is refused, and `revoke_whitelist` never downgrades + `Blocked` to the weaker `Revoked`. + +## Security and compliance assumptions + +These hold at the protocol level and are the assumptions a reviewer should +check against the issuer's off-chain controls: + +1. **A guard verdict is point-in-time, not a reservation.** Nothing is locked + between the read and the submission. A pause, a role revocation, or another + officer's write can land in between, so every client must still handle a + revert. Do not use `allowed == true` as an authorization decision on its own. +2. **The guard does not evaluate `require_auth`.** Whether the caller can + produce a valid signature is a property of the submitted transaction, not of + ledger state. `Allowed` means "the rules permit this caller", not "this + caller is authenticated". A pre-flight read for an arbitrary `caller` + address is therefore public information — treat the role table as public, + because it is. +3. **The admin is trusted.** The admin bypasses every role check and is the + sole authority able to lift `Blocked`. Admin-key compromise is out of scope + for these guards; see [`admin-misuse-risks.md`](admin-misuse-risks.md) and + [`threat-model.md`](threat-model.md). +4. **`Blocked` is a protocol freeze, not a sanctions determination.** The + contract records that an enforcement decision was made off-chain and + restricts who may reverse it. It performs no screening of its own. +5. **Reads leak status.** Compliance status and guard verdicts are readable by + anyone with RPC access, as is all contract state. Do not store personal data + on-chain; the lifecycle carries a status, never an identity. +6. **The pause is recoverable, never a lockout.** Guard 2 refuses everything + while paused; the same edges clear after `unpause`. See + [`emergency-pause.md`](emergency-pause.md). + +## Test coverage + +All tests are in [`src/test.rs`](../src/test.rs) under +`COMPLIANCE STATUS TRANSITION GUARDS`. The load-bearing ones assert +*agreement* rather than a hardcoded expectation, so they fail if the read path +and the write path ever diverge: + +| Test | What it proves | +| --- | --- | +| `test_guard_matches_enforcement_for_every_edge_as_officer` | All 5 × 5 source/target edges: the pre-flight verdict and the real submission agree on outcome, error code, and resulting status. Each edge runs on a fresh deployment. | +| `test_guard_matches_enforcement_for_every_edge_as_admin` | The same 25 edges for the admin, covering the admin-only exit from `Blocked`. | +| `test_guard_matches_enforcement_for_every_edge_as_unauthorized_caller` | The same 25 edges for a wrong-scoped role: always refused, always predicted in advance. | +| `test_guard_reports_blocked_requires_admin_not_generic_unauthorized` | A credentialed officer is refused on a blocked address with the specific reason; the admin may move it only to `Pending`. | +| `test_guard_reports_status_unchanged_for_every_self_edge` | Every no-op is caught, for all five statuses. | +| `test_guard_reports_target_unknown_as_its_own_reason` | `Unknown` is unreachable from every source and reports its dedicated reason. | +| `test_guard_reports_pause_ahead_of_authority` | The pause is reported first for every caller class, reads stay callable while paused, and the edge clears after `unpause`. | +| `test_guard_reports_not_initialized_instead_of_panicking` | The read answers on an unconfigured deployment instead of reverting, and its prediction holds. | +| `test_guard_reads_never_mutate_state` | Repeated pre-flight reads change no status, role, or pause flag, and emit **no** events — a pre-flight is not a compliance action and must leave no audit trace. | +| `test_guard_verdict_tracks_role_revocation` | Authority is evaluated at call time, never cached: revoking an officer's role flips the verdict immediately, including for addresses they approved. | +| `test_guard_accepts_emergency_officer_and_rejects_asset_manager` | Role scoping is exact. | +| `test_batch_guard_matches_batch_execution_when_every_entry_is_legal` | A fully-legal batch pre-flights clean and commits. | +| `test_batch_guard_flags_the_offending_entry_and_the_batch_fails_atomically` | The guard pinpoints the offending row; the batch fails whole and the legal row is not applied. | +| `test_batch_guard_flags_duplicate_addresses` | Only the repeat is flagged, and the batch is rejected. | +| `test_batch_guard_accepts_an_empty_batch` | Empty batches are legal and commit nothing. | +| `test_guard_agrees_with_the_legacy_whitelist_entrypoints` | The guard predicts the authorization outcome of `whitelist_user` across all five source statuses, including where the wrapper's idempotence absorbs a no-op. | + +Run them with `make test`. + +## Client guidance + +- **Render the reason, not the boolean.** `allowed == false` with + `BlockedRequiresAdmin` is an escalation, not an error message. +- **Re-check after any state change.** Verdicts are not cacheable across + ledgers. Pause state and role assignments both invalidate them. +- **Use `get_allowed_transitions_for(user)` to build the picker, then + `check_compliance_transition` to confirm the caller may take it.** The first + answers "what edges exist", the second "may *this* officer take one now". +- **Never treat a verdict as a substitute for handling the revert.** See + assumption 1. + +## Maintenance + +Any new compliance write entrypoint **must** reach its verdict through +`compliance_guards::require_transition` (or `require_transition_authority` for +a tolerant legacy-style wrapper). Adding a precondition means adding a +`TransitionGuard` variant, its error mapping, a row in the guard-chain table +above, and a case in the agreement tests — in the same change. A precondition +enforced outside the guard is a silent divergence between what clients are told +and what the contract does, which is exactly the failure mode this module +exists to prevent. diff --git a/fixtures/sdk/01-compliance.json b/fixtures/sdk/01-compliance.json index 339fff5..c7c61c0 100644 --- a/fixtures/sdk/01-compliance.json +++ b/fixtures/sdk/01-compliance.json @@ -113,6 +113,124 @@ ], "is_whitelisted_after": false }, + { + "id": "batch-set-compliance-status-success", + "description": "A ComplianceOfficer applies two typed lifecycle updates atomically. The call returns the applied count and emits one lifecycle event per address in input order.", + "call": "batch_set_compliance_status", + "args": [ + "compliance_officer", + [ + { + "user": "investor_alice", + "new_status": "Pending" + }, + { + "user": "investor_bob", + "new_status": "Approved" + } + ] + ], + "result": { + "ok": true + }, + "applied_count": 2, + "events": [ + { + "contract": "CCEOFPHM2IOUTJS53R74QWIEQXXEHLYOTZYMCS44UI735A4WCJZAQNWP", + "type": "contract", + "topic": "compliance_status_changed", + "topics": [ + "compliance_status_changed" + ], + "data": { + "caller": "GAEGCFR5CC2J5E5FVFDOJJS4TGNCBWTDMNILRHETSHDWXOXIOFWA25JU", + "new_status": [ + "Pending" + ], + "previous_status": [ + "Unknown" + ], + "user": "GAXRVA67D5NLMKP6H5IROF3IY5EMQW6AQBJLTUITGAAZUXMGY7CYO2KG" + }, + "xdr_base64": "AAAAAAAAAAGI4rzs0h1Jpl3cf8hZBIXuQ68OnnDBS5yiP76DlhJyCAAAAAEAAAAAAAAAAQAAAA4AAAAZY29tcGxpYW5jZV9zdGF0dXNfY2hhbmdlZAAAAAAAABEAAAABAAAABAAAAA8AAAAGY2FsbGVyAAAAAAASAAAAAAAAAAAIYRY9ELSek6WpRuSmXJmaINpjY1C4nJORx2u66HFsDQAAAA8AAAAKbmV3X3N0YXR1cwAAAAAAEAAAAAEAAAABAAAADwAAAAdQZW5kaW5nAAAAAA8AAAAPcHJldmlvdXNfc3RhdHVzAAAAABAAAAABAAAAAQAAAA8AAAAHVW5rbm93bgAAAAAPAAAABHVzZXIAAAASAAAAAAAAAAAvGoPfH1q2Kf4/URcXaMdIyFvAgFK50RMwAZpdhsfFhw==" + }, + { + "contract": "CCEOFPHM2IOUTJS53R74QWIEQXXEHLYOTZYMCS44UI735A4WCJZAQNWP", + "type": "contract", + "topic": "compliance_status_changed", + "topics": [ + "compliance_status_changed" + ], + "data": { + "caller": "GAEGCFR5CC2J5E5FVFDOJJS4TGNCBWTDMNILRHETSHDWXOXIOFWA25JU", + "new_status": [ + "Approved" + ], + "previous_status": [ + "Unknown" + ], + "user": "GD4YM2BO77TT5BMWZC7SMH74GWZ5TPTKVTGPW5X6VUKFXLLD6W3XPRYN" + }, + "xdr_base64": "AAAAAAAAAAGI4rzs0h1Jpl3cf8hZBIXuQ68OnnDBS5yiP76DlhJyCAAAAAEAAAAAAAAAAQAAAA4AAAAZY29tcGxpYW5jZV9zdGF0dXNfY2hhbmdlZAAAAAAAABEAAAABAAAABAAAAA8AAAAGY2FsbGVyAAAAAAASAAAAAAAAAAAIYRY9ELSek6WpRuSmXJmaINpjY1C4nJORx2u66HFsDQAAAA8AAAAKbmV3X3N0YXR1cwAAAAAAEAAAAAEAAAABAAAADwAAAAhBcHByb3ZlZAAAAA8AAAAPcHJldmlvdXNfc3RhdHVzAAAAABAAAAABAAAAAQAAAA8AAAAHVW5rbm93bgAAAAAPAAAABHVzZXIAAAASAAAAAAAAAAD5hmgu/+c+hZbIvyYf/DWz2b5qrMz7dv6tFFutY/W3dw==" + } + ], + "alice_status_after": [ + "Pending" + ], + "bob_status_after": [ + "Approved" + ] + }, + { + "id": "check-compliance-transition-allowed", + "description": "Pre-flight read: a ComplianceOfficer may approve an unknown address. The verdict comes from the same evaluation the write path enforces, so `allowed: true` means `set_compliance_status` would commit against this ledger state. Pure read — no events, no writes.", + "call": "check_compliance_transition", + "args": [ + "compliance_officer", + "investor_alice", + "Approved" + ], + "returns": { + "allowed": true, + "caller": "GAEGCFR5CC2J5E5FVFDOJJS4TGNCBWTDMNILRHETSHDWXOXIOFWA25JU", + "current_status": [ + "Unknown" + ], + "error_code": null, + "reason": [ + "Allowed" + ], + "requested_status": [ + "Approved" + ], + "user": "GAXRVA67D5NLMKP6H5IROF3IY5EMQW6AQBJLTUITGAAZUXMGY7CYO2KG" + } + }, + { + "id": "check-compliance-transition-blocked-requires-admin", + "description": "Pre-flight read: the same ComplianceOfficer is refused for a `Blocked` address. `reason` distinguishes an admin-only freeze from a missing role even though both surface as `Unauthorized` (3000) on-chain, and `error_code` pre-resolves the code a submission would revert with.", + "call": "check_compliance_transition", + "args": [ + "compliance_officer", + "investor_bob", + "Pending" + ], + "returns": { + "allowed": false, + "caller": "GAEGCFR5CC2J5E5FVFDOJJS4TGNCBWTDMNILRHETSHDWXOXIOFWA25JU", + "current_status": [ + "Blocked" + ], + "error_code": 3000, + "reason": [ + "BlockedRequiresAdmin" + ], + "requested_status": [ + "Pending" + ], + "user": "GD4YM2BO77TT5BMWZC7SMH74GWZ5TPTKVTGPW5X6VUKFXLLD6W3XPRYN" + } + }, { "id": "get-role-of-all-actors", "description": "Role reads for every actor. A `#[contracttype]` unit enum is encoded on the wire as a single-element vector holding the variant name, so `Role::Admin` renders as [\"Admin\"] and an unassigned address as [\"None\"].", diff --git a/fixtures/sdk/05-errors.json b/fixtures/sdk/05-errors.json index 60e5392..33dcd25 100644 --- a/fixtures/sdk/05-errors.json +++ b/fixtures/sdk/05-errors.json @@ -249,30 +249,30 @@ } }, { - "id": "error-7000-asset-not-active", - "description": "The asset lifecycle status is Draft (not Active), so issuance and transfers are blocked.", + "id": "error-7002-asset-blocked-restriction-draft", + "description": "The asset lifecycle status is Draft (not Active), so issuance and transfers are blocked. Reported as the granular restriction code `7002`, not the reserved `6000 AssetNotActive` it superseded (see docs/error-codes.md).", "call": "mint_asset", "result": { "ok": false, "error": { "type": "contract", - "code": 7000, - "name": "AssetNotActive", - "category": "unknown" + "code": 7002, + "name": "AssetBlockedRestriction", + "category": "transfer_restrictions" } } }, { - "id": "error-7001-asset-lifecycle-paused", + "id": "error-7000-asset-paused-restriction", "description": "The asset lifecycle status is Paused, so issuance and transfers are blocked. Distinct from the global contract pause (3004).", "call": "mint_asset", "result": { "ok": false, "error": { "type": "contract", - "code": 7001, - "name": "AssetLifecyclePaused", - "category": "unknown" + "code": 7000, + "name": "AssetPausedRestriction", + "category": "transfer_restrictions" } } }, @@ -284,9 +284,9 @@ "ok": false, "error": { "type": "contract", - "code": 7004, + "code": 6004, "name": "InvalidLifecycleTransition", - "category": "unknown" + "category": "asset_metadata" } } }, @@ -298,7 +298,7 @@ "ok": false, "error": { "type": "contract", - "code": 6002, + "code": 6006, "name": "AssetMetadataUpdateBlocked", "category": "asset_metadata" } diff --git a/src/capabilities.rs b/src/capabilities.rs index 1212d71..a84471a 100644 --- a/src/capabilities.rs +++ b/src/capabilities.rs @@ -17,7 +17,7 @@ use crate::{AegisContract, AegisContractArgs, AegisContractClient, DataKey}; /// does not know about. Fields are append-only: never remove or repurpose an /// existing field or key (same stability contract as `docs/events.md` topics /// and `docs/error-codes.md` numeric codes). -pub const CAPABILITY_SCHEMA_VERSION: u32 = 3; +pub const CAPABILITY_SCHEMA_VERSION: u32 = 4; // ─── Response types ─────────────────────────────────────────────────────────── @@ -72,6 +72,13 @@ pub struct ComplianceCapabilities { /// `is_compliance_transition_allowed` / /// `get_allowed_compliance_transitions`. pub lifecycle_transitions: CapabilityStatus, + /// Pre-flight transition guards (`check_compliance_transition`, + /// `get_compliance_transition_guard`, `check_compliance_batch`), which + /// return the typed reason a status change would be refused instead of + /// only a boolean. Evaluated by the same code the write path enforces, so + /// a client can disable an illegal action without simulating it. See + /// `docs/compliance-transition-guards.md`. + pub transition_guards: CapabilityStatus, /// Aggregated read helpers (`get_investor_eligibility`, /// `check_transfer_eligibility`). pub eligibility_reads: CapabilityStatus, @@ -332,6 +339,7 @@ pub fn get_capabilities(env: &Env) -> ContractCapabilities { investor_tiers: CapabilityStatus::Unsupported, lifecycle_states: CapabilityStatus::Supported, lifecycle_transitions: CapabilityStatus::Supported, + transition_guards: CapabilityStatus::Supported, eligibility_reads: CapabilityStatus::Supported, enforced_on_mint: true, enforced_on_transfer: true, @@ -447,6 +455,9 @@ pub fn supports_capability(env: &Env, capability: &Symbol) -> CapabilityStatus { if *capability == Symbol::new(env, "compliance_transitions") { return caps.compliance.lifecycle_transitions; } + if *capability == Symbol::new(env, "compliance_transition_guards") { + return caps.compliance.transition_guards; + } if *capability == Symbol::new(env, "eligibility_reads") { return caps.compliance.eligibility_reads; } diff --git a/src/compliance.rs b/src/compliance.rs index c228691..457a85f 100644 --- a/src/compliance.rs +++ b/src/compliance.rs @@ -27,8 +27,9 @@ use soroban_sdk::{contractimpl, contracttype, vec, Address, Env, Vec}; -use crate::admin::{get_admin, require_any_role, require_not_paused}; -use crate::{AegisContract, AegisContractArgs, AegisContractClient, DataKey, Error, Role}; +use crate::admin::require_not_paused; +use crate::compliance_guards::{require_transition, require_transition_authority}; +use crate::{AegisContract, AegisContractArgs, AegisContractClient, DataKey, Error}; // ─── Lifecycle state ────────────────────────────────────────────────────────── @@ -214,28 +215,6 @@ fn write_status(env: &Env, user: &Address, status: &ComplianceStatus) { } } -/// Authorization for a lifecycle transition. -/// -/// Leaving `Blocked` is restricted to the supreme admin, mirroring the -/// pause/unpause asymmetry in `admin.rs`: a compromised or coerced compliance -/// officer must not be able to lift a sanctions freeze. Every other -/// transition — including *entering* `Blocked` — is available to a -/// ComplianceOfficer, an EmergencyOfficer, or the admin. -fn require_transition_authority(env: &Env, caller: &Address, from: &ComplianceStatus) { - if from.is_blocked() { - if *caller != get_admin(env) { - soroban_sdk::panic_with_error!(env, Error::Unauthorized); - } - return; - } - - require_any_role( - env, - caller, - &[Role::ComplianceOfficer, Role::EmergencyOfficer], - ); -} - /// Applies a validated transition and emits `compliance_status_changed`. /// Assumes the caller has already been authorized. fn apply_transition( @@ -258,6 +237,13 @@ fn apply_transition( ); } +/// Resolves the current status and enforces every transition guard against it, +/// returning the status the write will be applied over. +/// +/// The rules themselves live in [`crate::compliance_guards`] and are shared +/// with the pre-flight read entrypoints, so a client that was told a +/// transition is allowed cannot be refused by a different rule here — and vice +/// versa. See `docs/compliance-transition-guards.md`. fn validate_transition( env: &Env, caller: &Address, @@ -265,15 +251,7 @@ fn validate_transition( new_status: &ComplianceStatus, ) -> Result { let current = get_compliance_status(env, user); - require_transition_authority(env, caller, ¤t); - - if current == *new_status { - return Err(Error::ComplianceStatusUnchanged); - } - if !transition_is_allowed(¤t, new_status) { - return Err(Error::InvalidComplianceTransition); - } - + require_transition(env, caller, ¤t, new_status)?; Ok(current) } diff --git a/src/compliance_guards.rs b/src/compliance_guards.rs new file mode 100644 index 0000000..2802e43 --- /dev/null +++ b/src/compliance_guards.rs @@ -0,0 +1,438 @@ +//! Compliance status transition guards. +//! +//! The compliance lifecycle in [`crate::compliance`] answers *what* the legal +//! states and edges are. This module answers the operational question that +//! sits on top of it: **"would this specific caller's status change succeed +//! right now, and if not, exactly why?"** +//! +//! Every precondition a committed transition must satisfy — initialization, +//! the global pause, the caller's authority (including the admin-only exit +//! from `Blocked`), no-op rejection, and the transition matrix itself — is +//! evaluated here, in one ordered pass, by a function that **never panics and +//! never writes**. Two consumers share that single evaluation: +//! +//! * **Enforcement.** `set_compliance_status`, `batch_set_compliance_status`, +//! `whitelist_user`, and `revoke_whitelist` all reach their verdict through +//! [`evaluate_transition`]. There is no second copy of the rules that could +//! drift from the one clients can read. +//! * **Pre-flight.** `check_compliance_transition` / +//! `check_compliance_batch` return the same verdict as a typed +//! [`ComplianceTransitionCheck`], so a dashboard can disable an illegal +//! action and explain it *before* an officer signs a transaction, instead of +//! submitting one and translating a revert. +//! +//! Because both paths are the same code, a pre-flight `allowed == true` is a +//! statement about the ledger state at read time — not a reservation. State +//! can change between the read and the submission (a pause, a role +//! revocation, another officer's write), so callers must still handle a +//! revert. See `docs/compliance-transition-guards.md`. + +use soroban_sdk::{contractimpl, contracttype, vec, Address, Env, Vec}; + +use crate::admin::{get_role, is_paused}; +use crate::compliance::{ + get_compliance_status, transition_is_allowed, ComplianceBatchUpdate, ComplianceStatus, +}; +use crate::{AegisContract, AegisContractArgs, AegisContractClient, DataKey, Error, Role}; + +// ─── Guard reasons ──────────────────────────────────────────────────────────── + +/// Why a compliance status transition is permitted or rejected. +/// +/// Exactly one reason is returned per evaluation: the **first** precondition +/// that fails, in the same order enforcement applies them. A client that wants +/// to surface every problem with a proposed change must fix one and re-check. +/// +/// The variant order is part of the contract's ABI: variants are append-only +/// and must never be reordered or repurposed (same stability contract as the +/// `docs/error-codes.md` numeric codes and the `docs/events.md` topics). +#[contracttype] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TransitionGuard { + /// Every precondition passes: this caller may commit this transition + /// against the current ledger state. + Allowed, + /// The contract has not been initialized, so no authority exists to check + /// the caller against. Maps to `NotInitialized` (2000). + NotInitialized, + /// The contract is globally paused. Every compliance write is blocked + /// while the pause is in force, regardless of caller or status. Maps to + /// `ContractPaused` (3004). Recoverable: the admin can `unpause`. + ContractPaused, + /// The caller holds neither the ComplianceOfficer nor the + /// EmergencyOfficer role and is not the admin. Maps to `Unauthorized` + /// (3000). + CallerUnauthorized, + /// The address is currently `Blocked` and the caller is not the supreme + /// admin. Distinguished from [`Self::CallerUnauthorized`] because the + /// caller may hold a perfectly valid compliance role and still be refused + /// here: lifting a sanctions freeze is admin-only by design. Maps to + /// `Unauthorized` (3000) — the same code, a different remediation + /// ("escalate to the admin", not "request a role"). + BlockedRequiresAdmin, + /// The requested status equals the current status. Rejected so a no-op + /// can never emit a misleading lifecycle event. Maps to + /// `ComplianceStatusUnchanged` (4007). + StatusUnchanged, + /// The requested target is `Unknown`. Compliance history is never erased; + /// offboarding is `Revoked`. Distinguished from + /// [`Self::TransitionForbidden`] because no source status can ever reach + /// it, so a client should not offer it at all. Maps to + /// `InvalidComplianceTransition` (4006). + TargetUnknownForbidden, + /// The `from -> to` edge is not in the transition matrix (for example + /// `Blocked -> Approved`, which must pass back through `Pending`). Maps + /// to `InvalidComplianceTransition` (4006). + TransitionForbidden, + /// Batch pre-flight only: the same address appears more than once in the + /// batch. Rejected so a batch cannot smuggle order-dependent compliance + /// intent. Maps to `InvalidComplianceTransition` (4006). + DuplicateUserInBatch, +} + +impl TransitionGuard { + /// Whether this verdict permits the transition. + pub fn is_allowed(&self) -> bool { + matches!(self, TransitionGuard::Allowed) + } + + /// Whether this verdict is an authorization failure — the caller is the + /// problem, not the requested edge. Lets a client route to "ask an + /// authorized officer" instead of "pick a different status". + pub fn is_authorization_failure(&self) -> bool { + matches!( + self, + TransitionGuard::CallerUnauthorized | TransitionGuard::BlockedRequiresAdmin + ) + } +} + +/// The contract error a rejected verdict produces, or `None` when allowed. +/// +/// This is the mapping that keeps a pre-flight read and a real invocation +/// telling the same story: whatever [`check_compliance_transition`] reports, +/// submitting the transition fails with exactly this code. +/// +/// [`check_compliance_transition`]: AegisContract::check_compliance_transition +pub fn error_for_guard(guard: &TransitionGuard) -> Option { + match guard { + TransitionGuard::Allowed => None, + TransitionGuard::NotInitialized => Some(Error::NotInitialized), + TransitionGuard::ContractPaused => Some(Error::ContractPaused), + TransitionGuard::CallerUnauthorized | TransitionGuard::BlockedRequiresAdmin => { + Some(Error::Unauthorized) + } + TransitionGuard::StatusUnchanged => Some(Error::ComplianceStatusUnchanged), + TransitionGuard::TargetUnknownForbidden + | TransitionGuard::TransitionForbidden + | TransitionGuard::DuplicateUserInBatch => Some(Error::InvalidComplianceTransition), + } +} + +/// Whether a rejected verdict aborts the invocation by **panicking** rather +/// than by returning `Err`. +/// +/// Authorization and availability failures (`NotInitialized`, +/// `ContractPaused`, and both unauthorized variants) have always panicked in +/// this contract, and downstream tests, SDKs, and the fixture set depend on +/// that. Rule violations that are the caller's *choice* of edge +/// (`StatusUnchanged`, the forbidden-transition variants) are returned as +/// typed `Err` values. Keeping the split explicit here is what lets the guard +/// become the single evaluation without changing any existing failure shape. +pub fn guard_panics(guard: &TransitionGuard) -> bool { + matches!( + guard, + TransitionGuard::NotInitialized + | TransitionGuard::ContractPaused + | TransitionGuard::CallerUnauthorized + | TransitionGuard::BlockedRequiresAdmin + ) +} + +// ─── Pre-flight report ──────────────────────────────────────────────────────── + +/// The full verdict for one proposed transition, as returned to clients. +/// +/// Carries the resolved current status alongside the verdict so a caller +/// cannot race a separate `get_compliance_status` read against this one and +/// render an inconsistent pair. +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ComplianceTransitionCheck { + /// The address whose status the transition would change. + pub user: Address, + /// The address that would sign and submit the transition. + pub caller: Address, + /// `user`'s status at read time (`Unknown` when no record exists). + pub current_status: ComplianceStatus, + /// The status the transition would move `user` to. + pub requested_status: ComplianceStatus, + /// Whether the transition would be committed if submitted now. + pub allowed: bool, + /// The first failing precondition, or `Allowed`. + pub reason: TransitionGuard, + /// The numeric error code a rejected submission would revert with, or + /// `None` when allowed. Pre-resolved so clients can reuse their existing + /// `docs/error-codes.md` mapping without duplicating the reason table. + pub error_code: Option, +} + +// ─── Evaluation ─────────────────────────────────────────────────────────────── + +/// Returns whether `caller` may authorize *any* transition away from `from`. +/// +/// Mirrors the pause/unpause asymmetry in `admin.rs`: leaving `Blocked` is +/// admin-only so a compromised or coerced compliance officer cannot lift a +/// sanctions freeze. Every other transition — including *entering* `Blocked` +/// — is available to a ComplianceOfficer, an EmergencyOfficer, or the admin. +/// +/// Pure read: unlike `admin::require_any_role`, it reports rather than panics, +/// so it is safe to call from a view entrypoint. +fn authority_guard(env: &Env, caller: &Address, from: &ComplianceStatus) -> TransitionGuard { + let admin: Address = match env.storage().instance().get(&DataKey::Admin) { + Some(admin) => admin, + None => return TransitionGuard::NotInitialized, + }; + + if *caller == admin { + return TransitionGuard::Allowed; + } + + if from.is_blocked() { + return TransitionGuard::BlockedRequiresAdmin; + } + + match get_role(env, caller) { + Role::ComplianceOfficer | Role::EmergencyOfficer => TransitionGuard::Allowed, + _ => TransitionGuard::CallerUnauthorized, + } +} + +/// Evaluates every precondition for `caller` moving `user` to `new_status`. +/// +/// **Never panics and never writes** — safe from both view entrypoints and +/// enforcement paths. Preconditions are applied in the order enforcement +/// applies them, and the first failure short-circuits: +/// +/// 1. contract initialized, +/// 2. contract not paused, +/// 3. caller authority for the *current* status, +/// 4. requested status differs from the current one, +/// 5. the target is not `Unknown`, +/// 6. the `from -> to` edge is in the transition matrix. +/// +/// One precondition is deliberately **not** evaluated: `require_auth`. Whether +/// the caller can actually produce a valid signature is a property of the +/// submitted transaction, not of ledger state, so a pre-flight `Allowed` means +/// "the rules permit this caller", not "this caller is authenticated". +pub fn evaluate_transition( + env: &Env, + caller: &Address, + user: &Address, + new_status: &ComplianceStatus, +) -> TransitionGuard { + let current = get_compliance_status(env, user); + evaluate_from_status(env, caller, ¤t, new_status) +} + +/// [`evaluate_transition`] against an already-resolved current status. +/// +/// Used by enforcement paths that have already read the status (so the guard +/// cannot be evaluated against a different one than the write applies to) and +/// by the matrix tests, which walk source statuses directly. +pub fn evaluate_from_status( + env: &Env, + caller: &Address, + current: &ComplianceStatus, + new_status: &ComplianceStatus, +) -> TransitionGuard { + if is_paused(env) { + // Checked before authority so a paused contract reports the pause + // rather than leaking whether the caller would otherwise qualify. + return TransitionGuard::ContractPaused; + } + + let authority = authority_guard(env, caller, current); + if !authority.is_allowed() { + return authority; + } + + if current == new_status { + return TransitionGuard::StatusUnchanged; + } + if *new_status == ComplianceStatus::Unknown { + return TransitionGuard::TargetUnknownForbidden; + } + if !transition_is_allowed(current, new_status) { + return TransitionGuard::TransitionForbidden; + } + + TransitionGuard::Allowed +} + +/// Builds the client-facing report for a proposed transition. +pub fn check_transition( + env: &Env, + caller: &Address, + user: &Address, + new_status: &ComplianceStatus, +) -> ComplianceTransitionCheck { + let current_status = get_compliance_status(env, user); + let reason = evaluate_from_status(env, caller, ¤t_status, new_status); + + ComplianceTransitionCheck { + user: user.clone(), + caller: caller.clone(), + current_status, + requested_status: *new_status, + allowed: reason.is_allowed(), + reason, + error_code: error_for_guard(&reason).map(|err| err as u32), + } +} + +// ─── Enforcement entry point ────────────────────────────────────────────────── + +/// Enforces the guard for a transition, returning the current status on +/// success. **This is the only path a state-changing compliance call may use +/// to reach a verdict**, so enforcement and pre-flight can never disagree. +/// +/// Rejected verdicts either panic or return `Err`, per [`guard_panics`], +/// preserving the failure shape each precondition had before the guard +/// existed. +pub fn require_transition( + env: &Env, + caller: &Address, + current: &ComplianceStatus, + new_status: &ComplianceStatus, +) -> Result<(), Error> { + let guard = evaluate_from_status(env, caller, current, new_status); + if guard.is_allowed() { + return Ok(()); + } + + // `error_for_guard` returns `None` only for `Allowed`, handled above. + let error = match error_for_guard(&guard) { + Some(error) => error, + None => return Ok(()), + }; + + if guard_panics(&guard) { + soroban_sdk::panic_with_error!(env, error); + } + + Err(error) +} + +/// Enforces only the *authority* half of the guard, for the two legacy +/// entrypoints (`whitelist_user` / `revoke_whitelist`) whose documented +/// behaviour is to tolerate a no-op rather than reject it. They still must not +/// tolerate an unauthorized caller. +pub fn require_transition_authority(env: &Env, caller: &Address, current: &ComplianceStatus) { + let guard = authority_guard(env, caller, current); + if guard.is_allowed() { + return; + } + if let Some(error) = error_for_guard(&guard) { + soroban_sdk::panic_with_error!(env, error); + } +} + +// ─── Batch pre-flight ───────────────────────────────────────────────────────── + +/// Whether `updates[index]` repeats an address that appears earlier in the +/// batch. Matches the duplicate rule `batch_set_compliance_status` enforces. +fn is_duplicate_at(updates: &Vec, index: u32) -> bool { + let current = updates.get(index).unwrap(); + for earlier in 0..index { + if updates.get(earlier).unwrap().user == current.user { + return true; + } + } + false +} + +/// Evaluates every entry of a proposed batch independently. +/// +/// Entries are independent because duplicate addresses are rejected outright: +/// with no address appearing twice, no entry can change the current status +/// another entry is evaluated against. An entry that repeats an earlier +/// address reports [`TransitionGuard::DuplicateUserInBatch`]. +/// +/// The batch is **atomic** on submission, so a single rejected entry fails the +/// whole call. Clients should treat any `allowed == false` row as "this batch +/// will not commit", not as "this row will be skipped". +pub fn check_batch( + env: &Env, + caller: &Address, + updates: &Vec, +) -> Vec { + let mut out = vec![env]; + + for index in 0..updates.len() { + let update = updates.get(index).unwrap(); + let mut check = check_transition(env, caller, &update.user, &update.new_status); + + if is_duplicate_at(updates, index) { + check.allowed = false; + check.reason = TransitionGuard::DuplicateUserInBatch; + check.error_code = error_for_guard(&check.reason).map(|err| err as u32); + } + + out.push_back(check); + } + + out +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +#[contractimpl] +impl AegisContract { + /// Returns whether `caller` could move `user` to `new_status` right now, + /// and the precise reason when it could not. + /// + /// Pure read: requires no authorization, changes nothing, never reverts, + /// and stays callable while the contract is paused (it reports the pause + /// as the reason). The verdict is produced by the same evaluation the + /// state-changing entrypoints use, so `allowed == false` guarantees a + /// submission would fail with `error_code`. + /// + /// Point-in-time only — the state it reads can change before a + /// transaction lands. See `docs/compliance-transition-guards.md`. + pub fn check_compliance_transition( + env: Env, + caller: Address, + user: Address, + new_status: ComplianceStatus, + ) -> ComplianceTransitionCheck { + check_transition(&env, &caller, &user, &new_status) + } + + /// Returns the guard verdict alone for a proposed transition, for clients + /// that only need to branch on the reason. Equivalent to the `reason` + /// field of [`Self::check_compliance_transition`]. + pub fn get_compliance_transition_guard( + env: Env, + caller: Address, + user: Address, + new_status: ComplianceStatus, + ) -> TransitionGuard { + evaluate_transition(&env, &caller, &user, &new_status) + } + + /// Pre-flights every entry of a `batch_set_compliance_status` call, + /// returning one verdict per entry in input order. + /// + /// The batch commits only if **every** entry is `allowed`; a single + /// rejection fails the whole submission. Pure read — never reverts, even + /// for an empty batch (which returns an empty vector, matching the + /// batch entrypoint's `0` result). + pub fn check_compliance_batch( + env: Env, + caller: Address, + updates: Vec, + ) -> Vec { + check_batch(&env, &caller, &updates) + } +} diff --git a/src/config.rs b/src/config.rs index 4e33265..f1fed2a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,15 +1,18 @@ #![allow(deprecated)] -use soroban_sdk::{contractimpl, contracttype, Env, Address}; -use crate::{admin::{get_admin, require_not_paused}, AegisContract, AegisContractClient, AegisContractArgs, DataKey, Error}; +use crate::{ + admin::{get_admin, require_not_paused}, + AegisContract, AegisContractArgs, AegisContractClient, DataKey, Error, +}; +use soroban_sdk::{contractimpl, contracttype, Address, Env}; #[contracttype] #[derive(Clone, Debug, PartialEq, Eq)] pub struct ProtocolConfig { - /// The minimum token amount allowed in a single transfer. + /// The minimum token amount allowed in a single transfer. /// 0 means no minimum is enforced. pub min_transfer_amount: i128, - + /// The maximum number of operations allowed in a single batch. /// Useful for enforcing gas/compute limits in batched compliance or transfer functions. pub max_batch_size: u32, @@ -43,7 +46,9 @@ pub fn get_config(env: &Env) -> ProtocolConfig { /// Returns the currently pending proposed configuration, if any. pub fn get_pending_config(env: &Env) -> Option { - env.storage().instance().get(&DataKey::ProtocolConfigCandidate) + env.storage() + .instance() + .get(&DataKey::ProtocolConfigCandidate) } #[contractimpl] @@ -97,11 +102,14 @@ impl AegisContract { return Err(Error::Unauthorized); } - let pending_config: ProtocolConfig = - match env.storage().instance().get(&DataKey::ProtocolConfigCandidate) { - Some(config) => config, - None => return Err(Error::NoPendingAdminTransfer), // Reusing error, ideally would add NoPendingConfig - }; + let pending_config: ProtocolConfig = match env + .storage() + .instance() + .get(&DataKey::ProtocolConfigCandidate) + { + Some(config) => config, + None => return Err(Error::NoPendingAdminTransfer), // Reusing error, ideally would add NoPendingConfig + }; // Clear the candidate env.storage() @@ -134,7 +142,11 @@ impl AegisContract { return Err(Error::Unauthorized); } - if !env.storage().instance().has(&DataKey::ProtocolConfigCandidate) { + if !env + .storage() + .instance() + .has(&DataKey::ProtocolConfigCandidate) + { return Err(Error::NoPendingAdminTransfer); // Using existing error } diff --git a/src/config_test.rs b/src/config_test.rs index c175e51..a8d91a9 100644 --- a/src/config_test.rs +++ b/src/config_test.rs @@ -1,7 +1,7 @@ #![cfg(test)] +use crate::{config::ProtocolConfig, AegisContract, AegisContractClient, Error, Role}; use soroban_sdk::{testutils::Address as _, Address, Env}; -use crate::{AegisContract, AegisContractClient, config::ProtocolConfig, Error, Role}; fn setup() -> (Env, AegisContractClient<'static>, Address, Address) { let env = Env::default(); @@ -34,9 +34,12 @@ fn test_propose_and_accept_config() { // 1. Propose client.propose_config(&admin, &new_config); - + // Verify candidate is set - assert_eq!(client.get_pending_protocol_config(), Some(new_config.clone())); + assert_eq!( + client.get_pending_protocol_config(), + Some(new_config.clone()) + ); // Verify active is not changed yet assert_eq!(client.get_protocol_config().min_transfer_amount, 0); diff --git a/src/lib.rs b/src/lib.rs index 7f31228..6824dd1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod admin; pub mod asset; pub mod capabilities; pub mod compliance; +pub mod compliance_guards; pub mod config; pub mod eligibility; pub mod errors; @@ -13,11 +14,11 @@ pub mod restrictions; pub mod lifecycle; +#[cfg(test)] +mod config_test; pub mod supply_cap; #[cfg(test)] mod test; -#[cfg(test)] -mod config_test; use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; diff --git a/src/test.rs b/src/test.rs index 66b37e9..3acb3ca 100644 --- a/src/test.rs +++ b/src/test.rs @@ -17,6 +17,8 @@ use crate::compliance::{ WhitelistRevokedEvent, }; +use crate::compliance_guards::TransitionGuard; + use crate::eligibility::InvestorEligibility; use crate::lifecycle::{AssetStatus, AssetStatusChangedEvent}; @@ -1500,6 +1502,7 @@ fn default_capabilities(env: &Env) -> ContractCapabilities { investor_tiers: CapabilityStatus::Unsupported, lifecycle_states: CapabilityStatus::Supported, lifecycle_transitions: CapabilityStatus::Supported, + transition_guards: CapabilityStatus::Supported, eligibility_reads: CapabilityStatus::Supported, enforced_on_mint: true, enforced_on_transfer: true, @@ -3125,7 +3128,7 @@ fn test_compliance_transition_events_have_exact_shape() { fixture .client .revoke_whitelist(&fixture.emergency, &fixture.target); - + assert_eq!( fixture.env.events().all(), vec![ @@ -3152,7 +3155,6 @@ fn test_compliance_transition_events_have_exact_shape() { ), ] ); - } #[test] @@ -4533,3 +4535,501 @@ fn test_repeated_rejected_mint_attempts_never_mutate_state() { assert_eq!(client.get_balance_of(&user2), 100); assert_eq!(client.get_total_supply(), 100); } + +// ─── COMPLIANCE STATUS TRANSITION GUARDS ────────────────────────────────────── +// +// The guard module (src/compliance_guards.rs) is the single evaluation shared +// by the pre-flight read entrypoints and by every state-changing compliance +// call. The invariant these tests exist to protect is therefore not "the guard +// returns the right answer" in isolation, but that the guard's answer and the +// contract's actual behaviour can never disagree — that is what makes it safe +// for a dashboard to disable an action, or an SDK to skip a simulation, on the +// strength of a read. The model is documented in +// docs/compliance-transition-guards.md. + +/// Drives `user` into `target` on a freshly initialized contract, using the +/// admin so the seeding itself can never be refused by a role check. +/// +/// Only legal edges are used, so seeding exercises the same matrix under test +/// instead of writing storage behind its back. +fn seed_status( + client: &AegisContractClient<'static>, + admin: &Address, + user: &Address, + target: ComplianceStatus, +) { + match target { + ComplianceStatus::Unknown => {} + ComplianceStatus::Pending => { + client.set_compliance_status(admin, user, &ComplianceStatus::Pending); + } + ComplianceStatus::Approved => { + client.set_compliance_status(admin, user, &ComplianceStatus::Approved); + } + ComplianceStatus::Revoked => { + client.set_compliance_status(admin, user, &ComplianceStatus::Pending); + client.set_compliance_status(admin, user, &ComplianceStatus::Revoked); + } + ComplianceStatus::Blocked => { + client.set_compliance_status(admin, user, &ComplianceStatus::Blocked); + } + } + assert_eq!(client.get_compliance_status(user), target, "seeding failed"); +} + +/// A fresh deployment with an admin, a ComplianceOfficer, and an investor. +fn setup_guard_world() -> (Env, AegisContractClient<'static>, Address, Address, Address) { + let (env, client, admin, officer, investor) = setup(); + env.mock_all_auths(); + client.initialize(&admin); + client.set_role(&admin, &officer, &Role::ComplianceOfficer); + (env, client, admin, officer, investor) +} + +/// Asserts that a pre-flight verdict and a real submission agree, and returns +/// nothing — the assertion *is* the point. +fn assert_guard_matches_execution( + client: &AegisContractClient<'static>, + caller: &Address, + user: &Address, + target: ComplianceStatus, +) { + let check = client.check_compliance_transition(caller, user, &target); + let before = client.get_compliance_status(user); + let result = client.try_set_compliance_status(caller, user, &target); + + match result { + Ok(_) => { + assert!( + check.allowed, + "guard rejected ({:?} -> {:?}) with {:?} but the call succeeded", + before, target, check.reason + ); + assert_eq!(check.error_code, None); + assert_eq!( + client.get_compliance_status(user), + target, + "committed transition did not reach the requested status" + ); + } + Err(Ok(err)) => { + assert!( + !check.allowed, + "guard allowed ({:?} -> {:?}) but the call reverted with {:?}", + before, target, err + ); + assert_eq!( + check.error_code, + Some(err as u32), + "guard predicted a different error code for ({:?} -> {:?})", + before, + target + ); + assert_eq!( + client.get_compliance_status(user), + before, + "a rejected transition changed the stored status" + ); + } + Err(Err(err)) => panic!("unexpected host error: {err:?}"), + } +} + +#[test] +fn test_guard_matches_enforcement_for_every_edge_as_officer() { + // 5 source statuses x 5 targets = the complete edge space, each on its own + // deployment so the result depends only on the pair under test. + for from in ALL_STATUSES { + for to in ALL_STATUSES { + let (_env, client, admin, officer, investor) = setup_guard_world(); + seed_status(&client, &admin, &investor, from); + assert_guard_matches_execution(&client, &officer, &investor, to); + } + } +} + +#[test] +fn test_guard_matches_enforcement_for_every_edge_as_admin() { + // The admin bypasses role checks, so this pass covers the edges an officer + // can never reach — in particular the admin-only exit from `Blocked`. + for from in ALL_STATUSES { + for to in ALL_STATUSES { + let (_env, client, admin, _officer, investor) = setup_guard_world(); + seed_status(&client, &admin, &investor, from); + assert_guard_matches_execution(&client, &admin, &investor, to); + } + } +} + +#[test] +fn test_guard_matches_enforcement_for_every_edge_as_unauthorized_caller() { + // A caller with a wrong-scoped role must be refused on every edge, and the + // guard must say so in advance rather than letting a client discover it + // from a revert. + for from in ALL_STATUSES { + for to in ALL_STATUSES { + let (_env, client, admin, _officer, investor) = setup_guard_world(); + let outsider = Address::generate(&_env); + client.set_role(&admin, &outsider, &Role::AssetManager); + seed_status(&client, &admin, &investor, from); + + let check = client.check_compliance_transition(&outsider, &investor, &to); + assert!(!check.allowed); + assert!(check.reason.is_authorization_failure()); + assert_guard_matches_execution(&client, &outsider, &investor, to); + } + } +} + +#[test] +fn test_guard_reports_blocked_requires_admin_not_generic_unauthorized() { + let (_env, client, admin, officer, investor) = setup_guard_world(); + seed_status(&client, &admin, &investor, ComplianceStatus::Blocked); + + // The officer holds a valid compliance role, so "you lack a role" would be + // the wrong explanation: the refusal is specific to leaving `Blocked`. + let check = client.check_compliance_transition(&officer, &investor, &ComplianceStatus::Pending); + assert!(!check.allowed); + assert_eq!(check.reason, TransitionGuard::BlockedRequiresAdmin); + assert_eq!(check.error_code, Some(Error::Unauthorized as u32)); + + // A caller with no role at all gets the same refusal for a blocked + // address — the block, not the missing role, is the binding constraint. + let nobody = Address::generate(&_env); + let nobody_check = + client.check_compliance_transition(&nobody, &investor, &ComplianceStatus::Pending); + assert_eq!(nobody_check.reason, TransitionGuard::BlockedRequiresAdmin); + + // Only the admin may lift it, and only into re-review. + let admin_check = + client.check_compliance_transition(&admin, &investor, &ComplianceStatus::Pending); + assert!(admin_check.allowed); + let admin_direct = + client.check_compliance_transition(&admin, &investor, &ComplianceStatus::Approved); + assert!(!admin_direct.allowed); + assert_eq!(admin_direct.reason, TransitionGuard::TransitionForbidden); +} + +#[test] +fn test_guard_reports_status_unchanged_for_every_self_edge() { + for status in ALL_STATUSES { + let (_env, client, admin, _officer, investor) = setup_guard_world(); + seed_status(&client, &admin, &investor, status); + + let check = client.check_compliance_transition(&admin, &investor, &status); + assert!(!check.allowed); + // `Blocked -> Blocked` is refused for authority before it is ever + // compared, because only the admin may act on a blocked address; the + // admin used here reaches the no-op rule itself. + assert_eq!(check.reason, TransitionGuard::StatusUnchanged); + assert_eq!( + check.error_code, + Some(Error::ComplianceStatusUnchanged as u32) + ); + } +} + +#[test] +fn test_guard_reports_target_unknown_as_its_own_reason() { + // `Unknown` is unreachable from every source, including itself. It gets a + // dedicated reason so a client can drop it from the target list entirely + // instead of showing an edge that can never be offered. + for from in ALL_STATUSES { + let (_env, client, admin, _officer, investor) = setup_guard_world(); + seed_status(&client, &admin, &investor, from); + + let check = + client.check_compliance_transition(&admin, &investor, &ComplianceStatus::Unknown); + assert!(!check.allowed); + let expected = if from == ComplianceStatus::Unknown { + // The no-op rule is evaluated first, so an already-unknown address + // reports the more specific "nothing would change". + TransitionGuard::StatusUnchanged + } else { + TransitionGuard::TargetUnknownForbidden + }; + assert_eq!(check.reason, expected, "from {from:?}"); + } +} + +#[test] +fn test_guard_reports_pause_ahead_of_authority() { + let (_env, client, admin, officer, investor) = setup_guard_world(); + seed_status(&client, &admin, &investor, ComplianceStatus::Pending); + client.pause(&admin); + + // The pause is reported first for every caller class, so a paused contract + // never leaks whether a caller would otherwise have qualified. + let outsider = Address::generate(&_env); + for caller in [&admin, &officer, &outsider] { + let check = + client.check_compliance_transition(caller, &investor, &ComplianceStatus::Approved); + assert!(!check.allowed); + assert_eq!(check.reason, TransitionGuard::ContractPaused); + assert_eq!(check.error_code, Some(Error::ContractPaused as u32)); + } + + // The read itself stays available while paused — a dashboard can still + // explain why every control is disabled. + assert_eq!( + client.get_compliance_status(&investor), + ComplianceStatus::Pending + ); + + // And the pause is not a lockout: the same edge clears once unpaused. + client.unpause(&admin); + let after = + client.check_compliance_transition(&officer, &investor, &ComplianceStatus::Approved); + assert!(after.allowed); + assert_eq!(after.reason, TransitionGuard::Allowed); +} + +#[test] +fn test_guard_reports_not_initialized_instead_of_panicking() { + // Before `initialize` there is no admin to check a caller against. The + // guard must still answer — a view entrypoint that panics is unusable for + // a dashboard rendering a not-yet-configured deployment. + let (env, client, admin, _user1, investor) = setup(); + env.mock_all_auths(); + + let check = client.check_compliance_transition(&admin, &investor, &ComplianceStatus::Approved); + assert!(!check.allowed); + assert_eq!(check.reason, TransitionGuard::NotInitialized); + assert_eq!(check.error_code, Some(Error::NotInitialized as u32)); + + // The prediction holds: submitting really does fail with that code. + let result = client.try_set_compliance_status(&admin, &investor, &ComplianceStatus::Approved); + assert_eq!(result, Err(Ok(Error::NotInitialized))); +} + +#[test] +fn test_guard_report_carries_a_consistent_status_snapshot() { + let (_env, client, admin, officer, investor) = setup_guard_world(); + seed_status(&client, &admin, &investor, ComplianceStatus::Approved); + + let check = client.check_compliance_transition(&officer, &investor, &ComplianceStatus::Revoked); + assert_eq!(check.user, investor); + assert_eq!(check.caller, officer); + assert_eq!(check.current_status, ComplianceStatus::Approved); + assert_eq!(check.requested_status, ComplianceStatus::Revoked); + assert!(check.allowed); + assert_eq!(check.reason, TransitionGuard::Allowed); + assert_eq!(check.error_code, None); +} + +#[test] +fn test_guard_reads_never_mutate_state() { + let (env, client, admin, officer, investor) = setup_guard_world(); + seed_status(&client, &admin, &investor, ComplianceStatus::Approved); + let events_before = env.events().all(); + + for to in ALL_STATUSES { + client.check_compliance_transition(&officer, &investor, &to); + client.get_compliance_transition_guard(&officer, &investor, &to); + } + + assert_eq!( + client.get_compliance_status(&investor), + ComplianceStatus::Approved + ); + assert!(client.is_whitelisted(&investor)); + assert_eq!(client.get_role_of(&officer), Role::ComplianceOfficer); + assert!(!client.is_paused()); + // A pre-flight read is not a compliance action and must leave no trace in + // the audit stream. + assert_eq!(env.events().all(), events_before); +} + +#[test] +fn test_guard_verdict_tracks_role_revocation() { + let (_env, client, admin, officer, investor) = setup_guard_world(); + + let before = + client.check_compliance_transition(&officer, &investor, &ComplianceStatus::Approved); + assert!(before.allowed); + + client.remove_role(&admin, &officer); + + // Authority is evaluated at call time, never cached, so the verdict flips + // immediately — including for an address this officer had approved before. + let after = + client.check_compliance_transition(&officer, &investor, &ComplianceStatus::Approved); + assert!(!after.allowed); + assert_eq!(after.reason, TransitionGuard::CallerUnauthorized); + assert_guard_matches_execution(&client, &officer, &investor, ComplianceStatus::Approved); +} + +#[test] +fn test_guard_accepts_emergency_officer_and_rejects_asset_manager() { + let (_env, client, admin, _officer, investor) = setup_guard_world(); + let emergency = Address::generate(&_env); + let manager = Address::generate(&_env); + client.set_role(&admin, &emergency, &Role::EmergencyOfficer); + client.set_role(&admin, &manager, &Role::AssetManager); + + let allowed = + client.check_compliance_transition(&emergency, &investor, &ComplianceStatus::Approved); + assert!(allowed.allowed); + + let refused = + client.check_compliance_transition(&manager, &investor, &ComplianceStatus::Approved); + assert!(!refused.allowed); + assert_eq!(refused.reason, TransitionGuard::CallerUnauthorized); +} + +#[test] +fn test_batch_guard_matches_batch_execution_when_every_entry_is_legal() { + let (env, client, admin, officer, investor) = setup_guard_world(); + let second = Address::generate(&env); + seed_status(&client, &admin, &investor, ComplianceStatus::Pending); + + let updates = vec![ + &env, + ComplianceBatchUpdate { + user: investor.clone(), + new_status: ComplianceStatus::Approved, + }, + ComplianceBatchUpdate { + user: second.clone(), + new_status: ComplianceStatus::Pending, + }, + ]; + + let checks = client.check_compliance_batch(&officer, &updates); + assert_eq!(checks.len(), 2); + for index in 0..checks.len() { + assert!(checks.get(index).unwrap().allowed); + } + + assert_eq!(client.batch_set_compliance_status(&officer, &updates), 2); + assert_eq!( + client.get_compliance_status(&investor), + ComplianceStatus::Approved + ); + assert_eq!( + client.get_compliance_status(&second), + ComplianceStatus::Pending + ); +} + +#[test] +fn test_batch_guard_flags_the_offending_entry_and_the_batch_fails_atomically() { + let (env, client, admin, officer, investor) = setup_guard_world(); + let blocked = Address::generate(&env); + seed_status(&client, &admin, &investor, ComplianceStatus::Pending); + seed_status(&client, &admin, &blocked, ComplianceStatus::Blocked); + + let updates = vec![ + &env, + ComplianceBatchUpdate { + user: investor.clone(), + new_status: ComplianceStatus::Approved, + }, + ComplianceBatchUpdate { + user: blocked.clone(), + new_status: ComplianceStatus::Approved, + }, + ]; + + // The guard pinpoints *which* row is the problem — the batch entrypoint + // itself can only report a single error for the whole call. + let checks = client.check_compliance_batch(&officer, &updates); + assert!(checks.get(0).unwrap().allowed); + let offending = checks.get(1).unwrap(); + assert!(!offending.allowed); + assert_eq!(offending.reason, TransitionGuard::BlockedRequiresAdmin); + + // A single rejected row fails the whole batch, and the legal row is not + // applied: pre-flight `allowed` on row 0 is a statement about the rule, + // not a promise that the row commits. + let result = client.try_batch_set_compliance_status(&officer, &updates); + assert_eq!(result, Err(Ok(Error::Unauthorized))); + assert_eq!( + client.get_compliance_status(&investor), + ComplianceStatus::Pending + ); + assert_eq!( + client.get_compliance_status(&blocked), + ComplianceStatus::Blocked + ); +} + +#[test] +fn test_batch_guard_flags_duplicate_addresses() { + let (env, client, _admin, officer, investor) = setup_guard_world(); + + let updates = vec![ + &env, + ComplianceBatchUpdate { + user: investor.clone(), + new_status: ComplianceStatus::Pending, + }, + ComplianceBatchUpdate { + user: investor.clone(), + new_status: ComplianceStatus::Approved, + }, + ]; + + let checks = client.check_compliance_batch(&officer, &updates); + // The first occurrence is judged on its own merits; only the repeat is + // flagged, so a client can point at the row to remove. + assert!(checks.get(0).unwrap().allowed); + let duplicate = checks.get(1).unwrap(); + assert!(!duplicate.allowed); + assert_eq!(duplicate.reason, TransitionGuard::DuplicateUserInBatch); + assert_eq!( + duplicate.error_code, + Some(Error::InvalidComplianceTransition as u32) + ); + + assert_eq!( + client.try_batch_set_compliance_status(&officer, &updates), + Err(Ok(Error::InvalidComplianceTransition)) + ); + assert_eq!( + client.get_compliance_status(&investor), + ComplianceStatus::Unknown + ); +} + +#[test] +fn test_batch_guard_accepts_an_empty_batch() { + let (env, client, _admin, officer, _investor) = setup_guard_world(); + let updates = vec![&env]; + + assert_eq!(client.check_compliance_batch(&officer, &updates).len(), 0); + assert_eq!(client.batch_set_compliance_status(&officer, &updates), 0); +} + +#[test] +fn test_guard_agrees_with_the_legacy_whitelist_entrypoints() { + // `whitelist_user` / `revoke_whitelist` are tolerant wrappers, but they + // share the guard's authority rules. The guard must therefore predict + // their *authorization* outcome exactly, even where their no-op tolerance + // makes the transition itself a success. + for from in ALL_STATUSES { + let (env, client, admin, officer, investor) = setup_guard_world(); + seed_status(&client, &admin, &investor, from); + + let check = + client.check_compliance_transition(&officer, &investor, &ComplianceStatus::Approved); + let result = client.try_whitelist_user(&officer, &investor); + + if check.reason.is_authorization_failure() { + assert_eq!(result, Err(Ok(Error::Unauthorized)), "from {from:?}"); + } else if from == ComplianceStatus::Approved { + // Already approved: the guard calls it a no-op, the legacy wrapper + // absorbs it as an idempotent success. + assert_eq!(check.reason, TransitionGuard::StatusUnchanged); + assert!(result.is_ok()); + } else { + assert!(check.allowed, "from {from:?}"); + assert!(result.is_ok()); + assert_eq!( + client.get_compliance_status(&investor), + ComplianceStatus::Approved + ); + } + let _ = &env; + } +} diff --git a/tests/sdk_fixtures.rs b/tests/sdk_fixtures.rs index 53dd209..f542174 100644 --- a/tests/sdk_fixtures.rs +++ b/tests/sdk_fixtures.rs @@ -41,6 +41,7 @@ use aegis_contracts::compliance::{ ComplianceBatchUpdate, ComplianceStatus, ComplianceStatusChangedEvent, UserWhitelistedEvent, WhitelistRevokedEvent, }; +use aegis_contracts::compliance_guards::TransitionGuard; use aegis_contracts::config::{ConfigAmendedEvent, ConfigProposedEvent, ProtocolConfig}; use aegis_contracts::holding::{HoldingCapAmendedEvent, HoldingCapProposedEvent}; use aegis_contracts::lifecycle::{AssetStatus, AssetStatusChangedEvent}; @@ -458,7 +459,75 @@ fn fixture_compliance() { ); } - // 5 — role read surface. + // 5 — pre-flight transition guard: allowed, and refused with a reason. + { + let h = Harness::new(); + let c = h.client(); + c.initialize(&h.actor("admin")); + c.set_role( + &h.actor("admin"), + &h.actor("compliance_officer"), + &Role::ComplianceOfficer, + ); + + let officer = h.actor("compliance_officer"); + let alice = h.actor("investor_alice"); + let allowed = c.check_compliance_transition(&officer, &alice, &ComplianceStatus::Approved); + assert!(allowed.allowed); + assert_eq!(allowed.reason, TransitionGuard::Allowed); + + scenarios.push( + Scenario::new( + "check-compliance-transition-allowed", + "Pre-flight read: a ComplianceOfficer may approve an unknown address. The verdict comes from the same evaluation the write path enforces, so `allowed: true` means `set_compliance_status` would commit against this ledger state. Pure read — no events, no writes.", + ) + .set("call", Json::str("check_compliance_transition")) + .set( + "args", + Json::Arr(vec![ + Json::str("compliance_officer"), + Json::str("investor_alice"), + Json::str("Approved"), + ]), + ) + .set("returns", h.render(allowed)) + .build(), + ); + + // A frozen address may only be released by the supreme admin, and the + // guard says so specifically rather than returning a bare + // "unauthorized" — the officer needs an escalation, not a role. + let bob = h.actor("investor_bob"); + c.set_compliance_status(&officer, &bob, &ComplianceStatus::Blocked); + let refused = c.check_compliance_transition(&officer, &bob, &ComplianceStatus::Pending); + assert!(!refused.allowed); + assert_eq!(refused.reason, TransitionGuard::BlockedRequiresAdmin); + assert_eq!(refused.error_code, Some(Error::Unauthorized as u32)); + + scenarios.push( + Scenario::new( + "check-compliance-transition-blocked-requires-admin", + "Pre-flight read: the same ComplianceOfficer is refused for a \ + `Blocked` address. `reason` distinguishes an admin-only freeze from \ + a missing role even though both surface as `Unauthorized` (3000) \ + on-chain, and `error_code` pre-resolves the code a submission would \ + revert with.", + ) + .set("call", Json::str("check_compliance_transition")) + .set( + "args", + Json::Arr(vec![ + Json::str("compliance_officer"), + Json::str("investor_bob"), + Json::str("Pending"), + ]), + ) + .set("returns", h.render(refused)) + .build(), + ); + } + + // 6 — role read surface. { let h = bootstrap(); let c = h.client(); @@ -1808,33 +1877,34 @@ fn fixture_errors() { ); } - // 7000 — AssetNotActive. + // 7002 — AssetBlockedRestriction (asset still in Draft). { let h = Harness::new(); let c = h.client(); c.initialize(&h.actor("admin")); let r = c.try_mint_asset(&h.actor("admin"), &h.actor("investor_alice"), &100); push_err( - "error-7000-asset-not-active", + "error-7002-asset-blocked-restriction-draft", "The asset lifecycle status is Draft (not Active), so issuance and transfers are \ - blocked.", + blocked. Reported as the granular restriction code `7002`, not the reserved \ + `6000 AssetNotActive` it superseded (see docs/error-codes.md).", "mint_asset", - expect_err(r, Error::AssetNotActive), + expect_err(r, Error::AssetBlockedRestriction), ); } - // 7001 — AssetLifecyclePaused. + // 7000 — AssetPausedRestriction. { let h = bootstrap(); let c = h.client(); c.set_asset_status(&h.actor("admin"), &AssetStatus::Paused); let r = c.try_mint_asset(&h.actor("asset_manager"), &h.actor("investor_alice"), &100); push_err( - "error-7001-asset-lifecycle-paused", + "error-7000-asset-paused-restriction", "The asset lifecycle status is Paused, so issuance and transfers are \ blocked. Distinct from the global contract pause (3004).", "mint_asset", - expect_err(r, Error::AssetLifecyclePaused), + expect_err(r, Error::AssetPausedRestriction), ); } @@ -1933,7 +2003,7 @@ fn fixture_errors() { Error::ReceiverNotWhitelisted, Error::InvalidAmount, Error::InsufficientBalance, - Error::AssetNotActive, + Error::AssetBlockedRestriction, Error::InvalidLifecycleTransition, Error::AssetMetadataUpdateBlocked, ];