diff --git a/README.md b/README.md index 015b906..4fd9cc1 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,8 @@ value-encoding rules, and the no-real-user-data guarantee. - [RWA Protocol Threat Model](docs/threat-model.md) — protected assets, trust boundaries, threat catalog (compliance bypass, admin/role misuse, minting and transfer risks, pause misuse, event reliability), and explicit off-chain/legal out-of-scope items - [Emergency Pause Policy](docs/emergency-pause.md) — global pause mechanism, authorization, and trust model - [Admin Roles & Permissions](docs/admin-roles.md) — role-based access control (RBAC) design +- [Issuer Role Separation](docs/issuer-role-separation.md) — separation-of-duties controls for issuance: the duty map per role, the opt-in policy (dual-duty, self-issuance, independent-approver), the `check_issuance_authority` pre-flight read, and what the controls do and do not defend against + - [Admin Misuse Risks](docs/admin-misuse-risks.md) — threat model and mitigations - [Supply Cap Amendment Governance](docs/supply-cap-governance.md) — 2-step cap amendment workflow and enforcement - [Protocol Configuration Governance](docs/protocol-configuration.md) — global configuration module (`ProtocolConfig`) 2-step governance workflow and RWA guardrails diff --git a/docs/admin-roles.md b/docs/admin-roles.md index 7193651..5cfebfa 100644 --- a/docs/admin-roles.md +++ b/docs/admin-roles.md @@ -9,7 +9,7 @@ This document describes the role-based access control (RBAC) system for the Aegi | `Admin` | Supreme authority. Can perform all operations and manage roles. | All operations + role management + admin transfer | | `ComplianceOfficer` | Manages the compliance whitelist and lifecycle. | `whitelist_user`, `revoke_whitelist`, `set_compliance_status`, `batch_set_compliance_status` | | `AssetManager` | Manages asset minting and yield distribution. | `mint_asset`, `distribute_yield` | -| `EmergencyOfficer` | Combined compliance + asset privileges for operational flexibility. | `whitelist_user`, `revoke_whitelist`, `set_compliance_status`, `batch_set_compliance_status`, `mint_asset`, `distribute_yield` | +| `EmergencyOfficer` | Compliance privileges plus the emergency pause. **Not** an issuer: `mint_asset` and `distribute_yield` call `require_role(AssetManager)`, which admits only an `AssetManager` or the admin. | `whitelist_user`, `revoke_whitelist`, `set_compliance_status`, `batch_set_compliance_status`, `pause` | | `None` | No role assigned. Cannot perform any privileged operation. | None (except `transfer` which requires self-auth) | ### Admin Bypass @@ -78,6 +78,7 @@ All role changes emit Soroban events for off-chain indexing and audit trails: | Admin renounced | `("admin_renounced",)` | `{ previous_admin, new_admin }` | | Contract paused | `("contract_paused",)` | `{ admin }` | | Contract unpaused | `("contract_unpaused",)` | `{ admin }` | +| Issuer separation policy updated | `("issuer_separation_policy_updated",)` | `{ admin, previous_policy, new_policy }` | See [`events.md`](events.md) for the full event schema reference and SDK/dashboard compatibility notes. @@ -120,6 +121,7 @@ If the candidate does not accept, the transfer can be superseded by a new `trans | `revoke_whitelist` | `ComplianceOfficer` | Yes | | `set_compliance_status` | `ComplianceOfficer`* | Yes | | `batch_set_compliance_status` | `ComplianceOfficer`* | Yes | +| `set_issuer_separation_policy` | `Admin` | N/A (admin-only) | | `set_role` | `Admin` | N/A (admin-only) | | `remove_role` | `Admin` | N/A (admin-only) | | `transfer` | Self-auth | N/A | @@ -129,6 +131,23 @@ If the candidate does not accept, the transfer can be superseded by a new `trans `*` Moving an address out of `Blocked` is admin-only, including inside `batch_set_compliance_status`. +## Separation of Duties + +Roles say *which privileges* an address holds; duties say *which classes of +decision* it can make. Only the admin currently carries both the compliance and +the issuance duty, which means one key can clear an investor and then fund +them. [`issuer-role-separation.md`](issuer-role-separation.md) specifies the +duty map, the opt-in policy that forbids that combination (plus self-issuance +and same-approver issuance), the `check_issuance_authority` pre-flight read, +and the assumptions the controls rest on. + +| Role | Compliance | Issuance | Emergency | Governance | +| --- | :---: | :---: | :---: | :---: | +| `ComplianceOfficer` | ✓ | | | | +| `AssetManager` | | ✓ | | | +| `EmergencyOfficer` | ✓ | | ✓ | | +| `Admin` | ✓ | ✓ | ✓ | ✓ | + ## Storage Layout | Key | Storage Type | Description | @@ -136,6 +155,8 @@ If the candidate does not accept, the transfer can be superseded by a new `trans | `DataKey::Admin` | Instance | The supreme admin address | | `DataKey::AdminCandidate` | Instance | Pending admin during 2-step transfer | | `DataKey::Role(Address)` | Persistent | The role assigned to an address | +| `DataKey::IssuerSeparationPolicy` | Instance | Issuer separation-of-duties policy (absent = permissive default) | +| `DataKey::ComplianceApprover(Address)` | Persistent | Address that last approved an investor's compliance | | `DataKey::Whitelist(Address)` | Persistent | Whitelist flag (legacy, kept for compatibility) | | `DataKey::Balance(Address)` | Persistent | Token balance | | `DataKey::TotalSupply` | Instance | Global total supply | diff --git a/docs/capabilities.md b/docs/capabilities.md index 201eba8..1b7d836 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -141,6 +141,8 @@ pub struct ContractCapabilities { | `supply_cap` | status | `Supported` | Global cap with 2-step governance. | | `supply_cap_enforced` | `bool` **(runtime)** | `false` | A cap is currently active (`> 0`). | | `yield_distribution` | status | `Planned` | `distribute_yield` emits an event only; it settles no value on-chain. | +| `issuer_separation` | status | `Supported` | Issuer separation-of-duties controls. See [`issuer-role-separation.md`](issuer-role-separation.md). | +| `issuer_separation_enforced` | `bool` | `false` | **Runtime**: whether the separation policy is currently enforced. | #### `transfers` @@ -222,6 +224,7 @@ Registry (also returned by `get_capability_keys()`): | `burning` | `minting.burning` | | `supply_cap` | `minting.supply_cap` | | `yield_distribution` | `minting.yield_distribution` | +| `issuer_separation` | `minting.issuer_separation` | | `transfers` | `transfers.transfers` | | `holding_cap` | `transfers.holding_cap` | | `allowances` | `transfers.allowances` | @@ -257,10 +260,9 @@ for the full field reference and usage guidance. ## Versioning `capability_version` is the schema version of the response -(`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. +(`CAPABILITY_SCHEMA_VERSION`, currently `5` — last bumped when the +`minting.issuer_separation` fields and the `issuer_separation` 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/error-codes.md b/docs/error-codes.md index f5597f0..cae8fea 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -47,6 +47,9 @@ variants can be added to a category without renumbering any other category. | 3004 | `ContractPaused` | Admin/Auth | The operation is blocked because the contract is paused. | | 3005 | `AlreadyPaused` | Admin/Auth | `pause` was called while already paused. | | 3006 | `NotPaused` | Admin/Auth | `unpause` was called while not paused. | +| 3007 | `IssuanceDutyConflict` | Admin/Auth | Issuer separation is enforced and the caller holds both the compliance and issuance duties. See [`issuer-role-separation.md`](issuer-role-separation.md). | +| 3008 | `SelfIssuanceForbidden` | Admin/Auth | Issuer separation is enforced and the caller is the recipient of its own issuance. | +| 3009 | `IssuanceApproverConflict` | Admin/Auth | Issuer separation is enforced and the caller approved the recipient's compliance. | | 4000 | `SenderNotWhitelisted` | Compliance | The sending address has no current clearance (`Unknown` or `Revoked`). | | 4001 | `ReceiverNotWhitelisted` | Compliance | The receiving address has no current clearance (`Unknown` or `Revoked`). | | 4002 | `SenderBlocked` | Compliance | The sending address is `Blocked` — sanctioned or frozen. | @@ -91,6 +94,10 @@ has recommended user-facing copy in perform this action." (Do not expose role internals to end users.) - `3004`–`3006` (pause-related) → "This contract is currently paused for maintenance. Please try again later." + - `3007`–`3009` (issuer separation) → "This issuance requires a different + authorized key." The action is not retryable by the same caller; route + the operator to a segregated issuance key rather than inviting a retry. + See [`issuer-role-separation.md`](issuer-role-separation.md). - `4000`/`4001` → "This address has not completed compliance verification." Prompt the user toward the whitelist/KYC flow rather than showing a raw error. diff --git a/docs/events.md b/docs/events.md index 595f224..80b1980 100644 --- a/docs/events.md +++ b/docs/events.md @@ -49,6 +49,7 @@ name, never on struct declaration order or Rust type layout.** | `asset_minted` | `AssetMintedEvent` | `asset.rs` | `mint_asset` | `caller: Address`, `to: Address`, `amount: i128`, `total_supply: i128` | | `transfer` | `TransferEvent` | `asset.rs` | `transfer` | `from: Address`, `to: Address`, `amount: i128` | | `yield_distributed` | `YieldDistributedEvent` | `asset.rs` | `distribute_yield` | `caller: Address`, `amount: i128` | +| `issuer_separation_policy_updated` | `IssuerSeparationPolicyUpdatedEvent` | `issuer.rs` | `set_issuer_separation_policy` | `admin: Address`, `previous_policy: IssuerSeparationPolicy`, `new_policy: IssuerSeparationPolicy` | > **Compliance transitions:** the authorisation, blocked (paused), and diff --git a/docs/issuer-role-separation.md b/docs/issuer-role-separation.md new file mode 100644 index 0000000..01bb35e --- /dev/null +++ b/docs/issuer-role-separation.md @@ -0,0 +1,247 @@ +# Issuer Role Separation + +This document specifies the separation-of-duties controls the Aegis RWA +Contracts apply to **issuance**: which duties each role actually carries, how a +deployment can require that clearing an investor and funding that investor be +two different keys, and what the controls do and do not defend against. + +> **Not legal or financial advice.** These are protocol-level access controls. +> Whether an issuer's operating model satisfies a real-world regulatory +> requirement for segregation of duties is determined off-chain by that +> issuer's compliance, audit, and legal functions — see +> [`legal-boundary-disclaimer.md`](legal-boundary-disclaimer.md). The contract +> can enforce that two distinct keys acted; it cannot attest that two distinct +> *people* did. + +## Why + +The role model in [`admin-roles.md`](admin-roles.md) answers *which privileges +an address holds*. It does not answer the question an RWA auditor asks first: + +> Can the same key both decide **who may hold** the asset and decide **who +> receives** it? + +Until now the answer was yes. The supreme admin bypasses every role check, so a +single admin key can approve an address and then mint to it, with no second +party involved and nothing in the contract to notice. That is the classic +control failure behind fictitious-holder and self-allocation issuance fraud: +not a bug in any one function, but the absence of a constraint *between* two +correctly-functioning ones. + +This module adds that constraint, as an **opt-in policy** so no existing +deployment's behaviour changes until an admin enables it. + +## Duties, not role names + +Roles are the unit of *assignment*; duties are the unit of *separation*. The +duty map is derived from what the contract **enforces**, not from what a role +is named after: + +| Role | Compliance | Issuance | Emergency | Governance | +| --- | :---: | :---: | :---: | :---: | +| `None` | | | | | +| `ComplianceOfficer` | ✓ | | | | +| `AssetManager` | | ✓ | | | +| `EmergencyOfficer` | ✓ | | ✓ | | +| `Admin` | ✓ | ✓ | ✓ | ✓ | + +> **Correction to an earlier claim.** `admin-roles.md` previously listed +> `mint_asset` and `distribute_yield` among `EmergencyOfficer`'s privileged +> operations. That has never been true in the code: both entrypoints call +> `require_role(AssetManager)`, which admits an `AssetManager` or the admin and +> nobody else. An `EmergencyOfficer` minting is rejected with `Unauthorized` +> (3000). The duty table above and `admin-roles.md` now match the contract. +> A separation control built on an inaccurate privilege map is worse than +> none, which is why this is stated rather than quietly fixed. + +The consequence: **today the admin is the only address carrying both the +compliance and issuance duties.** The dual-duty control below is therefore, in +practice, the control that forces an admin to delegate issuance to a dedicated +`AssetManager` key. It is written against duties rather than against "the +admin" so that it keeps working if a future role combines them. + +The map is readable on-chain: `get_role_duties(role)` and +`get_duties_of(address)` (which resolves the supreme admin to `Role::Admin` +whatever the role table says). + +## The policy + +```rust +pub struct IssuerSeparationPolicy { + pub enforced: bool, // master switch + pub allow_dual_duty_issuance: bool, + pub allow_self_issuance: bool, + pub require_independent_approver: bool, +} +``` + +| Control | When it refuses | Error | +| --- | --- | --- | +| `allow_dual_duty_issuance: false` | The caller carries **both** the compliance and issuance duties. | `IssuanceDutyConflict` (3007) | +| `allow_self_issuance: false` | The caller is the recipient. | `SelfIssuanceForbidden` (3008) | +| `require_independent_approver: true` | The caller is the recorded approver of the recipient's compliance. | `IssuanceApproverConflict` (3009) | + +The default, applied when no policy has been stored, is **fully permissive**: +`enforced: false` with every control relaxed. Adding this module changes no +deployment's behaviour; enabling separation is a deliberate, audited act. + +Controls are independent. An issuer can adopt only the parts their operating +model supports — for example allowing a dual-duty admin to issue generally +while still forbidding it from funding investors it personally cleared +(`allow_dual_duty_issuance: true, require_independent_approver: true`). + +### Evaluation order + +The first failing control is returned, in this order: + +1. contract initialized → `NotInitialized` +2. caller carries the issuance duty → `MissingIssuanceDuty` (an RBAC failure, + **not** a separation failure — `IssuanceGuard::is_separation_failure()` + distinguishes them) +3. `enforced`? if not, allow +4. dual duty → `DualDutyConflict` +5. self-issuance → `SelfIssuanceForbidden` +6. approver identity → `ApproverConflict` + +`distribute_yield` has no single beneficiary, so steps 5 and 6 do not apply to +it; only the duty-level control binds. + +## The approver record + +`DataKey::ComplianceApprover(Address)` records the caller of the most recent +committed transition **into** `ComplianceStatus::Approved` — via +`set_compliance_status`, `batch_set_compliance_status`, or the legacy +`whitelist_user`. It is exposed as `get_compliance_approver(user)`. + +Two deliberate choices: + +- **A revocation does not erase it.** Revoking clearance should not erase who + granted the clearance being revoked. Re-approval by a different officer + overwrites it. +- **Only the most recent approver is kept.** This is a control against one key + performing both steps, not a full historical audit. Reconstruct the complete + history from `compliance_status_changed` events + ([`events.md`](events.md)) — the contract deliberately stores one address, + not an unbounded list. + +## Governance + +`set_issuer_separation_policy(admin, policy)` — admin-only, blocked while +paused, emits `issuer_separation_policy_updated` carrying **both** the previous +and the new policy so an auditor can reconstruct when each control was in force +without replaying storage. + +Applied in a single call rather than through the 2-step flow used for cap +amendments: unlike a cap, tightening separation cannot strand value, and an +issuer responding to a suspected key compromise should not have to wait for a +second transaction to close the gap. + +**The policy can never lock a deployment out of issuance.** The setter is +deliberately not gated by the policy it sets, so if the strictest configuration +leaves no key able to mint, the admin relaxes it and issuance resumes. This is +verified by `test_separation_policy_can_never_lock_a_deployment_out_of_issuance`. + +## Pre-flight read + +`check_issuance_authority(caller, recipient) -> IssuanceAuthorityCheck` returns +the verdict from the **same evaluation `mint_asset` enforces**, so `allowed == +false` guarantees a mint reverts with `error_code`. It reports the caller's +effective role and duties and the recipient's recorded approver, so a dashboard +can explain a refusal rather than just reporting one. + +```rust +pub struct IssuanceAuthorityCheck { + pub caller: Address, + pub recipient: Address, + pub caller_role: Role, + pub caller_duties: Vec, + pub recipient_approver: Option
, + pub allowed: bool, + pub reason: IssuanceGuard, + pub error_code: Option, +} +``` + +**Separation only.** This answers "may this key issue to this address", not +"will this mint succeed". The recipient's compliance status, the supply and +holding caps, the asset lifecycle, the pause, and the amount are checked +separately — use `check_mint_restriction` +([`transfer-restrictions.md`](transfer-restrictions.md)) for those. + +## Security and compliance assumptions + +1. **This is not a defence against a compromised admin key.** An attacker + holding the admin key can lift the policy in one transaction and then issue. + These controls raise the cost of routine key misuse and operator error, and + they make both steps visible on-chain — they do not constrain an adversary + who already controls governance. See + [`admin-misuse-risks.md`](admin-misuse-risks.md) and + [`threat-model.md`](threat-model.md). +2. **Two keys are not two people.** The contract can only observe that distinct + addresses acted. Whether they are controlled by different individuals under + different approval chains is an off-chain organizational control that this + module assumes, and cannot verify. +3. **Only the most recent approver is enforced against.** An officer who + approves an investor, has a colleague re-approve them, and then issues will + pass the approver control. Four-eyes on the *approval* itself is off-chain. +4. **Duties are derived, not stored.** They come from the role table plus + `DataKey::Admin` at call time, so a role change takes effect immediately and + nothing is cached that could go stale. +5. **A verdict is point-in-time.** A policy change, a role change, or a + re-approval can land between a pre-flight read and a submission. Clients + must still handle a revert. +6. **The policy is a protocol control, not an attestation.** It records that + the issuer configured a separation requirement. It makes no claim about the + issuer's off-chain governance. + +## Test coverage + +All tests are in [`src/test.rs`](../src/test.rs) under +`ISSUER ROLE SEPARATION`. + +| Test | What it proves | +| --- | --- | +| `test_issuer_separation_is_off_by_default` | The default policy is permissive and every caller that could mint before still can — adding the module changes nothing. | +| `test_role_duty_table_is_exact` | The duty map matches enforced privileges for all five roles, and the admin resolves to the full duty set by address. | +| `test_dual_duty_issuance_is_refused_when_separation_is_enforced` | The dual-duty control binds the admin, leaves its other privileges intact, and leaves a scoped `AssetManager` unaffected. | +| `test_self_issuance_is_refused_when_disallowed` | An issuer cannot mint to itself; issuing to anyone else is untouched. | +| `test_independent_approver_control_enforces_four_eyes` | The approver of a recipient cannot fund that recipient; a different issuer can, and the same caller can fund investors someone else cleared. | +| `test_approver_record_tracks_every_approval_path` | The record is written by `set_compliance_status`, `whitelist_user`, and batch updates; survives revocation; is overwritten on re-approval; and is never created by a non-approving transition. | +| `test_separation_controls_are_independent` | Each control refuses on its own, so no test passes because a different rule fired. | +| `test_missing_issuance_duty_is_reported_separately_from_a_separation_failure` | An RBAC failure and a separation failure are distinguishable by the client. | +| `test_check_issuance_authority_matches_mint_enforcement` | Across five policies × four caller classes × two recipients, the pre-flight verdict and the real mint agree on outcome, error code, and resulting balance. | +| `test_issuance_check_reports_a_consistent_snapshot` | The report's role, duties, and approver fields are internally consistent. | +| `test_issuance_reads_never_mutate_state` | Reads change no balance, supply, role, or policy, and emit no events. | +| `test_policy_update_is_admin_only_and_emits_the_previous_policy` | Non-admins are refused; the event carries both policies with the exact shape. | +| `test_policy_update_is_blocked_while_paused` | Governance respects the global pause, and the read stays available during it. | +| `test_separation_policy_can_never_lock_a_deployment_out_of_issuance` | The strictest policy is always recoverable by the admin. | +| `test_yield_distribution_respects_the_duty_control_only` | Recipient-scoped controls cannot apply to a call with no beneficiary. | +| `test_issuance_guard_reports_not_initialized_instead_of_panicking` | The reads answer on an unconfigured deployment instead of reverting. | + +Run them with `make test`. + +## Recommended operating model + +For an issuer adopting these controls from scratch: + +1. Assign a dedicated `AssetManager` key that holds **no** compliance role. +2. Assign one or more `ComplianceOfficer` keys. +3. Keep the admin key in cold storage for governance only. +4. Set `enforced: true, allow_dual_duty_issuance: false, allow_self_issuance: + false, require_independent_approver: true`. +5. Verify with `check_issuance_authority` that the admin key is refused and the + `AssetManager` key is permitted — the intended shape of the separation. + +Reviewers should confirm steps 1–5 against +[`reviewer-checklist.md`](reviewer-checklist.md) before a production deployment. + +## Maintenance + +Any new issuance entrypoint **must** call +`issuer::require_issuance_authority`, passing the recipient (or `None` when +there is no single beneficiary). Adding a control means adding an +`IssuanceGuard` variant, its error mapping, a policy field defaulting to the +permissive value, a row in the tables above, and a case in +`test_check_issuance_authority_matches_mint_enforcement` — in the same change. +`IssuerDuty`, `IssuanceGuard`, and `IssuerSeparationPolicy` are append-only +ABI: never reorder or repurpose a variant or field. diff --git a/fixtures/sdk/04-events.json b/fixtures/sdk/04-events.json index 66ddad9..41a053a 100644 --- a/fixtures/sdk/04-events.json +++ b/fixtures/sdk/04-events.json @@ -470,6 +470,36 @@ } ] }, + { + "id": "event-issuer-separation-policy-updated", + "description": "Topic `issuer_separation_policy_updated`. Both the previous and the new policy are emitted so an auditor can reconstruct when each separation control came into force without replaying storage (see docs/issuer-role-separation.md).", + "events": [ + { + "contract": "CCEOFPHM2IOUTJS53R74QWIEQXXEHLYOTZYMCS44UI735A4WCJZAQNWP", + "type": "contract", + "topic": "issuer_separation_policy_updated", + "topics": [ + "issuer_separation_policy_updated" + ], + "data": { + "admin": "GDAVU6P2QJK4IWQWUNYUXBAFGTPF36MGBN5HBZGYCVCKO2DONWP7YDIJ", + "new_policy": { + "allow_dual_duty_issuance": false, + "allow_self_issuance": false, + "enforced": true, + "require_independent_approver": true + }, + "previous_policy": { + "allow_dual_duty_issuance": true, + "allow_self_issuance": true, + "enforced": false, + "require_independent_approver": false + } + }, + "xdr_base64": "AAAAAAAAAAGI4rzs0h1Jpl3cf8hZBIXuQ68OnnDBS5yiP76DlhJyCAAAAAEAAAAAAAAAAQAAAA4AAAAgaXNzdWVyX3NlcGFyYXRpb25fcG9saWN5X3VwZGF0ZWQAAAARAAAAAQAAAAMAAAAPAAAABWFkbWluAAAAAAAAEgAAAAAAAAAAwVp5+oJVxFoWo3FLhAU03l35hgt6cOTYFUSnaG5tn/wAAAAPAAAACm5ld19wb2xpY3kAAAAAABEAAAABAAAABAAAAA8AAAAYYWxsb3dfZHVhbF9kdXR5X2lzc3VhbmNlAAAAAAAAAAAAAAAPAAAAE2FsbG93X3NlbGZfaXNzdWFuY2UAAAAAAAAAAAAAAAAPAAAACGVuZm9yY2VkAAAAAAAAAAEAAAAPAAAAHHJlcXVpcmVfaW5kZXBlbmRlbnRfYXBwcm92ZXIAAAAAAAAAAQAAAA8AAAAPcHJldmlvdXNfcG9saWN5AAAAABEAAAABAAAABAAAAA8AAAAYYWxsb3dfZHVhbF9kdXR5X2lzc3VhbmNlAAAAAAAAAAEAAAAPAAAAE2FsbG93X3NlbGZfaXNzdWFuY2UAAAAAAAAAAAEAAAAPAAAACGVuZm9yY2VkAAAAAAAAAAAAAAAPAAAAHHJlcXVpcmVfaW5kZXBlbmRlbnRfYXBwcm92ZXIAAAAAAAAAAA==" + } + ] + }, { "id": "event-none-on-reverted-transfer", "description": "Soroban discards all events from a reverted invocation, so a compliance-blocked transfer emits nothing at all. The numeric error code is the only off-chain-observable signal — indexers must watch failed transaction results, not events, to audit blocked transfers (see docs/events.md).", diff --git a/fixtures/sdk/05-errors.json b/fixtures/sdk/05-errors.json index 33dcd25..c602cb2 100644 --- a/fixtures/sdk/05-errors.json +++ b/fixtures/sdk/05-errors.json @@ -331,6 +331,20 @@ "category": "minting_transfers" } } + }, + { + "id": "error-3007-issuance-duty-conflict", + "description": "Issuer separation is enforced and the caller holds both the compliance and issuance duties, so it may not issue. Recoverable: the admin can relax the policy, which is never self-locking.", + "call": "mint_asset", + "result": { + "ok": false, + "error": { + "type": "contract", + "code": 3007, + "name": "IssuanceDutyConflict", + "category": "admin_authorization" + } + } } ] } diff --git a/src/asset.rs b/src/asset.rs index d0197a3..e3a1989 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -8,6 +8,7 @@ use soroban_sdk::{contractimpl, contracttype, Address, Env, String}; use crate::admin::{require_not_paused, require_role}; use crate::compliance; use crate::holding; +use crate::issuer::require_issuance_authority; use crate::restrictions::{asset_status_reason, error_for_reason, RestrictionReason}; @@ -199,6 +200,13 @@ impl AegisContract { require_not_paused(&env); admin.require_auth(); require_role(&env, &admin, Role::AssetManager); + + // Separation of duties: the party that decides who may hold the asset + // must not also be the party that decides who receives it. Inert + // unless an admin has enabled the policy, so existing deployments are + // unaffected. See `docs/issuer-role-separation.md`. + require_issuance_authority(&env, &admin, Some(&to)); + if amount <= 0 { return Err(Error::InvalidAmount); } @@ -316,6 +324,12 @@ impl AegisContract { require_not_paused(&env); admin.require_auth(); require_role(&env, &admin, Role::AssetManager); + + // Yield distribution is an issuance action with no single beneficiary, + // so only the duty-level control applies (no self- or approver-check + // target exists). + require_issuance_authority(&env, &admin, None); + if amount <= 0 { return Err(Error::InvalidAmount); } diff --git a/src/capabilities.rs b/src/capabilities.rs index bb5fb1c..e4d8217 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 = 4; +pub const CAPABILITY_SCHEMA_VERSION: u32 = 5; // ─── Response types ─────────────────────────────────────────────────────────── @@ -106,6 +106,12 @@ pub struct MintingCapabilities { /// On-chain yield settlement. `distribute_yield` exists but only emits /// `yield_distributed` for off-chain indexing — it moves no value. pub yield_distribution: CapabilityStatus, + /// Issuer separation-of-duties controls (`set_issuer_separation_policy`, + /// `check_issuance_authority`). See `docs/issuer-role-separation.md`. + pub issuer_separation: CapabilityStatus, + /// Runtime: whether the separation policy is currently enforced. `false` + /// means issuance is governed by the role check alone — the default. + pub issuer_separation_enforced: bool, } /// Transfer capabilities (`asset.rs`, `holding.rs`, `eligibility.rs`). @@ -354,6 +360,8 @@ pub fn get_capabilities(env: &Env) -> ContractCapabilities { supply_cap_enforced, // Event-only today: no on-chain settlement of yield. yield_distribution: CapabilityStatus::Planned, + issuer_separation: CapabilityStatus::Supported, + issuer_separation_enforced: crate::issuer::get_policy(env).enforced, }, transfers: TransferCapabilities { @@ -472,6 +480,9 @@ pub fn supports_capability(env: &Env, capability: &Symbol) -> CapabilityStatus { if *capability == Symbol::new(env, "supply_cap") { return caps.minting.supply_cap; } + if *capability == Symbol::new(env, "issuer_separation") { + return caps.minting.issuer_separation; + } if *capability == Symbol::new(env, "yield_distribution") { return caps.minting.yield_distribution; } @@ -565,6 +576,7 @@ pub fn get_capability_keys(env: &Env) -> Vec { Symbol::new(env, "burning"), Symbol::new(env, "supply_cap"), Symbol::new(env, "yield_distribution"), + Symbol::new(env, "issuer_separation"), Symbol::new(env, "transfers"), Symbol::new(env, "holding_cap"), Symbol::new(env, "allowances"), diff --git a/src/compliance.rs b/src/compliance.rs index 457a85f..41f4f89 100644 --- a/src/compliance.rs +++ b/src/compliance.rs @@ -29,6 +29,7 @@ use soroban_sdk::{contractimpl, contracttype, vec, Address, Env, Vec}; use crate::admin::require_not_paused; use crate::compliance_guards::{require_transition, require_transition_authority}; +use crate::issuer::record_compliance_approver; use crate::{AegisContract, AegisContractArgs, AegisContractClient, DataKey, Error}; // ─── Lifecycle state ────────────────────────────────────────────────────────── @@ -226,6 +227,14 @@ fn apply_transition( ) { write_status(env, user, &new_status); + // Record who granted the clearance, so the issuer separation controls can + // enforce that the approver is not also the issuer. Written only on the + // way *into* `Approved`: a later revocation must not erase who granted the + // clearance being revoked. See `docs/issuer-role-separation.md`. + if new_status.is_approved() { + record_compliance_approver(env, user, caller); + } + env.events().publish( ("compliance_status_changed",), ComplianceStatusChangedEvent { diff --git a/src/errors.rs b/src/errors.rs index 53b5a22..a6cd9b8 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -34,6 +34,15 @@ pub enum Error { AlreadyPaused = 3005, /// The contract is not currently paused. NotPaused = 3006, + /// Issuer separation is enforced and the caller holds both the compliance + /// and issuance duties, so it may not issue. + IssuanceDutyConflict = 3007, + /// Issuer separation is enforced and the caller is the recipient of its + /// own issuance. + SelfIssuanceForbidden = 3008, + /// Issuer separation is enforced and the caller is the address that + /// approved the recipient's compliance. + IssuanceApproverConflict = 3009, // 4000s: Compliance /// The sending address has no current clearance (`Unknown` or `Revoked`). diff --git a/src/issuer.rs b/src/issuer.rs new file mode 100644 index 0000000..4e73223 --- /dev/null +++ b/src/issuer.rs @@ -0,0 +1,496 @@ +//! Issuer role separation. +//! +//! The RBAC in [`crate::admin`] answers *which* privileges an address holds. +//! This module answers the separation-of-duties question that sits on top of +//! it: **may the same key both clear an investor and issue that investor +//! units?** +//! +//! Under the base role model the answer is yes. The supreme admin bypasses +//! every role check, so a single key can approve an address and then mint to +//! it with no second party involved. For an operational convenience that is +//! fine; for an RWA issuer it is the classic control failure — the party that +//! decides *who may hold* the asset must not also be the party that decides +//! *who receives* it. +//! +//! Duties here are derived from what the contract **enforces**, not from what +//! a role is named after. `mint_asset` and `distribute_yield` call +//! `require_role(AssetManager)`, which admits only an `AssetManager` or the +//! admin, so `EmergencyOfficer` carries compliance and pause authority but +//! **not** issuance. Modelling it otherwise would describe a privilege that +//! does not exist, and a separation control built on an inaccurate privilege +//! map is worse than none. +//! +//! This module makes that separation enforceable **without changing any +//! existing deployment's behaviour**. Separation is an opt-in policy: until an +//! admin enables it, [`IssuerSeparationPolicy::default_policy`] permits +//! everything the contract permitted before. Once enabled, each control can be +//! relaxed independently, so an issuer can adopt the parts their operating +//! model supports: +//! +//! | Control | What it blocks | +//! |---|---| +//! | `allow_dual_duty_issuance: false` | A caller holding *both* compliance and issuance duties may not mint. | +//! | `allow_self_issuance: false` | A caller may not mint to their own address. | +//! | `require_independent_approver: true` | The caller who approved a recipient's compliance may not mint to that recipient (four-eyes). | +//! +//! The policy is admin-governed and never self-locking: `set_issuer_separation_policy` +//! is not itself gated by the policy, so an admin can always relax a rule that +//! turns out to be too strict. See `docs/issuer-role-separation.md`. + +// The legacy `Events::publish((topic,), payload)` API is used intentionally: +// docs/events.md freezes these (topic, payload) shapes as a stable off-chain +// contract, and src/test.rs asserts them exactly. +#![allow(deprecated)] + +use soroban_sdk::{contractimpl, contracttype, vec, Address, Env, Vec}; + +use crate::admin::{get_role, require_not_paused}; +use crate::{AegisContract, AegisContractArgs, AegisContractClient, DataKey, Error, Role}; + +// ─── Duties ─────────────────────────────────────────────────────────────────── + +/// A class of privilege, independent of which role happens to carry it. +/// +/// Roles are the unit of *assignment*; duties are the unit of *separation*. +/// The distinction matters because one role can carry several duties — the +/// admin carries all of them — and it is the *combination* of `Compliance` +/// and `Issuance` in one key that separation-of-duties controls exist to +/// detect. +/// +/// The variant order is part of the contract's ABI: variants are append-only +/// and must never be reordered or repurposed. +#[contracttype] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum IssuerDuty { + /// Deciding who may hold the asset: the compliance lifecycle and the + /// whitelist (`set_compliance_status`, `whitelist_user`, …). + Compliance, + /// Deciding who receives units and how many: `mint_asset`, + /// `distribute_yield`. + Issuance, + /// Halting the protocol: `pause`. Held by `EmergencyOfficer` and the + /// admin. + Emergency, + /// Changing the rules themselves: roles, caps, protocol config, and + /// lifting a pause. Held only by the admin. + Governance, +} + +/// Whether `role` carries `duty`. +/// +/// `Role::Admin` carries every duty by construction — the admin bypasses role +/// checks throughout the contract, so modelling it as anything narrower here +/// would describe a restriction that does not exist. +pub fn role_has_duty(role: &Role, duty: &IssuerDuty) -> bool { + match role { + Role::None => false, + Role::Admin => true, + Role::ComplianceOfficer => matches!(duty, IssuerDuty::Compliance), + Role::AssetManager => matches!(duty, IssuerDuty::Issuance), + // Compliance plus the pause switch — *not* issuance: `mint_asset` + // requires `AssetManager` specifically. See the module docs. + Role::EmergencyOfficer => { + matches!(duty, IssuerDuty::Compliance | IssuerDuty::Emergency) + } + } +} + +/// Every duty `role` carries, in ABI order. Empty for `Role::None`. +pub fn duties_of_role(env: &Env, role: &Role) -> Vec { + let all = [ + IssuerDuty::Compliance, + IssuerDuty::Issuance, + IssuerDuty::Emergency, + IssuerDuty::Governance, + ]; + + let mut out = vec![env]; + for duty in all.iter() { + if role_has_duty(role, duty) { + out.push_back(*duty); + } + } + out +} + +/// The effective role of `caller`, treating the supreme admin as `Role::Admin` +/// whatever the role table says. +/// +/// The admin's authority comes from `DataKey::Admin`, not from a role +/// assignment, so a duty check that consulted only `get_role` would understate +/// what the admin can actually do — and separation controls that understate +/// authority are worse than none. +/// +/// Returns `Role::None` on an uninitialized contract rather than panicking, so +/// the read entrypoints stay panic-free. +pub fn effective_role(env: &Env, caller: &Address) -> Role { + match env + .storage() + .instance() + .get::(&DataKey::Admin) + { + Some(admin) if admin == *caller => Role::Admin, + Some(_) => get_role(env, caller), + None => Role::None, + } +} + +// ─── Policy ─────────────────────────────────────────────────────────────────── + +/// The deployment's separation-of-duties configuration. +/// +/// Every field defaults to the permissive value, so a contract that has never +/// called `set_issuer_separation_policy` behaves exactly as it did before this +/// module existed. Enabling separation is a deliberate, audited act. +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IssuerSeparationPolicy { + /// Master switch. While `false`, every other field is inert and issuance + /// is governed by the role check alone. + pub enforced: bool, + /// Whether an address holding **both** the compliance and issuance duties + /// may issue. Today only the admin holds both, so this is in practice the + /// control that forces the admin to delegate issuance to a dedicated + /// `AssetManager` key. + /// + /// Setting this `false` is the core separation control: it forces issuance + /// through a key that cannot also alter the whitelist. It restricts the + /// **admin** deliberately — an unrestricted admin makes the control + /// decorative. The admin can still lift the policy, so this is a control + /// against routine key misuse and operator error, not a defence against a + /// compromised admin key. See `docs/admin-misuse-risks.md`. + pub allow_dual_duty_issuance: bool, + /// Whether an issuer may mint to their own address. Self-issuance is the + /// shortest path from "issuance key" to "holder of the asset". + pub allow_self_issuance: bool, + /// Whether the caller who last approved a recipient's compliance may issue + /// to that recipient. Setting this `true` enforces four-eyes: whoever + /// cleared the investor cannot be the one who funds them. + /// + /// Only the **most recent** approver is recorded, so this is a control + /// against one key performing both steps, not a full historical audit — + /// reconstruct that from `compliance_status_changed` events. + pub require_independent_approver: bool, +} + +impl IssuerSeparationPolicy { + /// The permissive default applied when no policy has been stored: separation + /// off, every control relaxed. Chosen so adding this module changes no + /// existing deployment's behaviour. + pub fn default_policy() -> Self { + IssuerSeparationPolicy { + enforced: false, + allow_dual_duty_issuance: true, + allow_self_issuance: true, + require_independent_approver: false, + } + } +} + +/// Returns the active policy, or the permissive default when none is stored. +/// Pure read: never panics, never writes. +pub fn get_policy(env: &Env) -> IssuerSeparationPolicy { + env.storage() + .instance() + .get(&DataKey::IssuerSeparationPolicy) + .unwrap_or_else(IssuerSeparationPolicy::default_policy) +} + +// ─── Approver record ────────────────────────────────────────────────────────── + +/// Records that `approver` moved `user` into `Approved`. +/// +/// Called from the compliance lifecycle writer on every committed transition +/// *into* `Approved`. The record is intentionally **not** cleared when the +/// address later leaves `Approved`: a revoked investor who is re-approved by a +/// different officer must overwrite it, but a revocation alone should not +/// erase who granted the clearance being revoked. +pub fn record_compliance_approver(env: &Env, user: &Address, approver: &Address) { + env.storage() + .persistent() + .set(&DataKey::ComplianceApprover(user.clone()), approver); +} + +/// Returns the address that last approved `user`'s compliance, if any. +/// Pure read: never panics, never writes. +pub fn get_compliance_approver(env: &Env, user: &Address) -> Option
{ + env.storage() + .persistent() + .get(&DataKey::ComplianceApprover(user.clone())) +} + +// ─── Events ─────────────────────────────────────────────────────────────────── + +#[contracttype] +#[derive(Clone, Debug)] +pub struct IssuerSeparationPolicyUpdatedEvent { + pub admin: Address, + pub previous_policy: IssuerSeparationPolicy, + pub new_policy: IssuerSeparationPolicy, +} + +// ─── Guard ──────────────────────────────────────────────────────────────────── + +/// Why an issuance is permitted or refused under the separation policy. +/// +/// Exactly one reason is returned: the **first** control that fails, in the +/// order enforcement applies them. Variants are append-only ABI. +#[contracttype] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum IssuanceGuard { + /// Every control passes. + Allowed, + /// The contract has not been initialized. Maps to `NotInitialized` (2000). + NotInitialized, + /// The caller does not carry the issuance duty at all — a plain RBAC + /// failure, not a separation one. Maps to `Unauthorized` (3000). + MissingIssuanceDuty, + /// The caller carries both the compliance and issuance duties while + /// `allow_dual_duty_issuance` is `false`. Maps to `IssuanceDutyConflict` + /// (3007). + DualDutyConflict, + /// The caller is the recipient while `allow_self_issuance` is `false`. + /// Maps to `SelfIssuanceForbidden` (3008). + SelfIssuanceForbidden, + /// The caller is the recorded approver of the recipient's compliance while + /// `require_independent_approver` is `true`. Maps to + /// `IssuanceApproverConflict` (3009). + ApproverConflict, +} + +impl IssuanceGuard { + /// Whether this verdict permits the issuance. + pub fn is_allowed(&self) -> bool { + matches!(self, IssuanceGuard::Allowed) + } + + /// Whether the refusal comes from the separation policy rather than from + /// the base role model. Lets a client distinguish "this key is not an + /// issuer" from "this key is an issuer, but not for *this* recipient". + pub fn is_separation_failure(&self) -> bool { + matches!( + self, + IssuanceGuard::DualDutyConflict + | IssuanceGuard::SelfIssuanceForbidden + | IssuanceGuard::ApproverConflict + ) + } +} + +/// The contract error a refused verdict produces, or `None` when allowed. +pub fn error_for_guard(guard: &IssuanceGuard) -> Option { + match guard { + IssuanceGuard::Allowed => None, + IssuanceGuard::NotInitialized => Some(Error::NotInitialized), + IssuanceGuard::MissingIssuanceDuty => Some(Error::Unauthorized), + IssuanceGuard::DualDutyConflict => Some(Error::IssuanceDutyConflict), + IssuanceGuard::SelfIssuanceForbidden => Some(Error::SelfIssuanceForbidden), + IssuanceGuard::ApproverConflict => Some(Error::IssuanceApproverConflict), + } +} + +/// Evaluates the separation controls for `caller` issuing to `recipient`. +/// +/// Pass `None` for `recipient` for an issuance with no single beneficiary +/// (`distribute_yield`); the recipient-scoped controls are then skipped, since +/// there is no address for them to be about. +/// +/// **Never panics and never writes** — safe from view entrypoints and from the +/// enforcement path, which is what keeps the pre-flight read +/// (`check_issuance_authority`) and `mint_asset` in agreement. +pub fn evaluate_issuance( + env: &Env, + caller: &Address, + recipient: Option<&Address>, +) -> IssuanceGuard { + if !env.storage().instance().has(&DataKey::Admin) { + return IssuanceGuard::NotInitialized; + } + + let role = effective_role(env, caller); + if !role_has_duty(&role, &IssuerDuty::Issuance) { + return IssuanceGuard::MissingIssuanceDuty; + } + + let policy = get_policy(env); + if !policy.enforced { + return IssuanceGuard::Allowed; + } + + if !policy.allow_dual_duty_issuance && role_has_duty(&role, &IssuerDuty::Compliance) { + return IssuanceGuard::DualDutyConflict; + } + + let recipient = match recipient { + Some(recipient) => recipient, + // No beneficiary: the recipient-scoped controls do not apply. + None => return IssuanceGuard::Allowed, + }; + + if !policy.allow_self_issuance && *caller == *recipient { + return IssuanceGuard::SelfIssuanceForbidden; + } + + if policy.require_independent_approver { + if let Some(approver) = get_compliance_approver(env, recipient) { + if approver == *caller { + return IssuanceGuard::ApproverConflict; + } + } + } + + IssuanceGuard::Allowed +} + +/// Enforces the separation controls, panicking with the mapped error on +/// refusal. Authorization failures have always aborted by panicking in this +/// contract; keeping that shape means adding these controls changes no +/// existing SDK error handling. +pub fn require_issuance_authority(env: &Env, caller: &Address, recipient: Option<&Address>) { + let guard = evaluate_issuance(env, caller, recipient); + if guard.is_allowed() { + return; + } + if let Some(error) = error_for_guard(&guard) { + soroban_sdk::panic_with_error!(env, error); + } +} + +// ─── Read response ──────────────────────────────────────────────────────────── + +/// The full separation verdict for a proposed issuance, as returned to clients. +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IssuanceAuthorityCheck { + /// The address that would sign and submit the issuance. + pub caller: Address, + /// The address that would receive the units. + pub recipient: Address, + /// `caller`'s effective role (`Admin` for the supreme admin, whatever the + /// role table holds). + pub caller_role: Role, + /// Every duty `caller_role` carries. A caller carrying both `Compliance` + /// and `Issuance` is the condition `allow_dual_duty_issuance` governs. + pub caller_duties: Vec, + /// The address that last approved `recipient`'s compliance, if any. + pub recipient_approver: Option
, + /// Whether the issuance would clear the separation controls now. + pub allowed: bool, + /// The first failing control, or `Allowed`. + pub reason: IssuanceGuard, + /// The numeric error code a refused submission would revert with. + pub error_code: Option, +} + +/// Builds the client-facing separation report for a proposed issuance. +pub fn check_issuance(env: &Env, caller: &Address, recipient: &Address) -> IssuanceAuthorityCheck { + let caller_role = effective_role(env, caller); + let reason = evaluate_issuance(env, caller, Some(recipient)); + + IssuanceAuthorityCheck { + caller: caller.clone(), + recipient: recipient.clone(), + caller_duties: duties_of_role(env, &caller_role), + caller_role, + recipient_approver: get_compliance_approver(env, recipient), + allowed: reason.is_allowed(), + reason, + error_code: error_for_guard(&reason).map(|err| err as u32), + } +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +#[contractimpl] +impl AegisContract { + /// Returns the active issuer separation policy, or the permissive default + /// when none has been set. Pure read; always available. + pub fn get_issuer_separation_policy(env: Env) -> IssuerSeparationPolicy { + get_policy(&env) + } + + /// Replaces the issuer separation policy. Admin-only; blocked while the + /// contract is paused. + /// + /// Applied in a single call rather than through 2-step governance: unlike + /// a supply cap, tightening separation cannot strand value, and an issuer + /// responding to a suspected key compromise should not have to wait a + /// second transaction to close the gap. Loosening it is equally immediate, + /// which is what guarantees the policy can never lock a deployment out of + /// issuance — see `docs/issuer-role-separation.md`. + /// + /// Emits `issuer_separation_policy_updated` with both the previous and the + /// new policy, so an auditor can reconstruct when each control was in force + /// without replaying storage. + pub fn set_issuer_separation_policy( + env: Env, + admin: Address, + policy: IssuerSeparationPolicy, + ) -> Result<(), Error> { + require_not_paused(&env); + admin.require_auth(); + if admin != crate::admin::get_admin(&env) { + return Err(Error::Unauthorized); + } + + let previous_policy = get_policy(&env); + env.storage() + .instance() + .set(&DataKey::IssuerSeparationPolicy, &policy); + + env.events().publish( + ("issuer_separation_policy_updated",), + IssuerSeparationPolicyUpdatedEvent { + admin, + previous_policy, + new_policy: policy, + }, + ); + + Ok(()) + } + + /// Returns the duties carried by `role`. Pure read; the duty table is + /// fixed for a contract build, so clients may cache it. + pub fn get_role_duties(env: Env, role: Role) -> Vec { + duties_of_role(&env, &role) + } + + /// Returns the duties `address` currently carries, resolving the supreme + /// admin to `Role::Admin`. Pure read. + pub fn get_duties_of(env: Env, address: Address) -> Vec { + let role = effective_role(&env, &address); + duties_of_role(&env, &role) + } + + /// Returns the address that last approved `user`'s compliance, or `None` + /// if `user` has never been approved. Pure read. + /// + /// This is the record `require_independent_approver` is evaluated against, + /// and it is exposed so a reviewer can verify a four-eyes claim without + /// replaying the event stream. + pub fn get_compliance_approver(env: Env, user: Address) -> Option
{ + get_compliance_approver(&env, &user) + } + + /// Returns whether `caller` could issue to `recipient` under the current + /// separation policy, and the precise reason when they could not. + /// + /// Pure read: no authorization, no writes, never reverts, callable while + /// paused. The verdict comes from the same evaluation `mint_asset` + /// enforces, so `allowed == false` guarantees a mint would revert with + /// `error_code`. + /// + /// **Separation only.** This answers "may this key issue to this address", + /// not "will this mint succeed": the recipient's compliance status, the + /// supply and holding caps, the asset lifecycle, the pause, and the amount + /// are all checked separately by `mint_asset`. Use + /// `check_mint_restriction` for those. + pub fn check_issuance_authority( + env: Env, + caller: Address, + recipient: Address, + ) -> IssuanceAuthorityCheck { + check_issuance(&env, &caller, &recipient) + } +} diff --git a/src/lib.rs b/src/lib.rs index 6824dd1..fa0ab0d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub mod config; pub mod eligibility; pub mod errors; pub mod holding; +pub mod issuer; pub mod restrictions; @@ -99,6 +100,13 @@ pub enum DataKey { AssetSymbol, /// Optional metadata URI for off-chain asset details. AssetMetadataUri, + /// The address that last moved an investor into `ComplianceStatus::Approved`. + /// Read by the issuer separation controls to enforce four-eyes issuance; + /// see `docs/issuer-role-separation.md`. + ComplianceApprover(Address), + /// The deployment's issuer separation-of-duties policy. Absent means the + /// permissive default (separation not enforced). + IssuerSeparationPolicy, /// The globally active protocol configuration. ProtocolConfig, /// The pending (proposed) protocol configuration. diff --git a/src/test.rs b/src/test.rs index b73acd4..3aa1f4e 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, - SchemaVersionRelation, TransferCapabilities, CAPABILITY_SCHEMA_VERSION, + MetadataCapabilities, MintingCapabilities, PauseCapabilities, SchemaVersionRelation, + TransferCapabilities, CAPABILITY_SCHEMA_VERSION, }; use crate::compliance::{ @@ -20,6 +20,9 @@ use crate::compliance::{ use crate::compliance_guards::TransitionGuard; use crate::eligibility::InvestorEligibility; +use crate::issuer::{ + IssuanceGuard, IssuerDuty, IssuerSeparationPolicy, IssuerSeparationPolicyUpdatedEvent, +}; use crate::lifecycle::{AssetStatus, AssetStatusChangedEvent}; use crate::errors::Error; @@ -1514,6 +1517,8 @@ fn default_capabilities(env: &Env) -> ContractCapabilities { supply_cap: CapabilityStatus::Supported, supply_cap_enforced: false, yield_distribution: CapabilityStatus::Planned, + issuer_separation: CapabilityStatus::Supported, + issuer_separation_enforced: false, }, transfers: TransferCapabilities { module_enabled: true, @@ -1608,7 +1613,7 @@ fn test_contract_capabilities() { // 5. Check capability keys registry agreement let keys = client.get_capability_keys(); - assert_eq!(keys.len(), 33); + assert_eq!(keys.len(), 34); assert!(keys.contains(Symbol::new(&env, "whitelist"))); assert!(keys.contains(Symbol::new(&env, "rbac"))); } @@ -2588,7 +2593,7 @@ fn test_check_transfer_eligibility_false_when_invalid_amount() { client.supports_capability(&soroban_sdk::Symbol::new(&env, "whitelist")), CapabilityStatus::Supported ); - assert_eq!(client.get_capability_keys().len(), 33); + assert_eq!(client.get_capability_keys().len(), 34); client.initialize(&admin); client.whitelist_user(&admin, &user1); @@ -2678,7 +2683,7 @@ fn test_check_transfer_eligibility_false_when_contract_paused() { client.supports_capability(&soroban_sdk::Symbol::new(&env, "whitelist")), CapabilityStatus::Supported ); - assert_eq!(client.get_capability_keys().len(), 33); + assert_eq!(client.get_capability_keys().len(), 34); // The read helper itself must remain callable while paused, but must // reflect that transfers cannot currently succeed. @@ -4332,8 +4337,7 @@ fn test_interface_compatibility_matching_schema_and_supported_keys_is_compatible Symbol::new(&env, "whitelist"), Symbol::new(&env, "transfers"), ]; - let report = - client.check_interface_compatibility(&CAPABILITY_SCHEMA_VERSION, &required); + 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); @@ -4391,8 +4395,7 @@ fn test_interface_compatibility_reports_every_unsupported_required_key() { Symbol::new(&env, "burning"), Symbol::new(&env, "allowances"), ]; - let report = - client.check_interface_compatibility(&CAPABILITY_SCHEMA_VERSION, &required); + let report = client.check_interface_compatibility(&CAPABILITY_SCHEMA_VERSION, &required); assert_eq!(report.unsupported_required.len(), 2); assert!(report @@ -4429,8 +4432,7 @@ fn test_interface_compatibility_agrees_with_supports_capability() { // 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 report = client.check_interface_compatibility(&CAPABILITY_SCHEMA_VERSION, &required); let direct_status = client.supports_capability(&key); assert_ne!(direct_status, CapabilityStatus::Supported); @@ -5204,3 +5206,532 @@ fn test_guard_agrees_with_the_legacy_whitelist_entrypoints() { let _ = &env; } } + +// ─── ISSUER ROLE SEPARATION ─────────────────────────────────────────────────── +// +// Separation of duties for issuance: the party that decides *who may hold* the +// asset must not also be the party that decides *who receives* it. The base +// role model allows exactly that overlap — the admin bypasses every role +// check, so one key can clear an investor and then fund them — so these tests +// cover the opt-in policy that closes it and, just as importantly, that the +// policy is inert until an admin turns it on. The model is documented in +// docs/issuer-role-separation.md. + +/// The strictest policy: every separation control engaged. +fn strict_policy() -> IssuerSeparationPolicy { + IssuerSeparationPolicy { + enforced: true, + allow_dual_duty_issuance: false, + allow_self_issuance: false, + require_independent_approver: true, + } +} + +/// Separation enforced, but only the named control engaged. Lets each control +/// be tested in isolation, so a passing test cannot be explained by a +/// different rule doing the work. +fn policy_with( + dual_duty: bool, + self_issuance: bool, + independent_approver: bool, +) -> IssuerSeparationPolicy { + IssuerSeparationPolicy { + enforced: true, + allow_dual_duty_issuance: dual_duty, + allow_self_issuance: self_issuance, + require_independent_approver: independent_approver, + } +} + +/// An initialized, Active contract with a compliance officer and an asset +/// manager whose duties do not overlap. +fn setup_issuer_world() -> ( + Env, + AegisContractClient<'static>, + Address, + Address, + Address, + Address, +) { + let (env, client, admin, officer, investor) = setup(); + env.mock_all_auths(); + client.initialize(&admin); + client.set_asset_status(&admin, &AssetStatus::Active); + let manager = Address::generate(&env); + client.set_role(&admin, &officer, &Role::ComplianceOfficer); + client.set_role(&admin, &manager, &Role::AssetManager); + client.set_compliance_status(&officer, &investor, &ComplianceStatus::Approved); + (env, client, admin, officer, manager, investor) +} + +#[test] +fn test_issuer_separation_is_off_by_default() { + // Adding the module must not change any existing deployment. The default + // policy is fully permissive and is what an un-configured contract reports. + let (_env, client, admin, _officer, manager, investor) = setup_issuer_world(); + + let policy = client.get_issuer_separation_policy(); + assert!(!policy.enforced); + assert!(policy.allow_dual_duty_issuance); + assert!(policy.allow_self_issuance); + assert!(!policy.require_independent_approver); + + // Every caller that could mint before still can: a scoped manager and the + // admin. + client.mint_asset(&manager, &investor, &10); + client.mint_asset(&admin, &investor, &10); + assert_eq!(client.get_balance_of(&investor), 20); + + // And a caller that could not mint before still cannot, for the same + // reason as before (the role check, not the separation policy). + let emergency = Address::generate(&_env); + client.set_role(&admin, &emergency, &Role::EmergencyOfficer); + assert_eq!( + client.try_mint_asset(&emergency, &investor, &10), + Err(Ok(Error::Unauthorized)) + ); +} + +#[test] +fn test_role_duty_table_is_exact() { + let (env, client, admin, officer, manager, _investor) = setup_issuer_world(); + + assert_eq!(client.get_role_duties(&Role::None), vec![&env]); + assert_eq!( + client.get_role_duties(&Role::ComplianceOfficer), + vec![&env, IssuerDuty::Compliance] + ); + assert_eq!( + client.get_role_duties(&Role::AssetManager), + vec![&env, IssuerDuty::Issuance] + ); + // Derived from what the contract enforces: `mint_asset` requires + // `AssetManager` specifically, so the emergency role carries compliance + // and the pause switch but *not* issuance. + assert_eq!( + client.get_role_duties(&Role::EmergencyOfficer), + vec![&env, IssuerDuty::Compliance, IssuerDuty::Emergency] + ); + // The admin bypasses every role check, so it carries every duty — which is + // precisely why it is the address the dual-duty control binds. + assert_eq!( + client.get_role_duties(&Role::Admin), + vec![ + &env, + IssuerDuty::Compliance, + IssuerDuty::Issuance, + IssuerDuty::Emergency, + IssuerDuty::Governance + ] + ); + + // The admin's authority comes from `DataKey::Admin`, not from the role + // table, so an address-keyed read must resolve it to the full duty set. + assert_eq!( + client.get_duties_of(&admin), + client.get_role_duties(&Role::Admin) + ); + assert_eq!( + client.get_duties_of(&officer), + vec![&env, IssuerDuty::Compliance] + ); + assert_eq!( + client.get_duties_of(&manager), + vec![&env, IssuerDuty::Issuance] + ); + assert_eq!(client.get_duties_of(&Address::generate(&env)), vec![&env]); +} + +#[test] +fn test_dual_duty_issuance_is_refused_when_separation_is_enforced() { + let (_env, client, admin, officer, manager, investor) = setup_issuer_world(); + client.set_issuer_separation_policy(&admin, &policy_with(false, true, false)); + + // The admin holds every duty, so it is the address this control binds — + // and it must bind the admin, or the separation is decorative for the most + // powerful key on the contract. + assert_eq!( + client.try_mint_asset(&admin, &investor, &10), + Err(Ok(Error::IssuanceDutyConflict)) + ); + assert_eq!(client.get_balance_of(&investor), 0); + + // The admin's other privileges are untouched: the control targets + // *issuance* by a dual-duty key, not the key's authority in general. + client.set_compliance_status(&admin, &investor, &ComplianceStatus::Revoked); + client.set_compliance_status(&admin, &investor, &ComplianceStatus::Approved); + + // A single-duty issuer is unaffected — that is the intended path: the + // admin delegates issuance to a dedicated AssetManager key. + client.mint_asset(&manager, &investor, &10); + assert_eq!(client.get_balance_of(&investor), 10); + let _ = &officer; +} + +#[test] +fn test_self_issuance_is_refused_when_disallowed() { + let (_env, client, admin, officer, manager, investor) = setup_issuer_world(); + // The manager is itself an approved holder — the situation the control is + // about: an issuance key that can legally hold the asset. + client.set_compliance_status(&officer, &manager, &ComplianceStatus::Approved); + client.set_issuer_separation_policy(&admin, &policy_with(true, false, false)); + + assert_eq!( + client.try_mint_asset(&manager, &manager, &10), + Err(Ok(Error::SelfIssuanceForbidden)) + ); + assert_eq!(client.get_balance_of(&manager), 0); + + // Issuing to anyone else is untouched. + client.mint_asset(&manager, &investor, &10); + assert_eq!(client.get_balance_of(&investor), 10); +} + +#[test] +fn test_independent_approver_control_enforces_four_eyes() { + let (env, client, admin, officer, manager, investor) = setup_issuer_world(); + // Dual duty is tolerated here so the *approver* control is the only thing + // that can refuse the mint below. + client.set_issuer_separation_policy(&admin, &policy_with(true, true, true)); + + // The admin clears an investor and then tries to fund them — both halves + // of the decision in one key. + let alice = Address::generate(&env); + client.set_compliance_status(&admin, &alice, &ComplianceStatus::Approved); + assert_eq!(client.get_compliance_approver(&alice), Some(admin.clone())); + assert_eq!( + client.try_mint_asset(&admin, &alice, &10), + Err(Ok(Error::IssuanceApproverConflict)) + ); + + // A different issuer may fund the same investor: the control is about the + // pair, not about the investor being tainted. + client.mint_asset(&manager, &alice, &10); + assert_eq!(client.get_balance_of(&alice), 10); + + // And the admin may fund an investor someone else cleared. + assert_eq!(client.get_compliance_approver(&investor), Some(officer)); + client.mint_asset(&admin, &investor, &10); + assert_eq!(client.get_balance_of(&investor), 10); +} + +#[test] +fn test_approver_record_tracks_every_approval_path() { + let (env, client, admin, officer, _manager, investor) = setup_issuer_world(); + let second_officer = Address::generate(&env); + client.set_role(&admin, &second_officer, &Role::EmergencyOfficer); + + // 1. set_compliance_status (recorded during setup). + assert_eq!( + client.get_compliance_approver(&investor), + Some(officer.clone()) + ); + + // 2. A revocation must not erase who granted the clearance being revoked. + client.set_compliance_status(&officer, &investor, &ComplianceStatus::Revoked); + assert_eq!( + client.get_compliance_approver(&investor), + Some(officer.clone()) + ); + + // 3. Re-approval by a different officer overwrites it — the record is + // "who cleared them now", not "who ever cleared them". + client.set_compliance_status(&second_officer, &investor, &ComplianceStatus::Approved); + assert_eq!( + client.get_compliance_approver(&investor), + Some(second_officer.clone()) + ); + + // 4. The legacy wrapper drives the same record. + let bob = Address::generate(&env); + client.whitelist_user(&officer, &bob); + assert_eq!(client.get_compliance_approver(&bob), Some(officer.clone())); + + // 5. So does a batch update. + let carol = Address::generate(&env); + client.batch_set_compliance_status( + &second_officer, + &vec![ + &env, + ComplianceBatchUpdate { + user: carol.clone(), + new_status: ComplianceStatus::Approved, + }, + ], + ); + assert_eq!(client.get_compliance_approver(&carol), Some(second_officer)); + + // 6. An address that was never approved has no record, and a non-approving + // transition never creates one. + let dave = Address::generate(&env); + client.set_compliance_status(&officer, &dave, &ComplianceStatus::Pending); + assert_eq!(client.get_compliance_approver(&dave), None); +} + +#[test] +fn test_separation_controls_are_independent() { + // Each control must be able to refuse on its own; a test that passes only + // because a *different* rule fired would prove nothing. + let (_env, client, admin, officer, _manager, _investor) = setup_issuer_world(); + client.set_compliance_status(&officer, &admin, &ComplianceStatus::Approved); + + // Enforced, but every control relaxed: nothing is refused. + client.set_issuer_separation_policy(&admin, &policy_with(true, true, false)); + assert!(client.check_issuance_authority(&admin, &admin).allowed); + + // Dual-duty only. + client.set_issuer_separation_policy(&admin, &policy_with(false, true, false)); + assert_eq!( + client.check_issuance_authority(&admin, &admin).reason, + IssuanceGuard::DualDutyConflict + ); + + // Self-issuance only. + client.set_issuer_separation_policy(&admin, &policy_with(true, false, false)); + assert_eq!( + client.check_issuance_authority(&admin, &admin).reason, + IssuanceGuard::SelfIssuanceForbidden + ); + + // Approver only — the compliance officer cleared the admin, not the admin + // itself, so this pair is clean. + client.set_issuer_separation_policy(&admin, &policy_with(true, true, true)); + assert!(client.check_issuance_authority(&admin, &admin).allowed); + + // ...and dirty once the admin re-clears itself. + client.set_compliance_status(&admin, &admin, &ComplianceStatus::Revoked); + client.set_compliance_status(&admin, &admin, &ComplianceStatus::Approved); + assert_eq!( + client.check_issuance_authority(&admin, &admin).reason, + IssuanceGuard::ApproverConflict + ); +} + +#[test] +fn test_missing_issuance_duty_is_reported_separately_from_a_separation_failure() { + let (_env, client, admin, officer, manager, investor) = setup_issuer_world(); + client.set_issuer_separation_policy(&admin, &strict_policy()); + + // A compliance officer is not an issuer at all: an RBAC failure, not a + // separation one. A client must be able to tell "you are not an issuer" + // from "you are an issuer, but not for this recipient". + let rbac = client.check_issuance_authority(&officer, &investor); + assert!(!rbac.allowed); + assert_eq!(rbac.reason, IssuanceGuard::MissingIssuanceDuty); + assert!(!rbac.reason.is_separation_failure()); + assert_eq!(rbac.error_code, Some(Error::Unauthorized as u32)); + + let separation = client.check_issuance_authority(&admin, &investor); + assert!(separation.reason.is_separation_failure()); + assert_eq!(separation.reason, IssuanceGuard::DualDutyConflict); + + // The scoped manager is the caller the strict policy is designed around. + assert!(client.check_issuance_authority(&manager, &investor).allowed); +} + +#[test] +fn test_check_issuance_authority_matches_mint_enforcement() { + // The read and the write path must never disagree: a dashboard that + // disables an action on the strength of this read would otherwise be + // wrong in exactly the cases that matter. + let policies = [ + policy_with(true, true, false), + policy_with(false, true, false), + policy_with(true, false, false), + policy_with(true, true, true), + strict_policy(), + ]; + + for policy in policies { + let (env, client, admin, officer, manager, investor) = setup_issuer_world(); + let emergency = Address::generate(&env); + client.set_role(&admin, &emergency, &Role::EmergencyOfficer); + // Every address that appears as a recipient below is compliance- + // approved, so the only thing that can refuse a mint here is a + // separation control — which is what this test is about. + for holder in [&emergency, &manager, &admin, &officer] { + client.set_compliance_status(&officer, holder, &ComplianceStatus::Approved); + } + client.set_issuer_separation_policy(&admin, &policy); + + for caller in [&admin, &manager, &emergency, &officer] { + for recipient in [&investor, caller] { + let check = client.check_issuance_authority(caller, recipient); + let balance_before = client.get_balance_of(recipient); + let result = client.try_mint_asset(caller, recipient, &10); + + match result { + Ok(_) => { + assert!( + check.allowed, + "guard refused with {:?} but the mint succeeded", + check.reason + ); + assert_eq!(client.get_balance_of(recipient), balance_before + 10); + } + Err(Ok(err)) => { + assert!( + !check.allowed, + "guard allowed but the mint reverted with {err:?}" + ); + assert_eq!(check.error_code, Some(err as u32)); + assert_eq!(client.get_balance_of(recipient), balance_before); + } + Err(Err(err)) => panic!("unexpected host error: {err:?}"), + } + } + } + } +} + +#[test] +fn test_issuance_check_reports_a_consistent_snapshot() { + let (env, client, admin, officer, _manager, investor) = setup_issuer_world(); + client.set_issuer_separation_policy(&admin, &strict_policy()); + + let check = client.check_issuance_authority(&admin, &investor); + assert_eq!(check.caller, admin); + assert_eq!(check.recipient, investor); + assert_eq!(check.caller_role, Role::Admin); + assert_eq!( + check.caller_duties, + vec![ + &env, + IssuerDuty::Compliance, + IssuerDuty::Issuance, + IssuerDuty::Emergency, + IssuerDuty::Governance + ] + ); + assert_eq!(check.recipient_approver, Some(officer)); + assert!(!check.allowed); + assert_eq!(check.reason, IssuanceGuard::DualDutyConflict); + assert_eq!(check.error_code, Some(Error::IssuanceDutyConflict as u32)); +} + +#[test] +fn test_issuance_reads_never_mutate_state() { + let (env, client, admin, _officer, manager, investor) = setup_issuer_world(); + client.set_issuer_separation_policy(&admin, &strict_policy()); + + for caller in [&admin, &manager, &investor] { + client.check_issuance_authority(caller, &investor); + client.get_duties_of(caller); + client.get_compliance_approver(caller); + } + + assert_eq!(client.get_balance_of(&investor), 0); + assert_eq!(client.get_total_supply(), 0); + assert_eq!(client.get_role_of(&manager), Role::AssetManager); + assert_eq!(client.get_issuer_separation_policy(), strict_policy()); + // A pre-flight read is not an issuance and must leave no audit trace. + // `env.events().all()` reports the most recent invocation's events, which + // for a pure read is nothing at all. + assert_eq!(env.events().all(), vec![&env]); +} + +#[test] +fn test_policy_update_is_admin_only_and_emits_the_previous_policy() { + let (env, client, admin, officer, manager, _investor) = setup_issuer_world(); + + for caller in [&officer, &manager] { + assert_eq!( + client.try_set_issuer_separation_policy(caller, &strict_policy()), + Err(Ok(Error::Unauthorized)) + ); + } + assert!(!client.get_issuer_separation_policy().enforced); + + client.set_issuer_separation_policy(&admin, &strict_policy()); + // Both policies are emitted so an auditor can reconstruct when each + // control came into force without replaying storage. + assert_eq!( + env.events().all(), + vec![ + &env, + ( + client.address.clone(), + ("issuer_separation_policy_updated",).into_val(&env), + IssuerSeparationPolicyUpdatedEvent { + admin: admin.clone(), + previous_policy: IssuerSeparationPolicy::default_policy(), + new_policy: strict_policy(), + } + .into_val(&env), + ), + ] + ); + assert_eq!(client.get_issuer_separation_policy(), strict_policy()); +} + +#[test] +fn test_policy_update_is_blocked_while_paused() { + let (_env, client, admin, _officer, _manager, _investor) = setup_issuer_world(); + client.pause(&admin); + + assert_eq!( + client.try_set_issuer_separation_policy(&admin, &strict_policy()), + Err(Ok(Error::ContractPaused)) + ); + assert!(!client.get_issuer_separation_policy().enforced); + + // The read stays available while paused, so a dashboard can still explain + // the configured controls during an incident. + client.unpause(&admin); + client.set_issuer_separation_policy(&admin, &strict_policy()); + assert!(client.get_issuer_separation_policy().enforced); +} + +#[test] +fn test_separation_policy_can_never_lock_a_deployment_out_of_issuance() { + // The strictest policy blocks the admin from issuing. That must be + // recoverable: `set_issuer_separation_policy` is deliberately not gated by + // the policy it sets, so the admin can always relax a rule that turns out + // to be too strict. + let (env, client, admin, officer, _manager, investor) = setup_issuer_world(); + client.set_issuer_separation_policy(&admin, &strict_policy()); + + // Revoke the only independent issuer, leaving no key able to mint. + let lone_manager = Address::generate(&env); + client.set_role(&admin, &lone_manager, &Role::AssetManager); + client.remove_role(&admin, &lone_manager); + assert_eq!( + client.try_mint_asset(&admin, &investor, &10), + Err(Ok(Error::IssuanceDutyConflict)) + ); + + // Recovery: relax the policy, then issue. + client.set_issuer_separation_policy(&admin, &IssuerSeparationPolicy::default_policy()); + client.mint_asset(&admin, &investor, &10); + assert_eq!(client.get_balance_of(&investor), 10); + let _ = &officer; +} + +#[test] +fn test_yield_distribution_respects_the_duty_control_only() { + let (_env, client, admin, officer, manager, _investor) = setup_issuer_world(); + // Every recipient-scoped control is engaged; none can apply to a call with + // no beneficiary, so only the duty control may bind here. + client.set_issuer_separation_policy(&admin, &strict_policy()); + + assert_eq!( + client.try_distribute_yield(&admin, &100), + Err(Ok(Error::IssuanceDutyConflict)) + ); + client.distribute_yield(&manager, &100); + let _ = &officer; +} + +#[test] +fn test_issuance_guard_reports_not_initialized_instead_of_panicking() { + let (env, client, admin, _user1, investor) = setup(); + env.mock_all_auths(); + + let check = client.check_issuance_authority(&admin, &investor); + assert!(!check.allowed); + assert_eq!(check.reason, IssuanceGuard::NotInitialized); + assert_eq!(check.error_code, Some(Error::NotInitialized as u32)); + assert_eq!(check.caller_role, Role::None); + // The policy read is equally safe before initialization. + assert!(!client.get_issuer_separation_policy().enforced); +} diff --git a/tests/sdk_fixtures.rs b/tests/sdk_fixtures.rs index 6e4e58d..918388e 100644 --- a/tests/sdk_fixtures.rs +++ b/tests/sdk_fixtures.rs @@ -44,6 +44,7 @@ use aegis_contracts::compliance::{ use aegis_contracts::compliance_guards::TransitionGuard; use aegis_contracts::config::{ConfigAmendedEvent, ConfigProposedEvent, ProtocolConfig}; use aegis_contracts::holding::{HoldingCapAmendedEvent, HoldingCapProposedEvent}; +use aegis_contracts::issuer::{IssuerSeparationPolicy, IssuerSeparationPolicyUpdatedEvent}; use aegis_contracts::lifecycle::{AssetStatus, AssetStatusChangedEvent}; use aegis_contracts::supply_cap::{SupplyCapAmendedEvent, SupplyCapProposedEvent}; use aegis_contracts::{ContractInitializedEvent, Error, Role}; @@ -151,6 +152,9 @@ fn error_name(e: Error) -> &'static str { Error::ContractPaused => "ContractPaused", Error::AlreadyPaused => "AlreadyPaused", Error::NotPaused => "NotPaused", + Error::IssuanceDutyConflict => "IssuanceDutyConflict", + Error::SelfIssuanceForbidden => "SelfIssuanceForbidden", + Error::IssuanceApproverConflict => "IssuanceApproverConflict", Error::SenderNotWhitelisted => "SenderNotWhitelisted", Error::ReceiverNotWhitelisted => "ReceiverNotWhitelisted", Error::SenderBlocked => "SenderBlocked", @@ -1501,6 +1505,37 @@ fn fixture_events() { ); } + // Issuer separation policy governance. + { + let h = bootstrap(); + let c = h.client(); + let policy = IssuerSeparationPolicy { + enforced: true, + allow_dual_duty_issuance: false, + allow_self_issuance: false, + require_independent_approver: true, + }; + c.set_issuer_separation_policy(&h.actor("admin"), &policy); + push_event( + "event-issuer-separation-policy-updated", + "Topic `issuer_separation_policy_updated`. Both the previous and the new \ + policy are emitted so an auditor can reconstruct when each separation \ + control came into force without replaying storage \ + (see docs/issuer-role-separation.md).", + typed_events!( + h, + ( + "issuer_separation_policy_updated", + IssuerSeparationPolicyUpdatedEvent { + admin: h.actor("admin"), + previous_policy: IssuerSeparationPolicy::default_policy(), + new_policy: policy, + } + ), + ), + ); + } + // Reverted invocations emit nothing. { let h = bootstrap(); @@ -1979,6 +2014,33 @@ fn fixture_errors() { ); } + // 3007 — IssuanceDutyConflict (issuer separation enforced). + { + let h = bootstrap(); + let c = h.client(); + c.set_issuer_separation_policy( + &h.actor("admin"), + &IssuerSeparationPolicy { + enforced: true, + allow_dual_duty_issuance: false, + allow_self_issuance: false, + require_independent_approver: true, + }, + ); + // The admin carries both the compliance and the issuance duty, so with + // separation enforced it may no longer issue: issuance must go through + // a dedicated AssetManager key. See docs/issuer-role-separation.md. + let r = c.try_mint_asset(&h.actor("admin"), &h.actor("investor_alice"), &100); + push_err( + "error-3007-issuance-duty-conflict", + "Issuer separation is enforced and the caller holds both the compliance and \ + issuance duties, so it may not issue. Recoverable: the admin can relax the \ + policy, which is never self-locking.", + "mint_asset", + expect_err(r, Error::IssuanceDutyConflict), + ); + } + assert_unique_ids(&scenarios); // Coverage guard: every variant of `Error` must have a captured example.